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

Freegle / Iznik / 23368

25 Jun 2026 09:25AM UTC coverage: 71.371% (-1.4%) from 72.796%
23368

push

circleci

web-flow
Merge pull request #887 from Freegle/feat/db-rw-split

DB read/write connection split (batch + Go API, V1 parity); login wording fix

11257 of 14954 branches covered (75.28%)

Branch coverage included in aggregate %.

34 of 46 new or added lines in 3 files covered. (73.91%)

218 existing lines in 8 files now uncovered.

123497 of 173854 relevant lines covered (71.03%)

37.29 hits per line

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

87.51
/iznik-batch/app/Console/Commands/Queue/ProcessBackgroundTasksCommand.php
1
<?php
2

3
namespace App\Console\Commands\Queue;
4

5
use App\Console\Concerns\PreventsOverlapping;
6
use App\Mail\Charity\CharitySignupMail;
7
use App\Mail\Chat\ChatSpamReportMail;
8
use App\Mail\Chat\ReferToSupportMail;
9
use App\Mail\Donation\DonateExternalMail;
10
use App\Mail\Newsfeed\ChitchatReportMail;
11
use App\Mail\Session\ForgotPasswordMail;
12
use App\Mail\Session\MergeOfferMail;
13
use App\Mail\Session\UnsubscribeConfirmMail;
14
use App\Mail\Session\VerifyEmailMail;
15
use App\Mail\Message\ModStdMessageMail;
16
use App\Models\BackgroundTask;
17
use App\Models\ChatRoom;
18
use App\Models\User;
19
use App\Services\EmailSpoolerService;
20
use App\Services\HousekeeperService;
21
use App\Services\PostcodeRemapService;
22
use App\Services\UserManagementService;
23
use App\Services\PushNotificationService;
24
use App\Traits\GracefulShutdown;
25
use Illuminate\Console\Command;
26
use Illuminate\Support\Facades\Artisan;
27
use Illuminate\Support\Facades\DB;
28
use Illuminate\Support\Facades\Log;
29
use Illuminate\Support\Facades\Mail;
30
use Illuminate\Support\Str;
31

32
/**
33
 * Processes background tasks queued by the Go API server.
34
 *
35
 * Go writes rows to the `background_tasks` table with a task_type and JSON data.
36
 * This command polls that table and dispatches each task to the appropriate handler.
37
 *
38
 * Runs as a daemon via supervisor or the Laravel scheduler.
39
 */
40
class ProcessBackgroundTasksCommand extends Command
41
{
42
    use GracefulShutdown;
43
    use PreventsOverlapping;
44

45
    protected $signature = 'queue:background-tasks
46
                            {--limit=50 : Maximum tasks to process per iteration}
47
                            {--max-iterations=60 : Maximum iterations before exiting (0 = infinite)}
48
                            {--sleep=5 : Seconds to sleep between iterations}
49
                            {--spool : Spool emails instead of sending directly}';
50

51
    protected $description = 'Process background tasks queued by the Go API server';
52

53
    private const MAX_ATTEMPTS = 3;
54

55
    public function handle(PushNotificationService $pushService, EmailSpoolerService $spooler): int
60✔
56
    {
57
        if (! $this->acquireLock()) {
60✔
UNCOV
58
            $this->info('Already running, exiting.');
×
59

UNCOV
60
            return Command::SUCCESS;
×
61
        }
62

63
        try {
64
            return $this->doHandle($pushService, $spooler);
60✔
65
        } finally {
66
            $this->releaseLock();
60✔
67
        }
68
    }
69

70
    protected function doHandle(PushNotificationService $pushService, EmailSpoolerService $spooler): int
60✔
71
    {
72
        $limit = (int) $this->option('limit');
60✔
73
        $maxIterations = (int) $this->option('max-iterations');
60✔
74
        $sleepSeconds = (int) $this->option('sleep');
60✔
75
        $shouldSpool = (bool) $this->option('spool');
60✔
76
        $iteration = 0;
60✔
77

78
        $this->registerShutdownHandlers();
60✔
79
        $this->registerFatalErrorHandler();
60✔
80
        $this->info("Processing background tasks (limit={$limit}, max-iterations={$maxIterations})");
60✔
81

82
        while (TRUE) {
60✔
83
            if ($this->shouldStop()) {
60✔
84
                $this->info('Shutdown signal received, stopping gracefully.');
×
UNCOV
85
                break;
×
86
            }
87

88
            $iteration++;
60✔
89
            if ($maxIterations > 0 && $iteration > $maxIterations) {
60✔
90
                $this->info("Reached max iterations ({$maxIterations}), exiting.");
60✔
91
                break;
60✔
92
            }
93

94
            $processed = $this->processIteration($limit, $pushService, $spooler, $shouldSpool);
60✔
95

96
            if ($processed === 0) {
60✔
97
                sleep($sleepSeconds);
9✔
98
            }
99
        }
100

101
        return Command::SUCCESS;
60✔
102
    }
103

104
    // Fatal errors bypass try/catch; this shutdown function is the only hook that runs after them.
105
    protected function registerFatalErrorHandler(): void
60✔
106
    {
107
        if ($this->isTestingEnvironment()) {
60✔
108
            return;
60✔
109
        }
110

111
        // 2 MB reserve freed at shutdown entry — ensures we have heap for Log/Sentry after OOM.
UNCOV
112
        $memoryReserve = str_repeat("\0", 2 * 1024 * 1024);
×
113

114
        register_shutdown_function(function () use (&$memoryReserve) {
×
115
            unset($memoryReserve);
×
116
            $this->handleFatalShutdown(error_get_last());
×
UNCOV
117
        });
×
118
    }
119

120
    public function handleFatalShutdown(?array $error): void
3✔
121
    {
122
        $fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR];
3✔
123

124
        if ($error === null || ! in_array($error['type'], $fatalTypes, TRUE)) {
3✔
125
            return;
2✔
126
        }
127

128
        $message = sprintf(
1✔
129
            'Fatal error in queue:background-tasks: %s in %s:%d',
1✔
130
            $error['message'],
1✔
131
            $error['file'],
1✔
132
            $error['line']
1✔
133
        );
1✔
134

135
        Log::critical($message, ['error' => $error]);
1✔
136

137
        if (app()->bound('sentry')) {
1✔
138
            app('sentry')->captureMessage($message, \Sentry\Severity::fatal());
1✔
139
        }
140
    }
141

142
    /**
143
     * Process one iteration of pending tasks.
144
     */
145
    protected function processIteration(
60✔
146
        int $limit,
147
        PushNotificationService $pushService,
148
        EmailSpoolerService $spooler,
149
        bool $shouldSpool
150
    ): int {
151
        $tasks = DB::select(
60✔
152
            'SELECT * FROM background_tasks WHERE processed_at IS NULL AND failed_at IS NULL AND attempts < ? ORDER BY created_at ASC LIMIT ?',
60✔
153
            [self::MAX_ATTEMPTS, $limit]
60✔
154
        );
60✔
155

156
        $processed = 0;
60✔
157

158
        foreach ($tasks as $task) {
60✔
159
            if ($this->shouldStop()) {
58✔
UNCOV
160
                break;
×
161
            }
162

163
            try {
164
                DB::table('background_tasks')
58✔
165
                    ->where('id', $task->id)
58✔
166
                    ->increment('attempts');
58✔
167

168
                $data = json_decode($task->data, TRUE);
58✔
169

170
                $this->dispatchTask($task->task_type, $data, $pushService, $spooler, $shouldSpool);
58✔
171

172
                DB::table('background_tasks')
51✔
173
                    ->where('id', $task->id)
51✔
174
                    ->update(['processed_at' => now()]);
51✔
175

176
                $processed++;
51✔
177
            } catch (\Throwable $e) {
7✔
178
                Log::error('Background task failed', [
7✔
179
                    'task_id' => $task->id,
7✔
180
                    'task_type' => $task->task_type,
7✔
181
                    'error' => $e->getMessage(),
7✔
182
                    'attempts' => $task->attempts + 1,
7✔
183
                ]);
7✔
184

185
                // Report to Sentry so we get alerted to task failures.
186
                if (app()->bound('sentry')) {
7✔
187
                    app('sentry')->captureException($e);
7✔
188
                }
189

190
                $update = ['error_message' => substr($e->getMessage(), 0, 65535)];
7✔
191

192
                if ($task->attempts + 1 >= self::MAX_ATTEMPTS) {
7✔
193
                    $update['failed_at'] = now();
3✔
194
                }
195

196
                DB::table('background_tasks')
7✔
197
                    ->where('id', $task->id)
7✔
198
                    ->update($update);
7✔
199
            }
200
        }
201

202
        if ($processed > 0) {
60✔
203
            $this->info("Processed {$processed} task(s).");
51✔
204
        }
205

206
        return $processed;
60✔
207
    }
208

209
    /**
210
     * Dispatch a task to the appropriate handler.
211
     */
212
    protected function dispatchTask(
58✔
213
        string $taskType,
214
        array $data,
215
        PushNotificationService $pushService,
216
        EmailSpoolerService $spooler,
217
        bool $shouldSpool
218
    ): void {
219
        match ($taskType) {
39✔
220
            BackgroundTask::TASK_PUSH_NOTIFY_CHAT_MESSAGE => $this->handlePushNotifyChatMessage($data, $pushService),
58✔
221
            BackgroundTask::TASK_PUSH_NOTIFY_GROUP_MODS  => $this->handlePushNotifyGroupMods($data, $pushService),
56✔
222
            BackgroundTask::TASK_EMAIL_CHITCHAT_REPORT   => $this->handleEmailChitchatReport($data, $spooler, $shouldSpool),
55✔
223
            BackgroundTask::TASK_EMAIL_CHAT_SPAM_REPORT  => $this->handleEmailChatSpamReport($data, $spooler, $shouldSpool),
50✔
224
            BackgroundTask::TASK_EMAIL_CHARITY_SIGNUP    => $this->handleEmailCharitySignup($data, $spooler, $shouldSpool),
47✔
225
            BackgroundTask::TASK_EMAIL_DONATE_EXTERNAL   => $this->handleEmailDonateExternal($data, $spooler, $shouldSpool),
47✔
226
            BackgroundTask::TASK_EMAIL_FORGOT_PASSWORD   => $this->handleEmailForgotPassword($data, $spooler, $shouldSpool),
43✔
227
            BackgroundTask::TASK_EMAIL_UNSUBSCRIBE       => $this->handleEmailUnsubscribe($data, $spooler, $shouldSpool),
41✔
228
            BackgroundTask::TASK_EMAIL_MESSAGE_APPROVED,
39✔
229
            BackgroundTask::TASK_EMAIL_MESSAGE_REJECTED,
39✔
230
            BackgroundTask::TASK_EMAIL_MESSAGE_REPLY     => $this->handleModStdMessage($taskType, $data, $pushService, $spooler, $shouldSpool),
39✔
231
            BackgroundTask::TASK_EMAIL_MOD_STDMSG        => $this->handleModStdMessageForMember($taskType, $data, $spooler, $shouldSpool),
30✔
232
            BackgroundTask::TASK_EMAIL_MERGE             => $this->handleEmailMerge($data, $spooler, $shouldSpool),
22✔
233
            BackgroundTask::TASK_EMAIL_VERIFY            => $this->handleEmailVerify($data, $spooler, $shouldSpool),
20✔
234
            BackgroundTask::TASK_REFER_TO_SUPPORT        => $this->handleReferToSupport($data, $spooler, $shouldSpool),
17✔
235
            BackgroundTask::TASK_MESSAGE_OUTCOME         => $this->handleMessageOutcome($data),
16✔
236
            BackgroundTask::TASK_FREEBIE_ALERTS_ADD      => $this->handleFreebieAlertsAdd($data),
13✔
237
            BackgroundTask::TASK_FREEBIE_ALERTS_REMOVE   => $this->handleFreebieAlertsRemove($data),
9✔
238
            BackgroundTask::TASK_HOUSEKEEPER_NOTIFY      => $this->handleHousekeeperNotify($data),
7✔
239
            BackgroundTask::TASK_REMAP_POSTCODES         => $this->handleRemapPostcodes($data),
7✔
240
            BackgroundTask::TASK_USER_FORGET             => $this->handleUserForget($data),
7✔
241
            BackgroundTask::TASK_TN_SYNC                 => $this->handleTnSyncCommand($data),
5✔
242
            default => throw new \RuntimeException("Unknown task type: {$taskType}"),
3✔
243
        };
39✔
244
    }
245

246
    /**
247
     * Send push notifications to all moderators of a group.
248
     */
249
    protected function handlePushNotifyGroupMods(array $data, PushNotificationService $pushService): void
2✔
250
    {
251
        $groupId = $data['group_id'] ?? NULL;
2✔
252

253
        if (! $groupId) {
2✔
UNCOV
254
            throw new \RuntimeException('push_notify_group_mods requires group_id');
×
255
        }
256

257
        $count = $pushService->notifyGroupMods((int) $groupId);
2✔
258
        Log::info('Notified group mods', ['group_id' => $groupId, 'notified' => $count]);
2✔
259
    }
260

261
    /**
262
     * Send chat-message push notifications to chat participants (and group
263
     * mods for User2Mod chats). Enqueued by ChatProcessService after a
264
     * message passes spam/review/ban checks — V1 parity for the push side
265
     * of ChatMessage::process() lost in commit 5cbb607b7.
266
     */
267
    protected function handlePushNotifyChatMessage(array $data, PushNotificationService $pushService): void
2✔
268
    {
269
        $messageId = (int) ($data['message_id'] ?? 0);
2✔
270

271
        if (! $messageId) {
2✔
272
            throw new \RuntimeException('push_notify_chat_message requires message_id');
1✔
273
        }
274

275
        $count = $pushService->notifyChatMessage($messageId);
1✔
276
        Log::info('Notified chat message', ['message_id' => $messageId, 'notified' => $count]);
1✔
277
    }
278

279
    /**
280
     * Send a ChitChat report email to support.
281
     */
282
    protected function handleEmailChitchatReport(
5✔
283
        array $data,
284
        EmailSpoolerService $spooler,
285
        bool $shouldSpool
286
    ): void {
287
        $required = ['user_id', 'newsfeed_id', 'reason'];
5✔
288
        foreach ($required as $field) {
5✔
289
            if (empty($data[$field])) {
5✔
290
                throw new \RuntimeException("email_chitchat_report requires {$field}");
1✔
291
            }
292
        }
293

294
        // user_name and user_email are cosmetic — reporters are identified by user_id.
295
        $reporterName = !empty($data['user_name']) ? $data['user_name'] : 'A Freegle user';
4✔
296

297
        $mail = new ChitchatReportMail(
4✔
298
            reporterName: $reporterName,
4✔
299
            reporterId: (int) $data['user_id'],
4✔
300
            reporterEmail: $data['user_email'] ?? '',
4✔
301
            newsfeedId: (int) $data['newsfeed_id'],
4✔
302
            reason: $data['reason'],
4✔
303
        );
4✔
304

305
        $recipients = array_map('trim', explode(',', config('freegle.mail.chitchat_support_addr')));
4✔
306
        $spooler->spool($mail, $recipients);
4✔
307

308
        Log::info('Sent ChitChat report email', [
4✔
309
            'reporter_id' => $data['user_id'],
4✔
310
            'newsfeed_id' => $data['newsfeed_id'],
4✔
311
        ]);
4✔
312
    }
313

314
    /**
315
     * Send an external donation notification email to the info address.
316
     */
317
    protected function handleEmailDonateExternal(
4✔
318
        array $data,
319
        EmailSpoolerService $spooler,
320
        bool $shouldSpool
321
    ): void {
322
        $required = ['user_id', 'user_name', 'user_email', 'amount'];
4✔
323
        foreach ($required as $field) {
4✔
324
            if (empty($data[$field])) {
4✔
325
                throw new \RuntimeException("email_donate_external requires {$field}");
1✔
326
            }
327
        }
328

329
        $mail = new DonateExternalMail(
3✔
330
            userName: $data['user_name'],
3✔
331
            userId: (int) $data['user_id'],
3✔
332
            userEmail: $data['user_email'],
3✔
333
            amount: (float) $data['amount'],
3✔
334
            source: $data['source'] ?? DonateExternalMail::SOURCE_EXTERNAL,
3✔
335
        );
3✔
336

337
        $spooler->spool($mail, config('freegle.mail.info_addr'));
3✔
338

339
        Log::info('Sent external donation email', [
3✔
340
            'user_id' => $data['user_id'],
3✔
341
            'amount' => $data['amount'],
3✔
342
        ]);
3✔
343
    }
344

345
    /**
346
     * Send a charity partner signup notification to the partnerships team.
347
     */
UNCOV
348
    protected function handleEmailCharitySignup(
×
349
        array $data,
350
        EmailSpoolerService $spooler,
351
        bool $shouldSpool
352
    ): void {
UNCOV
353
        if (empty($data['orgname']) || empty($data['contactemail'])) {
×
UNCOV
354
            throw new \RuntimeException('email_charity_signup requires orgname and contactemail');
×
355
        }
356

357
        $mail = new CharitySignupMail(
×
358
            charityId: (int) $data['charity_id'],
×
359
            orgName: $data['orgname'],
×
360
            orgType: $data['orgtype'] ?? 'registered',
×
361
            charityNumber: $data['charitynumber'] ?? null,
×
362
            contactEmail: $data['contactemail'],
×
363
            contactName: $data['contactname'] ?? null,
×
364
            website: $data['website'] ?? null,
×
UNCOV
365
            description: $data['description'] ?? null,
×
366
        );
×
367

368
        $spooler->spool($mail, config('freegle.mail.partnerships_addr'));
×
369

370
        Log::info('Sent charity signup notification', [
×
371
            'charity_id' => $data['charity_id'],
×
UNCOV
372
            'orgname' => $data['orgname'],
×
UNCOV
373
        ]);
×
374
    }
375

376
    /**
377
     * Send a forgot-password email with auto-login link.
378
     */
379
    protected function handleEmailForgotPassword(
2✔
380
        array $data,
381
        EmailSpoolerService $spooler,
382
        bool $shouldSpool
383
    ): void {
384
        $required = ['user_id', 'email', 'reset_url'];
2✔
385
        foreach ($required as $field) {
2✔
386
            if (empty($data[$field])) {
2✔
UNCOV
387
                throw new \RuntimeException("email_forgot_password requires {$field}");
×
388
            }
389
        }
390

391
        $mail = new ForgotPasswordMail(
2✔
392
            userId: (int) $data['user_id'],
2✔
393
            email: $data['email'],
2✔
394
            resetUrl: $data['reset_url'],
2✔
395
        );
2✔
396

397
        $spooler->spool($mail, $data['email']);
2✔
398

399
        Log::info('Sent forgot password email', [
2✔
400
            'user_id' => $data['user_id'],
2✔
401
        ]);
2✔
402
    }
403

404
    /**
405
     * Send an unsubscribe confirmation email with auto-login link.
406
     */
407
    protected function handleEmailUnsubscribe(
2✔
408
        array $data,
409
        EmailSpoolerService $spooler,
410
        bool $shouldSpool
411
    ): void {
412
        $required = ['user_id', 'email', 'unsub_url'];
2✔
413
        foreach ($required as $field) {
2✔
414
            if (empty($data[$field])) {
2✔
UNCOV
415
                throw new \RuntimeException("email_unsubscribe requires {$field}");
×
416
            }
417
        }
418

419
        $mail = new UnsubscribeConfirmMail(
2✔
420
            userId: (int) $data['user_id'],
2✔
421
            email: $data['email'],
2✔
422
            unsubUrl: $data['unsub_url'],
2✔
423
        );
2✔
424

425
        $spooler->spool($mail, $data['email']);
2✔
426

427
        Log::info('Sent unsubscribe confirmation email', [
2✔
428
            'user_id' => $data['user_id'],
2✔
429
        ]);
2✔
430
    }
431

432
    /**
433
     * Reopen any 'Closed' chat rosters for a chat so it reappears in the
434
     * recipient's chat list after a new message. The ModTools chat list filters
435
     * out rooms whose roster status is 'Closed' (iznik-server-go
436
     * chat/chatroom.go), so a mod who had previously closed a User2Mod chat would
437
     * not see it again even after sending a modmail to it (Discourse #9481/541).
438
     * Mirrors the reopen in V2 CreateChatMessage; 'Blocked' rosters are left as-is.
439
     */
440
    protected function reopenClosedRosters(int $chatId): void
15✔
441
    {
442
        DB::table('chat_roster')
15✔
443
            ->where('chatid', $chatId)
15✔
444
            ->where('status', 'Closed')
15✔
445
            ->update(['status' => 'Offline']);
15✔
446
    }
447

448
    /**
449
     * Handle mod standard message emails (approve, reject, reply).
450
     *
451
     * Looks up the message poster, group, and mod info, then:
452
     * 1. Sends the stdmsg email (if subject/body provided).
453
     * 2. Creates a User2Mod chat message for the mod log.
454
     * 3. Creates a mod log entry (always — even for plain approve with no stdmsg).
455
     * 4. Queues push notifications to group moderators.
456
     */
457
    protected function handleModStdMessage(
9✔
458
        string $taskType,
459
        array $data,
460
        PushNotificationService $pushService,
461
        EmailSpoolerService $spooler,
462
        bool $shouldSpool
463
    ): void {
464
        $msgId = (int) ($data['msgid'] ?? 0);
9✔
465
        $byUser = (int) ($data['byuser'] ?? 0);
9✔
466
        $groupId = (int) ($data['groupid'] ?? 0);
9✔
467
        $subject = $data['subject'] ?? '';
9✔
468
        $body = $data['body'] ?? '';
9✔
469
        $stdmsgId = (int) ($data['stdmsgid'] ?? 0);
9✔
470

471
        // Fall back to looking up group from messages_groups if not provided.
472
        if ($groupId === 0 && $msgId > 0) {
9✔
473
            $groupId = (int) (DB::table('messages_groups')->where('msgid', $msgId)->value('groupid') ?? 0);
1✔
474
        }
475

476
        if ($msgId === 0 || $byUser === 0) {
9✔
UNCOV
477
            throw new \RuntimeException("{$taskType} requires msgid and byuser");
×
478
        }
479

480
        // Look up the poster (needed for both log and email).
481
        $posterId = (int) DB::table('messages')->where('id', $msgId)->value('fromuser');
9✔
482

483
        // Determine the log subtype from the task type.
484
        // email_message_approved → Approved
485
        // email_message_rejected with subject → Rejected, without subject → Deleted
486
        // email_message_reply → Replied
487
        $subtype = match ($taskType) {
9✔
488
            BackgroundTask::TASK_EMAIL_MESSAGE_APPROVED => 'Approved',
9✔
489
            BackgroundTask::TASK_EMAIL_MESSAGE_REJECTED => $subject !== '' ? 'Rejected' : 'Deleted',
7✔
490
            BackgroundTask::TASK_EMAIL_MESSAGE_REPLY    => 'Replied',
1✔
UNCOV
491
            default => 'Approved',
×
492
        };
9✔
493

494
        // Always create the mod log entry (even if no stdmsg content).
495
        DB::table('logs')->insert([
9✔
496
            'timestamp' => now(),
9✔
497
            'type' => 'Message',
9✔
498
            'subtype' => $subtype,
9✔
499
            'msgid' => $msgId,
9✔
500
            'user' => $posterId ?: null,
9✔
501
            'byuser' => $byUser,
9✔
502
            'groupid' => $groupId ?: null,
9✔
503
            'stdmsgid' => $stdmsgId ?: null,
9✔
504
            'text' => $subject,
9✔
505
        ]);
9✔
506

507
        // Queue push notifications to group moderators.
508
        if ($groupId > 0) {
9✔
509
            $pushService->notifyGroupMods($groupId);
9✔
510
        }
511

512
        // No subject/body means no stdmsg email to send (e.g. plain approve without message).
513
        if ($subject === '' && $body === '') {
9✔
514
            Log::info("Mod action {$taskType} without stdmsg content, skipping email", [
1✔
515
                'msgid' => $msgId,
1✔
516
                'byuser' => $byUser,
1✔
517
            ]);
1✔
518
            return;
1✔
519
        }
520

521
        if (! $posterId) {
8✔
UNCOV
522
            Log::warning("No poster found for message {$msgId}");
×
UNCOV
523
            return;
×
524
        }
525

526
        $poster = User::find($posterId);
8✔
527
        $posterEmail = $poster?->email_preferred;
8✔
528

529
        if (! $posterEmail) {
8✔
UNCOV
530
            Log::warning("No email found for poster of message {$msgId}");
×
UNCOV
531
            return;
×
532
        }
533

534
        // Look up the group info.
535
        $groupName = '';
8✔
536
        $groupNameShort = '';
8✔
537
        $groupContactMail = null;
8✔
538
        if ($groupId > 0) {
8✔
539
            $group = DB::table('groups')->where('id', $groupId)->first();
8✔
540
            if ($group) {
8✔
541
                $groupName = $group->namefull ?: $group->nameshort ?? '';
8✔
542
                $groupNameShort = $group->nameshort ?? '';
8✔
543
                $groupContactMail = $group->contactmail ?: null;
8✔
544
            }
545
        }
546

547
        // Look up the mod's display name.
548
        $modName = DB::table('users')->where('id', $byUser)->value('fullname') ?? 'A volunteer';
8✔
549

550
        // Look up the message subject for context.
551
        $messageSubject = DB::table('messages')->where('id', $msgId)->value('subject') ?? '';
8✔
552

553
        $mail = new ModStdMessageMail(
8✔
554
            modName: $modName,
8✔
555
            groupName: $groupName,
8✔
556
            groupNameShort: $groupNameShort,
8✔
557
            stdSubject: $subject,
8✔
558
            stdBody: $body,
8✔
559
            messageSubject: $messageSubject,
8✔
560
            msgId: $msgId,
8✔
561
            recipientUserId: $posterId,
8✔
562
            recipientEmail: $posterEmail,
8✔
563
            groupContactMail: $groupContactMail,
8✔
564
        );
8✔
565

566
        $spooler->spool($mail, $posterEmail);
8✔
567

568
        // V1 parity: send BCC copy if configured in mod's ModConfig.
569
        $this->sendBccIfConfigured(
8✔
570
            data: $data,
8✔
571
            byUser: $byUser,
8✔
572
            groupId: $groupId,
8✔
573
            groupNameShort: $groupNameShort,
8✔
574
            groupName: $groupName,
8✔
575
            subject: $subject,
8✔
576
            body: $body,
8✔
577
            recipientUserId: $posterId,
8✔
578
            recipientEmail: $posterEmail,
8✔
579
            messageSubject: $messageSubject,
8✔
580
            msgId: $msgId,
8✔
581
            groupContactMail: $groupContactMail,
8✔
582
            modName: $modName,
8✔
583
            spooler: $spooler,
8✔
584
            shouldSpool: $shouldSpool,
8✔
585
        );
8✔
586

587
        // Create a User2Mod chat message so the conversation appears in modtools chats.
588
        if ($groupId > 0) {
8✔
589
            $chatRoom = ChatRoom::getOrCreateUser2Mod($posterId, $groupId);
8✔
590

591
            if ($chatRoom) {
8✔
592
                DB::table('chat_messages')->insert([
8✔
593
                    'chatid' => $chatRoom->id,
8✔
594
                    'userid' => $byUser,
8✔
595
                    'message' => "{$subject}\r\n\r\n{$body}",
8✔
596
                    'type' => 'ModMail',
8✔
597
                    'refmsgid' => $msgId,
8✔
598
                    'date' => now(),
8✔
599
                    'reviewrequired' => 0,
8✔
600
                    'processingrequired' => 0,
8✔
601
                    'processingsuccessful' => 1,
8✔
602
                ]);
8✔
603

604
                // Reopen any closed rosters so the chat reappears in the acting
605
                // mod's (and the member's) chat list, mirroring V2 CreateChatMessage
606
                // (iznik-server-go chat/chatmessage.go). Without this, a chat the mod
607
                // had previously closed stays hidden from their ModTools chats list
608
                // even after they send this modmail (Discourse #9481/541).
609
                $this->reopenClosedRosters((int) $chatRoom->id);
8✔
610
            }
611
        }
612

613
        Log::info("Sent mod stdmsg email ({$taskType})", [
8✔
614
            'msgid' => $msgId,
8✔
615
            'byuser' => $byUser,
8✔
616
            'groupid' => $groupId,
8✔
617
            'recipient' => $posterEmail,
8✔
618
        ]);
8✔
619
    }
620

621
    /**
622
     * Handle mod standard message emails sent to a member (not related to a message).
623
     *
624
     * V1 parity with User::mail() + User::maybeMail():
625
     * 1. Send email to the member.
626
     * 2. Create a User2Mod chat message for the mod log.
627
     */
628
    protected function handleModStdMessageForMember(
8✔
629
        string $taskType,
630
        array $data,
631
        EmailSpoolerService $spooler,
632
        bool $shouldSpool
633
    ): void {
634
        $userId = (int) ($data['userid'] ?? 0);
8✔
635
        $byUser = (int) ($data['byuser'] ?? 0);
8✔
636
        $groupId = (int) ($data['groupid'] ?? 0);
8✔
637
        $subject = $data['subject'] ?? '';
8✔
638
        $body = $data['body'] ?? '';
8✔
639
        $stdmsgId = (int) ($data['stdmsgid'] ?? 0);
8✔
640

641
        if ($userId === 0 || $byUser === 0) {
8✔
UNCOV
642
            throw new \RuntimeException('email_mod_stdmsg requires userid and byuser');
×
643
        }
644

645
        if ($subject === '' && $body === '') {
8✔
646
            Log::info('Mod stdmsg for member without content, skipping email', [
1✔
647
                'userid' => $userId,
1✔
648
                'byuser' => $byUser,
1✔
649
            ]);
1✔
650
            return;
1✔
651
        }
652

653
        // Look up the member's preferred email.
654
        $member = User::find($userId);
7✔
655
        $memberEmail = $member?->email_preferred;
7✔
656

657
        if (! $memberEmail) {
7✔
UNCOV
658
            Log::warning("No email found for member {$userId}");
×
UNCOV
659
            return;
×
660
        }
661

662
        // Look up group info.
663
        $groupName = '';
7✔
664
        $groupNameShort = '';
7✔
665
        $groupContactMail = null;
7✔
666
        if ($groupId > 0) {
7✔
667
            $group = DB::table('groups')->where('id', $groupId)->first();
7✔
668
            if ($group) {
7✔
669
                $groupName = $group->namefull ?: $group->nameshort ?? '';
7✔
670
                $groupNameShort = $group->nameshort ?? '';
7✔
671
                $groupContactMail = $group->contactmail ?: null;
7✔
672
            }
673
        }
674

675
        // Look up the mod's display name.
676
        $modName = DB::table('users')->where('id', $byUser)->value('fullname') ?? 'A volunteer';
7✔
677

678
        $mail = new ModStdMessageMail(
7✔
679
            modName: $modName,
7✔
680
            groupName: $groupName,
7✔
681
            groupNameShort: $groupNameShort,
7✔
682
            stdSubject: $subject,
7✔
683
            stdBody: $body,
7✔
684
            messageSubject: '',
7✔
685
            msgId: 0,
7✔
686
            recipientUserId: $userId,
7✔
687
            recipientEmail: $memberEmail,
7✔
688
            groupContactMail: $groupContactMail,
7✔
689
        );
7✔
690

691
        $spooler->spool($mail, $memberEmail);
7✔
692

693
        // V1 parity: send BCC copy if configured in mod's ModConfig.
694
        $this->sendBccIfConfigured(
7✔
695
            data: $data,
7✔
696
            byUser: $byUser,
7✔
697
            groupId: $groupId,
7✔
698
            groupNameShort: $groupNameShort,
7✔
699
            groupName: $groupName,
7✔
700
            subject: $subject,
7✔
701
            body: $body,
7✔
702
            recipientUserId: $userId,
7✔
703
            recipientEmail: $memberEmail,
7✔
704
            messageSubject: '',
7✔
705
            msgId: 0,
7✔
706
            groupContactMail: $groupContactMail,
7✔
707
            modName: $modName,
7✔
708
            spooler: $spooler,
7✔
709
            shouldSpool: $shouldSpool,
7✔
710
        );
7✔
711

712
        // Create a User2Mod chat message so the conversation appears in modtools chats.
713
        if ($groupId > 0) {
7✔
714
            $chatRoom = ChatRoom::getOrCreateUser2Mod($userId, $groupId);
7✔
715

716
            if ($chatRoom) {
7✔
717
                $chatMessageId = DB::table('chat_messages')->insertGetId([
7✔
718
                    'chatid' => $chatRoom->id,
7✔
719
                    'userid' => $byUser,
7✔
720
                    'message' => "{$subject}\r\n\r\n{$body}",
7✔
721
                    'type' => 'ModMail',
7✔
722
                    'date' => now(),
7✔
723
                    'reviewrequired' => 0,
7✔
724
                    'processingrequired' => 0,
7✔
725
                    'processingsuccessful' => 1,
7✔
726
                ]);
7✔
727

728
                // V1 parity: upToDate() — mark the chat message as already emailed to the member
729
                // so the notification daemon (NotifyUser2ModCommand) does not send a duplicate.
730
                // V1 calls $r->upToDate($fromuser) after the direct email send, which sets
731
                // lastmsgemailed = MAX(chat_messages.id) for the member's roster entry.
732
                DB::table('chat_roster')->upsert(
7✔
733
                    [
7✔
734
                        'chatid' => $chatRoom->id,
7✔
735
                        'userid' => $userId,
7✔
736
                        'lastmsgemailed' => $chatMessageId,
7✔
737
                        'lastemailed' => now(),
7✔
738
                    ],
7✔
739
                    ['chatid', 'userid'],
7✔
740
                    ['lastmsgemailed', 'lastemailed']
7✔
741
                );
7✔
742

743
                // Reopen any closed rosters so the chat reappears in the acting
744
                // mod's (and the member's) chat list — see reopenClosedRosters().
745
                $this->reopenClosedRosters((int) $chatRoom->id);
7✔
746
            }
747

748
            // Only create the User/Mailed log for email_mod_stdmsg (direct mod message to member).
749
            // Membership approve/reject actions no longer route here — they use email_mod_stdmsg
750
            // directly (or create no task if no content), so we only log when it's a direct modmail.
751
            if ($taskType === BackgroundTask::TASK_EMAIL_MOD_STDMSG) {
7✔
752
                DB::table('logs')->insert([
7✔
753
                    'timestamp' => now(),
7✔
754
                    'type' => 'User',
7✔
755
                    'subtype' => 'Mailed',
7✔
756
                    'byuser' => $byUser,
7✔
757
                    'user' => $userId,
7✔
758
                    'groupid' => $groupId,
7✔
759
                    'stdmsgid' => $stdmsgId ?: null,
7✔
760
                    'text' => $subject,
7✔
761
                ]);
7✔
762
                // Note: users_modmails is populated by the syncModMailCounts cron job
763
                // which scans the logs table — no direct insert needed here.
764
            }
765
        }
766

767
        Log::info('Sent mod stdmsg email to member', [
7✔
768
            'userid' => $userId,
7✔
769
            'byuser' => $byUser,
7✔
770
            'groupid' => $groupId,
7✔
771
            'recipient' => $memberEmail,
7✔
772
        ]);
7✔
773
    }
774

775
    /**
776
     * Handle message outcome background processing.
777
     *
778
     * V1 parity with Message::backgroundMark():
779
     * 1. Log the outcome to the logs table for each group the message is on.
780
     * 2. Notify interested users (who replied but didn't get the item) by creating
781
     *    TYPE_COMPLETED chat messages in their User2User chat rooms.
782
     */
783
    protected function handleMessageOutcome(array $data): void
3✔
784
    {
785
        $msgId = (int) ($data['msgid'] ?? 0);
3✔
786
        $byUser = (int) ($data['byuser'] ?? 0);
3✔
787
        $outcome = $data['outcome'] ?? '';
3✔
788
        $happiness = $data['happiness'] ?? '';
3✔
789
        $comment = $data['comment'] ?? '';
3✔
790
        $userid = (int) ($data['userid'] ?? 0);
3✔
791
        $messageForOthers = $data['message'] ?? '';
3✔
792

793
        if ($msgId === 0) {
3✔
UNCOV
794
            throw new \RuntimeException('message_outcome requires msgid');
×
795
        }
796

797
        // Get the message poster.
798
        $fromUser = (int) (DB::table('messages')->where('id', $msgId)->value('fromuser') ?? 0);
3✔
799

800
        // 1. Log the outcome for each group (V1: Log::TYPE_MESSAGE, Log::SUBTYPE_OUTCOME).
801
        $groups = DB::table('messages_groups')->where('msgid', $msgId)->pluck('groupid');
3✔
802

803
        foreach ($groups as $groupId) {
3✔
804
            DB::table('logs')->insert([
3✔
805
                'timestamp' => now(),
3✔
806
                'type' => 'Message',
3✔
807
                'subtype' => 'Outcome',
3✔
808
                'msgid' => $msgId,
3✔
809
                'user' => $fromUser ?: null,
3✔
810
                'byuser' => $byUser ?: null,
3✔
811
                'groupid' => $groupId,
3✔
812
                'text' => trim("{$outcome} {$comment}"),
3✔
813
            ]);
3✔
814
        }
815

816
        // 2. Notify interested users who replied but didn't get the item.
817
        // Find User2User chat rooms with INTERESTED messages referencing this message,
818
        // excluding users who are in messages_by (i.e. who got the item).
819
        $replies = DB::select(
3✔
820
            "SELECT DISTINCT chatid FROM chat_messages
3✔
821
             INNER JOIN chat_rooms ON chat_rooms.id = chat_messages.chatid AND chat_rooms.chattype = 'User2User'
822
             LEFT JOIN messages_by ON messages_by.msgid = chat_messages.refmsgid
823
                 AND messages_by.userid IN (chat_rooms.user1, chat_rooms.user2)
824
             WHERE refmsgid = ? AND chat_messages.type = 'Interested'
825
                 AND reviewrejected = 0 AND messages_by.id IS NULL",
3✔
826
            [$msgId]
3✔
827
        );
3✔
828

829
        foreach ($replies as $reply) {
3✔
830
            // Check if this message was unpromised in this chat (TYPE_RENEGED).
831
            // If so, don't send the generic message as it may not be appropriate.
832
            $unpromised = DB::table('chat_messages')
2✔
833
                ->where('chatid', $reply->chatid)
2✔
834
                ->where('refmsgid', $msgId)
2✔
835
                ->where('type', 'Reneged')
2✔
836
                ->exists();
2✔
837

838
            DB::table('chat_messages')->insert([
2✔
839
                'chatid' => $reply->chatid,
2✔
840
                'userid' => $fromUser,
2✔
841
                'message' => $unpromised ? null : ($messageForOthers ?: null),
2✔
842
                'type' => 'Completed',
2✔
843
                'refmsgid' => $msgId,
2✔
844
                'date' => now(),
2✔
845
                'reviewrequired' => 0,
2✔
846
                'processingrequired' => 0,
2✔
847
                'processingsuccessful' => 1,
2✔
848
            ]);
2✔
849

850
            // Mark the poster as up-to-date in this chat so it doesn't appear as unread to them.
851
            if ($fromUser) {
2✔
852
                DB::table('chat_roster')->updateOrInsert(
2✔
853
                    ['chatid' => $reply->chatid, 'userid' => $fromUser],
2✔
854
                    ['lastmsgseen' => DB::raw('(SELECT MAX(id) FROM chat_messages WHERE chatid = ' . (int) $reply->chatid . ')'), 'date' => now()]
2✔
855
                );
2✔
856
            }
857
        }
858

859
        Log::info('Processed message outcome', [
3✔
860
            'msgid' => $msgId,
3✔
861
            'outcome' => $outcome,
3✔
862
            'groups' => $groups->count(),
3✔
863
            'notified_chats' => count($replies),
3✔
864
        ]);
3✔
865
    }
866

867
    /**
868
     * Handle merge offer email — sends to both users involved in a merge.
869
     *
870
     * V1 parity: merge.php lines 149-211.
871
     */
872
    protected function handleEmailMerge(
2✔
873
        array $data,
874
        EmailSpoolerService $spooler,
875
        bool $shouldSpool
876
    ): void {
877
        $mergeId = (int) ($data['merge_id'] ?? 0);
2✔
878
        $uid = $data['uid'] ?? '';
2✔
879
        $user1Id = (int) ($data['user1'] ?? 0);
2✔
880
        $user2Id = (int) ($data['user2'] ?? 0);
2✔
881

882
        if ($mergeId === 0 || $uid === '' || $user1Id === 0 || $user2Id === 0) {
2✔
UNCOV
883
            throw new \RuntimeException('email_merge requires merge_id, uid, user1, user2');
×
884
        }
885

886
        $u1 = User::find($user1Id);
2✔
887
        $u2 = User::find($user2Id);
2✔
888

889
        if (! $u1 || ! $u2) {
2✔
UNCOV
890
            Log::warning('Merge user not found', ['user1' => $user1Id, 'user2' => $user2Id]);
×
UNCOV
891
            return;
×
892
        }
893

894
        $mergeUrl = config('freegle.sites.user') . '/merge?id=' . $mergeId . '&uid=' . $uid;
2✔
895
        $name1 = $u1->fullname ?: 'Freegle User';
2✔
896
        $name2 = $u2->fullname ?: 'Freegle User';
2✔
897
        $email1 = $this->obfuscateEmail($u1->email_preferred ?? '');
2✔
898
        $email2 = $this->obfuscateEmail($u2->email_preferred ?? '');
2✔
899

900
        // Send to both users.
901
        foreach ([$u1, $u2] as $recipient) {
2✔
902
            $recipientEmail = $recipient->email_preferred;
2✔
903

904
            if (! $recipientEmail) {
2✔
UNCOV
905
                continue;
×
906
            }
907

908
            $mail = new MergeOfferMail(
2✔
909
                recipientUserId: $recipient->id,
2✔
910
                recipientName: $recipient->fullname ?: 'Freegle User',
2✔
911
                recipientEmail: $recipientEmail,
2✔
912
                name1: $name1,
2✔
913
                email1: $email1,
2✔
914
                name2: $name2,
2✔
915
                email2: $email2,
2✔
916
                mergeUrl: $mergeUrl,
2✔
917
            );
2✔
918

919
            $spooler->spool($mail, $recipientEmail);
2✔
920
        }
921

922
        Log::info('Sent merge offer emails', [
2✔
923
            'merge_id' => $mergeId,
2✔
924
            'user1' => $user1Id,
2✔
925
            'user2' => $user2Id,
2✔
926
        ]);
2✔
927
    }
928

929
    /**
930
     * Handle email verification — generates a validate key and sends a confirmation link.
931
     *
932
     * V1 parity: User::verifyEmail() lines 3822-3896.
933
     */
934
    protected function handleEmailVerify(
3✔
935
        array $data,
936
        EmailSpoolerService $spooler,
937
        bool $shouldSpool
938
    ): void {
939
        $userId = (int) ($data['user_id'] ?? 0);
3✔
940
        $email = $data['email'] ?? '';
3✔
941

942
        if ($userId === 0 || $email === '') {
3✔
UNCOV
943
            throw new \RuntimeException('email_verify requires user_id and email');
×
944
        }
945

946
        $user = User::find($userId);
3✔
947

948
        if (! $user) {
3✔
UNCOV
949
            Log::warning("User not found for email verify: {$userId}");
×
UNCOV
950
            return;
×
951
        }
952

953
        // Check if this email is already one of the user's emails.
954
        $canon = strtolower(trim($email));
3✔
955
        $existing = DB::table('users_emails')
3✔
956
            ->where('userid', $userId)
3✔
957
            ->whereRaw('LOWER(email) = ?', [$canon])
3✔
958
            ->exists();
3✔
959

960
        if ($existing) {
3✔
961
            // Already the user's email — just make it primary.
962
            DB::table('users_emails')
1✔
963
                ->where('userid', $userId)
1✔
964
                ->whereRaw('LOWER(email) = ?', [$canon])
1✔
965
                ->update(['preferred' => 1]);
1✔
966

967
            DB::table('users_emails')
1✔
968
                ->where('userid', $userId)
1✔
969
                ->whereRaw('LOWER(email) != ?', [$canon])
1✔
970
                ->update(['preferred' => 0]);
1✔
971

972
            Log::info('Email already belongs to user, made primary', [
1✔
973
                'user_id' => $userId,
1✔
974
                'email' => $email,
1✔
975
            ]);
1✔
976
            return;
1✔
977
        }
978

979
        // Generate a validation key. Check if one was recently set (< 600s) to avoid confusion.
980
        $recentKey = DB::table('users_emails')
2✔
981
            ->where('canon', $canon)
2✔
982
            ->whereRaw('TIMESTAMPDIFF(SECOND, validatetime, NOW()) < 600')
2✔
983
            ->value('validatekey');
2✔
984

985
        $key = $recentKey;
2✔
986

987
        if (! $key) {
2✔
988
            $key = uniqid();
2✔
989
            DB::statement(
2✔
990
                'INSERT INTO users_emails (email, canon, validatekey, backwards) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE validatekey = ?',
2✔
991
                [$email, $canon, $key, strrev($canon), $key]
2✔
992
            );
2✔
993
        }
994

995
        // Generate the confirm URL with auto-login.
996
        $userKey = $user->getUserKey();
2✔
997
        $confirmPath = '/settings/confirmmail/' . urlencode($key);
2✔
998
        $userSite = config('freegle.sites.user');
2✔
999
        $confirmUrl = "{$userSite}{$confirmPath}?u={$userId}&k={$userKey}&src=changeemail";
2✔
1000

1001
        $mail = new VerifyEmailMail(
2✔
1002
            userId: $userId,
2✔
1003
            email: $email,
2✔
1004
            confirmUrl: $confirmUrl,
2✔
1005
        );
2✔
1006

1007
        $spooler->spool($mail, $email);
2✔
1008

1009
        Log::info('Sent email verification', [
2✔
1010
            'user_id' => $userId,
2✔
1011
            'email' => $email,
2✔
1012
        ]);
2✔
1013
    }
1014

1015
    /**
1016
     * Handle refer-to-support — sends a plain text email to the support team.
1017
     *
1018
     * V1 parity: ChatRoom::referToSupport() lines 2266-2284.
1019
     */
1020
    protected function handleReferToSupport(
1✔
1021
        array $data,
1022
        EmailSpoolerService $spooler,
1023
        bool $shouldSpool
1024
    ): void {
1025
        $chatId = (int) ($data['chatid'] ?? 0);
1✔
1026
        $userId = (int) ($data['userid'] ?? 0);
1✔
1027

1028
        if ($chatId === 0 || $userId === 0) {
1✔
UNCOV
1029
            throw new \RuntimeException('refer_to_support requires chatid and userid');
×
1030
        }
1031

1032
        $chat = DB::table('chat_rooms')->where('id', $chatId)->first();
1✔
1033

1034
        if (! $chat) {
1✔
UNCOV
1035
            Log::warning("Chat not found for refer_to_support: {$chatId}");
×
UNCOV
1036
            return;
×
1037
        }
1038

1039
        $user = User::find($userId);
1✔
1040

1041
        if (! $user) {
1✔
UNCOV
1042
            Log::warning("User not found for refer_to_support: {$userId}");
×
UNCOV
1043
            return;
×
1044
        }
1045

1046
        // The "other" user in the chat.
1047
        $otherUserId = $chat->user1 == $userId ? $chat->user2 : $chat->user1;
1✔
1048
        $otherUser = $otherUserId ? User::find($otherUserId) : null;
1✔
1049
        $otherUserName = $otherUser ? ($otherUser->fullname ?: 'Unknown') : 'Unknown';
1✔
1050

1051
        // Get group mods email for reply-to.
1052
        $groupId = $chat->groupid;
1✔
1053
        $replyToAddress = config('freegle.mail.noreply_addr');
1✔
1054
        $replyToName = config('freegle.branding.name');
1✔
1055

1056
        if ($groupId) {
1✔
1057
            $group = DB::table('groups')->where('id', $groupId)->first();
1✔
1058

1059
            if ($group) {
1✔
1060
                $groupNameShort = $group->nameshort ?? '';
1✔
1061
                $replyToAddress = $groupNameShort . '-volunteers@' . config('freegle.mail.group_domain', 'groups.ilovefreegle.org');
1✔
1062
                $replyToName = ($group->namefull ?: $groupNameShort) . ' Volunteers';
1✔
1063
            }
1064
        }
1065

1066
        $mail = new ReferToSupportMail(
1✔
1067
            userName: $user->fullname ?: 'Unknown',
1✔
1068
            userId: $userId,
1✔
1069
            chatId: $chatId,
1✔
1070
            otherUserName: $otherUserName,
1✔
1071
            otherUserId: (int) ($otherUserId ?? 0),
1✔
1072
            replyToAddress: $replyToAddress,
1✔
1073
            replyToName: $replyToName,
1✔
1074
        );
1✔
1075

1076
        $supportAddr = config('freegle.mail.support_addr', 'support@ilovefreegle.org');
1✔
1077
        $recipients = array_map('trim', explode(',', $supportAddr));
1✔
1078

1079
        $spooler->spool($mail, $recipients);
1✔
1080

1081
        Log::info('Sent refer to support email', [
1✔
1082
            'chat_id' => $chatId,
1✔
1083
            'user_id' => $userId,
1✔
1084
        ]);
1✔
1085
    }
1086

1087
    /**
1088
     * Email the central spam team when a user reports a chat with someone they
1089
     * share no Freegle group with (so it can't be routed to a community's mods).
1090
     */
1091
    protected function handleEmailChatSpamReport(
3✔
1092
        array $data,
1093
        EmailSpoolerService $spooler,
1094
        bool $shouldSpool
1095
    ): void {
1096
        $chatId = (int) ($data['chatid'] ?? 0);
3✔
1097
        $userId = (int) ($data['userid'] ?? 0);
3✔
1098

1099
        if ($chatId === 0 || $userId === 0) {
3✔
UNCOV
1100
            throw new \RuntimeException('email_chat_spam_report requires chatid and userid');
×
1101
        }
1102

1103
        $chat = DB::table('chat_rooms')->where('id', $chatId)->first();
3✔
1104
        if (! $chat) {
3✔
1105
            Log::warning("Chat not found for email_chat_spam_report: {$chatId}");
1✔
1106
            return;
1✔
1107
        }
1108

1109
        $user = User::find($userId);
2✔
1110
        if (! $user) {
2✔
UNCOV
1111
            Log::warning("User not found for email_chat_spam_report: {$userId}");
×
UNCOV
1112
            return;
×
1113
        }
1114

1115
        $otherUserId = $chat->user1 == $userId ? $chat->user2 : $chat->user1;
2✔
1116
        $otherUser = $otherUserId ? User::find($otherUserId) : null;
2✔
1117
        $otherUserName = $otherUser ? ($otherUser->fullname ?: 'Unknown') : 'Unknown';
2✔
1118

1119
        $mail = new ChatSpamReportMail(
2✔
1120
            reporterName: $user->fullname ?: 'Unknown',
2✔
1121
            reporterId: $userId,
2✔
1122
            otherUserName: $otherUserName,
2✔
1123
            otherUserId: (int) ($otherUserId ?? 0),
2✔
1124
            chatId: $chatId,
2✔
1125
            reason: (string) ($data['reason'] ?? ''),
2✔
1126
            comment: (string) ($data['comment'] ?? ''),
2✔
1127
        );
2✔
1128

1129
        $recipients = array_map('trim', explode(',', config('freegle.mail.spam_addr')));
2✔
1130
        $spooler->spool($mail, $recipients);
2✔
1131

1132
        Log::info('Sent chat spam report email', [
2✔
1133
            'reporter_id' => $userId,
2✔
1134
            'chat_id' => $chatId,
2✔
1135
        ]);
2✔
1136
    }
1137

1138
    /**
1139
     * Process a housekeeping notification from the Chrome extension.
1140
     */
UNCOV
1141
    protected function handleHousekeeperNotify(array $data): void
×
1142
    {
UNCOV
1143
        $service = app(HousekeeperService::class);
×
UNCOV
1144
        $service->process($data);
×
1145
    }
1146

1147
    /**
1148
     * Resolve the BCC address for a mod standard message action.
1149
     *
1150
     * V1 parity: ModConfig::getForGroup() + ModConfig::getBcc() + ModConfig::evalIt().
1151
     *
1152
     * @param int    $byUser  The moderator's user ID
1153
     * @param int    $groupId The group ID
1154
     * @param string $action  The action string (Approve, Reject, Leave Approved Member, etc.)
1155
     * @return string|null    The resolved BCC email address, or null if none configured
1156
     */
1157
    private function resolveBccAddress(int $byUser, int $groupId, string $action): ?string
7✔
1158
    {
1159
        if ($groupId === 0 || $action === '') {
7✔
UNCOV
1160
            return null;
×
1161
        }
1162

1163
        // Step 1: Find the mod's config for this group (V1: ModConfig::getForGroup).
1164
        $configId = DB::table('memberships')
7✔
1165
            ->where('userid', $byUser)
7✔
1166
            ->where('groupid', $groupId)
7✔
1167
            ->value('configid');
7✔
1168

1169
        if (! $configId) {
7✔
1170
            // Fall back to any other mod's config for this group.
1171
            $configId = DB::table('memberships')
3✔
1172
                ->where('groupid', $groupId)
3✔
1173
                ->whereIn('role', ['Moderator', 'Owner'])
3✔
1174
                ->whereNotNull('configid')
3✔
1175
                ->value('configid');
3✔
1176
        }
1177

1178
        if (! $configId) {
7✔
1179
            // Fall back to any config created by this mod.
1180
            $configId = DB::table('mod_configs')
2✔
1181
                ->where('createdby', $byUser)
2✔
1182
                ->value('id');
2✔
1183
        }
1184

1185
        if (! $configId) {
7✔
1186
            // Fall back to a default config.
1187
            $configId = DB::table('mod_configs')
2✔
1188
                ->where('default', 1)
2✔
1189
                ->value('id');
2✔
1190
        }
1191

1192
        if (! $configId) {
7✔
1193
            return null;
2✔
1194
        }
1195

1196
        // Step 2: Map action to CC column pair (V1: ModConfig::getBcc).
1197
        [$toColumn, $addrColumn] = match ($action) {
5✔
1198
            'Approve', 'Reject', 'Leave' => ['ccrejectto', 'ccrejectaddr'],
4✔
UNCOV
1199
            'Leave Member' => ['ccrejmembto', 'ccrejmembaddr'],
×
UNCOV
1200
            'Leave Approved Message', 'Delete Approved Message' => ['ccfollowupto', 'ccfollowupaddr'],
×
1201
            'Leave Approved Member', 'Delete Approved Member' => ['ccfollmembto', 'ccfollmembaddr'],
1✔
1202
            default => [null, null],
5✔
1203
        };
5✔
1204

1205
        if (! $toColumn) {
5✔
UNCOV
1206
            return null;
×
1207
        }
1208

1209
        // Step 3: Look up the config and evaluate (V1: ModConfig::evalIt).
1210
        $config = DB::table('mod_configs')
5✔
1211
            ->where('id', $configId)
5✔
1212
            ->first([$toColumn, $addrColumn]);
5✔
1213

1214
        if (! $config) {
5✔
UNCOV
1215
            return null;
×
1216
        }
1217

1218
        $to = $config->$toColumn;
5✔
1219
        $addr = $config->$addrColumn;
5✔
1220

1221
        if ($to === 'Me') {
5✔
1222
            $modUser = User::find($byUser);
1✔
1223

1224
            return $modUser?->email_preferred;
1✔
1225
        }
1226

1227
        if ($to === 'Specific') {
4✔
1228
            return $addr ?: null;
3✔
1229
        }
1230

1231
        return null;
1✔
1232
    }
1233

1234
    /**
1235
     * Send a BCC copy of a mod standard message if configured.
1236
     *
1237
     * V1 parity: both Message::mail() and User::maybeMail() send a BCC copy
1238
     * with body prefixed by "(This is a BCC of a message sent to Freegle user #...)".
1239
     */
1240
    private function sendBccIfConfigured(
15✔
1241
        array $data,
1242
        int $byUser,
1243
        int $groupId,
1244
        string $groupNameShort,
1245
        string $groupName,
1246
        string $subject,
1247
        string $body,
1248
        int $recipientUserId,
1249
        string $recipientEmail,
1250
        string $messageSubject,
1251
        int $msgId,
1252
        ?string $groupContactMail,
1253
        string $modName,
1254
        EmailSpoolerService $spooler,
1255
        bool $shouldSpool
1256
    ): void {
1257
        $action = $data['action'] ?? '';
15✔
1258
        if ($action === '' || $groupId === 0) {
15✔
1259
            return;
8✔
1260
        }
1261

1262
        $bccAddress = $this->resolveBccAddress($byUser, $groupId, $action);
7✔
1263
        if (! $bccAddress) {
7✔
1264
            return;
3✔
1265
        }
1266

1267
        // V1: replace $groupname in BCC address.
1268
        $bccAddress = str_replace('$groupname', $groupNameShort, $bccAddress);
4✔
1269

1270
        // V1: prefix BCC body with notice.
1271
        $bccBody = "(This is a BCC of a message sent to Freegle user #{$recipientUserId} {$recipientEmail})\n\n{$body}";
4✔
1272

1273
        $bccMail = new ModStdMessageMail(
4✔
1274
            modName: $modName,
4✔
1275
            groupName: $groupName,
4✔
1276
            groupNameShort: $groupNameShort,
4✔
1277
            stdSubject: $subject,
4✔
1278
            stdBody: $bccBody,
4✔
1279
            messageSubject: $messageSubject,
4✔
1280
            msgId: $msgId,
4✔
1281
            recipientUserId: 0,
4✔
1282
            recipientEmail: $bccAddress,
4✔
1283
            groupContactMail: $groupContactMail,
4✔
1284
        );
4✔
1285

1286
        $spooler->spool($bccMail, $bccAddress);
4✔
1287

1288
        Log::info('Sent BCC copy of mod stdmsg', [
4✔
1289
            'action' => $action,
4✔
1290
            'bcc' => $bccAddress,
4✔
1291
            'byuser' => $byUser,
4✔
1292
            'groupid' => $groupId,
4✔
1293
        ]);
4✔
1294
    }
1295

1296
    /**
1297
     * Add a post to freebiealerts.app.
1298
     *
1299
     * V1 parity with FreebieAlerts::add() — only sends outstanding Offers with
1300
     * a location. TrashNothing messages are skipped (TN syncs directly).
1301
     */
1302
    protected function handleFreebieAlertsAdd(array $data): void
4✔
1303
    {
1304
        $msgId = (int) ($data['msgid'] ?? 0);
4✔
1305
        if ($msgId === 0) {
4✔
UNCOV
1306
            throw new \RuntimeException('freebie_alerts_add requires msgid');
×
1307
        }
1308

1309
        $apiKey = config('freegle.freebie_alerts.api_key');
4✔
1310
        if (empty($apiKey)) {
4✔
1311
            Log::debug('Freebie Alerts API key not configured, skipping add', ['msgid' => $msgId]);
1✔
1312
            return;
1✔
1313
        }
1314

1315
        // Only outstanding Offers (no outcome yet).
1316
        $msg = DB::table('messages')->where('id', $msgId)->first();
3✔
1317
        if (! $msg || $msg->type !== 'Offer') {
3✔
1318
            return;
1✔
1319
        }
1320

1321
        $hasOutcome = DB::table('messages_outcomes')->where('msgid', $msgId)->exists();
2✔
1322
        if ($hasOutcome) {
2✔
1323
            return;
1✔
1324
        }
1325

1326
        // Skip TrashNothing messages — TN syncs to freebiealerts directly.
1327
        if ($msg->sourceheader && str_starts_with($msg->sourceheader, 'TN-')) {
1✔
1328
            return;
×
1329
        }
1330

1331
        $group = DB::table('messages_groups')
1✔
1332
            ->where('msgid', $msgId)
1✔
1333
            ->where('collection', 'Approved')
1✔
1334
            ->first();
1✔
1335
        if (! $group) {
1✔
1336
            return;
×
1337
        }
1338

1339
        if (! $msg->lat || ! $msg->lng) {
1✔
UNCOV
1340
            return;
×
1341
        }
1342

1343
        // Build image list from message attachments.
1344
        // Same pattern as digest emails: externalurl if set, otherwise {images.domain}/timg_{id}.jpg.
1345
        $imagesDomain = config('freegle.images.domain', 'https://images.ilovefreegle.org');
1✔
1346
        $attachments = DB::table('messages_attachments')->where('msgid', $msgId)->get();
1✔
1347
        $images = $attachments->map(function ($att) use ($imagesDomain) {
1✔
UNCOV
1348
            return ! empty($att->externalurl)
×
UNCOV
1349
                ? $att->externalurl
×
UNCOV
1350
                : "{$imagesDomain}/timg_{$att->id}.jpg";
×
1351
        })->implode(',');
1✔
1352

1353
        $body = $msg->textbody ?: 'No description';
1✔
1354

1355
        $payload = [
1✔
1356
            'id' => $msgId,
1✔
1357
            'title' => $msg->subject,
1✔
1358
            'description' => $body,
1✔
1359
            'latitude' => $msg->lat,
1✔
1360
            'longitude' => $msg->lng,
1✔
1361
            'images' => $images,
1✔
1362
            'created_at' => $group->arrival,
1✔
1363
        ];
1✔
1364

1365
        $apiUrl = config('freegle.freebie_alerts.api_url');
1✔
1366

1367
        try {
1368
            $response = \Illuminate\Support\Facades\Http::withHeaders([
1✔
1369
                'Content-type' => 'application/json',
1✔
1370
                'Key' => $apiKey,
1✔
1371
            ])->timeout(60)->post("{$apiUrl}/freegle/post/create", $payload);
1✔
1372

1373
            if ($response->successful()) {
1✔
1374
                Log::info('Added post to Freebie Alerts', ['msgid' => $msgId]);
1✔
1375
            } else {
UNCOV
1376
                Log::warning('Freebie Alerts add failed', [
×
UNCOV
1377
                    'msgid' => $msgId,
×
1378
                    'status' => $response->status(),
×
1379
                    'body' => $response->body(),
×
1380
                ]);
×
1381
                if (app()->bound('sentry')) {
×
1382
                    app('sentry')->captureMessage("Freebie Alerts add failed for message {$msgId}: {$response->status()}");
1✔
1383
                }
1384
            }
UNCOV
1385
        } catch (\Throwable $e) {
×
UNCOV
1386
            Log::error('Freebie Alerts add exception', [
×
UNCOV
1387
                'msgid' => $msgId,
×
UNCOV
1388
                'error' => $e->getMessage(),
×
UNCOV
1389
            ]);
×
UNCOV
1390
            if (app()->bound('sentry')) {
×
UNCOV
1391
                app('sentry')->captureException($e);
×
1392
            }
1393
        }
1394
    }
1395

1396
    /**
1397
     * Remove a post from freebiealerts.app.
1398
     *
1399
     * V1 parity with FreebieAlerts::remove().
1400
     */
1401
    protected function handleFreebieAlertsRemove(array $data): void
2✔
1402
    {
1403
        $msgId = (int) ($data['msgid'] ?? 0);
2✔
1404
        if ($msgId === 0) {
2✔
UNCOV
1405
            throw new \RuntimeException('freebie_alerts_remove requires msgid');
×
1406
        }
1407

1408
        $apiKey = config('freegle.freebie_alerts.api_key');
2✔
1409
        if (empty($apiKey)) {
2✔
1410
            Log::debug('Freebie Alerts API key not configured, skipping remove', ['msgid' => $msgId]);
1✔
1411
            return;
1✔
1412
        }
1413

1414
        $apiUrl = config('freegle.freebie_alerts.api_url');
1✔
1415

1416
        try {
1417
            $response = \Illuminate\Support\Facades\Http::withHeaders([
1✔
1418
                'Content-type' => 'application/json',
1✔
1419
                'Key' => $apiKey,
1✔
1420
            ])->timeout(60)->post("{$apiUrl}/freegle/post/{$msgId}/delete");
1✔
1421

1422
            if ($response->successful()) {
1✔
1423
                Log::info('Removed post from Freebie Alerts', ['msgid' => $msgId]);
1✔
1424
            } else {
1425
                Log::warning('Freebie Alerts remove failed', [
1✔
1426
                    'msgid' => $msgId,
1✔
1427
                    'status' => $response->status(),
1✔
1428
                    'body' => $response->body(),
1✔
1429
                ]);
1✔
1430
            }
UNCOV
1431
        } catch (\Throwable $e) {
×
UNCOV
1432
            Log::error('Freebie Alerts remove exception', [
×
UNCOV
1433
                'msgid' => $msgId,
×
UNCOV
1434
                'error' => $e->getMessage(),
×
UNCOV
1435
            ]);
×
UNCOV
1436
            if (app()->bound('sentry')) {
×
UNCOV
1437
                app('sentry')->captureException($e);
×
1438
            }
1439
        }
1440
    }
1441

1442
    /**
1443
     * Obfuscate an email address for display (e.g. "j***@example.com").
1444
     */
1445
    private function obfuscateEmail(string $email): string
2✔
1446
    {
1447
        if (! $email || ! str_contains($email, '@')) {
2✔
UNCOV
1448
            return $email;
×
1449
        }
1450

1451
        [$local, $domain] = explode('@', $email, 2);
2✔
1452

1453
        if (strlen($local) <= 1) {
2✔
UNCOV
1454
            return '*@' . $domain;
×
1455
        }
1456

1457
        return $local[0] . str_repeat('*', strlen($local) - 1) . '@' . $domain;
2✔
1458
    }
1459

1460
    /**
1461
     * Remap postcodes to their nearest area after a location geometry change.
1462
     */
UNCOV
1463
    protected function handleRemapPostcodes(array $data): void
×
1464
    {
UNCOV
1465
        $locationId = isset($data['location_id']) ? (int) $data['location_id'] : NULL;
×
UNCOV
1466
        $polygon = $data['polygon'] ?? NULL;
×
1467

UNCOV
1468
        $service = app(PostcodeRemapService::class);
×
UNCOV
1469
        $updated = $service->remapPostcodes($locationId, $polygon);
×
1470

UNCOV
1471
        Log::info('Remapped postcodes', [
×
UNCOV
1472
            'location_id' => $locationId,
×
UNCOV
1473
            'updated' => $updated,
×
UNCOV
1474
        ]);
×
1475
    }
1476

1477
    protected function handleUserForget(array $data): void
2✔
1478
    {
1479
        $userId = isset($data['user_id']) ? (int) $data['user_id'] : NULL;
2✔
1480
        $reason = $data['reason'] ?? 'Support purge';
2✔
1481

1482
        if (! $userId) {
2✔
1483
            throw new \RuntimeException('user_forget requires user_id');
1✔
1484
        }
1485

1486
        $service = app(UserManagementService::class);
1✔
1487
        $service->forgetUser($userId, $reason);
1✔
1488

1489
        Log::info('User forgotten via background task', [
1✔
1490
            'user_id' => $userId,
1✔
1491
            'reason'  => $reason,
1✔
1492
        ]);
1✔
1493
    }
1494

1495
    protected function handleTnSyncCommand(array $data): void
2✔
1496
    {
1497
        $args = [];
2✔
1498

1499
        if (!empty($data['from'])) {
2✔
1500
            $args['--from'] = (string) $data['from'];
2✔
1501
        }
1502

1503
        if (!empty($data['to'])) {
2✔
1504
            $args['--to'] = (string) $data['to'];
2✔
1505
        }
1506

1507
        if (!empty($data['run_id'])) {
2✔
UNCOV
1508
            $args['--run-id'] = (string) $data['run_id'];
×
1509
        }
1510

1511
        if (isset($data['dry_run']) && (bool) $data['dry_run']) {
2✔
UNCOV
1512
            $args['--dry-run'] = true;
×
1513
        }
1514

1515
        if (isset($data['local_testing']) && (bool) $data['local_testing']) {
2✔
1516
            $args['--local-testing'] = true;
1✔
1517
        }
1518

1519
        $exitCode = Artisan::call('tn:sync', $args);
2✔
1520

1521
        Log::info('Processed tn_sync_command task', [
2✔
1522
            'exit_code' => $exitCode,
2✔
1523
            'from' => $args['--from'] ?? null,
2✔
1524
            'to' => $args['--to'] ?? null,
2✔
1525
            'run_id' => $args['--run-id'] ?? null,
2✔
1526
            'dry_run' => $args['--dry-run'] ?? null,
2✔
1527
        ]);
2✔
1528

1529
        if ($exitCode !== 0) {
2✔
UNCOV
1530
            throw new \RuntimeException("tn:sync failed with exit code {$exitCode}");
×
1531
        }
1532
    }
1533
}
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