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

Freegle / Iznik / 4146

15 Apr 2026 04:16PM UTC coverage: 70.764% (-0.3%) from 71.047%
4146

push

circleci

web-flow
Merge pull request #140 from Freegle/feature/charity-badge

feat: Charity Partner landing page and badge

13043 of 19990 branches covered (65.25%)

Branch coverage included in aggregate %.

60 of 590 new or added lines in 7 files covered. (10.17%)

35 existing lines in 7 files now uncovered.

93350 of 130359 relevant lines covered (71.61%)

17.45 hits per line

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

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

3
namespace App\Console\Commands\Queue;
4

5
use App\Console\Concerns\PreventsOverlapping;
6
use App\Mail\Charity\CharitySignupMail;
7
use App\Mail\Chat\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\PushNotificationService;
21
use App\Traits\GracefulShutdown;
22
use Illuminate\Console\Command;
23
use Illuminate\Support\Facades\DB;
24
use Illuminate\Support\Facades\Log;
25
use Illuminate\Support\Facades\Mail;
26
use Illuminate\Support\Str;
27

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

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

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

49
    private const MAX_ATTEMPTS = 3;
50

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

56
            return Command::SUCCESS;
×
57
        }
58

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

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

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

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

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

89
            $processed = $this->processIteration($limit, $pushService, $spooler, $shouldSpool);
45✔
90

91
            if ($processed === 0) {
45✔
92
                sleep($sleepSeconds);
7✔
93
            }
94
        }
95

96
        return Command::SUCCESS;
45✔
97
    }
98

99
    /**
100
     * Process one iteration of pending tasks.
101
     */
102
    protected function processIteration(
45✔
103
        int $limit,
104
        PushNotificationService $pushService,
105
        EmailSpoolerService $spooler,
106
        bool $shouldSpool
107
    ): int {
108
        $tasks = DB::select(
45✔
109
            'SELECT * FROM background_tasks WHERE processed_at IS NULL AND failed_at IS NULL AND attempts < ? ORDER BY created_at ASC LIMIT ?',
45✔
110
            [self::MAX_ATTEMPTS, $limit]
45✔
111
        );
45✔
112

113
        $processed = 0;
45✔
114

115
        foreach ($tasks as $task) {
45✔
116
            if ($this->shouldStop()) {
43✔
117
                break;
×
118
            }
119

120
            try {
121
                DB::table('background_tasks')
43✔
122
                    ->where('id', $task->id)
43✔
123
                    ->increment('attempts');
43✔
124

125
                $data = json_decode($task->data, TRUE);
43✔
126

127
                $this->dispatchTask($task->task_type, $data, $pushService, $spooler, $shouldSpool);
43✔
128

129
                DB::table('background_tasks')
38✔
130
                    ->where('id', $task->id)
38✔
131
                    ->update(['processed_at' => now()]);
38✔
132

133
                $processed++;
38✔
134
            } catch (\Throwable $e) {
5✔
135
                Log::error('Background task failed', [
5✔
136
                    'task_id' => $task->id,
5✔
137
                    'task_type' => $task->task_type,
5✔
138
                    'error' => $e->getMessage(),
5✔
139
                    'attempts' => $task->attempts + 1,
5✔
140
                ]);
5✔
141

142
                // Report to Sentry so we get alerted to task failures.
143
                if (app()->bound('sentry')) {
5✔
144
                    app('sentry')->captureException($e);
5✔
145
                }
146

147
                $update = ['error_message' => substr($e->getMessage(), 0, 65535)];
5✔
148

149
                if ($task->attempts + 1 >= self::MAX_ATTEMPTS) {
5✔
150
                    $update['failed_at'] = now();
3✔
151
                }
152

153
                DB::table('background_tasks')
5✔
154
                    ->where('id', $task->id)
5✔
155
                    ->update($update);
5✔
156
            }
157
        }
158

159
        if ($processed > 0) {
45✔
160
            $this->info("Processed {$processed} task(s).");
38✔
161
        }
162

163
        return $processed;
45✔
164
    }
165

166
    /**
167
     * Dispatch a task to the appropriate handler.
168
     */
169
    protected function dispatchTask(
43✔
170
        string $taskType,
171
        array $data,
172
        PushNotificationService $pushService,
173
        EmailSpoolerService $spooler,
174
        bool $shouldSpool
175
    ): void {
176
        match ($taskType) {
43✔
177
            'push_notify_group_mods' => $this->handlePushNotifyGroupMods($data, $pushService),
2✔
178
            'email_chitchat_report' => $this->handleEmailChitchatReport($data, $spooler, $shouldSpool),
3✔
NEW
179
            'email_charity_signup' => $this->handleEmailCharitySignup($data, $spooler, $shouldSpool),
×
180
            'email_donate_external' => $this->handleEmailDonateExternal($data, $spooler, $shouldSpool),
2✔
181
            'email_forgot_password' => $this->handleEmailForgotPassword($data, $spooler, $shouldSpool),
2✔
182
            'email_unsubscribe' => $this->handleEmailUnsubscribe($data, $spooler, $shouldSpool),
2✔
183
            'email_message_approved', 'email_message_rejected', 'email_message_reply'
43✔
184
                => $this->handleModStdMessage($taskType, $data, $pushService, $spooler, $shouldSpool),
8✔
185
            'email_mod_stdmsg'
43✔
186
                => $this->handleModStdMessageForMember($taskType, $data, $spooler, $shouldSpool),
7✔
187
            'email_merge' => $this->handleEmailMerge($data, $spooler, $shouldSpool),
2✔
188
            'email_verify' => $this->handleEmailVerify($data, $spooler, $shouldSpool),
3✔
189
            'refer_to_support' => $this->handleReferToSupport($data, $spooler, $shouldSpool),
1✔
190
            'message_outcome' => $this->handleMessageOutcome($data),
3✔
191
            'freebie_alerts_add' => $this->handleFreebieAlertsAdd($data),
4✔
192
            'freebie_alerts_remove' => $this->handleFreebieAlertsRemove($data),
2✔
193
            'housekeeper_notify' => $this->handleHousekeeperNotify($data),
×
194
            'remap_postcodes' => $this->handleRemapPostcodes($data),
×
195
            default => throw new \RuntimeException("Unknown task type: {$taskType}"),
3✔
196
        };
43✔
197
    }
198

199
    /**
200
     * Send push notifications to all moderators of a group.
201
     */
202
    protected function handlePushNotifyGroupMods(array $data, PushNotificationService $pushService): void
2✔
203
    {
204
        $groupId = $data['group_id'] ?? NULL;
2✔
205

206
        if (! $groupId) {
2✔
207
            throw new \RuntimeException('push_notify_group_mods requires group_id');
×
208
        }
209

210
        $count = $pushService->notifyGroupMods((int) $groupId);
2✔
211
        Log::info('Notified group mods', ['group_id' => $groupId, 'notified' => $count]);
2✔
212
    }
213

214
    /**
215
     * Send a ChitChat report email to support.
216
     */
217
    protected function handleEmailChitchatReport(
3✔
218
        array $data,
219
        EmailSpoolerService $spooler,
220
        bool $shouldSpool
221
    ): void {
222
        $required = ['user_id', 'user_name', 'user_email', 'newsfeed_id', 'reason'];
3✔
223
        foreach ($required as $field) {
3✔
224
            if (empty($data[$field])) {
3✔
225
                throw new \RuntimeException("email_chitchat_report requires {$field}");
1✔
226
            }
227
        }
228

229
        $mail = new ChitchatReportMail(
2✔
230
            reporterName: $data['user_name'],
2✔
231
            reporterId: (int) $data['user_id'],
2✔
232
            reporterEmail: $data['user_email'],
2✔
233
            newsfeedId: (int) $data['newsfeed_id'],
2✔
234
            reason: $data['reason'],
2✔
235
        );
2✔
236

237
        if ($shouldSpool) {
2✔
238
            $recipients = array_map('trim', explode(',', config('freegle.mail.chitchat_support_addr')));
×
239
            $spooler->spool($mail, $recipients);
×
240
        } else {
241
            Mail::send($mail);
2✔
242
        }
243

244
        Log::info('Sent ChitChat report email', [
2✔
245
            'reporter_id' => $data['user_id'],
2✔
246
            'newsfeed_id' => $data['newsfeed_id'],
2✔
247
        ]);
2✔
248
    }
249

250
    /**
251
     * Send an external donation notification email to the info address.
252
     */
253
    protected function handleEmailDonateExternal(
2✔
254
        array $data,
255
        EmailSpoolerService $spooler,
256
        bool $shouldSpool
257
    ): void {
258
        $required = ['user_id', 'user_name', 'user_email', 'amount'];
2✔
259
        foreach ($required as $field) {
2✔
260
            if (empty($data[$field])) {
2✔
261
                throw new \RuntimeException("email_donate_external requires {$field}");
1✔
262
            }
263
        }
264

265
        $mail = new DonateExternalMail(
1✔
266
            userName: $data['user_name'],
1✔
267
            userId: (int) $data['user_id'],
1✔
268
            userEmail: $data['user_email'],
1✔
269
            amount: (float) $data['amount'],
1✔
270
        );
1✔
271

272
        if ($shouldSpool) {
1✔
273
            $spooler->spool($mail, config('freegle.mail.info_addr'));
×
274
        } else {
275
            Mail::send($mail);
1✔
276
        }
277

278
        Log::info('Sent external donation email', [
1✔
279
            'user_id' => $data['user_id'],
1✔
280
            'amount' => $data['amount'],
1✔
281
        ]);
1✔
282
    }
283

284
    /**
285
     * Send a charity partner signup notification to the partnerships team.
286
     */
NEW
287
    protected function handleEmailCharitySignup(
×
288
        array $data,
289
        EmailSpoolerService $spooler,
290
        bool $shouldSpool
291
    ): void {
NEW
292
        if (empty($data['orgname']) || empty($data['contactemail'])) {
×
NEW
293
            throw new \RuntimeException('email_charity_signup requires orgname and contactemail');
×
294
        }
295

NEW
296
        $mail = new CharitySignupMail(
×
NEW
297
            charityId: (int) $data['charity_id'],
×
NEW
298
            orgName: $data['orgname'],
×
NEW
299
            orgType: $data['orgtype'] ?? 'registered',
×
NEW
300
            charityNumber: $data['charitynumber'] ?? null,
×
NEW
301
            contactEmail: $data['contactemail'],
×
NEW
302
            contactName: $data['contactname'] ?? null,
×
NEW
303
            website: $data['website'] ?? null,
×
NEW
304
            description: $data['description'] ?? null,
×
NEW
305
        );
×
306

NEW
307
        if ($shouldSpool) {
×
NEW
308
            $spooler->spool($mail, config('freegle.mail.partnerships_addr'));
×
309
        } else {
NEW
310
            Mail::send($mail);
×
311
        }
312

NEW
313
        Log::info('Sent charity signup notification', [
×
NEW
314
            'charity_id' => $data['charity_id'],
×
NEW
315
            'orgname' => $data['orgname'],
×
NEW
316
        ]);
×
317
    }
318

319
    /**
320
     * Send a forgot-password email with auto-login link.
321
     */
322
    protected function handleEmailForgotPassword(
2✔
323
        array $data,
324
        EmailSpoolerService $spooler,
325
        bool $shouldSpool
326
    ): void {
327
        $required = ['user_id', 'email', 'reset_url'];
2✔
328
        foreach ($required as $field) {
2✔
329
            if (empty($data[$field])) {
2✔
330
                throw new \RuntimeException("email_forgot_password requires {$field}");
×
331
            }
332
        }
333

334
        $mail = new ForgotPasswordMail(
2✔
335
            userId: (int) $data['user_id'],
2✔
336
            email: $data['email'],
2✔
337
            resetUrl: $data['reset_url'],
2✔
338
        );
2✔
339

340
        if ($shouldSpool) {
2✔
341
            $spooler->spool($mail, $data['email']);
×
342
        } else {
343
            Mail::send($mail);
2✔
344
        }
345

346
        Log::info('Sent forgot password email', [
2✔
347
            'user_id' => $data['user_id'],
2✔
348
        ]);
2✔
349
    }
350

351
    /**
352
     * Send an unsubscribe confirmation email with auto-login link.
353
     */
354
    protected function handleEmailUnsubscribe(
2✔
355
        array $data,
356
        EmailSpoolerService $spooler,
357
        bool $shouldSpool
358
    ): void {
359
        $required = ['user_id', 'email', 'unsub_url'];
2✔
360
        foreach ($required as $field) {
2✔
361
            if (empty($data[$field])) {
2✔
362
                throw new \RuntimeException("email_unsubscribe requires {$field}");
×
363
            }
364
        }
365

366
        $mail = new UnsubscribeConfirmMail(
2✔
367
            userId: (int) $data['user_id'],
2✔
368
            email: $data['email'],
2✔
369
            unsubUrl: $data['unsub_url'],
2✔
370
        );
2✔
371

372
        if ($shouldSpool) {
2✔
373
            $spooler->spool($mail, $data['email']);
×
374
        } else {
375
            Mail::send($mail);
2✔
376
        }
377

378
        Log::info('Sent unsubscribe confirmation email', [
2✔
379
            'user_id' => $data['user_id'],
2✔
380
        ]);
2✔
381
    }
382

383
    /**
384
     * Handle mod standard message emails (approve, reject, reply).
385
     *
386
     * Looks up the message poster, group, and mod info, then:
387
     * 1. Sends the stdmsg email (if subject/body provided).
388
     * 2. Creates a User2Mod chat message for the mod log.
389
     * 3. Creates a mod log entry (always — even for plain approve with no stdmsg).
390
     * 4. Queues push notifications to group moderators.
391
     */
392
    protected function handleModStdMessage(
8✔
393
        string $taskType,
394
        array $data,
395
        PushNotificationService $pushService,
396
        EmailSpoolerService $spooler,
397
        bool $shouldSpool
398
    ): void {
399
        $msgId = (int) ($data['msgid'] ?? 0);
8✔
400
        $byUser = (int) ($data['byuser'] ?? 0);
8✔
401
        $groupId = (int) ($data['groupid'] ?? 0);
8✔
402
        $subject = $data['subject'] ?? '';
8✔
403
        $body = $data['body'] ?? '';
8✔
404
        $stdmsgId = (int) ($data['stdmsgid'] ?? 0);
8✔
405

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

411
        if ($msgId === 0 || $byUser === 0) {
8✔
412
            throw new \RuntimeException("{$taskType} requires msgid and byuser");
×
413
        }
414

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

418
        // Determine the log subtype from the task type.
419
        // email_message_approved → Approved
420
        // email_message_rejected with subject → Rejected, without subject → Deleted
421
        // email_message_reply → Replied
422
        $subtype = match ($taskType) {
8✔
423
            'email_message_approved' => 'Approved',
2✔
424
            'email_message_rejected' => $subject !== '' ? 'Rejected' : 'Deleted',
5✔
425
            'email_message_reply' => 'Replied',
1✔
426
            default => 'Approved',
×
427
        };
8✔
428

429
        // Always create the mod log entry (even if no stdmsg content).
430
        DB::table('logs')->insert([
8✔
431
            'timestamp' => now(),
8✔
432
            'type' => 'Message',
8✔
433
            'subtype' => $subtype,
8✔
434
            'msgid' => $msgId,
8✔
435
            'user' => $posterId ?: null,
8✔
436
            'byuser' => $byUser,
8✔
437
            'groupid' => $groupId ?: null,
8✔
438
            'stdmsgid' => $stdmsgId ?: null,
8✔
439
            'text' => $subject,
8✔
440
        ]);
8✔
441

442
        // Queue push notifications to group moderators.
443
        if ($groupId > 0) {
8✔
444
            $pushService->notifyGroupMods($groupId);
8✔
445
        }
446

447
        // No subject/body means no stdmsg email to send (e.g. plain approve without message).
448
        if ($subject === '' && $body === '') {
8✔
449
            Log::info("Mod action {$taskType} without stdmsg content, skipping email", [
1✔
450
                'msgid' => $msgId,
1✔
451
                'byuser' => $byUser,
1✔
452
            ]);
1✔
453
            return;
1✔
454
        }
455

456
        if (! $posterId) {
7✔
457
            Log::warning("No poster found for message {$msgId}");
×
458
            return;
×
459
        }
460

461
        $poster = User::find($posterId);
7✔
462
        $posterEmail = $poster?->email_preferred;
7✔
463

464
        if (! $posterEmail) {
7✔
465
            Log::warning("No email found for poster of message {$msgId}");
×
466
            return;
×
467
        }
468

469
        // Look up the group info.
470
        $groupName = '';
7✔
471
        $groupNameShort = '';
7✔
472
        $groupContactMail = null;
7✔
473
        if ($groupId > 0) {
7✔
474
            $group = DB::table('groups')->where('id', $groupId)->first();
7✔
475
            if ($group) {
7✔
476
                $groupName = $group->namefull ?: $group->nameshort ?? '';
7✔
477
                $groupNameShort = $group->nameshort ?? '';
7✔
478
                $groupContactMail = $group->contactmail ?: null;
7✔
479
            }
480
        }
481

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

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

488
        $mail = new ModStdMessageMail(
7✔
489
            modName: $modName,
7✔
490
            groupName: $groupName,
7✔
491
            groupNameShort: $groupNameShort,
7✔
492
            stdSubject: $subject,
7✔
493
            stdBody: $body,
7✔
494
            messageSubject: $messageSubject,
7✔
495
            msgId: $msgId,
7✔
496
            recipientUserId: $posterId,
7✔
497
            recipientEmail: $posterEmail,
7✔
498
            groupContactMail: $groupContactMail,
7✔
499
        );
7✔
500

501
        if ($shouldSpool) {
7✔
502
            $spooler->spool($mail, $posterEmail);
×
503
        } else {
504
            Mail::to($posterEmail)->send($mail);
7✔
505
        }
506

507
        // V1 parity: send BCC copy if configured in mod's ModConfig.
508
        $this->sendBccIfConfigured(
7✔
509
            data: $data,
7✔
510
            byUser: $byUser,
7✔
511
            groupId: $groupId,
7✔
512
            groupNameShort: $groupNameShort,
7✔
513
            groupName: $groupName,
7✔
514
            subject: $subject,
7✔
515
            body: $body,
7✔
516
            recipientUserId: $posterId,
7✔
517
            recipientEmail: $posterEmail,
7✔
518
            messageSubject: $messageSubject,
7✔
519
            msgId: $msgId,
7✔
520
            groupContactMail: $groupContactMail,
7✔
521
            modName: $modName,
7✔
522
            spooler: $spooler,
7✔
523
            shouldSpool: $shouldSpool,
7✔
524
        );
7✔
525

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

530
            if ($chatRoom) {
7✔
531
                DB::table('chat_messages')->insert([
7✔
532
                    'chatid' => $chatRoom->id,
7✔
533
                    'userid' => $byUser,
7✔
534
                    'message' => "{$subject}\r\n\r\n{$body}",
7✔
535
                    'type' => 'ModMail',
7✔
536
                    'refmsgid' => $msgId,
7✔
537
                    'date' => now(),
7✔
538
                    'reviewrequired' => 0,
7✔
539
                    'processingrequired' => 0,
7✔
540
                    'processingsuccessful' => 1,
7✔
541
                ]);
7✔
542
            }
543
        }
544

545
        Log::info("Sent mod stdmsg email ({$taskType})", [
7✔
546
            'msgid' => $msgId,
7✔
547
            'byuser' => $byUser,
7✔
548
            'groupid' => $groupId,
7✔
549
            'recipient' => $posterEmail,
7✔
550
        ]);
7✔
551
    }
552

553
    /**
554
     * Handle mod standard message emails sent to a member (not related to a message).
555
     *
556
     * V1 parity with User::mail() + User::maybeMail():
557
     * 1. Send email to the member.
558
     * 2. Create a User2Mod chat message for the mod log.
559
     */
560
    protected function handleModStdMessageForMember(
7✔
561
        string $taskType,
562
        array $data,
563
        EmailSpoolerService $spooler,
564
        bool $shouldSpool
565
    ): void {
566
        $userId = (int) ($data['userid'] ?? 0);
7✔
567
        $byUser = (int) ($data['byuser'] ?? 0);
7✔
568
        $groupId = (int) ($data['groupid'] ?? 0);
7✔
569
        $subject = $data['subject'] ?? '';
7✔
570
        $body = $data['body'] ?? '';
7✔
571
        $stdmsgId = (int) ($data['stdmsgid'] ?? 0);
7✔
572

573
        if ($userId === 0 || $byUser === 0) {
7✔
574
            throw new \RuntimeException('email_mod_stdmsg requires userid and byuser');
×
575
        }
576

577
        if ($subject === '' && $body === '') {
7✔
578
            Log::info('Mod stdmsg for member without content, skipping email', [
1✔
579
                'userid' => $userId,
1✔
580
                'byuser' => $byUser,
1✔
581
            ]);
1✔
582
            return;
1✔
583
        }
584

585
        // Look up the member's preferred email.
586
        $member = User::find($userId);
6✔
587
        $memberEmail = $member?->email_preferred;
6✔
588

589
        if (! $memberEmail) {
6✔
590
            Log::warning("No email found for member {$userId}");
×
591
            return;
×
592
        }
593

594
        // Look up group info.
595
        $groupName = '';
6✔
596
        $groupNameShort = '';
6✔
597
        $groupContactMail = null;
6✔
598
        if ($groupId > 0) {
6✔
599
            $group = DB::table('groups')->where('id', $groupId)->first();
6✔
600
            if ($group) {
6✔
601
                $groupName = $group->namefull ?: $group->nameshort ?? '';
6✔
602
                $groupNameShort = $group->nameshort ?? '';
6✔
603
                $groupContactMail = $group->contactmail ?: null;
6✔
604
            }
605
        }
606

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

610
        $mail = new ModStdMessageMail(
6✔
611
            modName: $modName,
6✔
612
            groupName: $groupName,
6✔
613
            groupNameShort: $groupNameShort,
6✔
614
            stdSubject: $subject,
6✔
615
            stdBody: $body,
6✔
616
            messageSubject: '',
6✔
617
            msgId: 0,
6✔
618
            recipientUserId: $userId,
6✔
619
            recipientEmail: $memberEmail,
6✔
620
            groupContactMail: $groupContactMail,
6✔
621
        );
6✔
622

623
        if ($shouldSpool) {
6✔
624
            $spooler->spool($mail, $memberEmail);
×
625
        } else {
626
            Mail::to($memberEmail)->send($mail);
6✔
627
        }
628

629
        // V1 parity: send BCC copy if configured in mod's ModConfig.
630
        $this->sendBccIfConfigured(
6✔
631
            data: $data,
6✔
632
            byUser: $byUser,
6✔
633
            groupId: $groupId,
6✔
634
            groupNameShort: $groupNameShort,
6✔
635
            groupName: $groupName,
6✔
636
            subject: $subject,
6✔
637
            body: $body,
6✔
638
            recipientUserId: $userId,
6✔
639
            recipientEmail: $memberEmail,
6✔
640
            messageSubject: '',
6✔
641
            msgId: 0,
6✔
642
            groupContactMail: $groupContactMail,
6✔
643
            modName: $modName,
6✔
644
            spooler: $spooler,
6✔
645
            shouldSpool: $shouldSpool,
6✔
646
        );
6✔
647

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

652
            if ($chatRoom) {
6✔
653
                $chatMessageId = DB::table('chat_messages')->insertGetId([
6✔
654
                    'chatid' => $chatRoom->id,
6✔
655
                    'userid' => $byUser,
6✔
656
                    'message' => "{$subject}\r\n\r\n{$body}",
6✔
657
                    'type' => 'ModMail',
6✔
658
                    'date' => now(),
6✔
659
                    'reviewrequired' => 0,
6✔
660
                    'processingrequired' => 0,
6✔
661
                    'processingsuccessful' => 1,
6✔
662
                ]);
6✔
663

664
                // V1 parity: upToDate() — mark the chat message as already emailed to the member
665
                // so the notification daemon (NotifyUser2ModCommand) does not send a duplicate.
666
                // V1 calls $r->upToDate($fromuser) after the direct email send, which sets
667
                // lastmsgemailed = MAX(chat_messages.id) for the member's roster entry.
668
                DB::table('chat_roster')->upsert(
6✔
669
                    [
6✔
670
                        'chatid' => $chatRoom->id,
6✔
671
                        'userid' => $userId,
6✔
672
                        'lastmsgemailed' => $chatMessageId,
6✔
673
                        'lastemailed' => now(),
6✔
674
                    ],
6✔
675
                    ['chatid', 'userid'],
6✔
676
                    ['lastmsgemailed', 'lastemailed']
6✔
677
                );
6✔
678
            }
679

680
            // Only create the User/Mailed log for email_mod_stdmsg (direct mod message to member).
681
            // Membership approve/reject actions no longer route here — they use email_mod_stdmsg
682
            // directly (or create no task if no content), so we only log when it's a direct modmail.
683
            if ($taskType === 'email_mod_stdmsg') {
6✔
684
                DB::table('logs')->insert([
6✔
685
                    'timestamp' => now(),
6✔
686
                    'type' => 'User',
6✔
687
                    'subtype' => 'Mailed',
6✔
688
                    'byuser' => $byUser,
6✔
689
                    'user' => $userId,
6✔
690
                    'groupid' => $groupId,
6✔
691
                    'stdmsgid' => $stdmsgId ?: null,
6✔
692
                    'text' => $subject,
6✔
693
                ]);
6✔
694
                // Note: users_modmails is populated by the syncModMailCounts cron job
695
                // which scans the logs table — no direct insert needed here.
696
            }
697
        }
698

699
        Log::info('Sent mod stdmsg email to member', [
6✔
700
            'userid' => $userId,
6✔
701
            'byuser' => $byUser,
6✔
702
            'groupid' => $groupId,
6✔
703
            'recipient' => $memberEmail,
6✔
704
        ]);
6✔
705
    }
706

707
    /**
708
     * Handle message outcome background processing.
709
     *
710
     * V1 parity with Message::backgroundMark():
711
     * 1. Log the outcome to the logs table for each group the message is on.
712
     * 2. Notify interested users (who replied but didn't get the item) by creating
713
     *    TYPE_COMPLETED chat messages in their User2User chat rooms.
714
     */
715
    protected function handleMessageOutcome(array $data): void
3✔
716
    {
717
        $msgId = (int) ($data['msgid'] ?? 0);
3✔
718
        $byUser = (int) ($data['byuser'] ?? 0);
3✔
719
        $outcome = $data['outcome'] ?? '';
3✔
720
        $happiness = $data['happiness'] ?? '';
3✔
721
        $comment = $data['comment'] ?? '';
3✔
722
        $userid = (int) ($data['userid'] ?? 0);
3✔
723
        $messageForOthers = $data['message'] ?? '';
3✔
724

725
        if ($msgId === 0) {
3✔
726
            throw new \RuntimeException('message_outcome requires msgid');
×
727
        }
728

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

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

735
        foreach ($groups as $groupId) {
3✔
736
            DB::table('logs')->insert([
3✔
737
                'timestamp' => now(),
3✔
738
                'type' => 'Message',
3✔
739
                'subtype' => 'Outcome',
3✔
740
                'msgid' => $msgId,
3✔
741
                'user' => $fromUser ?: null,
3✔
742
                'byuser' => $byUser ?: null,
3✔
743
                'groupid' => $groupId,
3✔
744
                'text' => trim("{$outcome} {$comment}"),
3✔
745
            ]);
3✔
746
        }
747

748
        // 2. Notify interested users who replied but didn't get the item.
749
        // Find User2User chat rooms with INTERESTED messages referencing this message,
750
        // excluding users who are in messages_by (i.e. who got the item).
751
        $replies = DB::select(
3✔
752
            "SELECT DISTINCT chatid FROM chat_messages
3✔
753
             INNER JOIN chat_rooms ON chat_rooms.id = chat_messages.chatid AND chat_rooms.chattype = 'User2User'
754
             LEFT JOIN messages_by ON messages_by.msgid = chat_messages.refmsgid
755
                 AND messages_by.userid IN (chat_rooms.user1, chat_rooms.user2)
756
             WHERE refmsgid = ? AND chat_messages.type = 'Interested'
757
                 AND reviewrejected = 0 AND messages_by.id IS NULL",
3✔
758
            [$msgId]
3✔
759
        );
3✔
760

761
        foreach ($replies as $reply) {
3✔
762
            // Check if this message was unpromised in this chat (TYPE_RENEGED).
763
            // If so, don't send the generic message as it may not be appropriate.
764
            $unpromised = DB::table('chat_messages')
2✔
765
                ->where('chatid', $reply->chatid)
2✔
766
                ->where('refmsgid', $msgId)
2✔
767
                ->where('type', 'Reneged')
2✔
768
                ->exists();
2✔
769

770
            DB::table('chat_messages')->insert([
2✔
771
                'chatid' => $reply->chatid,
2✔
772
                'userid' => $fromUser,
2✔
773
                'message' => $unpromised ? null : ($messageForOthers ?: null),
2✔
774
                'type' => 'Completed',
2✔
775
                'refmsgid' => $msgId,
2✔
776
                'date' => now(),
2✔
777
                'reviewrequired' => 0,
2✔
778
                'processingrequired' => 0,
2✔
779
                'processingsuccessful' => 1,
2✔
780
            ]);
2✔
781

782
            // Mark the poster as up-to-date in this chat so it doesn't appear as unread to them.
783
            if ($fromUser) {
2✔
784
                DB::table('chat_roster')->updateOrInsert(
2✔
785
                    ['chatid' => $reply->chatid, 'userid' => $fromUser],
2✔
786
                    ['lastmsgseen' => DB::raw('(SELECT MAX(id) FROM chat_messages WHERE chatid = ' . (int) $reply->chatid . ')'), 'date' => now()]
2✔
787
                );
2✔
788
            }
789
        }
790

791
        Log::info('Processed message outcome', [
3✔
792
            'msgid' => $msgId,
3✔
793
            'outcome' => $outcome,
3✔
794
            'groups' => $groups->count(),
3✔
795
            'notified_chats' => count($replies),
3✔
796
        ]);
3✔
797
    }
798

799
    /**
800
     * Handle merge offer email — sends to both users involved in a merge.
801
     *
802
     * V1 parity: merge.php lines 149-211.
803
     */
804
    protected function handleEmailMerge(
2✔
805
        array $data,
806
        EmailSpoolerService $spooler,
807
        bool $shouldSpool
808
    ): void {
809
        $mergeId = (int) ($data['merge_id'] ?? 0);
2✔
810
        $uid = $data['uid'] ?? '';
2✔
811
        $user1Id = (int) ($data['user1'] ?? 0);
2✔
812
        $user2Id = (int) ($data['user2'] ?? 0);
2✔
813

814
        if ($mergeId === 0 || $uid === '' || $user1Id === 0 || $user2Id === 0) {
2✔
815
            throw new \RuntimeException('email_merge requires merge_id, uid, user1, user2');
×
816
        }
817

818
        $u1 = User::find($user1Id);
2✔
819
        $u2 = User::find($user2Id);
2✔
820

821
        if (! $u1 || ! $u2) {
2✔
822
            Log::warning('Merge user not found', ['user1' => $user1Id, 'user2' => $user2Id]);
×
823
            return;
×
824
        }
825

826
        $mergeUrl = config('freegle.sites.user') . '/merge?id=' . $mergeId . '&uid=' . $uid;
2✔
827
        $name1 = $u1->fullname ?: 'Freegle User';
2✔
828
        $name2 = $u2->fullname ?: 'Freegle User';
2✔
829
        $email1 = $this->obfuscateEmail($u1->email_preferred ?? '');
2✔
830
        $email2 = $this->obfuscateEmail($u2->email_preferred ?? '');
2✔
831

832
        // Send to both users.
833
        foreach ([$u1, $u2] as $recipient) {
2✔
834
            $recipientEmail = $recipient->email_preferred;
2✔
835

836
            if (! $recipientEmail) {
2✔
837
                continue;
×
838
            }
839

840
            $mail = new MergeOfferMail(
2✔
841
                recipientUserId: $recipient->id,
2✔
842
                recipientName: $recipient->fullname ?: 'Freegle User',
2✔
843
                recipientEmail: $recipientEmail,
2✔
844
                name1: $name1,
2✔
845
                email1: $email1,
2✔
846
                name2: $name2,
2✔
847
                email2: $email2,
2✔
848
                mergeUrl: $mergeUrl,
2✔
849
            );
2✔
850

851
            if ($shouldSpool) {
2✔
852
                $spooler->spool($mail, $recipientEmail);
×
853
            } else {
854
                Mail::to($recipientEmail)->send($mail);
2✔
855
            }
856
        }
857

858
        Log::info('Sent merge offer emails', [
2✔
859
            'merge_id' => $mergeId,
2✔
860
            'user1' => $user1Id,
2✔
861
            'user2' => $user2Id,
2✔
862
        ]);
2✔
863
    }
864

865
    /**
866
     * Handle email verification — generates a validate key and sends a confirmation link.
867
     *
868
     * V1 parity: User::verifyEmail() lines 3822-3896.
869
     */
870
    protected function handleEmailVerify(
3✔
871
        array $data,
872
        EmailSpoolerService $spooler,
873
        bool $shouldSpool
874
    ): void {
875
        $userId = (int) ($data['user_id'] ?? 0);
3✔
876
        $email = $data['email'] ?? '';
3✔
877

878
        if ($userId === 0 || $email === '') {
3✔
879
            throw new \RuntimeException('email_verify requires user_id and email');
×
880
        }
881

882
        $user = User::find($userId);
3✔
883

884
        if (! $user) {
3✔
885
            Log::warning("User not found for email verify: {$userId}");
×
886
            return;
×
887
        }
888

889
        // Check if this email is already one of the user's emails.
890
        $canon = strtolower(trim($email));
3✔
891
        $existing = DB::table('users_emails')
3✔
892
            ->where('userid', $userId)
3✔
893
            ->whereRaw('LOWER(email) = ?', [$canon])
3✔
894
            ->exists();
3✔
895

896
        if ($existing) {
3✔
897
            // Already the user's email — just make it primary.
898
            DB::table('users_emails')
1✔
899
                ->where('userid', $userId)
1✔
900
                ->whereRaw('LOWER(email) = ?', [$canon])
1✔
901
                ->update(['preferred' => 1]);
1✔
902

903
            DB::table('users_emails')
1✔
904
                ->where('userid', $userId)
1✔
905
                ->whereRaw('LOWER(email) != ?', [$canon])
1✔
906
                ->update(['preferred' => 0]);
1✔
907

908
            Log::info('Email already belongs to user, made primary', [
1✔
909
                'user_id' => $userId,
1✔
910
                'email' => $email,
1✔
911
            ]);
1✔
912
            return;
1✔
913
        }
914

915
        // Generate a validation key. Check if one was recently set (< 600s) to avoid confusion.
916
        $recentKey = DB::table('users_emails')
2✔
917
            ->where('canon', $canon)
2✔
918
            ->whereRaw('TIMESTAMPDIFF(SECOND, validatetime, NOW()) < 600')
2✔
919
            ->value('validatekey');
2✔
920

921
        $key = $recentKey;
2✔
922

923
        if (! $key) {
2✔
924
            $key = uniqid();
2✔
925
            DB::statement(
2✔
926
                'INSERT INTO users_emails (email, canon, validatekey, backwards) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE validatekey = ?',
2✔
927
                [$email, $canon, $key, strrev($canon), $key]
2✔
928
            );
2✔
929
        }
930

931
        // Generate the confirm URL with auto-login.
932
        $userKey = $user->getUserKey();
2✔
933
        $confirmPath = '/settings/confirmmail/' . urlencode($key);
2✔
934
        $userSite = config('freegle.sites.user');
2✔
935
        $confirmUrl = "{$userSite}{$confirmPath}?u={$userId}&k={$userKey}&src=changeemail";
2✔
936

937
        $mail = new VerifyEmailMail(
2✔
938
            userId: $userId,
2✔
939
            email: $email,
2✔
940
            confirmUrl: $confirmUrl,
2✔
941
        );
2✔
942

943
        if ($shouldSpool) {
2✔
944
            $spooler->spool($mail, $email);
×
945
        } else {
946
            Mail::to($email)->send($mail);
2✔
947
        }
948

949
        Log::info('Sent email verification', [
2✔
950
            'user_id' => $userId,
2✔
951
            'email' => $email,
2✔
952
        ]);
2✔
953
    }
954

955
    /**
956
     * Handle refer-to-support — sends a plain text email to the support team.
957
     *
958
     * V1 parity: ChatRoom::referToSupport() lines 2266-2284.
959
     */
960
    protected function handleReferToSupport(
1✔
961
        array $data,
962
        EmailSpoolerService $spooler,
963
        bool $shouldSpool
964
    ): void {
965
        $chatId = (int) ($data['chatid'] ?? 0);
1✔
966
        $userId = (int) ($data['userid'] ?? 0);
1✔
967

968
        if ($chatId === 0 || $userId === 0) {
1✔
969
            throw new \RuntimeException('refer_to_support requires chatid and userid');
×
970
        }
971

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

974
        if (! $chat) {
1✔
975
            Log::warning("Chat not found for refer_to_support: {$chatId}");
×
976
            return;
×
977
        }
978

979
        $user = User::find($userId);
1✔
980

981
        if (! $user) {
1✔
982
            Log::warning("User not found for refer_to_support: {$userId}");
×
983
            return;
×
984
        }
985

986
        // The "other" user in the chat.
987
        $otherUserId = $chat->user1 == $userId ? $chat->user2 : $chat->user1;
1✔
988
        $otherUser = $otherUserId ? User::find($otherUserId) : null;
1✔
989
        $otherUserName = $otherUser ? ($otherUser->fullname ?: 'Unknown') : 'Unknown';
1✔
990

991
        // Get group mods email for reply-to.
992
        $groupId = $chat->groupid;
1✔
993
        $replyToAddress = config('freegle.mail.noreply_addr');
1✔
994
        $replyToName = config('freegle.branding.name');
1✔
995

996
        if ($groupId) {
1✔
997
            $group = DB::table('groups')->where('id', $groupId)->first();
1✔
998

999
            if ($group) {
1✔
1000
                $groupNameShort = $group->nameshort ?? '';
1✔
1001
                $replyToAddress = $groupNameShort . '-volunteers@' . config('freegle.mail.group_domain', 'groups.ilovefreegle.org');
1✔
1002
                $replyToName = ($group->namefull ?: $groupNameShort) . ' Volunteers';
1✔
1003
            }
1004
        }
1005

1006
        $mail = new ReferToSupportMail(
1✔
1007
            userName: $user->fullname ?: 'Unknown',
1✔
1008
            userId: $userId,
1✔
1009
            chatId: $chatId,
1✔
1010
            otherUserName: $otherUserName,
1✔
1011
            otherUserId: (int) ($otherUserId ?? 0),
1✔
1012
            replyToAddress: $replyToAddress,
1✔
1013
            replyToName: $replyToName,
1✔
1014
        );
1✔
1015

1016
        $supportAddr = config('freegle.mail.support_addr', 'support@ilovefreegle.org');
1✔
1017
        $recipients = array_map('trim', explode(',', $supportAddr));
1✔
1018

1019
        if ($shouldSpool) {
1✔
1020
            $spooler->spool($mail, $recipients);
×
1021
        } else {
1022
            Mail::to($recipients)->send($mail);
1✔
1023
        }
1024

1025
        Log::info('Sent refer to support email', [
1✔
1026
            'chat_id' => $chatId,
1✔
1027
            'user_id' => $userId,
1✔
1028
        ]);
1✔
1029
    }
1030

1031
    /**
1032
     * Process a housekeeping notification from the Chrome extension.
1033
     */
1034
    protected function handleHousekeeperNotify(array $data): void
×
1035
    {
1036
        $service = app(HousekeeperService::class);
×
1037
        $service->process($data);
×
1038
    }
1039

1040
    /**
1041
     * Resolve the BCC address for a mod standard message action.
1042
     *
1043
     * V1 parity: ModConfig::getForGroup() + ModConfig::getBcc() + ModConfig::evalIt().
1044
     *
1045
     * @param int    $byUser  The moderator's user ID
1046
     * @param int    $groupId The group ID
1047
     * @param string $action  The action string (Approve, Reject, Leave Approved Member, etc.)
1048
     * @return string|null    The resolved BCC email address, or null if none configured
1049
     */
1050
    private function resolveBccAddress(int $byUser, int $groupId, string $action): ?string
7✔
1051
    {
1052
        if ($groupId === 0 || $action === '') {
7✔
1053
            return null;
×
1054
        }
1055

1056
        // Step 1: Find the mod's config for this group (V1: ModConfig::getForGroup).
1057
        $configId = DB::table('memberships')
7✔
1058
            ->where('userid', $byUser)
7✔
1059
            ->where('groupid', $groupId)
7✔
1060
            ->value('configid');
7✔
1061

1062
        if (! $configId) {
7✔
1063
            // Fall back to any other mod's config for this group.
1064
            $configId = DB::table('memberships')
3✔
1065
                ->where('groupid', $groupId)
3✔
1066
                ->whereIn('role', ['Moderator', 'Owner'])
3✔
1067
                ->whereNotNull('configid')
3✔
1068
                ->value('configid');
3✔
1069
        }
1070

1071
        if (! $configId) {
7✔
1072
            // Fall back to any config created by this mod.
1073
            $configId = DB::table('mod_configs')
2✔
1074
                ->where('createdby', $byUser)
2✔
1075
                ->value('id');
2✔
1076
        }
1077

1078
        if (! $configId) {
7✔
1079
            // Fall back to a default config.
1080
            $configId = DB::table('mod_configs')
2✔
1081
                ->where('default', 1)
2✔
1082
                ->value('id');
2✔
1083
        }
1084

1085
        if (! $configId) {
7✔
1086
            return null;
2✔
1087
        }
1088

1089
        // Step 2: Map action to CC column pair (V1: ModConfig::getBcc).
1090
        [$toColumn, $addrColumn] = match ($action) {
5✔
1091
            'Approve', 'Reject', 'Leave' => ['ccrejectto', 'ccrejectaddr'],
4✔
1092
            'Leave Member' => ['ccrejmembto', 'ccrejmembaddr'],
×
1093
            'Leave Approved Message', 'Delete Approved Message' => ['ccfollowupto', 'ccfollowupaddr'],
×
1094
            'Leave Approved Member', 'Delete Approved Member' => ['ccfollmembto', 'ccfollmembaddr'],
1✔
1095
            default => [null, null],
5✔
1096
        };
5✔
1097

1098
        if (! $toColumn) {
5✔
1099
            return null;
×
1100
        }
1101

1102
        // Step 3: Look up the config and evaluate (V1: ModConfig::evalIt).
1103
        $config = DB::table('mod_configs')
5✔
1104
            ->where('id', $configId)
5✔
1105
            ->first([$toColumn, $addrColumn]);
5✔
1106

1107
        if (! $config) {
5✔
1108
            return null;
×
1109
        }
1110

1111
        $to = $config->$toColumn;
5✔
1112
        $addr = $config->$addrColumn;
5✔
1113

1114
        if ($to === 'Me') {
5✔
1115
            $modUser = User::find($byUser);
1✔
1116

1117
            return $modUser?->email_preferred;
1✔
1118
        }
1119

1120
        if ($to === 'Specific') {
4✔
1121
            return $addr ?: null;
3✔
1122
        }
1123

1124
        return null;
1✔
1125
    }
1126

1127
    /**
1128
     * Send a BCC copy of a mod standard message if configured.
1129
     *
1130
     * V1 parity: both Message::mail() and User::maybeMail() send a BCC copy
1131
     * with body prefixed by "(This is a BCC of a message sent to Freegle user #...)".
1132
     */
1133
    private function sendBccIfConfigured(
13✔
1134
        array $data,
1135
        int $byUser,
1136
        int $groupId,
1137
        string $groupNameShort,
1138
        string $groupName,
1139
        string $subject,
1140
        string $body,
1141
        int $recipientUserId,
1142
        string $recipientEmail,
1143
        string $messageSubject,
1144
        int $msgId,
1145
        ?string $groupContactMail,
1146
        string $modName,
1147
        EmailSpoolerService $spooler,
1148
        bool $shouldSpool
1149
    ): void {
1150
        $action = $data['action'] ?? '';
13✔
1151
        if ($action === '' || $groupId === 0) {
13✔
1152
            return;
6✔
1153
        }
1154

1155
        $bccAddress = $this->resolveBccAddress($byUser, $groupId, $action);
7✔
1156
        if (! $bccAddress) {
7✔
1157
            return;
3✔
1158
        }
1159

1160
        // V1: replace $groupname in BCC address.
1161
        $bccAddress = str_replace('$groupname', $groupNameShort, $bccAddress);
4✔
1162

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

1166
        $bccMail = new ModStdMessageMail(
4✔
1167
            modName: $modName,
4✔
1168
            groupName: $groupName,
4✔
1169
            groupNameShort: $groupNameShort,
4✔
1170
            stdSubject: $subject,
4✔
1171
            stdBody: $bccBody,
4✔
1172
            messageSubject: $messageSubject,
4✔
1173
            msgId: $msgId,
4✔
1174
            recipientUserId: 0,
4✔
1175
            recipientEmail: $bccAddress,
4✔
1176
            groupContactMail: $groupContactMail,
4✔
1177
        );
4✔
1178

1179
        if ($shouldSpool) {
4✔
1180
            $spooler->spool($bccMail, $bccAddress);
×
1181
        } else {
1182
            Mail::to($bccAddress)->send($bccMail);
4✔
1183
        }
1184

1185
        Log::info('Sent BCC copy of mod stdmsg', [
4✔
1186
            'action' => $action,
4✔
1187
            'bcc' => $bccAddress,
4✔
1188
            'byuser' => $byUser,
4✔
1189
            'groupid' => $groupId,
4✔
1190
        ]);
4✔
1191
    }
1192

1193
    /**
1194
     * Add a post to freebiealerts.app.
1195
     *
1196
     * V1 parity with FreebieAlerts::add() — only sends outstanding Offers with
1197
     * a location. TrashNothing messages are skipped (TN syncs directly).
1198
     */
1199
    protected function handleFreebieAlertsAdd(array $data): void
4✔
1200
    {
1201
        $msgId = (int) ($data['msgid'] ?? 0);
4✔
1202
        if ($msgId === 0) {
4✔
1203
            throw new \RuntimeException('freebie_alerts_add requires msgid');
×
1204
        }
1205

1206
        $apiKey = config('freegle.freebie_alerts.api_key');
4✔
1207
        if (empty($apiKey)) {
4✔
1208
            Log::debug('Freebie Alerts API key not configured, skipping add', ['msgid' => $msgId]);
1✔
1209
            return;
1✔
1210
        }
1211

1212
        // Only outstanding Offers (no outcome yet).
1213
        $msg = DB::table('messages')->where('id', $msgId)->first();
3✔
1214
        if (! $msg || $msg->type !== 'Offer') {
3✔
1215
            return;
1✔
1216
        }
1217

1218
        $hasOutcome = DB::table('messages_outcomes')->where('msgid', $msgId)->exists();
2✔
1219
        if ($hasOutcome) {
2✔
1220
            return;
1✔
1221
        }
1222

1223
        // Skip TrashNothing messages — TN syncs to freebiealerts directly.
1224
        if ($msg->sourceheader && str_starts_with($msg->sourceheader, 'TN-')) {
1✔
1225
            return;
×
1226
        }
1227

1228
        $group = DB::table('messages_groups')
1✔
1229
            ->where('msgid', $msgId)
1✔
1230
            ->where('collection', 'Approved')
1✔
1231
            ->first();
1✔
1232
        if (! $group) {
1✔
1233
            return;
×
1234
        }
1235

1236
        if (! $msg->lat || ! $msg->lng) {
1✔
1237
            return;
×
1238
        }
1239

1240
        // Build image list from message attachments.
1241
        // Same pattern as digest emails: externalurl if set, otherwise {images.domain}/timg_{id}.jpg.
1242
        $imagesDomain = config('freegle.images.domain', 'https://images.ilovefreegle.org');
1✔
1243
        $attachments = DB::table('messages_attachments')->where('msgid', $msgId)->get();
1✔
1244
        $images = $attachments->map(function ($att) use ($imagesDomain) {
1✔
1245
            return ! empty($att->externalurl)
×
1246
                ? $att->externalurl
×
1247
                : "{$imagesDomain}/timg_{$att->id}.jpg";
×
1248
        })->implode(',');
1✔
1249

1250
        $body = $msg->textbody ?: 'No description';
1✔
1251

1252
        $payload = [
1✔
1253
            'id' => $msgId,
1✔
1254
            'title' => $msg->subject,
1✔
1255
            'description' => $body,
1✔
1256
            'latitude' => $msg->lat,
1✔
1257
            'longitude' => $msg->lng,
1✔
1258
            'images' => $images,
1✔
1259
            'created_at' => $group->arrival,
1✔
1260
        ];
1✔
1261

1262
        $apiUrl = config('freegle.freebie_alerts.api_url');
1✔
1263

1264
        try {
1265
            $response = \Illuminate\Support\Facades\Http::withHeaders([
1✔
1266
                'Content-type' => 'application/json',
1✔
1267
                'Key' => $apiKey,
1✔
1268
            ])->timeout(60)->post("{$apiUrl}/freegle/post/create", $payload);
1✔
1269

1270
            if ($response->successful()) {
1✔
1271
                Log::info('Added post to Freebie Alerts', ['msgid' => $msgId]);
1✔
1272
            } else {
1273
                Log::warning('Freebie Alerts add failed', [
×
1274
                    'msgid' => $msgId,
×
1275
                    'status' => $response->status(),
×
1276
                    'body' => $response->body(),
×
1277
                ]);
×
1278
                if (app()->bound('sentry')) {
×
1279
                    app('sentry')->captureMessage("Freebie Alerts add failed for message {$msgId}: {$response->status()}");
1✔
1280
                }
1281
            }
1282
        } catch (\Throwable $e) {
×
1283
            Log::error('Freebie Alerts add exception', [
×
1284
                'msgid' => $msgId,
×
1285
                'error' => $e->getMessage(),
×
1286
            ]);
×
1287
            if (app()->bound('sentry')) {
×
1288
                app('sentry')->captureException($e);
×
1289
            }
1290
        }
1291
    }
1292

1293
    /**
1294
     * Remove a post from freebiealerts.app.
1295
     *
1296
     * V1 parity with FreebieAlerts::remove().
1297
     */
1298
    protected function handleFreebieAlertsRemove(array $data): void
2✔
1299
    {
1300
        $msgId = (int) ($data['msgid'] ?? 0);
2✔
1301
        if ($msgId === 0) {
2✔
1302
            throw new \RuntimeException('freebie_alerts_remove requires msgid');
×
1303
        }
1304

1305
        $apiKey = config('freegle.freebie_alerts.api_key');
2✔
1306
        if (empty($apiKey)) {
2✔
1307
            Log::debug('Freebie Alerts API key not configured, skipping remove', ['msgid' => $msgId]);
1✔
1308
            return;
1✔
1309
        }
1310

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

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

1319
            if ($response->successful()) {
1✔
1320
                Log::info('Removed post from Freebie Alerts', ['msgid' => $msgId]);
1✔
1321
            } else {
1322
                Log::warning('Freebie Alerts remove failed', [
1✔
1323
                    'msgid' => $msgId,
1✔
1324
                    'status' => $response->status(),
1✔
1325
                    'body' => $response->body(),
1✔
1326
                ]);
1✔
1327
            }
1328
        } catch (\Throwable $e) {
×
1329
            Log::error('Freebie Alerts remove 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
     * Obfuscate an email address for display (e.g. "j***@example.com").
1341
     */
1342
    private function obfuscateEmail(string $email): string
2✔
1343
    {
1344
        if (! $email || ! str_contains($email, '@')) {
2✔
1345
            return $email;
×
1346
        }
1347

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

1350
        if (strlen($local) <= 1) {
2✔
1351
            return '*@' . $domain;
×
1352
        }
1353

1354
        return $local[0] . str_repeat('*', strlen($local) - 1) . '@' . $domain;
2✔
1355
    }
1356

1357
    /**
1358
     * Remap postcodes to their nearest area after a location geometry change.
1359
     */
1360
    protected function handleRemapPostcodes(array $data): void
×
1361
    {
1362
        $locationId = isset($data['location_id']) ? (int) $data['location_id'] : NULL;
×
1363
        $polygon = $data['polygon'] ?? NULL;
×
1364

1365
        $service = app(PostcodeRemapService::class);
×
1366
        $updated = $service->remapPostcodes($locationId, $polygon);
×
1367

1368
        Log::info('Remapped postcodes', [
×
1369
            'location_id' => $locationId,
×
1370
            'updated' => $updated,
×
1371
        ]);
×
1372
    }
1373
}
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