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

Freegle / Iznik / 8557

30 Apr 2026 04:52PM UTC coverage: 72.312% (+0.02%) from 72.29%
8557

push

circleci

edwh
Revert "fix(playwright): make spinner wait more robust in modtools page load test"

This reverts commit a188f6802.

13627 of 20588 branches covered (66.19%)

Branch coverage included in aggregate %.

98319 of 134222 relevant lines covered (73.25%)

22.29 hits per line

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

85.52
/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\ChatRoom;
16
use App\Models\User;
17
use App\Services\EmailSpoolerService;
18
use App\Services\HousekeeperService;
19
use App\Services\PostcodeRemapService;
20
use App\Services\UserManagementService;
21
use App\Services\PushNotificationService;
22
use App\Traits\GracefulShutdown;
23
use Illuminate\Console\Command;
24
use Illuminate\Support\Facades\DB;
25
use Illuminate\Support\Facades\Log;
26
use Illuminate\Support\Facades\Mail;
27
use Illuminate\Support\Str;
28

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

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

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

50
    private const MAX_ATTEMPTS = 3;
51

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

57
            return Command::SUCCESS;
×
58
        }
59

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

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

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

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

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

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

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

98
        return Command::SUCCESS;
49✔
99
    }
100

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

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

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

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

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

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

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

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

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

153
        $processed = 0;
49✔
154

155
        foreach ($tasks as $task) {
49✔
156
            if ($this->shouldStop()) {
47✔
157
                break;
×
158
            }
159

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

165
                $data = json_decode($task->data, TRUE);
47✔
166

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

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

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

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

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

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

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

199
        if ($processed > 0) {
49✔
200
            $this->info("Processed {$processed} task(s).");
41✔
201
        }
202

203
        return $processed;
49✔
204
    }
205

206
    /**
207
     * Dispatch a task to the appropriate handler.
208
     */
209
    protected function dispatchTask(
47✔
210
        string $taskType,
211
        array $data,
212
        PushNotificationService $pushService,
213
        EmailSpoolerService $spooler,
214
        bool $shouldSpool
215
    ): void {
216
        match ($taskType) {
47✔
217
            'push_notify_group_mods' => $this->handlePushNotifyGroupMods($data, $pushService),
2✔
218
            'email_chitchat_report' => $this->handleEmailChitchatReport($data, $spooler, $shouldSpool),
3✔
219
            'email_charity_signup' => $this->handleEmailCharitySignup($data, $spooler, $shouldSpool),
×
220
            'email_donate_external' => $this->handleEmailDonateExternal($data, $spooler, $shouldSpool),
4✔
221
            'email_forgot_password' => $this->handleEmailForgotPassword($data, $spooler, $shouldSpool),
2✔
222
            'email_unsubscribe' => $this->handleEmailUnsubscribe($data, $spooler, $shouldSpool),
2✔
223
            'email_message_approved', 'email_message_rejected', 'email_message_reply'
47✔
224
                => $this->handleModStdMessage($taskType, $data, $pushService, $spooler, $shouldSpool),
8✔
225
            'email_mod_stdmsg'
47✔
226
                => $this->handleModStdMessageForMember($taskType, $data, $spooler, $shouldSpool),
7✔
227
            'email_merge' => $this->handleEmailMerge($data, $spooler, $shouldSpool),
2✔
228
            'email_verify' => $this->handleEmailVerify($data, $spooler, $shouldSpool),
3✔
229
            'refer_to_support' => $this->handleReferToSupport($data, $spooler, $shouldSpool),
1✔
230
            'message_outcome' => $this->handleMessageOutcome($data),
3✔
231
            'freebie_alerts_add' => $this->handleFreebieAlertsAdd($data),
4✔
232
            'freebie_alerts_remove' => $this->handleFreebieAlertsRemove($data),
2✔
233
            'housekeeper_notify' => $this->handleHousekeeperNotify($data),
×
234
            'remap_postcodes' => $this->handleRemapPostcodes($data),
×
235
            'user_forget' => $this->handleUserForget($data),
2✔
236
            default => throw new \RuntimeException("Unknown task type: {$taskType}"),
3✔
237
        };
47✔
238
    }
239

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

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

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

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

270
        $mail = new ChitchatReportMail(
2✔
271
            reporterName: $data['user_name'],
2✔
272
            reporterId: (int) $data['user_id'],
2✔
273
            reporterEmail: $data['user_email'],
2✔
274
            newsfeedId: (int) $data['newsfeed_id'],
2✔
275
            reason: $data['reason'],
2✔
276
        );
2✔
277

278
        if ($shouldSpool) {
2✔
279
            $recipients = array_map('trim', explode(',', config('freegle.mail.chitchat_support_addr')));
×
280
            $spooler->spool($mail, $recipients);
×
281
        } else {
282
            Mail::send($mail);
2✔
283
        }
284

285
        Log::info('Sent ChitChat report email', [
2✔
286
            'reporter_id' => $data['user_id'],
2✔
287
            'newsfeed_id' => $data['newsfeed_id'],
2✔
288
        ]);
2✔
289
    }
290

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

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

314
        if ($shouldSpool) {
3✔
315
            $spooler->spool($mail, config('freegle.mail.info_addr'));
×
316
        } else {
317
            Mail::send($mail);
3✔
318
        }
319

320
        Log::info('Sent external donation email', [
3✔
321
            'user_id' => $data['user_id'],
3✔
322
            'amount' => $data['amount'],
3✔
323
        ]);
3✔
324
    }
325

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

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

349
        if ($shouldSpool) {
×
350
            $spooler->spool($mail, config('freegle.mail.partnerships_addr'));
×
351
        } else {
352
            Mail::send($mail);
×
353
        }
354

355
        Log::info('Sent charity signup notification', [
×
356
            'charity_id' => $data['charity_id'],
×
357
            'orgname' => $data['orgname'],
×
358
        ]);
×
359
    }
360

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

376
        $mail = new ForgotPasswordMail(
2✔
377
            userId: (int) $data['user_id'],
2✔
378
            email: $data['email'],
2✔
379
            resetUrl: $data['reset_url'],
2✔
380
        );
2✔
381

382
        if ($shouldSpool) {
2✔
383
            $spooler->spool($mail, $data['email']);
×
384
        } else {
385
            Mail::send($mail);
2✔
386
        }
387

388
        Log::info('Sent forgot password email', [
2✔
389
            'user_id' => $data['user_id'],
2✔
390
        ]);
2✔
391
    }
392

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

408
        $mail = new UnsubscribeConfirmMail(
2✔
409
            userId: (int) $data['user_id'],
2✔
410
            email: $data['email'],
2✔
411
            unsubUrl: $data['unsub_url'],
2✔
412
        );
2✔
413

414
        if ($shouldSpool) {
2✔
415
            $spooler->spool($mail, $data['email']);
×
416
        } else {
417
            Mail::send($mail);
2✔
418
        }
419

420
        Log::info('Sent unsubscribe confirmation email', [
2✔
421
            'user_id' => $data['user_id'],
2✔
422
        ]);
2✔
423
    }
424

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

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

453
        if ($msgId === 0 || $byUser === 0) {
8✔
454
            throw new \RuntimeException("{$taskType} requires msgid and byuser");
×
455
        }
456

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

460
        // Determine the log subtype from the task type.
461
        // email_message_approved → Approved
462
        // email_message_rejected with subject → Rejected, without subject → Deleted
463
        // email_message_reply → Replied
464
        $subtype = match ($taskType) {
8✔
465
            'email_message_approved' => 'Approved',
2✔
466
            'email_message_rejected' => $subject !== '' ? 'Rejected' : 'Deleted',
5✔
467
            'email_message_reply' => 'Replied',
1✔
468
            default => 'Approved',
×
469
        };
8✔
470

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

484
        // Queue push notifications to group moderators.
485
        if ($groupId > 0) {
8✔
486
            $pushService->notifyGroupMods($groupId);
8✔
487
        }
488

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

498
        if (! $posterId) {
7✔
499
            Log::warning("No poster found for message {$msgId}");
×
500
            return;
×
501
        }
502

503
        $poster = User::find($posterId);
7✔
504
        $posterEmail = $poster?->email_preferred;
7✔
505

506
        if (! $posterEmail) {
7✔
507
            Log::warning("No email found for poster of message {$msgId}");
×
508
            return;
×
509
        }
510

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

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

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

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

543
        if ($shouldSpool) {
7✔
544
            $spooler->spool($mail, $posterEmail);
×
545
        } else {
546
            Mail::to($posterEmail)->send($mail);
7✔
547
        }
548

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

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

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

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

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

615
        if ($userId === 0 || $byUser === 0) {
7✔
616
            throw new \RuntimeException('email_mod_stdmsg requires userid and byuser');
×
617
        }
618

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

627
        // Look up the member's preferred email.
628
        $member = User::find($userId);
6✔
629
        $memberEmail = $member?->email_preferred;
6✔
630

631
        if (! $memberEmail) {
6✔
632
            Log::warning("No email found for member {$userId}");
×
633
            return;
×
634
        }
635

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

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

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

665
        if ($shouldSpool) {
6✔
666
            $spooler->spool($mail, $memberEmail);
×
667
        } else {
668
            Mail::to($memberEmail)->send($mail);
6✔
669
        }
670

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

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

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

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

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

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

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

767
        if ($msgId === 0) {
3✔
768
            throw new \RuntimeException('message_outcome requires msgid');
×
769
        }
770

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

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

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

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

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

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

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

833
        Log::info('Processed message outcome', [
3✔
834
            'msgid' => $msgId,
3✔
835
            'outcome' => $outcome,
3✔
836
            'groups' => $groups->count(),
3✔
837
            'notified_chats' => count($replies),
3✔
838
        ]);
3✔
839
    }
840

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

856
        if ($mergeId === 0 || $uid === '' || $user1Id === 0 || $user2Id === 0) {
2✔
857
            throw new \RuntimeException('email_merge requires merge_id, uid, user1, user2');
×
858
        }
859

860
        $u1 = User::find($user1Id);
2✔
861
        $u2 = User::find($user2Id);
2✔
862

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

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

874
        // Send to both users.
875
        foreach ([$u1, $u2] as $recipient) {
2✔
876
            $recipientEmail = $recipient->email_preferred;
2✔
877

878
            if (! $recipientEmail) {
2✔
879
                continue;
×
880
            }
881

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

893
            if ($shouldSpool) {
2✔
894
                $spooler->spool($mail, $recipientEmail);
×
895
            } else {
896
                Mail::to($recipientEmail)->send($mail);
2✔
897
            }
898
        }
899

900
        Log::info('Sent merge offer emails', [
2✔
901
            'merge_id' => $mergeId,
2✔
902
            'user1' => $user1Id,
2✔
903
            'user2' => $user2Id,
2✔
904
        ]);
2✔
905
    }
906

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

920
        if ($userId === 0 || $email === '') {
3✔
921
            throw new \RuntimeException('email_verify requires user_id and email');
×
922
        }
923

924
        $user = User::find($userId);
3✔
925

926
        if (! $user) {
3✔
927
            Log::warning("User not found for email verify: {$userId}");
×
928
            return;
×
929
        }
930

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

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

945
            DB::table('users_emails')
1✔
946
                ->where('userid', $userId)
1✔
947
                ->whereRaw('LOWER(email) != ?', [$canon])
1✔
948
                ->update(['preferred' => 0]);
1✔
949

950
            Log::info('Email already belongs to user, made primary', [
1✔
951
                'user_id' => $userId,
1✔
952
                'email' => $email,
1✔
953
            ]);
1✔
954
            return;
1✔
955
        }
956

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

963
        $key = $recentKey;
2✔
964

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

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

979
        $mail = new VerifyEmailMail(
2✔
980
            userId: $userId,
2✔
981
            email: $email,
2✔
982
            confirmUrl: $confirmUrl,
2✔
983
        );
2✔
984

985
        if ($shouldSpool) {
2✔
986
            $spooler->spool($mail, $email);
×
987
        } else {
988
            Mail::to($email)->send($mail);
2✔
989
        }
990

991
        Log::info('Sent email verification', [
2✔
992
            'user_id' => $userId,
2✔
993
            'email' => $email,
2✔
994
        ]);
2✔
995
    }
996

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

1010
        if ($chatId === 0 || $userId === 0) {
1✔
1011
            throw new \RuntimeException('refer_to_support requires chatid and userid');
×
1012
        }
1013

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

1016
        if (! $chat) {
1✔
1017
            Log::warning("Chat not found for refer_to_support: {$chatId}");
×
1018
            return;
×
1019
        }
1020

1021
        $user = User::find($userId);
1✔
1022

1023
        if (! $user) {
1✔
1024
            Log::warning("User not found for refer_to_support: {$userId}");
×
1025
            return;
×
1026
        }
1027

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

1033
        // Get group mods email for reply-to.
1034
        $groupId = $chat->groupid;
1✔
1035
        $replyToAddress = config('freegle.mail.noreply_addr');
1✔
1036
        $replyToName = config('freegle.branding.name');
1✔
1037

1038
        if ($groupId) {
1✔
1039
            $group = DB::table('groups')->where('id', $groupId)->first();
1✔
1040

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

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

1058
        $supportAddr = config('freegle.mail.support_addr', 'support@ilovefreegle.org');
1✔
1059
        $recipients = array_map('trim', explode(',', $supportAddr));
1✔
1060

1061
        if ($shouldSpool) {
1✔
1062
            $spooler->spool($mail, $recipients);
×
1063
        } else {
1064
            Mail::to($recipients)->send($mail);
1✔
1065
        }
1066

1067
        Log::info('Sent refer to support email', [
1✔
1068
            'chat_id' => $chatId,
1✔
1069
            'user_id' => $userId,
1✔
1070
        ]);
1✔
1071
    }
1072

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

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

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

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

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

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

1127
        if (! $configId) {
7✔
1128
            return null;
2✔
1129
        }
1130

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

1140
        if (! $toColumn) {
5✔
1141
            return null;
×
1142
        }
1143

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

1149
        if (! $config) {
5✔
1150
            return null;
×
1151
        }
1152

1153
        $to = $config->$toColumn;
5✔
1154
        $addr = $config->$addrColumn;
5✔
1155

1156
        if ($to === 'Me') {
5✔
1157
            $modUser = User::find($byUser);
1✔
1158

1159
            return $modUser?->email_preferred;
1✔
1160
        }
1161

1162
        if ($to === 'Specific') {
4✔
1163
            return $addr ?: null;
3✔
1164
        }
1165

1166
        return null;
1✔
1167
    }
1168

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

1197
        $bccAddress = $this->resolveBccAddress($byUser, $groupId, $action);
7✔
1198
        if (! $bccAddress) {
7✔
1199
            return;
3✔
1200
        }
1201

1202
        // V1: replace $groupname in BCC address.
1203
        $bccAddress = str_replace('$groupname', $groupNameShort, $bccAddress);
4✔
1204

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

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

1221
        if ($shouldSpool) {
4✔
1222
            $spooler->spool($bccMail, $bccAddress);
×
1223
        } else {
1224
            Mail::to($bccAddress)->send($bccMail);
4✔
1225
        }
1226

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

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

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

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

1260
        $hasOutcome = DB::table('messages_outcomes')->where('msgid', $msgId)->exists();
2✔
1261
        if ($hasOutcome) {
2✔
1262
            return;
1✔
1263
        }
1264

1265
        // Skip TrashNothing messages — TN syncs to freebiealerts directly.
1266
        if ($msg->sourceheader && str_starts_with($msg->sourceheader, 'TN-')) {
1✔
1267
            return;
×
1268
        }
1269

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

1278
        if (! $msg->lat || ! $msg->lng) {
1✔
1279
            return;
×
1280
        }
1281

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

1292
        $body = $msg->textbody ?: 'No description';
1✔
1293

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

1304
        $apiUrl = config('freegle.freebie_alerts.api_url');
1✔
1305

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

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

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

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

1353
        $apiUrl = config('freegle.freebie_alerts.api_url');
1✔
1354

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

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

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

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

1392
        if (strlen($local) <= 1) {
2✔
1393
            return '*@' . $domain;
×
1394
        }
1395

1396
        return $local[0] . str_repeat('*', strlen($local) - 1) . '@' . $domain;
2✔
1397
    }
1398

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

1407
        $service = app(PostcodeRemapService::class);
×
1408
        $updated = $service->remapPostcodes($locationId, $polygon);
×
1409

1410
        Log::info('Remapped postcodes', [
×
1411
            'location_id' => $locationId,
×
1412
            'updated' => $updated,
×
1413
        ]);
×
1414
    }
1415

1416
    protected function handleUserForget(array $data): void
2✔
1417
    {
1418
        $userId = isset($data['user_id']) ? (int) $data['user_id'] : NULL;
2✔
1419
        $reason = $data['reason'] ?? 'Support purge';
2✔
1420

1421
        if (! $userId) {
2✔
1422
            throw new \RuntimeException('user_forget requires user_id');
1✔
1423
        }
1424

1425
        $service = app(UserManagementService::class);
1✔
1426
        $service->forgetUser($userId, $reason);
1✔
1427

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