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

Freegle / Iznik / 15405

19 May 2026 04:44PM UTC coverage: 69.555% (-3.4%) from 72.97%
15405

push

circleci

edwh
fix(fastlane): correct File.exist? path for modtools Google Play key

Fastlane's CWD is the fastlane/ directory, so File.exist? must use a
bare filename — not 'fastlane/modtools-google-play-api-key.json' which
resolves to fastlane/fastlane/... and is always missing.
Matches the pattern used by the working Freegle beta lane.

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

10030 of 13466 branches covered (74.48%)

Branch coverage included in aggregate %.

106846 of 154568 relevant lines covered (69.13%)

34.65 hits per line

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

85.91
/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\DB;
26
use Illuminate\Support\Facades\Log;
27
use Illuminate\Support\Facades\Mail;
28
use Illuminate\Support\Str;
29

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

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

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

51
    private const MAX_ATTEMPTS = 3;
52

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

58
            return Command::SUCCESS;
×
59
        }
60

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

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

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

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

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

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

94
            if ($processed === 0) {
50✔
95
                sleep($sleepSeconds);
8✔
96
            }
97
        }
98

99
        return Command::SUCCESS;
50✔
100
    }
101

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

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

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

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

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

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

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

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

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

154
        $processed = 0;
50✔
155

156
        foreach ($tasks as $task) {
50✔
157
            if ($this->shouldStop()) {
48✔
158
                break;
×
159
            }
160

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

166
                $data = json_decode($task->data, TRUE);
48✔
167

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

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

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

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

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

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

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

200
        if ($processed > 0) {
50✔
201
            $this->info("Processed {$processed} task(s).");
42✔
202
        }
203

204
        return $processed;
50✔
205
    }
206

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

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

248
        if (! $groupId) {
2✔
249
            throw new \RuntimeException('push_notify_group_mods requires group_id');
×
250
        }
251

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

256
    /**
257
     * Send a ChitChat report email to support.
258
     */
259
    protected function handleEmailChitchatReport(
4✔
260
        array $data,
261
        EmailSpoolerService $spooler,
262
        bool $shouldSpool
263
    ): void {
264
        $required = ['user_id', 'user_email', 'newsfeed_id', 'reason'];
4✔
265
        foreach ($required as $field) {
4✔
266
            if (empty($data[$field])) {
4✔
267
                throw new \RuntimeException("email_chitchat_report requires {$field}");
1✔
268
            }
269
        }
270

271
        // user_name is cosmetic — reporters identified by id+email; fall back if blank.
272
        $reporterName = !empty($data['user_name']) ? $data['user_name'] : 'A Freegle user';
3✔
273

274
        $mail = new ChitchatReportMail(
3✔
275
            reporterName: $reporterName,
3✔
276
            reporterId: (int) $data['user_id'],
3✔
277
            reporterEmail: $data['user_email'],
3✔
278
            newsfeedId: (int) $data['newsfeed_id'],
3✔
279
            reason: $data['reason'],
3✔
280
        );
3✔
281

282
        if ($shouldSpool) {
3✔
283
            $recipients = array_map('trim', explode(',', config('freegle.mail.chitchat_support_addr')));
×
284
            $spooler->spool($mail, $recipients);
×
285
        } else {
286
            Mail::send($mail);
3✔
287
        }
288

289
        Log::info('Sent ChitChat report email', [
3✔
290
            'reporter_id' => $data['user_id'],
3✔
291
            'newsfeed_id' => $data['newsfeed_id'],
3✔
292
        ]);
3✔
293
    }
294

295
    /**
296
     * Send an external donation notification email to the info address.
297
     */
298
    protected function handleEmailDonateExternal(
4✔
299
        array $data,
300
        EmailSpoolerService $spooler,
301
        bool $shouldSpool
302
    ): void {
303
        $required = ['user_id', 'user_name', 'user_email', 'amount'];
4✔
304
        foreach ($required as $field) {
4✔
305
            if (empty($data[$field])) {
4✔
306
                throw new \RuntimeException("email_donate_external requires {$field}");
1✔
307
            }
308
        }
309

310
        $mail = new DonateExternalMail(
3✔
311
            userName: $data['user_name'],
3✔
312
            userId: (int) $data['user_id'],
3✔
313
            userEmail: $data['user_email'],
3✔
314
            amount: (float) $data['amount'],
3✔
315
            source: $data['source'] ?? DonateExternalMail::SOURCE_EXTERNAL,
3✔
316
        );
3✔
317

318
        if ($shouldSpool) {
3✔
319
            $spooler->spool($mail, config('freegle.mail.info_addr'));
×
320
        } else {
321
            Mail::send($mail);
3✔
322
        }
323

324
        Log::info('Sent external donation email', [
3✔
325
            'user_id' => $data['user_id'],
3✔
326
            'amount' => $data['amount'],
3✔
327
        ]);
3✔
328
    }
329

330
    /**
331
     * Send a charity partner signup notification to the partnerships team.
332
     */
333
    protected function handleEmailCharitySignup(
×
334
        array $data,
335
        EmailSpoolerService $spooler,
336
        bool $shouldSpool
337
    ): void {
338
        if (empty($data['orgname']) || empty($data['contactemail'])) {
×
339
            throw new \RuntimeException('email_charity_signup requires orgname and contactemail');
×
340
        }
341

342
        $mail = new CharitySignupMail(
×
343
            charityId: (int) $data['charity_id'],
×
344
            orgName: $data['orgname'],
×
345
            orgType: $data['orgtype'] ?? 'registered',
×
346
            charityNumber: $data['charitynumber'] ?? null,
×
347
            contactEmail: $data['contactemail'],
×
348
            contactName: $data['contactname'] ?? null,
×
349
            website: $data['website'] ?? null,
×
350
            description: $data['description'] ?? null,
×
351
        );
×
352

353
        if ($shouldSpool) {
×
354
            $spooler->spool($mail, config('freegle.mail.partnerships_addr'));
×
355
        } else {
356
            Mail::send($mail);
×
357
        }
358

359
        Log::info('Sent charity signup notification', [
×
360
            'charity_id' => $data['charity_id'],
×
361
            'orgname' => $data['orgname'],
×
362
        ]);
×
363
    }
364

365
    /**
366
     * Send a forgot-password email with auto-login link.
367
     */
368
    protected function handleEmailForgotPassword(
2✔
369
        array $data,
370
        EmailSpoolerService $spooler,
371
        bool $shouldSpool
372
    ): void {
373
        $required = ['user_id', 'email', 'reset_url'];
2✔
374
        foreach ($required as $field) {
2✔
375
            if (empty($data[$field])) {
2✔
376
                throw new \RuntimeException("email_forgot_password requires {$field}");
×
377
            }
378
        }
379

380
        $mail = new ForgotPasswordMail(
2✔
381
            userId: (int) $data['user_id'],
2✔
382
            email: $data['email'],
2✔
383
            resetUrl: $data['reset_url'],
2✔
384
        );
2✔
385

386
        if ($shouldSpool) {
2✔
387
            $spooler->spool($mail, $data['email']);
×
388
        } else {
389
            Mail::send($mail);
2✔
390
        }
391

392
        Log::info('Sent forgot password email', [
2✔
393
            'user_id' => $data['user_id'],
2✔
394
        ]);
2✔
395
    }
396

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

412
        $mail = new UnsubscribeConfirmMail(
2✔
413
            userId: (int) $data['user_id'],
2✔
414
            email: $data['email'],
2✔
415
            unsubUrl: $data['unsub_url'],
2✔
416
        );
2✔
417

418
        if ($shouldSpool) {
2✔
419
            $spooler->spool($mail, $data['email']);
×
420
        } else {
421
            Mail::send($mail);
2✔
422
        }
423

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

429
    /**
430
     * Handle mod standard message emails (approve, reject, reply).
431
     *
432
     * Looks up the message poster, group, and mod info, then:
433
     * 1. Sends the stdmsg email (if subject/body provided).
434
     * 2. Creates a User2Mod chat message for the mod log.
435
     * 3. Creates a mod log entry (always — even for plain approve with no stdmsg).
436
     * 4. Queues push notifications to group moderators.
437
     */
438
    protected function handleModStdMessage(
8✔
439
        string $taskType,
440
        array $data,
441
        PushNotificationService $pushService,
442
        EmailSpoolerService $spooler,
443
        bool $shouldSpool
444
    ): void {
445
        $msgId = (int) ($data['msgid'] ?? 0);
8✔
446
        $byUser = (int) ($data['byuser'] ?? 0);
8✔
447
        $groupId = (int) ($data['groupid'] ?? 0);
8✔
448
        $subject = $data['subject'] ?? '';
8✔
449
        $body = $data['body'] ?? '';
8✔
450
        $stdmsgId = (int) ($data['stdmsgid'] ?? 0);
8✔
451

452
        // Fall back to looking up group from messages_groups if not provided.
453
        if ($groupId === 0 && $msgId > 0) {
8✔
454
            $groupId = (int) (DB::table('messages_groups')->where('msgid', $msgId)->value('groupid') ?? 0);
1✔
455
        }
456

457
        if ($msgId === 0 || $byUser === 0) {
8✔
458
            throw new \RuntimeException("{$taskType} requires msgid and byuser");
×
459
        }
460

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

464
        // Determine the log subtype from the task type.
465
        // email_message_approved → Approved
466
        // email_message_rejected with subject → Rejected, without subject → Deleted
467
        // email_message_reply → Replied
468
        $subtype = match ($taskType) {
8✔
469
            BackgroundTask::TASK_EMAIL_MESSAGE_APPROVED => 'Approved',
8✔
470
            BackgroundTask::TASK_EMAIL_MESSAGE_REJECTED => $subject !== '' ? 'Rejected' : 'Deleted',
6✔
471
            BackgroundTask::TASK_EMAIL_MESSAGE_REPLY    => 'Replied',
1✔
472
            default => 'Approved',
×
473
        };
8✔
474

475
        // Always create the mod log entry (even if no stdmsg content).
476
        DB::table('logs')->insert([
8✔
477
            'timestamp' => now(),
8✔
478
            'type' => 'Message',
8✔
479
            'subtype' => $subtype,
8✔
480
            'msgid' => $msgId,
8✔
481
            'user' => $posterId ?: null,
8✔
482
            'byuser' => $byUser,
8✔
483
            'groupid' => $groupId ?: null,
8✔
484
            'stdmsgid' => $stdmsgId ?: null,
8✔
485
            'text' => $subject,
8✔
486
        ]);
8✔
487

488
        // Queue push notifications to group moderators.
489
        if ($groupId > 0) {
8✔
490
            $pushService->notifyGroupMods($groupId);
8✔
491
        }
492

493
        // No subject/body means no stdmsg email to send (e.g. plain approve without message).
494
        if ($subject === '' && $body === '') {
8✔
495
            Log::info("Mod action {$taskType} without stdmsg content, skipping email", [
1✔
496
                'msgid' => $msgId,
1✔
497
                'byuser' => $byUser,
1✔
498
            ]);
1✔
499
            return;
1✔
500
        }
501

502
        if (! $posterId) {
7✔
503
            Log::warning("No poster found for message {$msgId}");
×
504
            return;
×
505
        }
506

507
        $poster = User::find($posterId);
7✔
508
        $posterEmail = $poster?->email_preferred;
7✔
509

510
        if (! $posterEmail) {
7✔
511
            Log::warning("No email found for poster of message {$msgId}");
×
512
            return;
×
513
        }
514

515
        // Look up the group info.
516
        $groupName = '';
7✔
517
        $groupNameShort = '';
7✔
518
        $groupContactMail = null;
7✔
519
        if ($groupId > 0) {
7✔
520
            $group = DB::table('groups')->where('id', $groupId)->first();
7✔
521
            if ($group) {
7✔
522
                $groupName = $group->namefull ?: $group->nameshort ?? '';
7✔
523
                $groupNameShort = $group->nameshort ?? '';
7✔
524
                $groupContactMail = $group->contactmail ?: null;
7✔
525
            }
526
        }
527

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

531
        // Look up the message subject for context.
532
        $messageSubject = DB::table('messages')->where('id', $msgId)->value('subject') ?? '';
7✔
533

534
        $mail = new ModStdMessageMail(
7✔
535
            modName: $modName,
7✔
536
            groupName: $groupName,
7✔
537
            groupNameShort: $groupNameShort,
7✔
538
            stdSubject: $subject,
7✔
539
            stdBody: $body,
7✔
540
            messageSubject: $messageSubject,
7✔
541
            msgId: $msgId,
7✔
542
            recipientUserId: $posterId,
7✔
543
            recipientEmail: $posterEmail,
7✔
544
            groupContactMail: $groupContactMail,
7✔
545
        );
7✔
546

547
        if ($shouldSpool) {
7✔
548
            $spooler->spool($mail, $posterEmail);
×
549
        } else {
550
            Mail::to($posterEmail)->send($mail);
7✔
551
        }
552

553
        // V1 parity: send BCC copy if configured in mod's ModConfig.
554
        $this->sendBccIfConfigured(
7✔
555
            data: $data,
7✔
556
            byUser: $byUser,
7✔
557
            groupId: $groupId,
7✔
558
            groupNameShort: $groupNameShort,
7✔
559
            groupName: $groupName,
7✔
560
            subject: $subject,
7✔
561
            body: $body,
7✔
562
            recipientUserId: $posterId,
7✔
563
            recipientEmail: $posterEmail,
7✔
564
            messageSubject: $messageSubject,
7✔
565
            msgId: $msgId,
7✔
566
            groupContactMail: $groupContactMail,
7✔
567
            modName: $modName,
7✔
568
            spooler: $spooler,
7✔
569
            shouldSpool: $shouldSpool,
7✔
570
        );
7✔
571

572
        // Create a User2Mod chat message so the conversation appears in modtools chats.
573
        if ($groupId > 0) {
7✔
574
            $chatRoom = ChatRoom::getOrCreateUser2Mod($posterId, $groupId);
7✔
575

576
            if ($chatRoom) {
7✔
577
                DB::table('chat_messages')->insert([
7✔
578
                    'chatid' => $chatRoom->id,
7✔
579
                    'userid' => $byUser,
7✔
580
                    'message' => "{$subject}\r\n\r\n{$body}",
7✔
581
                    'type' => 'ModMail',
7✔
582
                    'refmsgid' => $msgId,
7✔
583
                    'date' => now(),
7✔
584
                    'reviewrequired' => 0,
7✔
585
                    'processingrequired' => 0,
7✔
586
                    'processingsuccessful' => 1,
7✔
587
                ]);
7✔
588
            }
589
        }
590

591
        Log::info("Sent mod stdmsg email ({$taskType})", [
7✔
592
            'msgid' => $msgId,
7✔
593
            'byuser' => $byUser,
7✔
594
            'groupid' => $groupId,
7✔
595
            'recipient' => $posterEmail,
7✔
596
        ]);
7✔
597
    }
598

599
    /**
600
     * Handle mod standard message emails sent to a member (not related to a message).
601
     *
602
     * V1 parity with User::mail() + User::maybeMail():
603
     * 1. Send email to the member.
604
     * 2. Create a User2Mod chat message for the mod log.
605
     */
606
    protected function handleModStdMessageForMember(
7✔
607
        string $taskType,
608
        array $data,
609
        EmailSpoolerService $spooler,
610
        bool $shouldSpool
611
    ): void {
612
        $userId = (int) ($data['userid'] ?? 0);
7✔
613
        $byUser = (int) ($data['byuser'] ?? 0);
7✔
614
        $groupId = (int) ($data['groupid'] ?? 0);
7✔
615
        $subject = $data['subject'] ?? '';
7✔
616
        $body = $data['body'] ?? '';
7✔
617
        $stdmsgId = (int) ($data['stdmsgid'] ?? 0);
7✔
618

619
        if ($userId === 0 || $byUser === 0) {
7✔
620
            throw new \RuntimeException('email_mod_stdmsg requires userid and byuser');
×
621
        }
622

623
        if ($subject === '' && $body === '') {
7✔
624
            Log::info('Mod stdmsg for member without content, skipping email', [
1✔
625
                'userid' => $userId,
1✔
626
                'byuser' => $byUser,
1✔
627
            ]);
1✔
628
            return;
1✔
629
        }
630

631
        // Look up the member's preferred email.
632
        $member = User::find($userId);
6✔
633
        $memberEmail = $member?->email_preferred;
6✔
634

635
        if (! $memberEmail) {
6✔
636
            Log::warning("No email found for member {$userId}");
×
637
            return;
×
638
        }
639

640
        // Look up group info.
641
        $groupName = '';
6✔
642
        $groupNameShort = '';
6✔
643
        $groupContactMail = null;
6✔
644
        if ($groupId > 0) {
6✔
645
            $group = DB::table('groups')->where('id', $groupId)->first();
6✔
646
            if ($group) {
6✔
647
                $groupName = $group->namefull ?: $group->nameshort ?? '';
6✔
648
                $groupNameShort = $group->nameshort ?? '';
6✔
649
                $groupContactMail = $group->contactmail ?: null;
6✔
650
            }
651
        }
652

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

656
        $mail = new ModStdMessageMail(
6✔
657
            modName: $modName,
6✔
658
            groupName: $groupName,
6✔
659
            groupNameShort: $groupNameShort,
6✔
660
            stdSubject: $subject,
6✔
661
            stdBody: $body,
6✔
662
            messageSubject: '',
6✔
663
            msgId: 0,
6✔
664
            recipientUserId: $userId,
6✔
665
            recipientEmail: $memberEmail,
6✔
666
            groupContactMail: $groupContactMail,
6✔
667
        );
6✔
668

669
        if ($shouldSpool) {
6✔
670
            $spooler->spool($mail, $memberEmail);
×
671
        } else {
672
            Mail::to($memberEmail)->send($mail);
6✔
673
        }
674

675
        // V1 parity: send BCC copy if configured in mod's ModConfig.
676
        $this->sendBccIfConfigured(
6✔
677
            data: $data,
6✔
678
            byUser: $byUser,
6✔
679
            groupId: $groupId,
6✔
680
            groupNameShort: $groupNameShort,
6✔
681
            groupName: $groupName,
6✔
682
            subject: $subject,
6✔
683
            body: $body,
6✔
684
            recipientUserId: $userId,
6✔
685
            recipientEmail: $memberEmail,
6✔
686
            messageSubject: '',
6✔
687
            msgId: 0,
6✔
688
            groupContactMail: $groupContactMail,
6✔
689
            modName: $modName,
6✔
690
            spooler: $spooler,
6✔
691
            shouldSpool: $shouldSpool,
6✔
692
        );
6✔
693

694
        // Create a User2Mod chat message so the conversation appears in modtools chats.
695
        if ($groupId > 0) {
6✔
696
            $chatRoom = ChatRoom::getOrCreateUser2Mod($userId, $groupId);
6✔
697

698
            if ($chatRoom) {
6✔
699
                $chatMessageId = DB::table('chat_messages')->insertGetId([
6✔
700
                    'chatid' => $chatRoom->id,
6✔
701
                    'userid' => $byUser,
6✔
702
                    'message' => "{$subject}\r\n\r\n{$body}",
6✔
703
                    'type' => 'ModMail',
6✔
704
                    'date' => now(),
6✔
705
                    'reviewrequired' => 0,
6✔
706
                    'processingrequired' => 0,
6✔
707
                    'processingsuccessful' => 1,
6✔
708
                ]);
6✔
709

710
                // V1 parity: upToDate() — mark the chat message as already emailed to the member
711
                // so the notification daemon (NotifyUser2ModCommand) does not send a duplicate.
712
                // V1 calls $r->upToDate($fromuser) after the direct email send, which sets
713
                // lastmsgemailed = MAX(chat_messages.id) for the member's roster entry.
714
                DB::table('chat_roster')->upsert(
6✔
715
                    [
6✔
716
                        'chatid' => $chatRoom->id,
6✔
717
                        'userid' => $userId,
6✔
718
                        'lastmsgemailed' => $chatMessageId,
6✔
719
                        'lastemailed' => now(),
6✔
720
                    ],
6✔
721
                    ['chatid', 'userid'],
6✔
722
                    ['lastmsgemailed', 'lastemailed']
6✔
723
                );
6✔
724
            }
725

726
            // Only create the User/Mailed log for email_mod_stdmsg (direct mod message to member).
727
            // Membership approve/reject actions no longer route here — they use email_mod_stdmsg
728
            // directly (or create no task if no content), so we only log when it's a direct modmail.
729
            if ($taskType === BackgroundTask::TASK_EMAIL_MOD_STDMSG) {
6✔
730
                DB::table('logs')->insert([
6✔
731
                    'timestamp' => now(),
6✔
732
                    'type' => 'User',
6✔
733
                    'subtype' => 'Mailed',
6✔
734
                    'byuser' => $byUser,
6✔
735
                    'user' => $userId,
6✔
736
                    'groupid' => $groupId,
6✔
737
                    'stdmsgid' => $stdmsgId ?: null,
6✔
738
                    'text' => $subject,
6✔
739
                ]);
6✔
740
                // Note: users_modmails is populated by the syncModMailCounts cron job
741
                // which scans the logs table — no direct insert needed here.
742
            }
743
        }
744

745
        Log::info('Sent mod stdmsg email to member', [
6✔
746
            'userid' => $userId,
6✔
747
            'byuser' => $byUser,
6✔
748
            'groupid' => $groupId,
6✔
749
            'recipient' => $memberEmail,
6✔
750
        ]);
6✔
751
    }
752

753
    /**
754
     * Handle message outcome background processing.
755
     *
756
     * V1 parity with Message::backgroundMark():
757
     * 1. Log the outcome to the logs table for each group the message is on.
758
     * 2. Notify interested users (who replied but didn't get the item) by creating
759
     *    TYPE_COMPLETED chat messages in their User2User chat rooms.
760
     */
761
    protected function handleMessageOutcome(array $data): void
3✔
762
    {
763
        $msgId = (int) ($data['msgid'] ?? 0);
3✔
764
        $byUser = (int) ($data['byuser'] ?? 0);
3✔
765
        $outcome = $data['outcome'] ?? '';
3✔
766
        $happiness = $data['happiness'] ?? '';
3✔
767
        $comment = $data['comment'] ?? '';
3✔
768
        $userid = (int) ($data['userid'] ?? 0);
3✔
769
        $messageForOthers = $data['message'] ?? '';
3✔
770

771
        if ($msgId === 0) {
3✔
772
            throw new \RuntimeException('message_outcome requires msgid');
×
773
        }
774

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

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

781
        foreach ($groups as $groupId) {
3✔
782
            DB::table('logs')->insert([
3✔
783
                'timestamp' => now(),
3✔
784
                'type' => 'Message',
3✔
785
                'subtype' => 'Outcome',
3✔
786
                'msgid' => $msgId,
3✔
787
                'user' => $fromUser ?: null,
3✔
788
                'byuser' => $byUser ?: null,
3✔
789
                'groupid' => $groupId,
3✔
790
                'text' => trim("{$outcome} {$comment}"),
3✔
791
            ]);
3✔
792
        }
793

794
        // 2. Notify interested users who replied but didn't get the item.
795
        // Find User2User chat rooms with INTERESTED messages referencing this message,
796
        // excluding users who are in messages_by (i.e. who got the item).
797
        $replies = DB::select(
3✔
798
            "SELECT DISTINCT chatid FROM chat_messages
3✔
799
             INNER JOIN chat_rooms ON chat_rooms.id = chat_messages.chatid AND chat_rooms.chattype = 'User2User'
800
             LEFT JOIN messages_by ON messages_by.msgid = chat_messages.refmsgid
801
                 AND messages_by.userid IN (chat_rooms.user1, chat_rooms.user2)
802
             WHERE refmsgid = ? AND chat_messages.type = 'Interested'
803
                 AND reviewrejected = 0 AND messages_by.id IS NULL",
3✔
804
            [$msgId]
3✔
805
        );
3✔
806

807
        foreach ($replies as $reply) {
3✔
808
            // Check if this message was unpromised in this chat (TYPE_RENEGED).
809
            // If so, don't send the generic message as it may not be appropriate.
810
            $unpromised = DB::table('chat_messages')
2✔
811
                ->where('chatid', $reply->chatid)
2✔
812
                ->where('refmsgid', $msgId)
2✔
813
                ->where('type', 'Reneged')
2✔
814
                ->exists();
2✔
815

816
            DB::table('chat_messages')->insert([
2✔
817
                'chatid' => $reply->chatid,
2✔
818
                'userid' => $fromUser,
2✔
819
                'message' => $unpromised ? null : ($messageForOthers ?: null),
2✔
820
                'type' => 'Completed',
2✔
821
                'refmsgid' => $msgId,
2✔
822
                'date' => now(),
2✔
823
                'reviewrequired' => 0,
2✔
824
                'processingrequired' => 0,
2✔
825
                'processingsuccessful' => 1,
2✔
826
            ]);
2✔
827

828
            // Mark the poster as up-to-date in this chat so it doesn't appear as unread to them.
829
            if ($fromUser) {
2✔
830
                DB::table('chat_roster')->updateOrInsert(
2✔
831
                    ['chatid' => $reply->chatid, 'userid' => $fromUser],
2✔
832
                    ['lastmsgseen' => DB::raw('(SELECT MAX(id) FROM chat_messages WHERE chatid = ' . (int) $reply->chatid . ')'), 'date' => now()]
2✔
833
                );
2✔
834
            }
835
        }
836

837
        Log::info('Processed message outcome', [
3✔
838
            'msgid' => $msgId,
3✔
839
            'outcome' => $outcome,
3✔
840
            'groups' => $groups->count(),
3✔
841
            'notified_chats' => count($replies),
3✔
842
        ]);
3✔
843
    }
844

845
    /**
846
     * Handle merge offer email — sends to both users involved in a merge.
847
     *
848
     * V1 parity: merge.php lines 149-211.
849
     */
850
    protected function handleEmailMerge(
2✔
851
        array $data,
852
        EmailSpoolerService $spooler,
853
        bool $shouldSpool
854
    ): void {
855
        $mergeId = (int) ($data['merge_id'] ?? 0);
2✔
856
        $uid = $data['uid'] ?? '';
2✔
857
        $user1Id = (int) ($data['user1'] ?? 0);
2✔
858
        $user2Id = (int) ($data['user2'] ?? 0);
2✔
859

860
        if ($mergeId === 0 || $uid === '' || $user1Id === 0 || $user2Id === 0) {
2✔
861
            throw new \RuntimeException('email_merge requires merge_id, uid, user1, user2');
×
862
        }
863

864
        $u1 = User::find($user1Id);
2✔
865
        $u2 = User::find($user2Id);
2✔
866

867
        if (! $u1 || ! $u2) {
2✔
868
            Log::warning('Merge user not found', ['user1' => $user1Id, 'user2' => $user2Id]);
×
869
            return;
×
870
        }
871

872
        $mergeUrl = config('freegle.sites.user') . '/merge?id=' . $mergeId . '&uid=' . $uid;
2✔
873
        $name1 = $u1->fullname ?: 'Freegle User';
2✔
874
        $name2 = $u2->fullname ?: 'Freegle User';
2✔
875
        $email1 = $this->obfuscateEmail($u1->email_preferred ?? '');
2✔
876
        $email2 = $this->obfuscateEmail($u2->email_preferred ?? '');
2✔
877

878
        // Send to both users.
879
        foreach ([$u1, $u2] as $recipient) {
2✔
880
            $recipientEmail = $recipient->email_preferred;
2✔
881

882
            if (! $recipientEmail) {
2✔
883
                continue;
×
884
            }
885

886
            $mail = new MergeOfferMail(
2✔
887
                recipientUserId: $recipient->id,
2✔
888
                recipientName: $recipient->fullname ?: 'Freegle User',
2✔
889
                recipientEmail: $recipientEmail,
2✔
890
                name1: $name1,
2✔
891
                email1: $email1,
2✔
892
                name2: $name2,
2✔
893
                email2: $email2,
2✔
894
                mergeUrl: $mergeUrl,
2✔
895
            );
2✔
896

897
            if ($shouldSpool) {
2✔
898
                $spooler->spool($mail, $recipientEmail);
×
899
            } else {
900
                Mail::to($recipientEmail)->send($mail);
2✔
901
            }
902
        }
903

904
        Log::info('Sent merge offer emails', [
2✔
905
            'merge_id' => $mergeId,
2✔
906
            'user1' => $user1Id,
2✔
907
            'user2' => $user2Id,
2✔
908
        ]);
2✔
909
    }
910

911
    /**
912
     * Handle email verification — generates a validate key and sends a confirmation link.
913
     *
914
     * V1 parity: User::verifyEmail() lines 3822-3896.
915
     */
916
    protected function handleEmailVerify(
3✔
917
        array $data,
918
        EmailSpoolerService $spooler,
919
        bool $shouldSpool
920
    ): void {
921
        $userId = (int) ($data['user_id'] ?? 0);
3✔
922
        $email = $data['email'] ?? '';
3✔
923

924
        if ($userId === 0 || $email === '') {
3✔
925
            throw new \RuntimeException('email_verify requires user_id and email');
×
926
        }
927

928
        $user = User::find($userId);
3✔
929

930
        if (! $user) {
3✔
931
            Log::warning("User not found for email verify: {$userId}");
×
932
            return;
×
933
        }
934

935
        // Check if this email is already one of the user's emails.
936
        $canon = strtolower(trim($email));
3✔
937
        $existing = DB::table('users_emails')
3✔
938
            ->where('userid', $userId)
3✔
939
            ->whereRaw('LOWER(email) = ?', [$canon])
3✔
940
            ->exists();
3✔
941

942
        if ($existing) {
3✔
943
            // Already the user's email — just make it primary.
944
            DB::table('users_emails')
1✔
945
                ->where('userid', $userId)
1✔
946
                ->whereRaw('LOWER(email) = ?', [$canon])
1✔
947
                ->update(['preferred' => 1]);
1✔
948

949
            DB::table('users_emails')
1✔
950
                ->where('userid', $userId)
1✔
951
                ->whereRaw('LOWER(email) != ?', [$canon])
1✔
952
                ->update(['preferred' => 0]);
1✔
953

954
            Log::info('Email already belongs to user, made primary', [
1✔
955
                'user_id' => $userId,
1✔
956
                'email' => $email,
1✔
957
            ]);
1✔
958
            return;
1✔
959
        }
960

961
        // Generate a validation key. Check if one was recently set (< 600s) to avoid confusion.
962
        $recentKey = DB::table('users_emails')
2✔
963
            ->where('canon', $canon)
2✔
964
            ->whereRaw('TIMESTAMPDIFF(SECOND, validatetime, NOW()) < 600')
2✔
965
            ->value('validatekey');
2✔
966

967
        $key = $recentKey;
2✔
968

969
        if (! $key) {
2✔
970
            $key = uniqid();
2✔
971
            DB::statement(
2✔
972
                'INSERT INTO users_emails (email, canon, validatekey, backwards) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE validatekey = ?',
2✔
973
                [$email, $canon, $key, strrev($canon), $key]
2✔
974
            );
2✔
975
        }
976

977
        // Generate the confirm URL with auto-login.
978
        $userKey = $user->getUserKey();
2✔
979
        $confirmPath = '/settings/confirmmail/' . urlencode($key);
2✔
980
        $userSite = config('freegle.sites.user');
2✔
981
        $confirmUrl = "{$userSite}{$confirmPath}?u={$userId}&k={$userKey}&src=changeemail";
2✔
982

983
        $mail = new VerifyEmailMail(
2✔
984
            userId: $userId,
2✔
985
            email: $email,
2✔
986
            confirmUrl: $confirmUrl,
2✔
987
        );
2✔
988

989
        if ($shouldSpool) {
2✔
990
            $spooler->spool($mail, $email);
×
991
        } else {
992
            Mail::to($email)->send($mail);
2✔
993
        }
994

995
        Log::info('Sent email verification', [
2✔
996
            'user_id' => $userId,
2✔
997
            'email' => $email,
2✔
998
        ]);
2✔
999
    }
1000

1001
    /**
1002
     * Handle refer-to-support — sends a plain text email to the support team.
1003
     *
1004
     * V1 parity: ChatRoom::referToSupport() lines 2266-2284.
1005
     */
1006
    protected function handleReferToSupport(
1✔
1007
        array $data,
1008
        EmailSpoolerService $spooler,
1009
        bool $shouldSpool
1010
    ): void {
1011
        $chatId = (int) ($data['chatid'] ?? 0);
1✔
1012
        $userId = (int) ($data['userid'] ?? 0);
1✔
1013

1014
        if ($chatId === 0 || $userId === 0) {
1✔
1015
            throw new \RuntimeException('refer_to_support requires chatid and userid');
×
1016
        }
1017

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

1020
        if (! $chat) {
1✔
1021
            Log::warning("Chat not found for refer_to_support: {$chatId}");
×
1022
            return;
×
1023
        }
1024

1025
        $user = User::find($userId);
1✔
1026

1027
        if (! $user) {
1✔
1028
            Log::warning("User not found for refer_to_support: {$userId}");
×
1029
            return;
×
1030
        }
1031

1032
        // The "other" user in the chat.
1033
        $otherUserId = $chat->user1 == $userId ? $chat->user2 : $chat->user1;
1✔
1034
        $otherUser = $otherUserId ? User::find($otherUserId) : null;
1✔
1035
        $otherUserName = $otherUser ? ($otherUser->fullname ?: 'Unknown') : 'Unknown';
1✔
1036

1037
        // Get group mods email for reply-to.
1038
        $groupId = $chat->groupid;
1✔
1039
        $replyToAddress = config('freegle.mail.noreply_addr');
1✔
1040
        $replyToName = config('freegle.branding.name');
1✔
1041

1042
        if ($groupId) {
1✔
1043
            $group = DB::table('groups')->where('id', $groupId)->first();
1✔
1044

1045
            if ($group) {
1✔
1046
                $groupNameShort = $group->nameshort ?? '';
1✔
1047
                $replyToAddress = $groupNameShort . '-volunteers@' . config('freegle.mail.group_domain', 'groups.ilovefreegle.org');
1✔
1048
                $replyToName = ($group->namefull ?: $groupNameShort) . ' Volunteers';
1✔
1049
            }
1050
        }
1051

1052
        $mail = new ReferToSupportMail(
1✔
1053
            userName: $user->fullname ?: 'Unknown',
1✔
1054
            userId: $userId,
1✔
1055
            chatId: $chatId,
1✔
1056
            otherUserName: $otherUserName,
1✔
1057
            otherUserId: (int) ($otherUserId ?? 0),
1✔
1058
            replyToAddress: $replyToAddress,
1✔
1059
            replyToName: $replyToName,
1✔
1060
        );
1✔
1061

1062
        $supportAddr = config('freegle.mail.support_addr', 'support@ilovefreegle.org');
1✔
1063
        $recipients = array_map('trim', explode(',', $supportAddr));
1✔
1064

1065
        if ($shouldSpool) {
1✔
1066
            $spooler->spool($mail, $recipients);
×
1067
        } else {
1068
            Mail::to($recipients)->send($mail);
1✔
1069
        }
1070

1071
        Log::info('Sent refer to support email', [
1✔
1072
            'chat_id' => $chatId,
1✔
1073
            'user_id' => $userId,
1✔
1074
        ]);
1✔
1075
    }
1076

1077
    /**
1078
     * Process a housekeeping notification from the Chrome extension.
1079
     */
1080
    protected function handleHousekeeperNotify(array $data): void
×
1081
    {
1082
        $service = app(HousekeeperService::class);
×
1083
        $service->process($data);
×
1084
    }
1085

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

1102
        // Step 1: Find the mod's config for this group (V1: ModConfig::getForGroup).
1103
        $configId = DB::table('memberships')
7✔
1104
            ->where('userid', $byUser)
7✔
1105
            ->where('groupid', $groupId)
7✔
1106
            ->value('configid');
7✔
1107

1108
        if (! $configId) {
7✔
1109
            // Fall back to any other mod's config for this group.
1110
            $configId = DB::table('memberships')
3✔
1111
                ->where('groupid', $groupId)
3✔
1112
                ->whereIn('role', ['Moderator', 'Owner'])
3✔
1113
                ->whereNotNull('configid')
3✔
1114
                ->value('configid');
3✔
1115
        }
1116

1117
        if (! $configId) {
7✔
1118
            // Fall back to any config created by this mod.
1119
            $configId = DB::table('mod_configs')
2✔
1120
                ->where('createdby', $byUser)
2✔
1121
                ->value('id');
2✔
1122
        }
1123

1124
        if (! $configId) {
7✔
1125
            // Fall back to a default config.
1126
            $configId = DB::table('mod_configs')
2✔
1127
                ->where('default', 1)
2✔
1128
                ->value('id');
2✔
1129
        }
1130

1131
        if (! $configId) {
7✔
1132
            return null;
2✔
1133
        }
1134

1135
        // Step 2: Map action to CC column pair (V1: ModConfig::getBcc).
1136
        [$toColumn, $addrColumn] = match ($action) {
5✔
1137
            'Approve', 'Reject', 'Leave' => ['ccrejectto', 'ccrejectaddr'],
4✔
1138
            'Leave Member' => ['ccrejmembto', 'ccrejmembaddr'],
×
1139
            'Leave Approved Message', 'Delete Approved Message' => ['ccfollowupto', 'ccfollowupaddr'],
×
1140
            'Leave Approved Member', 'Delete Approved Member' => ['ccfollmembto', 'ccfollmembaddr'],
1✔
1141
            default => [null, null],
5✔
1142
        };
5✔
1143

1144
        if (! $toColumn) {
5✔
1145
            return null;
×
1146
        }
1147

1148
        // Step 3: Look up the config and evaluate (V1: ModConfig::evalIt).
1149
        $config = DB::table('mod_configs')
5✔
1150
            ->where('id', $configId)
5✔
1151
            ->first([$toColumn, $addrColumn]);
5✔
1152

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

1157
        $to = $config->$toColumn;
5✔
1158
        $addr = $config->$addrColumn;
5✔
1159

1160
        if ($to === 'Me') {
5✔
1161
            $modUser = User::find($byUser);
1✔
1162

1163
            return $modUser?->email_preferred;
1✔
1164
        }
1165

1166
        if ($to === 'Specific') {
4✔
1167
            return $addr ?: null;
3✔
1168
        }
1169

1170
        return null;
1✔
1171
    }
1172

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

1201
        $bccAddress = $this->resolveBccAddress($byUser, $groupId, $action);
7✔
1202
        if (! $bccAddress) {
7✔
1203
            return;
3✔
1204
        }
1205

1206
        // V1: replace $groupname in BCC address.
1207
        $bccAddress = str_replace('$groupname', $groupNameShort, $bccAddress);
4✔
1208

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

1212
        $bccMail = new ModStdMessageMail(
4✔
1213
            modName: $modName,
4✔
1214
            groupName: $groupName,
4✔
1215
            groupNameShort: $groupNameShort,
4✔
1216
            stdSubject: $subject,
4✔
1217
            stdBody: $bccBody,
4✔
1218
            messageSubject: $messageSubject,
4✔
1219
            msgId: $msgId,
4✔
1220
            recipientUserId: 0,
4✔
1221
            recipientEmail: $bccAddress,
4✔
1222
            groupContactMail: $groupContactMail,
4✔
1223
        );
4✔
1224

1225
        if ($shouldSpool) {
4✔
1226
            $spooler->spool($bccMail, $bccAddress);
×
1227
        } else {
1228
            Mail::to($bccAddress)->send($bccMail);
4✔
1229
        }
1230

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

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

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

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

1264
        $hasOutcome = DB::table('messages_outcomes')->where('msgid', $msgId)->exists();
2✔
1265
        if ($hasOutcome) {
2✔
1266
            return;
1✔
1267
        }
1268

1269
        // Skip TrashNothing messages — TN syncs to freebiealerts directly.
1270
        if ($msg->sourceheader && str_starts_with($msg->sourceheader, 'TN-')) {
1✔
1271
            return;
×
1272
        }
1273

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

1282
        if (! $msg->lat || ! $msg->lng) {
1✔
1283
            return;
×
1284
        }
1285

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

1296
        $body = $msg->textbody ?: 'No description';
1✔
1297

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

1308
        $apiUrl = config('freegle.freebie_alerts.api_url');
1✔
1309

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

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

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

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

1357
        $apiUrl = config('freegle.freebie_alerts.api_url');
1✔
1358

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

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

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

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

1396
        if (strlen($local) <= 1) {
2✔
1397
            return '*@' . $domain;
×
1398
        }
1399

1400
        return $local[0] . str_repeat('*', strlen($local) - 1) . '@' . $domain;
2✔
1401
    }
1402

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

1411
        $service = app(PostcodeRemapService::class);
×
1412
        $updated = $service->remapPostcodes($locationId, $polygon);
×
1413

1414
        Log::info('Remapped postcodes', [
×
1415
            'location_id' => $locationId,
×
1416
            'updated' => $updated,
×
1417
        ]);
×
1418
    }
1419

1420
    protected function handleUserForget(array $data): void
2✔
1421
    {
1422
        $userId = isset($data['user_id']) ? (int) $data['user_id'] : NULL;
2✔
1423
        $reason = $data['reason'] ?? 'Support purge';
2✔
1424

1425
        if (! $userId) {
2✔
1426
            throw new \RuntimeException('user_forget requires user_id');
1✔
1427
        }
1428

1429
        $service = app(UserManagementService::class);
1✔
1430
        $service->forgetUser($userId, $reason);
1✔
1431

1432
        Log::info('User forgotten via background task', [
1✔
1433
            'user_id' => $userId,
1✔
1434
            'reason'  => $reason,
1✔
1435
        ]);
1✔
1436
    }
1437
}
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