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

Freegle / Iznik / 12501

10 May 2026 05:54PM UTC coverage: 72.97% (+3.8%) from 69.155%
12501

push

circleci

web-flow
Merge pull request #426 from Freegle/feat/newsfeed-mod-notif

feat(batch): migrate newsfeed_modnotif.php to mail:newsfeed-mod-notif

13845 of 20786 branches covered (66.61%)

Branch coverage included in aggregate %.

95 of 122 new or added lines in 3 files covered. (77.87%)

101 existing lines in 2 files now uncovered.

103382 of 139865 relevant lines covered (73.92%)

22.42 hits per line

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

88.08
/iznik-batch/app/Services/NotificationChaseUpService.php
1
<?php
2

3
namespace App\Services;
4

5
use App\Mail\Notification\ChaseUpMail;
6
use App\Mail\Traits\AvatarResolver;
7
use App\Mail\Traits\FeatureFlags;
8
use App\Models\Notification;
9
use App\Models\User;
10
use Illuminate\Support\Collection;
11
use Illuminate\Support\Facades\DB;
12
use Illuminate\Support\Facades\Log;
13
use Illuminate\Support\Facades\Mail;
14

15
class NotificationChaseUpService
16
{
17
    use AvatarResolver;
18
    use FeatureFlags;
19

20
    public const EMAIL_TYPE = 'NotificationChaseUp';
21

22
    /**
23
     * How far back to look for notifications (oldest that qualify).
24
     */
25
    public const DEFAULT_SINCE_HOURS = 24;
26

27
    /**
28
     * Minimum age before sending — gives the user time to see the notification first.
29
     */
30
    public const DEFAULT_BEFORE_MINUTES = 5;
31

32
    /**
33
     * How long before a user is considered inactive (6 months).
34
     */
35
    private const INACTIVE_DAYS = 183;
36

37
    /**
38
     * Notification types that are never included in chaseup emails (mirrors V1).
39
     */
40
    public const EXCLUDED_TYPES = ['TryFeed', 'AboutMe', 'GiftAid', 'OpenPosts'];
41

42
    /**
43
     * Spam-user collection types — notifications from these users are excluded.
44
     */
45
    private const SPAM_COLLECTIONS = ['Spammer', 'PendingAdd'];
46

47
    /**
48
     * Send chaseup emails for unmailed, unseen notifications.
49
     *
50
     * Mirrors V1 Notifications::sendEmails() called from notification_chaseup.php.
51
     */
52
    public function sendEmails(
25✔
53
        ?int $userId = null,
54
        int $beforeMinutes = self::DEFAULT_BEFORE_MINUTES,
55
        int $sinceHours = self::DEFAULT_SINCE_HOURS,
56
        bool $dryRun = false
57
    ): int {
58
        if (! self::isEmailTypeEnabled(self::EMAIL_TYPE)) {
25✔
59
            Log::info('NotificationChaseUp emails disabled via FREEGLE_MAIL_ENABLED_TYPES');
1✔
60
            return 0;
1✔
61
        }
62

63
        $users = $this->getUsersWithPendingNotifications($userId, $beforeMinutes, $sinceHours);
24✔
64

65
        $total = 0;
24✔
66

67
        foreach ($users as $user) {
24✔
68
            $total += $this->processUser($user, $dryRun);
14✔
69
        }
70

71
        return $total;
24✔
72
    }
73

74
    /**
75
     * Find users with unmailed, unseen notifications in the given time window,
76
     * excluding spammers (from-user check, same as V1 SQL JOIN).
77
     */
78
    private function getUsersWithPendingNotifications(
24✔
79
        ?int $userId,
80
        int $beforeMinutes,
81
        int $sinceHours
82
    ): Collection {
83
        $before = now()->subMinutes($beforeMinutes);
24✔
84
        $since  = now()->subHours($sinceHours);
24✔
85

86
        $query = DB::table('users_notifications')
24✔
87
            ->leftJoin('spam_users', function ($join) {
24✔
88
                $join->on('spam_users.userid', '=', 'users_notifications.fromuser')
24✔
89
                    ->whereIn('spam_users.collection', self::SPAM_COLLECTIONS);
24✔
90
            })
24✔
91
            ->join('users', 'users.id', '=', 'users_notifications.touser')
24✔
92
            ->where('users_notifications.timestamp', '<=', $before)
24✔
93
            ->where('users_notifications.timestamp', '>=', $since)
24✔
94
            ->where('users_notifications.seen', 0)
24✔
95
            ->where('users_notifications.mailed', 0)
24✔
96
            ->whereNotIn('users_notifications.type', self::EXCLUDED_TYPES)
24✔
97
            ->whereNull('spam_users.userid')
24✔
98
            ->whereNull('users.deleted')
24✔
99
            ->select('users_notifications.touser')
24✔
100
            ->distinct();
24✔
101

102
        if ($userId !== null) {
24✔
103
            $query->where('users_notifications.touser', $userId);
1✔
104
        }
105

106
        $userIds = $query->pluck('touser');
24✔
107

108
        return User::whereIn('id', $userIds)->get();
24✔
109
    }
110

111
    /**
112
     * Process a single user: check eligibility, prepare notifications, send email.
113
     */
114
    private function processUser(User $user, bool $dryRun): int
14✔
115
    {
116
        if (! $this->isUserEligible($user)) {
14✔
117
            return 0;
4✔
118
        }
119

120
        if (! $this->wantsNotificationEmails($user)) {
10✔
121
            return 0;
1✔
122
        }
123

124
        $notifications = $this->getUserNotifications($user->id);
9✔
125
        if ($notifications->isEmpty()) {
9✔
126
            return 0;
×
127
        }
128

129
        $notifData = $this->prepareNotifications($notifications);
9✔
130
        if (empty($notifData)) {
9✔
131
            return 0;
×
132
        }
133

134
        $subject = $this->getNotifTitle($notifData);
9✔
135
        if (! $subject) {
9✔
136
            return 0;
×
137
        }
138

139
        $email = $user->email_preferred;
9✔
140
        if (! $email) {
9✔
141
            return 0;
×
142
        }
143

144
        if (! $dryRun) {
9✔
145
            // Mark as mailed before sending (like V1).
146
            $notifIds = array_column($notifData, 'id');
8✔
147
            DB::table('users_notifications')
8✔
148
                ->whereIn('id', $notifIds)
8✔
149
                ->update(['mailed' => 1]);
8✔
150

151
            Mail::to($email)->send(new ChaseUpMail($user, $notifData, $subject));
8✔
152
        }
153

154
        return 1;
9✔
155
    }
156

157
    /**
158
     * Check whether the user should receive emails at all.
159
     * Mirrors V1 User::sendOurMails() checks.
160
     */
161
    private function isUserEligible(User $user): bool
14✔
162
    {
163
        if ($user->deleted) {
14✔
164
            return false;
×
165
        }
166

167
        if ($user->getSimpleMail() === User::SIMPLE_MAIL_NONE) {
14✔
168
            return false;
1✔
169
        }
170

171
        // Must have been active in the last ~6 months.
172
        if (! $user->lastaccess || $user->lastaccess->diffInDays(now()) > self::INACTIVE_DAYS) {
13✔
173
            return false;
1✔
174
        }
175

176
        // Not on holiday.
177
        if ($user->onholidaytill && now()->lt($user->onholidaytill)) {
12✔
178
            return false;
1✔
179
        }
180

181
        // Not bouncing.
182
        if ($user->bouncing) {
11✔
183
            return false;
1✔
184
        }
185

186
        return true;
10✔
187
    }
188

189
    /**
190
     * Check the user's notification-mail preference (V1 settings.notificationmails, default true).
191
     */
192
    private function wantsNotificationEmails(User $user): bool
10✔
193
    {
194
        $settings = $user->settings ?? [];
10✔
195
        return (bool) ($settings['notificationmails'] ?? true);
10✔
196
    }
197

198
    /**
199
     * Fetch the latest unseen notifications for this user (mirrors V1 Notifications::get()).
200
     */
201
    private function getUserNotifications(int $userId): Collection
9✔
202
    {
203
        return Notification::where('touser', $userId)
9✔
204
            ->where('seen', 0)
9✔
205
            ->where('mailed', 0)
9✔
206
            ->orderByDesc('id')
9✔
207
            ->limit(10)
9✔
208
            ->get();
9✔
209
    }
210

211
    /**
212
     * Enrich notification rows with from-user display names and newsfeed content.
213
     * Skips deleted items. Returns array suitable for template rendering.
214
     */
215
    private function prepareNotifications(Collection $notifications): array
9✔
216
    {
217
        // Bulk-load from-users.
218
        $fromUserIds = $notifications->pluck('fromuser')->filter()->unique()->values()->toArray();
9✔
219
        $fromUsers = User::whereIn('id', $fromUserIds)->select('id', 'fullname')->get()->keyBy('id');
9✔
220

221
        // Bulk-load newsfeed items and their reply-tos.
222
        $newsfeedIds = $notifications->pluck('newsfeedid')->filter()->unique()->values()->toArray();
9✔
223
        $newsfeeds = [];
9✔
224

225
        if (! empty($newsfeedIds)) {
9✔
226
            $rows = DB::table('newsfeed')->whereIn('id', $newsfeedIds)->get();
1✔
227

228
            foreach ($rows as $row) {
1✔
229
                $newsfeeds[$row->id] = $row;
1✔
230
            }
231

232
            $replytoIds = $rows->pluck('replyto')->filter()->unique()->values()->toArray();
1✔
233

234
            if (! empty($replytoIds)) {
1✔
235
                $replyRows = DB::table('newsfeed')->whereIn('id', $replytoIds)->get();
×
236
                foreach ($replyRows as $row) {
×
UNCOV
237
                    $newsfeeds[$row->id] = $row;
×
238
                }
239
            }
240
        }
241

242
        $result = [];
9✔
243

244
        foreach ($notifications as $notif) {
9✔
245
            if (in_array($notif->type, self::EXCLUDED_TYPES, true)) {
9✔
UNCOV
246
                continue;
×
247
            }
248

249
            $newsfeed = $notif->newsfeedid ? ($newsfeeds[$notif->newsfeedid] ?? null) : null;
9✔
250

251
            // Skip if the newsfeed item has been deleted.
252
            if ($newsfeed && $newsfeed->deleted) {
9✔
UNCOV
253
                continue;
×
254
            }
255

256
            // Skip if the thread being replied to has been deleted.
257
            $replyto = null;
9✔
258
            if ($newsfeed && $newsfeed->replyto) {
9✔
259
                $replyto = $newsfeeds[$newsfeed->replyto] ?? null;
×
260
                if ($replyto && $replyto->deleted) {
×
UNCOV
261
                    continue;
×
262
                }
263
            }
264

265
            $fromUser = isset($fromUsers[$notif->fromuser]) ? $fromUsers[$notif->fromuser] : null;
9✔
266

267
            $result[] = [
9✔
268
                'id'        => $notif->id,
9✔
269
                'type'      => $notif->type,
9✔
270
                'fromname'  => $fromUser ? $fromUser->fullname : 'Someone',
9✔
271
                'fromimage' => $this->resolveAvatarUrl($fromUser),
9✔
272
                'title'    => $notif->title,
9✔
273
                'text'     => $notif->text,
9✔
274
                'url'      => $notif->url,
9✔
275
                'timestamp' => \Carbon\Carbon::parse($notif->timestamp, 'UTC')
9✔
276
                                   ->setTimezone('Europe/London')
9✔
277
                                   ->format('D, jS F g:ia'),
9✔
278
                'seen'     => $notif->seen,
9✔
279
                'newsfeed' => $newsfeed ? [
9✔
280
                    'id'      => $newsfeed->id,
9✔
281
                    'type'    => $newsfeed->type ?? 'Message',
9✔
282
                    'message' => $this->snip($newsfeed->message),
9✔
283
                    'replyto' => $replyto ? [
1✔
284
                        'id'      => $replyto->id,
1✔
285
                        'message' => $this->snip($replyto->message),
1✔
286
                    ] : null,
287
                ] : null,
288
            ];
9✔
289
        }
290

291
        return $result;
9✔
292
    }
293

294
    /**
295
     * Generate the email subject line. Mirrors V1 Notifications::getNotifTitle().
296
     *
297
     * Priority: CommentOnCommented > CommentOnYourPost > LovedPost/LovedComment > Exhort.
298
     * If multiple notifications, appends "+ N more...".
299
     */
300
    public function getNotifTitle(array $notifs): string
18✔
301
    {
302
        $title = '';
18✔
303
        $count = 0;
18✔
304

305
        foreach ($notifs as $notif) {
18✔
306
            if ($notif['type'] === 'TryFeed') {
18✔
UNCOV
307
                continue;
×
308
            }
309

310
            $fromname = $notif['fromname'];
18✔
311
            $shortmsg = null;
18✔
312

313
            if (
314
                ! empty($notif['newsfeed']['message']) &&
18✔
315
                ($notif['newsfeed']['type'] ?? '') !== 'Noticeboard'
18✔
316
            ) {
317
                $msg      = $notif['newsfeed']['message'];
6✔
318
                $shortmsg = strlen($msg) > 30 ? (substr($msg, 0, 30) . '...') : $msg;
6✔
319
            }
320

321
            switch ($notif['type']) {
18✔
322
                case 'CommentOnCommented':
18✔
323
                    $title = "{$fromname} replied: {$shortmsg}";
1✔
324
                    $count++;
1✔
325
                    break;
1✔
326
                case 'CommentOnYourPost':
17✔
327
                    $title = "{$fromname} commented: {$shortmsg}";
8✔
328
                    $count++;
8✔
329
                    break;
8✔
330
                case 'LovedPost':
10✔
331
                    if (! $title) {
2✔
332
                        $title = "{$fromname} loved your post" . ($shortmsg ? " '{$shortmsg}'" : '');
1✔
333
                    }
334
                    $count++;
2✔
335
                    break;
2✔
336
                case 'LovedComment':
8✔
337
                    if (! $title) {
1✔
338
                        $title = "{$fromname} loved your comment" . ($shortmsg ? " '{$shortmsg}'" : '');
1✔
339
                    }
340
                    $count++;
1✔
341
                    break;
1✔
342
                case 'AboutMe':
7✔
343
                    if (! $title) {
×
UNCOV
344
                        $title = "Why not introduce yourself to other freeglers? You'll get a better response.";
×
345
                    }
346
                    $count++;
×
UNCOV
347
                    break;
×
348
                case 'Exhort':
7✔
349
                    if (! $title) {
1✔
350
                        $title = $notif['title'] ?? '';
1✔
351
                    }
352
                    $count++;
1✔
353
                    break;
1✔
354
                case 'MembershipPending':
6✔
355
                    if (! $title) {
2✔
356
                        $title = "Your application to {$notif['url']} requires approval";
2✔
357
                    }
358
                    $count++;
2✔
359
                    break;
2✔
360
                case 'MembershipApproved':
4✔
361
                    if (! $title) {
2✔
362
                        $title = "Your application to {$notif['url']} has been approved!";
2✔
363
                    }
364
                    $count++;
2✔
365
                    break;
2✔
366
                case 'MembershipRejected':
2✔
367
                    if (! $title) {
2✔
368
                        $title = "Sorry, your application to {$notif['url']} was rejected";
2✔
369
                    }
370
                    $count++;
2✔
371
                    break;
2✔
372
            }
373
        }
374

375
        if ($count > 1) {
18✔
376
            $title = $title . ' +' . ($count - 1) . ' more...';
1✔
377
        }
378

379
        return $title;
18✔
380
    }
381

382
    /**
383
     * Truncate a message to ~57 chars at a word boundary, matching V1 Notifications::snip().
384
     */
385
    private function snip(?string $msg): ?string
1✔
386
    {
387
        if (! $msg) {
1✔
UNCOV
388
            return $msg;
×
389
        }
390

391
        if (strlen($msg) > 57) {
1✔
392
            $msg = wordwrap($msg, 60);
×
393
            $p   = strpos($msg, "\n");
×
394
            $msg = ($p !== false) ? substr($msg, 0, $p) : $msg;
×
UNCOV
395
            $msg .= '...';
×
396
        }
397

398
        return $msg;
1✔
399
    }
400
}
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

© 2026 Coveralls, Inc