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

Freegle / Iznik / 5738

23 Apr 2026 09:52PM UTC coverage: 71.614% (+0.04%) from 71.571%
5738

push

circleci

edwh
fix(ci): timeout docker pull to prevent Build containers step hanging indefinitely

docker pull has no default timeout — when GHCR is slow or stalled the
pre-pull step hangs for 1+ hours, blocking the whole CI job. Wrapping
in 'timeout 180s' causes the step to continue with cached layers if the
pull does not complete in 3 minutes. The build itself will re-attempt
the pull with its own retry logic if needed.

13405 of 20350 branches covered (65.87%)

Branch coverage included in aggregate %.

96341 of 132896 relevant lines covered (72.49%)

21.48 hits per line

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

90.2
/iznik-batch/app/Services/ChatNotificationService.php
1
<?php
2

3
namespace App\Services;
4

5
use App\Mail\Chat\ChatNotification;
6
use App\Mail\Traits\FeatureFlags;
7
use App\Models\ChatMessage;
8
use App\Models\ChatRoom;
9
use App\Models\ChatRoster;
10
use App\Models\User;
11
use Illuminate\Support\Collection;
12
use Illuminate\Support\Facades\Log;
13
use Illuminate\Support\Facades\Mail;
14

15
class ChatNotificationService
16
{
17
    use FeatureFlags;
18

19
    /**
20
     * Email type identifier for feature flag checking (User2User chats).
21
     */
22
    private const EMAIL_TYPE = 'ChatNotification';
23

24
    /**
25
     * Email type identifier for User2Mod chats (separate flag for gradual rollout).
26
     */
27
    private const EMAIL_TYPE_USER2MOD = 'ChatNotificationUser2Mod';
28

29
    /**
30
     * Email type identifier for Mod2Mod chats (separate flag for gradual rollout).
31
     */
32
    private const EMAIL_TYPE_MOD2MOD = 'ChatNotificationMod2Mod';
33

34
    /**
35
     * Default delay in seconds before notifying about a message.
36
     * This allows users to type multiple messages before notification.
37
     */
38
    public const DEFAULT_DELAY = 30;
39

40
    /**
41
     * How far back to look for unmailed messages.
42
     */
43
    public const DEFAULT_SINCE_HOURS = 24;
44

45
    /**
46
     * Optional spooler for deferred email sending.
47
     */
48
    protected ?EmailSpoolerService $spooler = null;
49

50
    /**
51
     * Set the spooler for deferred email sending.
52
     */
53
    public function setSpooler(EmailSpoolerService $spooler): void
×
54
    {
55
        $this->spooler = $spooler;
×
56
    }
57

58
    /**
59
     * Send notifications for a specific chat type.
60
     * Simplified: sends each message individually (no batching).
61
     */
62
    public function notifyByEmail(
44✔
63
        string $chatType,
64
        ?int $chatId = null,
65
        int $delay = self::DEFAULT_DELAY,
66
        int $sinceHours = self::DEFAULT_SINCE_HOURS,
67
        bool $forceAll = false,
68
        bool $dryRun = false
69
    ): int {
70
        // Check if ChatNotification emails are enabled.
71
        if (! self::isEmailTypeEnabled(self::EMAIL_TYPE)) {
44✔
72
            Log::info("ChatNotification emails are not enabled. Set FREEGLE_MAIL_ENABLED_TYPES to include 'ChatNotification'.");
×
73

74
            return 0;
×
75
        }
76

77
        // User2Mod notifications have a separate feature flag for gradual rollout.
78
        if ($chatType === ChatRoom::TYPE_USER2MOD && ! self::isEmailTypeEnabled(self::EMAIL_TYPE_USER2MOD)) {
44✔
79
            Log::info("ChatNotificationUser2Mod emails are not enabled. Set FREEGLE_MAIL_ENABLED_TYPES to include 'ChatNotificationUser2Mod'.");
×
80

81
            return 0;
×
82
        }
83

84
        // Mod2Mod notifications have a separate feature flag for gradual rollout.
85
        if ($chatType === ChatRoom::TYPE_MOD2MOD && ! self::isEmailTypeEnabled(self::EMAIL_TYPE_MOD2MOD)) {
44✔
86
            Log::info("ChatNotificationMod2Mod emails are not enabled. Set FREEGLE_MAIL_ENABLED_TYPES to include 'ChatNotificationMod2Mod'.");
×
87

88
            return 0;
×
89
        }
90

91
        $notified = 0;
44✔
92

93
        // Get unmailed messages that need notification.
94
        $messages = $this->getUnmailedMessages(
44✔
95
            $chatType,
44✔
96
            $chatId,
44✔
97
            $delay,
44✔
98
            $sinceHours,
44✔
99
            $forceAll
44✔
100
        );
44✔
101

102
        foreach ($messages as $message) {
44✔
103
            try {
104
                $notified += $this->processMessage($message, $chatType, $forceAll, $dryRun);
28✔
105
            } catch (\Exception $e) {
×
106
                Log::error("Error processing chat message {$message->id}: ".$e->getMessage());
×
107
            }
108
        }
109

110
        return $notified;
44✔
111
    }
112

113
    /**
114
     * Get unmailed messages that need notification.
115
     */
116
    protected function getUnmailedMessages(
44✔
117
        string $chatType,
118
        ?int $chatId,
119
        int $delay,
120
        int $sinceHours,
121
        bool $forceAll
122
    ): Collection {
123
        $startTime = now()->subHours($sinceHours);
44✔
124
        $endTime = now()->subSeconds($delay);
44✔
125

126
        $query = ChatMessage::query()
44✔
127
            ->join('chat_rooms', 'chat_messages.chatid', '=', 'chat_rooms.id')
44✔
128
            ->join('users', 'chat_messages.userid', '=', 'users.id')
44✔
129
            ->where('chat_rooms.chattype', $chatType)
44✔
130
            ->where('chat_messages.date', '>=', $startTime)
44✔
131
            ->where('chat_messages.date', '<=', $endTime)
44✔
132
            ->whereNull('users.deleted')
44✔
133
            ->select('chat_messages.*');
44✔
134

135
        // For User2User chats, only include reviewed messages.
136
        if ($chatType === ChatRoom::TYPE_USER2USER) {
44✔
137
            $query->where('chat_messages.reviewrequired', 0)
27✔
138
                ->where('chat_messages.processingrequired', 0)
27✔
139
                ->where('chat_messages.processingsuccessful', 1);
27✔
140
        }
141

142
        if (! $forceAll) {
44✔
143
            $query->where('chat_messages.mailedtoall', 0)
41✔
144
                ->where('chat_messages.seenbyall', 0)
41✔
145
                ->where('chat_messages.reviewrejected', 0);
41✔
146
        }
147

148
        if ($chatId) {
44✔
149
            $query->where('chat_rooms.id', $chatId);
44✔
150
        }
151

152
        return $query->orderBy('chat_messages.id', 'asc')
44✔
153
            ->with(['chatRoom', 'user', 'refMessage'])
44✔
154
            ->get();
44✔
155
    }
156

157
    /**
158
     * Process a single message and send notifications to relevant users.
159
     */
160
    protected function processMessage(ChatMessage $message, string $chatType, bool $forceAll, bool $dryRun = false): int
28✔
161
    {
162
        $notified = 0;
28✔
163
        $chatRoom = $message->chatRoom;
28✔
164

165
        if (! $chatRoom) {
28✔
166
            return 0;
×
167
        }
168

169
        // Get members who need to be notified about this message.
170
        $membersToNotify = $this->getMembersToNotify($chatRoom, $message, $forceAll);
28✔
171

172
        foreach ($membersToNotify as $roster) {
28✔
173
            try {
174
                $sendingTo = $roster->user;
28✔
175
                if (! $sendingTo || ! $sendingTo->email_preferred) {
28✔
176
                    continue;
1✔
177
                }
178

179
                // Check if we should notify this user about this message.
180
                if (! $this->shouldNotifyUser($sendingTo, $message, $chatRoom, $chatType, $roster->isModerator ?? false)) {
28✔
181
                    continue;
27✔
182
                }
183

184
                // Get the sender in the conversation.
185
                // For Mod2Mod, the sender is always the message author.
186
                // For User2User/User2Mod:
187
                //   - If this is a copy-to-self (recipient is the message author), use the message author.
188
                //   - Otherwise, use the "other" user in the chat.
189
                if ($chatType === ChatRoom::TYPE_MOD2MOD) {
24✔
190
                    $sendingFrom = $message->user;
4✔
191
                } elseif ($message->userid === $sendingTo->id) {
20✔
192
                    // Copy-to-self: recipient is the message author, so sender should be themselves.
193
                    $sendingFrom = $message->user;
1✔
194
                } else {
195
                    $sendingFrom = $this->getOtherUser($chatRoom, $sendingTo);
20✔
196
                }
197

198
                if ($dryRun) {
24✔
199
                    Log::info('Dry run: would send chat notification', [
×
200
                        'chat_id' => $chatRoom->id,
×
201
                        'message_id' => $message->id,
×
202
                        'to_user' => $sendingTo->id,
×
203
                    ]);
×
204
                } else {
205
                    // Send the notification email.
206
                    $this->sendNotificationEmail(
24✔
207
                        $sendingTo,
24✔
208
                        $sendingFrom,
24✔
209
                        $chatRoom,
24✔
210
                        $message,
24✔
211
                        $chatType
24✔
212
                    );
24✔
213

214
                    // Update roster with last message emailed and notified.
215
                    // lastmsgnotified is used by V1's push notification cron (notification_chaseup.php)
216
                    // to avoid re-notifying users for messages already handled by email.
217
                    $roster->update([
24✔
218
                        'lastmsgemailed' => $message->id,
24✔
219
                        'lastmsgnotified' => $message->id,
24✔
220
                    ]);
24✔
221

222
                    // Update message mailedtoall if all members have been notified.
223
                    $this->updateMailedToAll($message);
24✔
224
                }
225

226
                $notified++;
24✔
227

228
                Log::info('Sent chat notification', [
24✔
229
                    'chat_id' => $chatRoom->id,
24✔
230
                    'message_id' => $message->id,
24✔
231
                    'to_user' => $sendingTo->id,
24✔
232
                    'from_user' => $sendingFrom?->id,
24✔
233
                ]);
24✔
234
            } catch (\Exception $e) {
×
235
                Log::error("Error notifying user {$roster->userid} for message {$message->id}: ".$e->getMessage());
×
236
            }
237
        }
238

239
        return $notified;
28✔
240
    }
241

242
    /**
243
     * Get members who need to be notified about a specific message.
244
     *
245
     * For User2User chats: both users are in the roster, we notify those who haven't been mailed.
246
     * For User2Mod chats: we notify the member (user1) from roster, AND all group moderators.
247
     * For Mod2Mod chats: all mods are in the roster, we notify those who haven't been mailed.
248
     *
249
     * Users who have blocked the chat (status = 'Blocked') are excluded from notifications.
250
     */
251
    protected function getMembersToNotify(ChatRoom $chatRoom, ChatMessage $message, bool $forceAll): Collection
28✔
252
    {
253
        $results = collect();
28✔
254

255
        // Mod2Mod: All mods in the group chat are in the roster.
256
        if ($chatRoom->chattype === ChatRoom::TYPE_MOD2MOD && $chatRoom->groupid) {
28✔
257
            $query = ChatRoster::where('chatid', $chatRoom->id)
4✔
258
                ->notBlocked()
4✔
259
                ->with('user');
4✔
260

261
            if (! $forceAll) {
4✔
262
                $query->where(function ($q) use ($message) {
3✔
263
                    $q->whereNull('lastmsgemailed')
3✔
264
                        ->orWhere('lastmsgemailed', '<', $message->id);
3✔
265
                });
3✔
266
            }
267

268
            return $query->get()->map(function ($roster) {
4✔
269
                $roster->isModerator = true;
4✔
270

271
                return $roster;
4✔
272
            });
4✔
273
        }
274

275
        if ($chatRoom->chattype === ChatRoom::TYPE_USER2MOD && $chatRoom->groupid) {
24✔
276
            // User2Mod: Get member from roster.
277
            $memberRoster = ChatRoster::where('chatid', $chatRoom->id)
8✔
278
                ->where('userid', $chatRoom->user1)
8✔
279
                ->notBlocked()
8✔
280
                ->with('user')
8✔
281
                ->first();
8✔
282

283
            if ($memberRoster) {
8✔
284
                if ($forceAll || is_null($memberRoster->lastmsgemailed) || $memberRoster->lastmsgemailed < $message->id) {
8✔
285
                    $memberRoster->isModerator = false;
8✔
286
                    $results->push($memberRoster);
8✔
287
                }
288
            }
289

290
            // User2Mod: Get active group moderators (not backup mods).
291
            // Backup mods have settings['active'] = false and shouldn't receive notifications.
292
            $group = $chatRoom->group;
8✔
293
            if ($group) {
8✔
294
                $moderators = $group->memberships()
8✔
295
                    ->activeModerators()
8✔
296
                    ->get();
8✔
297

298
                foreach ($moderators as $membership) {
8✔
299
                    // Ensure mod is in roster (so we can track what we've mailed).
300
                    $roster = ChatRoster::firstOrCreate(
8✔
301
                        ['chatid' => $chatRoom->id, 'userid' => $membership->userid],
8✔
302
                        ['lastmsgseen' => null, 'lastmsgemailed' => null]
8✔
303
                    );
8✔
304

305
                    // Load the user relationship.
306
                    $roster->load('user');
8✔
307

308
                    // Skip mods who have closed or blocked this chat — V1 PHP also
309
                    // skips these via the status filter in unseenCountForUser().
310
                    if (in_array($roster->status, [ChatRoster::STATUS_CLOSED, ChatRoster::STATUS_BLOCKED])) {
8✔
311
                        continue;
1✔
312
                    }
313

314
                    // Check if we need to notify this moderator.
315
                    if ($forceAll || is_null($roster->lastmsgemailed) || $roster->lastmsgemailed < $message->id) {
7✔
316
                        $roster->isModerator = true;
7✔
317
                        $results->push($roster);
7✔
318
                    }
319
                }
320
            }
321

322
            return $results->unique('userid');
8✔
323
        }
324

325
        // User2User: Use standard roster-based logic.
326
        // Only notify the actual chat participants (user1/user2), not mods who may have
327
        // added mod notes to the chat. See original iznik-server getMembersStatus().
328
        $query = ChatRoster::where('chatid', $chatRoom->id)
16✔
329
            ->whereIn('userid', [$chatRoom->user1, $chatRoom->user2])
16✔
330
            ->notBlocked()
16✔
331
            ->with('user');
16✔
332

333
        if (! $forceAll) {
16✔
334
            // Only get members who haven't been mailed this message yet.
335
            $query->where(function ($q) use ($message) {
14✔
336
                $q->whereNull('lastmsgemailed')
14✔
337
                    ->orWhere('lastmsgemailed', '<', $message->id);
14✔
338
            });
14✔
339
        }
340

341
        return $query->get()->map(function ($roster) {
16✔
342
            $roster->isModerator = false;
16✔
343

344
            return $roster;
16✔
345
        });
16✔
346
    }
347

348
    /**
349
     * Check if a user should be notified about a specific message.
350
     *
351
     * @param  User  $user  The user to potentially notify
352
     * @param  ChatMessage  $message  The message to notify about
353
     * @param  ChatRoom  $chatRoom  The chat room
354
     * @param  string  $chatType  The chat type (User2User, User2Mod, etc.)
355
     * @param  bool  $isModerator  Whether this user is a moderator in this chat context
356
     */
357
    protected function shouldNotifyUser(User $user, ChatMessage $message, ChatRoom $chatRoom, string $chatType, bool $isModerator = false): bool
28✔
358
    {
359
        // Check if this is the user's own message.
360
        $isOwnMessage = $message->userid === $user->id;
28✔
361

362
        if ($isOwnMessage) {
28✔
363
            // Only send copy of own messages if user has this preference enabled.
364
            if (! $user->notifsOn(User::NOTIFS_EMAIL_MINE)) {
28✔
365
                return false;
27✔
366
            }
367
        }
368

369
        // For User2Mod chats:
370
        // - Always notify the member (user1)
371
        // - Notify moderators based on their notification preferences
372
        if ($chatType === ChatRoom::TYPE_USER2MOD) {
24✔
373
            if ($chatRoom->user1 === $user->id) {
8✔
374
                // Always notify the member.
375
                return true;
3✔
376
            }
377

378
            if ($isModerator) {
7✔
379
                // Notify moderator based on their email notification preferences.
380
                // Mods might have notifications off, in which case we don't bother them.
381
                return $user->notifsOn(User::NOTIFS_EMAIL, $chatRoom->groupid);
6✔
382
            }
383
        }
384

385
        // For Mod2Mod chats:
386
        // - All participants are moderators
387
        // - Notify based on their email notification preferences for the group
388
        if ($chatType === ChatRoom::TYPE_MOD2MOD) {
17✔
389
            return $user->notifsOn(User::NOTIFS_EMAIL, $chatRoom->groupid);
4✔
390
        }
391

392
        // TN users always get notifications.
393
        if ($user->isTN()) {
13✔
394
            return true;
×
395
        }
396

397
        // Check user's notification preferences.
398
        return $user->notifsOn(User::NOTIFS_EMAIL, $chatRoom->groupid);
13✔
399
    }
400

401
    /**
402
     * Get the other user in a chat room.
403
     */
404
    protected function getOtherUser(ChatRoom $chatRoom, User $currentUser): ?User
20✔
405
    {
406
        if ($chatRoom->user1 === $currentUser->id) {
20✔
407
            return User::find($chatRoom->user2);
3✔
408
        }
409

410
        return User::find($chatRoom->user1);
19✔
411
    }
412

413
    /**
414
     * Get previous messages for context (up to 3 messages before the current one).
415
     */
416
    protected function getPreviousMessages(
24✔
417
        ChatRoom $chatRoom,
418
        ChatMessage $currentMessage,
419
        int $limit = 3
420
    ): Collection {
421
        return ChatMessage::where('chatid', $chatRoom->id)
24✔
422
            ->where('id', '<', $currentMessage->id)
24✔
423
            ->where('date', '>=', now()->subDays(90))
24✔
424
            ->where('reviewrejected', 0)
24✔
425
            ->whereHas('user', function ($q) {
24✔
426
                $q->whereNull('deleted');
24✔
427
            })
24✔
428
            ->orderBy('id', 'desc')
24✔
429
            ->limit($limit)
24✔
430
            ->with(['user', 'refMessage'])
24✔
431
            ->get()
24✔
432
            ->reverse()
24✔
433
            ->values();
24✔
434
    }
435

436
    /**
437
     * Send a notification email to a user.
438
     */
439
    protected function sendNotificationEmail(
24✔
440
        User $sendingTo,
441
        ?User $sendingFrom,
442
        ChatRoom $chatRoom,
443
        ChatMessage $message,
444
        string $chatType
445
    ): void {
446
        // Get previous messages for context.
447
        $previousMessages = $this->getPreviousMessages($chatRoom, $message);
24✔
448

449
        $mailable = new ChatNotification(
24✔
450
            $sendingTo,
24✔
451
            $sendingFrom,
24✔
452
            $chatRoom,
24✔
453
            $message,
24✔
454
            $chatType,
24✔
455
            $previousMessages
24✔
456
        );
24✔
457

458
        if ($this->spooler) {
24✔
459
            $this->spooler->spool($mailable, $sendingTo->email_preferred, 'chat');
×
460
        } else {
461
            Mail::send($mailable);
24✔
462
        }
463
    }
464

465
    /**
466
     * Update the mailedtoall flag if all roster members have been notified.
467
     */
468
    protected function updateMailedToAll(ChatMessage $message): void
24✔
469
    {
470
        // Check if all roster members have been mailed this message.
471
        $notMailedCount = ChatRoster::where('chatid', $message->chatid)
24✔
472
            ->where(function ($q) use ($message) {
24✔
473
                $q->whereNull('lastmsgemailed')
24✔
474
                    ->orWhere('lastmsgemailed', '<', $message->id);
24✔
475
            })
24✔
476
            ->count();
24✔
477

478
        if ($notMailedCount === 0) {
24✔
479
            $message->update(['mailedtoall' => 1]);
1✔
480
        }
481
    }
482
}
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