• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Freegle / iznik-server / 8f95df89-fc50-42ce-a8c3-2f689f922056

13 Jun 2024 04:23PM UTC coverage: 94.806% (-0.009%) from 94.815%
8f95df89-fc50-42ce-a8c3-2f689f922056

push

circleci

edwh
Uploadcare - noticeboard and story images

8 of 10 new or added lines in 2 files covered. (80.0%)

3 existing lines in 2 files now uncovered.

25444 of 26838 relevant lines covered (94.81%)

31.48 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.57
/include/user/Engage.php
1
<?php
2
namespace Freegle\Iznik;
3

4

5

6
class Engage
7
{
8
    private $dbhr, $dbhm;
9

10
    const USER_INACTIVE = 365 * 24 * 60 * 60 / 2;
11
    const LOOKBACK = 31;
12

13
    const FILTER_INACTIVE = 'Inactive';
14

15
    const ENGAGEMENT_UT = 'UT';
16
    const ENGAGEMENT_NEW = 'New';
17
    const ENGAGEMENT_OCCASIONAL = 'Occasional';
18
    const ENGAGEMENT_FREQUENT = 'Frequent';
19
    const ENGAGEMENT_OBSESSED = 'Obsessed';
20
    const ENGAGEMENT_INACTIVE = 'Inactive';
21
    const ENGAGEMENT_ATRISK = 'AtRisk';
22
    const ENGAGEMENT_DORMANT = 'Dormant';
23

24
    function __construct(LoggedPDO $dbhr, LoggedPDO $dbhm)
25
    {
26
        $this->dbhr = $dbhr;
4✔
27
        $this->dbhm = $dbhm;
4✔
28

29
        $loader = new \Twig_Loader_Filesystem(IZNIK_BASE . '/mailtemplates/twig/engage');
4✔
30
        $this->twig = new \Twig_Environment($loader);
4✔
31
    }
32
    # Split out for UT to override
33

34
    public function sendOne($mailer, $message, $uid) {
35
        Mail::addHeaders($this->dbhr, $this->dbhm, $message, Mail::MISSING, $uid);
1✔
36
        $mailer->send($message);
1✔
37
    }
38

39
    public function findUsersByFilter($id = NULL, $filter, $limit = NULL) {
40
        $userids = [];
2✔
41
        $limq = $limit ? " LIMIT $limit " : "";
2✔
42

43
        switch ($filter) {
44
            case Engage::FILTER_INACTIVE: {
45
                # Find people who we'll stop sending mails to soon.  This time is related to sendOurMails in User.
46
                $activeon = date("Y-m-d", strtotime("@" . (time() - Engage::USER_INACTIVE + 7 * 24 * 60 * 60)));
2✔
47
                $uq = $id ? " AND users.id = $id " : "";
2✔
48
                $sql = "SELECT id FROM users WHERE DATE(lastaccess) = ? $uq $limq;";
2✔
49
                $users = $this->dbhr->preQuery($sql, [
2✔
50
                    $activeon
2✔
51
                ]);
2✔
52

53
                $userids = array_column($users, 'id');
2✔
54
                break;
2✔
55
            }
56
        }
57

58
        return $userids;
2✔
59
    }
60

61
    public function findUsersByEngagement($id = NULL, $engagement, $limit = NULL) {
62
        $uq = $id ? " users.id = $id AND " : "";
2✔
63
        $limq = $limit ? " LIMIT $limit " : "";
2✔
64

65
        $sql = "SELECT id FROM users WHERE $uq engagement = ? $limq;";
2✔
66
        $users = $this->dbhr->preQuery($sql, [
2✔
67
            $engagement
2✔
68
        ]);
2✔
69

70
        $userids = array_column($users, 'id');
2✔
71
        return $userids;
2✔
72
    }
73

74
    private function setEngagement($id, $engagement, &$count) {
75
        #error_log("Set engagement $id = $engagement");
76
        $count++;
1✔
77
        $this->dbhm->preExec("UPDATE users SET engagement = ? WHERE id = ?;", [
1✔
78
            $engagement,
1✔
79
            $id
1✔
80
        ], FALSE, FALSE);
1✔
81
    }
82

83
    private function postsSince($id, $time) {
84
        $messages = $this->dbhr->preQuery("SELECT COUNT(*) AS count FROM messages INNER JOIN messages_groups ON messages_groups.msgid = messages.id WHERE fromuser = ? AND messages.arrival >= ?;", [
1✔
85
            $id,
1✔
86
            date("Y-m-d", strtotime($time))
1✔
87
        ]);
1✔
88

89
        #error_log("Posts since {$messages[0]['count']}");
90
        return $messages[0]['count'];
1✔
91
    }
92

93
    private function lastPostOrReply($id) {
94
        $ret = NULL;
1✔
95

96
        $reply = $this->dbhr->preQuery("SELECT MAX(date) AS date FROM chat_messages WHERE userid = ?;", [
1✔
97
            $id
1✔
98
        ]);
1✔
99

100
        if (count($reply)) {
1✔
101
            $ret = $reply[0]['date'];
1✔
102
        }
103

104
        $messages = $this->dbhr->preQuery("SELECT MAX(messages.arrival) AS date FROM messages INNER JOIN messages_groups ON messages_groups.msgid = messages.id WHERE fromuser = ?;", [
1✔
105
            $id
1✔
106
        ]);
1✔
107

108
        if (count($messages)) {
1✔
109
            if (!$ret || (strtotime($messages[0]['date']) > strtotime($ret))) {
1✔
110
                $ret = $messages[0]['date'];
1✔
111
            }
112
        }
113

114
        return $ret;
1✔
115
    }
116

117
    private function engagementProgress($total, &$count) {
118
        $count++;
1✔
119

120
        if ($count % 1000 == 0) { error_log("...$count / $total"); }
1✔
121
    }
122

123
    public function updateEngagement($id = NULL) {
124
        $ret = 0;
1✔
125
        $uq = $id ? " users.id = $id AND " : "";
1✔
126

127
        $lookback = date("Y-m-d", strtotime("midnight " . Engage::LOOKBACK . " days ago"));
1✔
128

129
        # Set new users
130
        $users = $this->dbhr->preQuery("SELECT id FROM users WHERE $uq added >= ? AND engagement IS NULL ;", [
1✔
131
            $lookback
1✔
132
        ]);
1✔
133

134
        error_log("NULL => New " . count($users));
1✔
135

136
        $count = 0;
1✔
137
        foreach ($users as $user) {
1✔
138
            $this->engagementProgress(count($users), $count);
1✔
139
            $this->setEngagement($user['id'], Engage::ENGAGEMENT_NEW, $ret);
1✔
140
        }
141

142
        # NULL => Inactive
143
        $users = $this->dbhr->preQuery("SELECT id FROM users WHERE $uq engagement IS NULL ;");
1✔
144

145
        error_log("NULL => Inactive " . count($users));
1✔
146

147
        $count = 0;
1✔
148
        foreach ($users as $user) {
1✔
149
            $this->engagementProgress(count($users), $count);
×
150
            $this->setEngagement($user['id'], Engage::ENGAGEMENT_INACTIVE, $ret);
×
151
        }
152

153
        # New, Occasional => Inactive.
154
        $mysqltime = date("Y-m-d", strtotime("midnight 14 days ago"));
1✔
155
        $users = $this->dbhr->preQuery("SELECT id FROM users WHERE $uq (lastaccess IS NULL OR lastaccess < ?) AND (engagement IS NULL OR engagement = ? OR engagement = ?);", [
1✔
156
            $mysqltime,
1✔
157
            Engage::ENGAGEMENT_NEW,
1✔
158
            Engage::ENGAGEMENT_OCCASIONAL
1✔
159
        ]);
1✔
160

161
        error_log("New, Occasional => Inactive " . count($users));
1✔
162

163
        $count = 0;
1✔
164
        foreach ($users as $user) {
1✔
165
            $this->engagementProgress(count($users), $count);
1✔
166
            $this->setEngagement($user['id'], Engage::ENGAGEMENT_INACTIVE, $ret);
1✔
167
        }
168

169
        # Inactive => Dormant.
170
        $activeon = date("Y-m-d", strtotime("@" . (time() - Engage::USER_INACTIVE)));
1✔
171
        $users = $this->dbhr->preQuery("SELECT id FROM users WHERE $uq (lastaccess IS NULL OR lastaccess < ?) AND engagement != ?;", [
1✔
172
            $activeon,
1✔
173
            self::ENGAGEMENT_DORMANT
1✔
174
        ]);
1✔
175

176
        error_log("Inactive => Dormant " . count($users));
1✔
177

178
        $count = 0;
1✔
179
        foreach ($users as $user) {
1✔
180
            $this->engagementProgress(count($users), $count);
1✔
181
            $this->setEngagement($user['id'], Engage::ENGAGEMENT_DORMANT, $ret);
1✔
182
        }
183

184
        # New, Inactive, Dormant => Occasional.
185
        $activeon = date("Y-m-d", strtotime("midnight 14 days ago"));
1✔
186
        $users = $this->dbhr->preQuery("SELECT id FROM users WHERE lastaccess >= ? AND (engagement IS NULL OR engagement = ? OR engagement = ?  OR engagement = ?);", [
1✔
187
            $activeon,
1✔
188
            Engage::ENGAGEMENT_NEW,
1✔
189
            Engage::ENGAGEMENT_INACTIVE,
1✔
190
            Engage::ENGAGEMENT_DORMANT
1✔
191
        ]);
1✔
192

193
        error_log("New, Inactive, Dormant => Occasional " . count($users));
1✔
194
        $count = 0;
1✔
195
        foreach ($users as $user) {
1✔
196
            $this->engagementProgress(count($users), $count);
1✔
197
            $active = $this->lastPostOrReply($user['id']);
1✔
198
            #error_log("Last active $active");
199
            if ($active && strtotime($active) > time() - 14 * 24 * 60 *60) {
1✔
200
                $this->setEngagement($user['id'], Engage::ENGAGEMENT_OCCASIONAL, $ret);
1✔
201
            }
202
        }
203

204
        # Occasional => Frequent.
205
        $users = $this->dbhr->preQuery("SELECT id FROM users WHERE $uq engagement = ?;", [
1✔
206
            Engage::ENGAGEMENT_OCCASIONAL
1✔
207
        ]);
1✔
208

209
        error_log("Occasional => Frequent " . count($users));
1✔
210
        $count = 0;
1✔
211
        foreach ($users as $user) {
1✔
212
            $this->engagementProgress(count($users), $count);
1✔
213
            if ($this->postsSince($user['id'], "90 days ago") > 3) {
1✔
214
                $this->setEngagement($user['id'], Engage::ENGAGEMENT_FREQUENT, $ret);
1✔
215
            }
216
        }
217

218
        # Frequent => Obsessed.
219
        $users = $this->dbhr->preQuery("SELECT id FROM users WHERE $uq engagement = ?;", [
1✔
220
            Engage::ENGAGEMENT_FREQUENT
1✔
221
        ]);
1✔
222

223
        error_log("Frequent => Obsessed " . count($users));
1✔
224
        $count = 0;
1✔
225
        foreach ($users as $user) {
1✔
226
            $this->engagementProgress(count($users), $count);
1✔
227
            if ($this->postsSince($user['id'], "31 days ago") >= 4) {
1✔
228
                $this->setEngagement($user['id'], Engage::ENGAGEMENT_OBSESSED, $ret);
1✔
229
            }
230
        }
231

232
        # Obsessed => Frequent.
233
        $users = $this->dbhr->preQuery("SELECT id FROM users WHERE $uq engagement = ?;", [
1✔
234
            Engage::ENGAGEMENT_OBSESSED
1✔
235
        ]);
1✔
236

237
        error_log("Obsessed => Frequent " . count($users));
1✔
238
        $count = 0;
1✔
239
        foreach ($users as $user) {
1✔
240
            $this->engagementProgress(count($users), $count);
1✔
241
            if ($this->postsSince($user['id'], "90 days ago") <= 3) {
1✔
242
                $this->setEngagement($user['id'], Engage::ENGAGEMENT_FREQUENT, $ret);
1✔
243
            }
244
        }
245
    }
246

247
    public function sendUsers($engagement, $uids, $force = FALSE) {
248
        $count = 0;
2✔
249

250
        foreach ($uids as $uid) {
2✔
251
            $u = new User($this->dbhr, $this->dbhm, $uid);
2✔
252
            #error_log("Consider $uid");
253

254
            # Only send to users who have not turned off this kind of mail.
255
            if ($force || $u->getPrivate('relevantallowed')) {
2✔
256
                #error_log("...allowed by user");
257
                try {
258
                    // ...and who have a membership.
259
                    $membs = $u->getMemberships(FALSE, Group::GROUP_FREEGLE);
2✔
260

261
                    if (count($membs)) {
2✔
262
                        // ...and where that group allows engagement.
263
                        $enabled = FALSE;
2✔
264

265
                        foreach ($membs as $memb) {
2✔
266
                            $enabled |= array_key_exists('engagement', $memb['settings']) ? $memb['settings']['engagement'] : TRUE;
2✔
267
                        }
268

269
                        #error_log("...has membership");
270
                        if ($enabled) {
2✔
271
                            // ...and where we've not tried them in the last week.
272
                            $last = $this->dbhr->preQuery("SELECT MAX(timestamp) AS last FROM engage WHERE userid = ?;", [
1✔
273
                                $uid
1✔
274
                            ]);
1✔
275

276
                            if (!$last[0]['last'] || (time() - strtotime($last[0]['last']) > 7 * 24 * 60 * 60)) {
1✔
277
                                #error_log("...not tried recently");
278
                                list ($eid, $mail) = $this->chooseMail($uid, $engagement);
1✔
279
                                $subject = $mail['subject'];
1✔
280
                                $textbody = $mail['text'];
1✔
281
                                $template = $mail['template'] . '.html';
1✔
282

283
                                list ($transport, $mailer) = Mail::getMailer();
1✔
284
                                $m = \Swift_Message::newInstance()
1✔
285
                                    ->setSubject($subject)
1✔
286
                                    ->setFrom([NOREPLY_ADDR => SITE_NAME])
1✔
287
                                    ->setReplyTo(NOREPLY_ADDR)
1✔
288
                                    ->setTo($u->getEmailPreferred())
1✔
289
//                        ->setTo('log@ehibbert.org.uk')
1✔
290
                                    ->setBody($textbody);
1✔
291

292
                                Mail::addHeaders($this->dbhr, $this->dbhm, $m, Mail::MISSING, $u->getId());
1✔
293

294
                                $html = $this->twig->render($template, [
1✔
295
                                    'name' => $u->getName(),
1✔
296
                                    'email' => $u->getEmailPreferred(),
1✔
297
                                    'subject' => $subject,
1✔
298
                                    'unsubscribe' => $u->loginLink(USER_SITE, $u->getId(), "/unsubscribe", NULL),
1✔
299
                                    'engageid' => $eid
1✔
300
                                ]);
1✔
301

302
                                # Add HTML in base-64 as default quoted-printable encoding leads to problems on
303
                                # Outlook.
304
                                $htmlPart = \Swift_MimePart::newInstance();
1✔
305
                                $htmlPart->setCharset('utf-8');
1✔
306
                                $htmlPart->setEncoder(new \Swift_Mime_ContentEncoder_Base64ContentEncoder);
1✔
307
                                $htmlPart->setContentType('text/html');
1✔
308
                                $htmlPart->setBody($html);
1✔
309
                                $m->attach($htmlPart);
1✔
310

311
                                $this->sendOne($mailer, $m, $u->getId());
1✔
312
                                $count++;
2✔
313
                            }
314
                        }
315
                    }
316
                } catch (\Exception $e) { error_log("Failed " . $e->getMessage()); };
×
317
            }
318
        }
319

320
        return $count;
2✔
321
    }
322

323
    public function chooseMail($userid, $engagement) {
324
        # We want to choose a suitable mail to send to this user for their current engagement.  We use similar logic
325
        # to abtest, i.e. bandit testing.  We get the benefit of the best option, while still exploring others.
326
        # See http://stevehanov.ca/blog/index.php?id=132 for an example description.
327
        $variants = $this->dbhr->preQuery("SELECT * FROM engage_mails WHERE engagement = ? ORDER BY rate DESC, RAND();", [
1✔
328
            $engagement
1✔
329
        ]);
1✔
330

331
        $r = Utils::randomFloat();
1✔
332

333
        if ($r < 0.1) {
1✔
334
            # The 10% case we choose a random one of the other options.
UNCOV
335
            $s = rand(1, count($variants) - 1);
×
UNCOV
336
            $variant = $variants[$s];
×
337
        } else {
338
            # Most of the time we choose the currently best-performing option.
339
            $variant = count($variants) > 0 ? $variants[0] : NULL;
1✔
340
        }
341

342
        # Record that we've chosen this one.
343
        $this->dbhm->preExec("UPDATE engage_mails SET shown = shown + 1, rate = COALESCE(100 * action / shown, 0) WHERE id = ?;", [
1✔
344
            $variant['id']
1✔
345
        ]);
1✔
346

347
        # And record this specific attempt.
348
        $this->dbhm->preExec("INSERT INTO engage (userid, mailid, engagement, timestamp) VALUES (?, ?, ?, NOW());", [
1✔
349
            $userid,
1✔
350
            $variant['id'],
1✔
351
            $engagement
1✔
352
        ]);
1✔
353

354
        return [ $this->dbhm->lastInsertId(), $variant ];
1✔
355
    }
356

357
    public function recordSuccess($id) {
358
        $engages = $this->dbhr->preQuery("SELECT * FROM engage WHERE id = ?;", [
2✔
359
            $id
2✔
360
        ]);
2✔
361

362
        foreach ($engages as $engage) {
2✔
363
            $this->dbhm->preExec("UPDATE engage SET succeeded = NOW() WHERE id = ?;", [
1✔
364
                $id
1✔
365
            ]);
1✔
366

367
            # Update the stats for the corresponding email type.
368
            $this->dbhm->preExec("UPDATE engage_mails SET action = action + 1, rate = COALESCE(100 * action / shown, 0) WHERE id = ?;", [
1✔
369
                $engage['mailid']
1✔
370
            ]);
1✔
371
        }
372
    }
373

374
    public function process($id = NULL) {
375
        $count = 0;
2✔
376

377
        # Inactive users
378
        #
379
        # First the ones who will shortly become dormant.  We always want to send this, even if they have turned
380
        # off the normal engagement mails.
381
        $uids = $this->findUsersByFilter($id, Engage::FILTER_INACTIVE);
2✔
382
        $count += $this->sendUsers(Engage::ENGAGEMENT_ATRISK, $uids, TRUE);
2✔
383

384
        # Then the other inactive ones.
385
        $uids = $this->findUsersByEngagement($id, Engage::ENGAGEMENT_INACTIVE, 10000);
2✔
386
        $count += $this->sendUsers(Engage::FILTER_INACTIVE, $uids);
2✔
387

388
        return $count;
2✔
389
    }
390
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc