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

Freegle / Iznik / 20959

13 Jun 2026 02:43PM UTC coverage: 71.021% (+1.5%) from 69.555%
20959

push

circleci

edwh
feat(web): redirect /councils to /partnerships

The councils content now lives at /partnerships; add a route rule so the old
/councils URL (which 404'd) redirects there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

10705 of 14260 branches covered (75.07%)

Branch coverage included in aggregate %.

116833 of 165317 relevant lines covered (70.67%)

35.88 hits per line

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

87.39
/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\ReferToSupportMail;
8
use App\Mail\Donation\DonateExternalMail;
9
use App\Mail\Newsfeed\ChitchatReportMail;
10
use App\Mail\Session\ForgotPasswordMail;
11
use App\Mail\Session\MergeOfferMail;
12
use App\Mail\Session\UnsubscribeConfirmMail;
13
use App\Mail\Session\VerifyEmailMail;
14
use App\Mail\Message\ModStdMessageMail;
15
use App\Models\BackgroundTask;
16
use App\Models\ChatRoom;
17
use App\Models\User;
18
use App\Services\EmailSpoolerService;
19
use App\Services\HousekeeperService;
20
use App\Services\PostcodeRemapService;
21
use App\Services\UserManagementService;
22
use App\Services\PushNotificationService;
23
use App\Traits\GracefulShutdown;
24
use Illuminate\Console\Command;
25
use Illuminate\Support\Facades\Artisan;
26
use Illuminate\Support\Facades\DB;
27
use Illuminate\Support\Facades\Log;
28
use Illuminate\Support\Facades\Mail;
29
use Illuminate\Support\Str;
30

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

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

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

52
    private const MAX_ATTEMPTS = 3;
53

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

59
            return Command::SUCCESS;
×
60
        }
61

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

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

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

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

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

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

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

100
        return Command::SUCCESS;
57✔
101
    }
102

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

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

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

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

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

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

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

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

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

155
        $processed = 0;
57✔
156

157
        foreach ($tasks as $task) {
57✔
158
            if ($this->shouldStop()) {
55✔
159
                break;
×
160
            }
161

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

167
                $data = json_decode($task->data, TRUE);
55✔
168

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

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

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

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

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

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

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

201
        if ($processed > 0) {
57✔
202
            $this->info("Processed {$processed} task(s).");
48✔
203
        }
204

205
        return $processed;
57✔
206
    }
207

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

564
        $spooler->spool($mail, $posterEmail);
8✔
565

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

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

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

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

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

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

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

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

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

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

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

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

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

689
        $spooler->spool($mail, $memberEmail);
7✔
690

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

884
        $u1 = User::find($user1Id);
2✔
885
        $u2 = User::find($user2Id);
2✔
886

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

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

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

902
            if (! $recipientEmail) {
2✔
903
                continue;
×
904
            }
905

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

917
            $spooler->spool($mail, $recipientEmail);
2✔
918
        }
919

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

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

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

944
        $user = User::find($userId);
3✔
945

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

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

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

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

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

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

983
        $key = $recentKey;
2✔
984

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

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

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

1005
        $spooler->spool($mail, $email);
2✔
1006

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

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

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

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

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

1037
        $user = User::find($userId);
1✔
1038

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

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

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

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

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

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

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

1077
        $spooler->spool($mail, $recipients);
1✔
1078

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

1085
    /**
1086
     * Process a housekeeping notification from the Chrome extension.
1087
     */
1088
    protected function handleHousekeeperNotify(array $data): void
×
1089
    {
1090
        $service = app(HousekeeperService::class);
×
1091
        $service->process($data);
×
1092
    }
1093

1094
    /**
1095
     * Resolve the BCC address for a mod standard message action.
1096
     *
1097
     * V1 parity: ModConfig::getForGroup() + ModConfig::getBcc() + ModConfig::evalIt().
1098
     *
1099
     * @param int    $byUser  The moderator's user ID
1100
     * @param int    $groupId The group ID
1101
     * @param string $action  The action string (Approve, Reject, Leave Approved Member, etc.)
1102
     * @return string|null    The resolved BCC email address, or null if none configured
1103
     */
1104
    private function resolveBccAddress(int $byUser, int $groupId, string $action): ?string
7✔
1105
    {
1106
        if ($groupId === 0 || $action === '') {
7✔
1107
            return null;
×
1108
        }
1109

1110
        // Step 1: Find the mod's config for this group (V1: ModConfig::getForGroup).
1111
        $configId = DB::table('memberships')
7✔
1112
            ->where('userid', $byUser)
7✔
1113
            ->where('groupid', $groupId)
7✔
1114
            ->value('configid');
7✔
1115

1116
        if (! $configId) {
7✔
1117
            // Fall back to any other mod's config for this group.
1118
            $configId = DB::table('memberships')
3✔
1119
                ->where('groupid', $groupId)
3✔
1120
                ->whereIn('role', ['Moderator', 'Owner'])
3✔
1121
                ->whereNotNull('configid')
3✔
1122
                ->value('configid');
3✔
1123
        }
1124

1125
        if (! $configId) {
7✔
1126
            // Fall back to any config created by this mod.
1127
            $configId = DB::table('mod_configs')
2✔
1128
                ->where('createdby', $byUser)
2✔
1129
                ->value('id');
2✔
1130
        }
1131

1132
        if (! $configId) {
7✔
1133
            // Fall back to a default config.
1134
            $configId = DB::table('mod_configs')
2✔
1135
                ->where('default', 1)
2✔
1136
                ->value('id');
2✔
1137
        }
1138

1139
        if (! $configId) {
7✔
1140
            return null;
2✔
1141
        }
1142

1143
        // Step 2: Map action to CC column pair (V1: ModConfig::getBcc).
1144
        [$toColumn, $addrColumn] = match ($action) {
5✔
1145
            'Approve', 'Reject', 'Leave' => ['ccrejectto', 'ccrejectaddr'],
4✔
1146
            'Leave Member' => ['ccrejmembto', 'ccrejmembaddr'],
×
1147
            'Leave Approved Message', 'Delete Approved Message' => ['ccfollowupto', 'ccfollowupaddr'],
×
1148
            'Leave Approved Member', 'Delete Approved Member' => ['ccfollmembto', 'ccfollmembaddr'],
1✔
1149
            default => [null, null],
5✔
1150
        };
5✔
1151

1152
        if (! $toColumn) {
5✔
1153
            return null;
×
1154
        }
1155

1156
        // Step 3: Look up the config and evaluate (V1: ModConfig::evalIt).
1157
        $config = DB::table('mod_configs')
5✔
1158
            ->where('id', $configId)
5✔
1159
            ->first([$toColumn, $addrColumn]);
5✔
1160

1161
        if (! $config) {
5✔
1162
            return null;
×
1163
        }
1164

1165
        $to = $config->$toColumn;
5✔
1166
        $addr = $config->$addrColumn;
5✔
1167

1168
        if ($to === 'Me') {
5✔
1169
            $modUser = User::find($byUser);
1✔
1170

1171
            return $modUser?->email_preferred;
1✔
1172
        }
1173

1174
        if ($to === 'Specific') {
4✔
1175
            return $addr ?: null;
3✔
1176
        }
1177

1178
        return null;
1✔
1179
    }
1180

1181
    /**
1182
     * Send a BCC copy of a mod standard message if configured.
1183
     *
1184
     * V1 parity: both Message::mail() and User::maybeMail() send a BCC copy
1185
     * with body prefixed by "(This is a BCC of a message sent to Freegle user #...)".
1186
     */
1187
    private function sendBccIfConfigured(
15✔
1188
        array $data,
1189
        int $byUser,
1190
        int $groupId,
1191
        string $groupNameShort,
1192
        string $groupName,
1193
        string $subject,
1194
        string $body,
1195
        int $recipientUserId,
1196
        string $recipientEmail,
1197
        string $messageSubject,
1198
        int $msgId,
1199
        ?string $groupContactMail,
1200
        string $modName,
1201
        EmailSpoolerService $spooler,
1202
        bool $shouldSpool
1203
    ): void {
1204
        $action = $data['action'] ?? '';
15✔
1205
        if ($action === '' || $groupId === 0) {
15✔
1206
            return;
8✔
1207
        }
1208

1209
        $bccAddress = $this->resolveBccAddress($byUser, $groupId, $action);
7✔
1210
        if (! $bccAddress) {
7✔
1211
            return;
3✔
1212
        }
1213

1214
        // V1: replace $groupname in BCC address.
1215
        $bccAddress = str_replace('$groupname', $groupNameShort, $bccAddress);
4✔
1216

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

1220
        $bccMail = new ModStdMessageMail(
4✔
1221
            modName: $modName,
4✔
1222
            groupName: $groupName,
4✔
1223
            groupNameShort: $groupNameShort,
4✔
1224
            stdSubject: $subject,
4✔
1225
            stdBody: $bccBody,
4✔
1226
            messageSubject: $messageSubject,
4✔
1227
            msgId: $msgId,
4✔
1228
            recipientUserId: 0,
4✔
1229
            recipientEmail: $bccAddress,
4✔
1230
            groupContactMail: $groupContactMail,
4✔
1231
        );
4✔
1232

1233
        $spooler->spool($bccMail, $bccAddress);
4✔
1234

1235
        Log::info('Sent BCC copy of mod stdmsg', [
4✔
1236
            'action' => $action,
4✔
1237
            'bcc' => $bccAddress,
4✔
1238
            'byuser' => $byUser,
4✔
1239
            'groupid' => $groupId,
4✔
1240
        ]);
4✔
1241
    }
1242

1243
    /**
1244
     * Add a post to freebiealerts.app.
1245
     *
1246
     * V1 parity with FreebieAlerts::add() — only sends outstanding Offers with
1247
     * a location. TrashNothing messages are skipped (TN syncs directly).
1248
     */
1249
    protected function handleFreebieAlertsAdd(array $data): void
4✔
1250
    {
1251
        $msgId = (int) ($data['msgid'] ?? 0);
4✔
1252
        if ($msgId === 0) {
4✔
1253
            throw new \RuntimeException('freebie_alerts_add requires msgid');
×
1254
        }
1255

1256
        $apiKey = config('freegle.freebie_alerts.api_key');
4✔
1257
        if (empty($apiKey)) {
4✔
1258
            Log::debug('Freebie Alerts API key not configured, skipping add', ['msgid' => $msgId]);
1✔
1259
            return;
1✔
1260
        }
1261

1262
        // Only outstanding Offers (no outcome yet).
1263
        $msg = DB::table('messages')->where('id', $msgId)->first();
3✔
1264
        if (! $msg || $msg->type !== 'Offer') {
3✔
1265
            return;
1✔
1266
        }
1267

1268
        $hasOutcome = DB::table('messages_outcomes')->where('msgid', $msgId)->exists();
2✔
1269
        if ($hasOutcome) {
2✔
1270
            return;
1✔
1271
        }
1272

1273
        // Skip TrashNothing messages — TN syncs to freebiealerts directly.
1274
        if ($msg->sourceheader && str_starts_with($msg->sourceheader, 'TN-')) {
1✔
1275
            return;
×
1276
        }
1277

1278
        $group = DB::table('messages_groups')
1✔
1279
            ->where('msgid', $msgId)
1✔
1280
            ->where('collection', 'Approved')
1✔
1281
            ->first();
1✔
1282
        if (! $group) {
1✔
1283
            return;
×
1284
        }
1285

1286
        if (! $msg->lat || ! $msg->lng) {
1✔
1287
            return;
×
1288
        }
1289

1290
        // Build image list from message attachments.
1291
        // Same pattern as digest emails: externalurl if set, otherwise {images.domain}/timg_{id}.jpg.
1292
        $imagesDomain = config('freegle.images.domain', 'https://images.ilovefreegle.org');
1✔
1293
        $attachments = DB::table('messages_attachments')->where('msgid', $msgId)->get();
1✔
1294
        $images = $attachments->map(function ($att) use ($imagesDomain) {
1✔
1295
            return ! empty($att->externalurl)
×
1296
                ? $att->externalurl
×
1297
                : "{$imagesDomain}/timg_{$att->id}.jpg";
×
1298
        })->implode(',');
1✔
1299

1300
        $body = $msg->textbody ?: 'No description';
1✔
1301

1302
        $payload = [
1✔
1303
            'id' => $msgId,
1✔
1304
            'title' => $msg->subject,
1✔
1305
            'description' => $body,
1✔
1306
            'latitude' => $msg->lat,
1✔
1307
            'longitude' => $msg->lng,
1✔
1308
            'images' => $images,
1✔
1309
            'created_at' => $group->arrival,
1✔
1310
        ];
1✔
1311

1312
        $apiUrl = config('freegle.freebie_alerts.api_url');
1✔
1313

1314
        try {
1315
            $response = \Illuminate\Support\Facades\Http::withHeaders([
1✔
1316
                'Content-type' => 'application/json',
1✔
1317
                'Key' => $apiKey,
1✔
1318
            ])->timeout(60)->post("{$apiUrl}/freegle/post/create", $payload);
1✔
1319

1320
            if ($response->successful()) {
1✔
1321
                Log::info('Added post to Freebie Alerts', ['msgid' => $msgId]);
1✔
1322
            } else {
1323
                Log::warning('Freebie Alerts add failed', [
×
1324
                    'msgid' => $msgId,
×
1325
                    'status' => $response->status(),
×
1326
                    'body' => $response->body(),
×
1327
                ]);
×
1328
                if (app()->bound('sentry')) {
×
1329
                    app('sentry')->captureMessage("Freebie Alerts add failed for message {$msgId}: {$response->status()}");
1✔
1330
                }
1331
            }
1332
        } catch (\Throwable $e) {
×
1333
            Log::error('Freebie Alerts add exception', [
×
1334
                'msgid' => $msgId,
×
1335
                'error' => $e->getMessage(),
×
1336
            ]);
×
1337
            if (app()->bound('sentry')) {
×
1338
                app('sentry')->captureException($e);
×
1339
            }
1340
        }
1341
    }
1342

1343
    /**
1344
     * Remove a post from freebiealerts.app.
1345
     *
1346
     * V1 parity with FreebieAlerts::remove().
1347
     */
1348
    protected function handleFreebieAlertsRemove(array $data): void
2✔
1349
    {
1350
        $msgId = (int) ($data['msgid'] ?? 0);
2✔
1351
        if ($msgId === 0) {
2✔
1352
            throw new \RuntimeException('freebie_alerts_remove requires msgid');
×
1353
        }
1354

1355
        $apiKey = config('freegle.freebie_alerts.api_key');
2✔
1356
        if (empty($apiKey)) {
2✔
1357
            Log::debug('Freebie Alerts API key not configured, skipping remove', ['msgid' => $msgId]);
1✔
1358
            return;
1✔
1359
        }
1360

1361
        $apiUrl = config('freegle.freebie_alerts.api_url');
1✔
1362

1363
        try {
1364
            $response = \Illuminate\Support\Facades\Http::withHeaders([
1✔
1365
                'Content-type' => 'application/json',
1✔
1366
                'Key' => $apiKey,
1✔
1367
            ])->timeout(60)->post("{$apiUrl}/freegle/post/{$msgId}/delete");
1✔
1368

1369
            if ($response->successful()) {
1✔
1370
                Log::info('Removed post from Freebie Alerts', ['msgid' => $msgId]);
1✔
1371
            } else {
1372
                Log::warning('Freebie Alerts remove failed', [
1✔
1373
                    'msgid' => $msgId,
1✔
1374
                    'status' => $response->status(),
1✔
1375
                    'body' => $response->body(),
1✔
1376
                ]);
1✔
1377
            }
1378
        } catch (\Throwable $e) {
×
1379
            Log::error('Freebie Alerts remove exception', [
×
1380
                'msgid' => $msgId,
×
1381
                'error' => $e->getMessage(),
×
1382
            ]);
×
1383
            if (app()->bound('sentry')) {
×
1384
                app('sentry')->captureException($e);
×
1385
            }
1386
        }
1387
    }
1388

1389
    /**
1390
     * Obfuscate an email address for display (e.g. "j***@example.com").
1391
     */
1392
    private function obfuscateEmail(string $email): string
2✔
1393
    {
1394
        if (! $email || ! str_contains($email, '@')) {
2✔
1395
            return $email;
×
1396
        }
1397

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

1400
        if (strlen($local) <= 1) {
2✔
1401
            return '*@' . $domain;
×
1402
        }
1403

1404
        return $local[0] . str_repeat('*', strlen($local) - 1) . '@' . $domain;
2✔
1405
    }
1406

1407
    /**
1408
     * Remap postcodes to their nearest area after a location geometry change.
1409
     */
1410
    protected function handleRemapPostcodes(array $data): void
×
1411
    {
1412
        $locationId = isset($data['location_id']) ? (int) $data['location_id'] : NULL;
×
1413
        $polygon = $data['polygon'] ?? NULL;
×
1414

1415
        $service = app(PostcodeRemapService::class);
×
1416
        $updated = $service->remapPostcodes($locationId, $polygon);
×
1417

1418
        Log::info('Remapped postcodes', [
×
1419
            'location_id' => $locationId,
×
1420
            'updated' => $updated,
×
1421
        ]);
×
1422
    }
1423

1424
    protected function handleUserForget(array $data): void
2✔
1425
    {
1426
        $userId = isset($data['user_id']) ? (int) $data['user_id'] : NULL;
2✔
1427
        $reason = $data['reason'] ?? 'Support purge';
2✔
1428

1429
        if (! $userId) {
2✔
1430
            throw new \RuntimeException('user_forget requires user_id');
1✔
1431
        }
1432

1433
        $service = app(UserManagementService::class);
1✔
1434
        $service->forgetUser($userId, $reason);
1✔
1435

1436
        Log::info('User forgotten via background task', [
1✔
1437
            'user_id' => $userId,
1✔
1438
            'reason'  => $reason,
1✔
1439
        ]);
1✔
1440
    }
1441

1442
    protected function handleTnSyncCommand(array $data): void
2✔
1443
    {
1444
        $args = [];
2✔
1445

1446
        if (!empty($data['from'])) {
2✔
1447
            $args['--from'] = (string) $data['from'];
2✔
1448
        }
1449

1450
        if (!empty($data['to'])) {
2✔
1451
            $args['--to'] = (string) $data['to'];
2✔
1452
        }
1453

1454
        if (!empty($data['run_id'])) {
2✔
1455
            $args['--run-id'] = (string) $data['run_id'];
×
1456
        }
1457

1458
        if (isset($data['dry_run']) && (bool) $data['dry_run']) {
2✔
1459
            $args['--dry-run'] = true;
×
1460
        }
1461

1462
        if (isset($data['local_testing']) && (bool) $data['local_testing']) {
2✔
1463
            $args['--local-testing'] = true;
1✔
1464
        }
1465

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

1468
        Log::info('Processed tn_sync_command task', [
2✔
1469
            'exit_code' => $exitCode,
2✔
1470
            'from' => $args['--from'] ?? null,
2✔
1471
            'to' => $args['--to'] ?? null,
2✔
1472
            'run_id' => $args['--run-id'] ?? null,
2✔
1473
            'dry_run' => $args['--dry-run'] ?? null,
2✔
1474
        ]);
2✔
1475

1476
        if ($exitCode !== 0) {
2✔
1477
            throw new \RuntimeException("tn:sync failed with exit code {$exitCode}");
×
1478
        }
1479
    }
1480
}
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