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

Freegle / Iznik / 11404

08 May 2026 04:22PM UTC coverage: 72.752% (+0.06%) from 72.691%
11404

push

circleci

fnnbrr
Merge remote-tracking branch 'origin/master' into tn-integration-refactor

13726 of 20598 branches covered (66.64%)

Branch coverage included in aggregate %.

3277 of 4560 new or added lines in 82 files covered. (71.86%)

25 existing lines in 7 files now uncovered.

102718 of 139457 relevant lines covered (73.66%)

22.46 hits per line

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

97.21
/iznik-batch/app/Services/ModNotifService.php
1
<?php
2

3
namespace App\Services;
4

5
use App\Models\Group;
6
use App\Models\Membership;
7
use App\Models\MessageGroup;
8
use App\Models\User;
9
use App\Models\UserEmail;
10
use Carbon\Carbon;
11
use Illuminate\Support\Facades\DB;
12
use Illuminate\Support\Facades\Log;
13

14
/**
15
 * Computes pending moderation work per moderator and manages notification dedup.
16
 *
17
 * Mirrors V1 cron/mod_notifs.php:
18
 * - For each active Freegle group, find moderators active in the last 90 days.
19
 * - For each mod, count pending work items filtered by their notification threshold (minage hours).
20
 * - Skip if minage < 0 (notifications disabled).
21
 * - Only send if the summary has changed since last notification, or it's been > 24h.
22
 */
23
class ModNotifService
24
{
25
    // Default notification thresholds (hours old items must be before notifying)
26
    private const DEFAULT_ACTIVE_MOD_THRESHOLD = 4;
27

28
    private const DEFAULT_BACKUP_MOD_THRESHOLD = 12;
29

30
    // Moderators inactive for more than this many days are skipped
31
    private const MAX_INACTIVE_DAYS = 90;
32

33
    // Re-send the same summary after this many seconds even if unchanged
34
    private const RESEND_INTERVAL_SECONDS = 24 * 3600;
35

36
    /**
37
     * Build a pending-work notification for every eligible mod on every active group.
38
     *
39
     * Returns an array of notification records:
40
     * [
41
     *   'user_id'      => int,
42
     *   'email'        => string,
43
     *   'name'         => string,
44
     *   'text_summary' => string,
45
     *   'html_summary' => string,
46
     *   'total'        => int,
47
     *   'subject'      => string,
48
     * ]
49
     */
50
    public function getNotificationsToSend(): array
3✔
51
    {
52
        $notifications = [];
3✔
53

54
        // Per-mod accumulator keyed by user ID so a mod on many groups gets one email.
55
        $modData = [];
3✔
56

57
        $groups = Group::activeFreegle()->get();
3✔
58

59
        foreach ($groups as $group) {
3✔
60
            $mods = Membership::where('groupid', $group->id)
3✔
61
                ->whereIn('role', [Membership::ROLE_MODERATOR, Membership::ROLE_OWNER])
3✔
62
                ->get();
3✔
63

64
            foreach ($mods as $membership) {
3✔
65
                $modId = $membership->userid;
3✔
66
                $isActive = $membership->isActiveMod();
3✔
67
                $modSettings = $this->getModSettings($modId, $membership, $isActive);
3✔
68

69
                if ($modSettings['minage'] < 0) {
3✔
70
                    continue;
1✔
71
                }
72

73
                // Skip mods who have been inactive for too long
74
                if (!$this->isModRecentlyActive($modId)) {
3✔
75
                    continue;
3✔
76
                }
77

78
                $work = $this->getPendingWork($modId, $group->id, $modSettings['minage']);
1✔
79
                $chatReview = $this->getChatReviewCount($modId, $modSettings['minage']);
1✔
80
                $total = array_sum($work) + $chatReview;
1✔
81

82
                if ($total === 0) {
1✔
NEW
83
                    continue;
×
84
                }
85

86
                if (!isset($modData[$modId])) {
1✔
87
                    $user = User::find($modId);
1✔
88
                    $email = UserEmail::where('userid', $modId)->where('preferred', 1)->first();
1✔
89

90
                    if (!$user || !$email) {
1✔
NEW
91
                        continue;
×
92
                    }
93

94
                    $modData[$modId] = [
1✔
95
                        'user_id' => $modId,
1✔
96
                        'email' => $email->email,
1✔
97
                        'name' => $user->displayname ?? $user->fullname ?? 'Moderator',
1✔
98
                        'groups' => [],
1✔
99
                        'chat_review' => 0,
1✔
100
                    ];
1✔
101
                }
102

103
                if ($chatReview > 0) {
1✔
NEW
104
                    $modData[$modId]['chat_review'] += $chatReview;
×
105
                }
106

107
                $nonZeroWork = array_filter($work, fn ($v) => $v > 0);
1✔
108
                if (!empty($nonZeroWork)) {
1✔
109
                    $modData[$modId]['groups'][$group->nameshort] = $nonZeroWork;
1✔
110
                }
111
            }
112
        }
113

114
        $modtoolsUrl = config('freegle.sites.mod', 'https://modtools.org');
3✔
115

116
        foreach ($modData as $modId => $data) {
3✔
117
            $textSummary = $this->buildTextSummary($data['groups'], $data['chat_review'], $modtoolsUrl);
1✔
118
            $htmlSummary = $this->buildHtmlSummary($data['groups'], $data['chat_review']);
1✔
119

120
            if (!$this->shouldSend($modId, $textSummary)) {
1✔
NEW
121
                continue;
×
122
            }
123

124
            $total = $data['chat_review'];
1✔
125
            foreach ($data['groups'] as $groupWork) {
1✔
126
                $total += array_sum($groupWork);
1✔
127
            }
128

129
            $notifications[] = [
1✔
130
                'user_id' => $modId,
1✔
131
                'email' => $data['email'],
1✔
132
                'name' => $data['name'],
1✔
133
                'text_summary' => $textSummary,
1✔
134
                'html_summary' => $htmlSummary,
1✔
135
                'total' => $total,
1✔
136
                'subject' => "MODERATE: {$total} thing" . ($total === 1 ? '' : 's') . ' to do',
1✔
137
            ];
1✔
138
        }
139

140
        return $notifications;
3✔
141
    }
142

143
    /**
144
     * Get notification threshold settings for a moderator.
145
     */
146
    public function getModSettings(int $modId, Membership $membership, bool $isActive): array
7✔
147
    {
148
        $user = User::find($modId);
7✔
149
        $settings = $user ? ($user->settings ?? []) : [];
7✔
150

151
        $activeMinage = (int) ($settings['modnotifs'] ?? self::DEFAULT_ACTIVE_MOD_THRESHOLD);
7✔
152
        $backupMinage = (int) ($settings['backupmodnotifs'] ?? self::DEFAULT_BACKUP_MOD_THRESHOLD);
7✔
153

154
        return [
7✔
155
            'minage' => $isActive ? $activeMinage : $backupMinage,
7✔
156
        ];
7✔
157
    }
158

159
    /**
160
     * Check if the moderator has been active (approved a message) in the last 90 days.
161
     *
162
     * A value of 0 means they approved something today.
163
     */
164
    public function isModRecentlyActive(int $modId): bool
6✔
165
    {
166
        $row = DB::table('messages_groups')
6✔
167
            ->selectRaw('DATEDIFF(NOW(), MAX(arrival)) AS activeago')
6✔
168
            ->where('approvedby', $modId)
6✔
169
            ->first();
6✔
170

171
        if (!$row) {
6✔
NEW
172
            return false;
×
173
        }
174

175
        $activeago = $row->activeago;
6✔
176

177
        // '0' or null means approved today; an integer <= 90 means recently active
178
        if ($activeago === null) {
6✔
179
            return false;
4✔
180
        }
181

182
        if ($activeago == '0' || ($activeago !== null && (int) $activeago <= self::MAX_INACTIVE_DAYS)) {
3✔
183
            return true;
2✔
184
        }
185

186
        return false;
1✔
187
    }
188

189
    /**
190
     * Count pending work items for a moderator on a specific group.
191
     *
192
     * @param  int       $minage  Hours old items must be before they appear (0 = all items)
193
     * @return array<string, int>
194
     */
195
    public function getPendingWork(int $modId, int $groupId, int $minage): array
7✔
196
    {
197
        $minageFilter = $minage > 0 ? now()->subHours($minage) : null;
7✔
198
        $now = now();
7✔
199
        $earliest = now()->subDays(31)->startOfDay();
7✔
200

201
        // Pending messages
202
        $pendingMessages = DB::table('messages')
7✔
203
            ->join('messages_groups', 'messages.id', '=', 'messages_groups.msgid')
7✔
204
            ->where('messages_groups.groupid', $groupId)
7✔
205
            ->where('messages_groups.collection', MessageGroup::COLLECTION_PENDING)
7✔
206
            ->where('messages_groups.deleted', 0)
7✔
207
            ->whereNull('messages.heldby')
7✔
208
            ->whereNull('messages.deleted')
7✔
209
            ->when($minageFilter, fn ($q) => $q->where('messages_groups.arrival', '<', $minageFilter))
7✔
210
            ->count();
7✔
211

212
        // Pending community events
213
        $pendingEvents = DB::table('communityevents')
7✔
214
            ->join('communityevents_dates', 'communityevents_dates.eventid', '=', 'communityevents.id')
7✔
215
            ->join('communityevents_groups', 'communityevents_groups.eventid', '=', 'communityevents.id')
7✔
216
            ->where('communityevents_groups.groupid', $groupId)
7✔
217
            ->where('communityevents.pending', 1)
7✔
218
            ->where('communityevents.deleted', 0)
7✔
219
            ->where('communityevents_dates.end', '>=', $now)
7✔
220
            ->when($minageFilter, fn ($q) => $q->where('communityevents.added', '<', $minageFilter))
7✔
221
            ->distinct('communityevents.id')
7✔
222
            ->count('communityevents.id');
7✔
223

224
        // Pending volunteering
225
        $pendingVolunteering = DB::table('volunteering')
7✔
226
            ->join('volunteering_groups', 'volunteering_groups.volunteeringid', '=', 'volunteering.id')
7✔
227
            ->leftJoin('volunteering_dates', 'volunteering_dates.volunteeringid', '=', 'volunteering.id')
7✔
228
            ->where('volunteering_groups.groupid', $groupId)
7✔
229
            ->where('volunteering.pending', 1)
7✔
230
            ->where('volunteering.deleted', 0)
7✔
231
            ->where('volunteering.expired', 0)
7✔
232
            ->where(fn ($q) => $q->whereNull('volunteering_dates.applyby')->orWhere('volunteering_dates.applyby', '>=', $now))
7✔
233
            ->where(fn ($q) => $q->whereNull('volunteering_dates.end')->orWhere('volunteering_dates.end', '>=', $now))
7✔
234
            ->when($minageFilter, fn ($q) => $q->where('volunteering.added', '<', $minageFilter))
7✔
235
            ->distinct('volunteering.id')
7✔
236
            ->count('volunteering.id');
7✔
237

238
        // Members to review
239
        $membersToReview = DB::table('memberships')
7✔
240
            ->where('groupid', $groupId)
7✔
241
            ->whereNotNull('reviewrequestedat')
7✔
242
            ->when($minageFilter, fn ($q) => $q->where('reviewrequestedat', '>=', $minageFilter))
7✔
243
            ->where(fn ($q) => $q->whereNull('reviewedat')
7✔
244
                ->orWhereRaw('DATE(reviewedat) < DATE_SUB(NOW(), INTERVAL 31 DAY)'))
7✔
245
            ->count();
7✔
246

247
        // Pending admins
248
        $pendingAdmins = DB::table('admins')
7✔
249
            ->where('groupid', $groupId)
7✔
250
            ->whereNull('complete')
7✔
251
            ->where('pending', 1)
7✔
252
            ->whereNull('heldby')
7✔
253
            ->where('created', '>=', $earliest)
7✔
254
            ->distinct('id')
7✔
255
            ->count('id');
7✔
256

257
        return [
7✔
258
            'Pending Messages' => $pendingMessages,
7✔
259
            'Pending Community Events' => $pendingEvents,
7✔
260
            'Pending Volunteering Opportunities' => $pendingVolunteering,
7✔
261
            'Members to Review' => $membersToReview,
7✔
262
            'Pending Admins' => $pendingAdmins,
7✔
263
        ];
7✔
264
    }
265

266
    /**
267
     * Count chat messages awaiting review by this moderator.
268
     */
269
    public function getChatReviewCount(int $modId, int $minage): int
4✔
270
    {
271
        $minageFilter = $minage > 0 ? now()->subHours($minage) : null;
4✔
272

273
        $count = DB::table('chat_messages')
4✔
274
            ->join('chat_rooms', 'chat_rooms.id', '=', 'chat_messages.chatid')
4✔
275
            ->where('chat_messages.reviewrequired', 1)
4✔
276
            ->whereNull('chat_messages.reviewedby')
4✔
277
            ->when($minageFilter, fn ($q) => $q->where('chat_messages.date', '<', $minageFilter))
4✔
278
            ->count();
4✔
279

280
        return $count;
4✔
281
    }
282

283
    /**
284
     * Build plain-text summary of pending work.
285
     */
286
    public function buildTextSummary(array $groupWork, int $chatReview, string $modtoolsUrl): string
4✔
287
    {
288
        $text = "There's stuff to do on ModTools:\r\n\r\n";
4✔
289

290
        if ($chatReview > 0) {
4✔
291
            $text .= "You have {$chatReview} chat message" . ($chatReview > 1 ? 's' : '') . " to review.\r\n\r\n";
1✔
292
        }
293

294
        foreach ($groupWork as $groupName => $work) {
4✔
295
            $text .= "\r\n{$groupName}\r\n:";
2✔
296
            foreach ($work as $key => $val) {
2✔
297
                if ($val > 0) {
2✔
298
                    $text .= "{$key}: {$val}\r\n";
2✔
299
                }
300
            }
301
        }
302

303
        $text .= "\r\nYou can control how often you get these mails or turn them off entirely from https://{$modtoolsUrl}/settings\r\n";
4✔
304

305
        return $text;
4✔
306
    }
307

308
    /**
309
     * Build HTML summary for MJML template.
310
     */
311
    public function buildHtmlSummary(array $groupWork, int $chatReview): string
4✔
312
    {
313
        $html = '';
4✔
314

315
        if ($chatReview > 0) {
4✔
316
            $html .= "<p>You have <b>{$chatReview}</b> chat message" . ($chatReview > 1 ? 's' : '') . ' to review.</p>';
1✔
317
        }
318

319
        foreach ($groupWork as $groupName => $work) {
4✔
320
            $html .= "<p>{$groupName}</p><ul>";
2✔
321
            foreach ($work as $key => $val) {
2✔
322
                if ($val > 0) {
2✔
323
                    $html .= "<li>{$key}: <b>{$val}</b></li>";
2✔
324
                }
325
            }
326
            $html .= '</ul>';
2✔
327
        }
328

329
        return $html;
4✔
330
    }
331

332
    /**
333
     * Check whether to send a notification for this moderator.
334
     *
335
     * Sends if: no previous record, summary has changed, or it's been > 24h since last send.
336
     */
337
    public function shouldSend(int $modId, string $textSummary): bool
5✔
338
    {
339
        $record = DB::table('modnotifs')->where('userid', $modId)->first();
5✔
340

341
        if (!$record) {
5✔
342
            return true;
2✔
343
        }
344

345
        if ($record->data !== $textSummary) {
3✔
346
            return true;
1✔
347
        }
348

349
        $age = Carbon::parse($record->timestamp)->diffInSeconds(now());
2✔
350

351
        return $age > self::RESEND_INTERVAL_SECONDS;
2✔
352
    }
353

354
    /**
355
     * Record that a notification was sent.
356
     */
357
    public function recordSent(int $modId, string $textSummary): void
2✔
358
    {
359
        DB::table('modnotifs')->updateOrInsert(
2✔
360
            ['userid' => $modId],
2✔
361
            ['data' => $textSummary, 'timestamp' => now()]
2✔
362
        );
2✔
363
    }
364
}
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