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

Freegle / Iznik / 27897

20 Jul 2026 06:20AM UTC coverage: 72.494% (+0.06%) from 72.432%
27897

push

circleci

web-flow
Merge pull request #1122 from Freegle/feat/matched-posts-email

feat: matched-posts notifications + email (vector resurrection of "Any of these take your fancy?")

13216 of 17415 branches covered (75.89%)

Branch coverage included in aggregate %.

488 of 587 new or added lines in 12 files covered. (83.13%)

2 existing lines in 1 file now uncovered.

138966 of 192508 relevant lines covered (72.19%)

37.93 hits per line

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

95.95
/iznik-batch/app/Console/Commands/Message/NotifyMatchedPostsCommand.php
1
<?php
2

3
namespace App\Console\Commands\Message;
4

5
use App\Mail\Matched\MatchedPosts;
6
use App\Models\User;
7
use App\Services\EmailSpoolerService;
8
use App\Services\MatchedPostsService;
9
use App\Services\PushNotificationService;
10
use App\Traits\GracefulShutdown;
11
use App\Traits\LogsBatchJob;
12
use Carbon\Carbon;
13
use Illuminate\Console\Command;
14
use Illuminate\Support\Facades\DB;
15
use Illuminate\Support\Facades\Log;
16
use Illuminate\Support\Str;
17

18
/**
19
 * Emails members the opposite-type posts near them that match their own open
20
 * Offer/Wanted — the resurrected "Any of these take your fancy?" mail, rebuilt on
21
 * vector matching (V1: cron/relevant.php). Runs every 10 minutes; the service
22
 * already applies every dedup/eligibility guard, so this command just renders,
23
 * spools, and records what was sent so nothing is mailed twice.
24
 */
25
class NotifyMatchedPostsCommand extends Command
26
{
27
    use GracefulShutdown, LogsBatchJob;
28

29
    protected $signature = 'matches:notify
30
                            {--dry-run : Build and report notifications without sending or recording anything}';
31

32
    protected $description = 'Notify members (in-app + push, and email) of opposite-type posts that match their open offers/wanteds (vector matched-posts)';
33

34
    public function handle(MatchedPostsService $service, EmailSpoolerService $spooler, PushNotificationService $push): int
4✔
35
    {
36
        $this->registerShutdownHandlers();
4✔
37

38
        $dryRun = (bool) $this->option('dry-run');
4✔
39
        if ($dryRun) {
4✔
40
            $this->warn('DRY RUN MODE — nothing sent, no ledger written.');
1✔
41
        }
42

43
        return $this->runWithLogging(function () use ($service, $spooler, $push, $dryRun) {
4✔
44
            $now = Carbon::now();
4✔
45
            $notifications = $service->buildNotifications($now);
4✔
46

47
            $notified = 0;
4✔
48
            $emails = 0;
4✔
49
            $matches = 0;
4✔
50

51
            foreach ($notifications as $n) {
4✔
52
                if ($this->shouldStop()) {
4✔
NEW
53
                    break;
×
54
                }
55

56
                $user = $n['user'];
4✔
57
                $items = $n['items'];
4✔
58
                $email = $user->email_preferred ?? $user->email ?? null;
4✔
59

60
                if ($dryRun) {
4✔
61
                    $notified++;
1✔
62
                    $emails += $email ? 1 : 0;
1✔
63
                    $matches += count($items);
1✔
64

65
                    continue;
1✔
66
                }
67

68
                // In-app (bell) notification + device push — the primary channel,
69
                // delivered to every eligible recipient regardless of email.
70
                $this->notifyUserOfMatches($user, $items, $now, $push);
3✔
71
                $notified++;
3✔
72

73
                // Email too, when we have an address.
74
                if ($email) {
3✔
75
                    $spooler->spool(new MatchedPosts($user, $email, $items));
3✔
76
                    $emails++;
3✔
77
                }
78

79
                // Record every matched post as notified to this user (never
80
                // re-send, across both channels) and bump the per-user cooldown.
81
                $rows = array_map(fn ($i) => [
3✔
82
                    'msgid' => (int) $i['message']->id,
3✔
83
                    'userid' => (int) $user->id,
3✔
84
                    'mailed_at' => $now->toDateTimeString(),
3✔
85
                ], $items);
3✔
86
                DB::table('messages_matched_notified')->insertOrIgnore($rows);
3✔
87
                DB::table('users')->where('id', $user->id)->update(['lastrelevantcheck' => $now->toDateTimeString()]);
3✔
88

89
                $matches += count($items);
3✔
90
            }
91

92
            $prefix = $dryRun ? 'Would ' : '';
4✔
93
            $this->newLine();
4✔
94
            $this->table(
4✔
95
                ['Operation', 'Count'],
4✔
96
                [
4✔
97
                    [$prefix . 'Members notified (in-app + push)', number_format($notified)],
4✔
98
                    [$prefix . 'Members also emailed', number_format($emails)],
4✔
99
                    [$prefix . 'Matched posts included', number_format($matches)],
4✔
100
                ]
4✔
101
            );
4✔
102

103
            Log::info('Matched-posts notify completed', [
4✔
104
                'notified' => $notified,
4✔
105
                'emails' => $emails,
4✔
106
                'matches' => $matches,
4✔
107
                'dry_run' => $dryRun,
4✔
108
            ]);
4✔
109

110
            return Command::SUCCESS;
4✔
111
        });
4✔
112
    }
113

114
    /**
115
     * Create the in-app (bell) notification row and fire a device push. The push
116
     * is a no-op when the user has no registered Freegle-app device. One row per
117
     * run (the per-user cooldown already bounds frequency), pointing at the top
118
     * match; the subject mirrors the email.
119
     *
120
     * @param  array<int, array{message: \App\Models\Message, reason: \App\Models\Message, score: float}>  $items
121
     */
122
    private function notifyUserOfMatches(User $user, array $items, Carbon $now, PushNotificationService $push): void
3✔
123
    {
124
        $top = $items[0]['message'];
3✔
125
        $count = count($items);
3✔
126

127
        if ($count === 1) {
3✔
128
            $verb = $top->type === 'Offer' ? 'Someone is offering' : 'Someone wants';
3✔
129
            $title = $verb . ': ' . MatchedPostsService::itemName($top);
3✔
130
            $text = null;
3✔
131
        } else {
NEW
132
            $title = 'Freegle matches for you';
×
NEW
133
            $text = $count . " posts near you match what you've offered or asked for";
×
134
        }
135

136
        DB::table('users_notifications')->insert([
3✔
137
            'touser' => (int) $user->id,
3✔
138
            'fromuser' => null,
3✔
139
            'type' => 'MatchedPost',
3✔
140
            'url' => '/message/' . $top->id,
3✔
141
            'title' => Str::limit($title, 79, ''),
3✔
142
            'text' => $text,
3✔
143
            'timestamp' => $now->toDateTimeString(),
3✔
144
            'seen' => 0,
3✔
145
            'mailed' => 0,
3✔
146
        ]);
3✔
147

148
        $push->notifyUser((int) $user->id);
3✔
149
    }
150
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc