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

Freegle / Iznik / 10247

05 May 2026 09:52PM UTC coverage: 72.569% (-0.09%) from 72.654%
10247

push

circleci

edwh
fix(v2): support/admin users search all groups with groupids=0

When a support or admin user selects "All communities" in Modtools
(which sends groupids=0), the search was replacing the zero with
their own group memberships, limiting results to groups they belong
to. V1 PHP has no such restriction for admin/support users.

Fix: when groupids=0 and the caller IsAdminOrSupport, set groupids=nil
so groupFilter() emits no WHERE clause and the search covers all groups.
Regular users and mods continue to be restricted to their own groups.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

13740 of 20699 branches covered (66.38%)

Branch coverage included in aggregate %.

8 of 8 new or added lines in 1 file covered. (100.0%)

459 existing lines in 9 files now uncovered.

98686 of 134223 relevant lines covered (73.52%)

22.68 hits per line

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

90.29
/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(
45✔
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)) {
45✔
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)) {
45✔
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)) {
45✔
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;
45✔
92

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

102
        foreach ($messages as $message) {
45✔
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;
45✔
111
    }
112

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

227
                $notified++;
24✔
228

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

240
        return $notified;
28✔
241
    }
242

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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