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

Freegle / Iznik / 27835

19 Jul 2026 10:21AM UTC coverage: 72.449%. First build
27835

Pull #1122

circleci

edwh
feat(matched): resurrect matched-posts email with vector matching

For each recently-arrived Offer/Wanted, email the poster the opposite-type
posts near them that match, and the owners of matched posts the new arrival.
Rebuilds V1 cron/relevant.php on the vector embeddings.

- apiv2 GET /message/{id}/matches: opposite-type vector matches near a post,
  reach-filtered against the post owner (batch calls unauthenticated).
  Killswitch FEATURE_MATCHED_POSTS.
- matches:notify command, scheduled every 10 min, arrival-driven (~100 posts
  /run on prod). One vector search per fresh post feeds both directions.
- Adaptive MjmlMailable (hero for one match, list for several) with a
  "matches your wanted/offer" reason line, reusing shared chrome + tracking.
- Never re-mails: per-(msgid,userid) ledger messages_matched_notified; skips
  posts already clicked to view (messages_likes View + pageview=1); ~4h
  per-user cooldown; honours relevantallowed opt-out; excludes own/taken/
  deleted/unapproved posts.

Separate from the digest relevance ranking (PR #956), left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YqwEGSmzjUbHM3QinYAxX
Pull Request #1122: feat: matched-posts email (vector-based resurrection of "Any of these take your fancy?")

13213 of 17412 branches covered (75.88%)

Branch coverage included in aggregate %.

348 of 448 new or added lines in 7 files covered. (77.68%)

138590 of 192119 relevant lines covered (72.14%)

37.88 hits per line

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

95.92
/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\Services\EmailSpoolerService;
7
use App\Services\MatchedPostsService;
8
use App\Traits\GracefulShutdown;
9
use App\Traits\LogsBatchJob;
10
use Carbon\Carbon;
11
use Illuminate\Console\Command;
12
use Illuminate\Support\Facades\DB;
13
use Illuminate\Support\Facades\Log;
14

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

26
    protected $signature = 'matches:notify
27
                            {--dry-run : Build and report notifications without sending or recording anything}';
28

29
    protected $description = 'Email members opposite-type posts that match their open offers/wanteds (vector matched-posts mail)';
30

31
    public function handle(MatchedPostsService $service, EmailSpoolerService $spooler): int
3✔
32
    {
33
        $this->registerShutdownHandlers();
3✔
34

35
        $dryRun = (bool) $this->option('dry-run');
3✔
36
        if ($dryRun) {
3✔
37
            $this->warn('DRY RUN MODE — no mail sent, no ledger written.');
1✔
38
        }
39

40
        return $this->runWithLogging(function () use ($service, $spooler, $dryRun) {
3✔
41
            $now = Carbon::now();
3✔
42
            $notifications = $service->buildNotifications($now);
3✔
43

44
            $emails = 0;
3✔
45
            $matches = 0;
3✔
46

47
            foreach ($notifications as $n) {
3✔
48
                if ($this->shouldStop()) {
3✔
NEW
49
                    break;
×
50
                }
51

52
                $user = $n['user'];
3✔
53
                $items = $n['items'];
3✔
54
                $email = $user->email_preferred ?? $user->email ?? null;
3✔
55
                if (! $email) {
3✔
NEW
56
                    continue;
×
57
                }
58

59
                if ($dryRun) {
3✔
60
                    $emails++;
1✔
61
                    $matches += count($items);
1✔
62

63
                    continue;
1✔
64
                }
65

66
                $mail = new MatchedPosts($user, $email, $items);
2✔
67
                $spooler->spool($mail);
2✔
68

69
                // Record every matched post as mailed to this user (never re-send),
70
                // and bump the per-user cooldown watermark.
71
                $rows = array_map(fn ($i) => [
2✔
72
                    'msgid' => (int) $i['message']->id,
2✔
73
                    'userid' => (int) $user->id,
2✔
74
                    'mailed_at' => $now->toDateTimeString(),
2✔
75
                ], $items);
2✔
76
                DB::table('messages_matched_notified')->insertOrIgnore($rows);
2✔
77
                DB::table('users')->where('id', $user->id)->update(['lastrelevantcheck' => $now->toDateTimeString()]);
2✔
78

79
                $emails++;
2✔
80
                $matches += count($items);
2✔
81
            }
82

83
            $prefix = $dryRun ? 'Would ' : '';
3✔
84
            $this->newLine();
3✔
85
            $this->table(
3✔
86
                ['Operation', 'Count'],
3✔
87
                [
3✔
88
                    [$prefix . 'Members emailed', number_format($emails)],
3✔
89
                    [$prefix . 'Matched posts included', number_format($matches)],
3✔
90
                ]
3✔
91
            );
3✔
92

93
            Log::info('Matched-posts notify completed', [
3✔
94
                'emails' => $emails,
3✔
95
                'matches' => $matches,
3✔
96
                'dry_run' => $dryRun,
3✔
97
            ]);
3✔
98

99
            return Command::SUCCESS;
3✔
100
        });
3✔
101
    }
102
}
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