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

Freegle / Iznik / 27909

20 Jul 2026 05:04PM UTC coverage: 72.497% (+0.003%) from 72.494%
27909

push

circleci

invalid-email-address
test(matched): cover relevance floor + crosspost dedup

Two unit tests for the fixes in 8159cec13, using the existing FreegleApiClient::fake harness:
- a 0.60 match (below the 0.66 floor) fans out to nobody;
- two same-owner/same-subject crossposts collapse to one card (highest score kept).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

13218 of 17414 branches covered (75.9%)

Branch coverage included in aggregate %.

138978 of 192520 relevant lines covered (72.19%)

37.95 hits per line

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

91.76
/iznik-batch/app/Services/MatchedPostsService.php
1
<?php
2

3
namespace App\Services;
4

5
use App\Models\Message;
6
use App\Models\User;
7
use App\Support\SubjectParser;
8
use Carbon\Carbon;
9
use Illuminate\Support\Collection;
10
use Illuminate\Support\Facades\DB;
11

12
/**
13
 * Builds the matched-posts notifications: for each recently-arrived Offer/Wanted,
14
 * ask apiv2's vector store for the opposite-type posts near it, then fan those
15
 * matches out to the two people who care — the fresh post's owner ("here are
16
 * offers/wanteds for what you just posted") and each matched post's owner ("a new
17
 * post matches your open offer/wanted"). Applies every dedup/eligibility guard so
18
 * the command can just render and send what comes back.
19
 *
20
 * Vector similarity itself lives in apiv2 (in-memory store); this service only
21
 * orchestrates and resolves the surrounding data from our own DB.
22
 */
23
class MatchedPostsService
24
{
25
    public function __construct(private FreegleApiClient $api) {}
12✔
26

27
    /**
28
     * @return array<int, array{user: User, items: array<int, array{message: Message, reason: Message, score: float}>}>
29
     *         One entry per eligible recipient, each with the matched posts to show them.
30
     */
31
    public function buildNotifications(?Carbon $now = null): array
12✔
32
    {
33
        $now = $now ?? Carbon::now();
12✔
34

35
        if (! config('freegle.matched.enabled', true)) {
12✔
36
            return [];
×
37
        }
38

39
        $window = (int) config('freegle.matched.fresh_window_minutes', 20);
12✔
40
        $perPost = (int) config('freegle.matched.match_limit_per_post', 10);
12✔
41

42
        $fresh = $this->freshPosts($now->copy()->subMinutes($window));
12✔
43
        if ($fresh->isEmpty()) {
12✔
44
            return [];
×
45
        }
46

47
        // Ask apiv2 for each fresh post's opposite-type matches. Collect the raw
48
        // (freshPost, matchedPost) links plus every message id involved.
49
        // $matchCache[postId] = [matched msgids] — apiv2 reach-filters each result
50
        // for THAT post's owner, so it doubles as the per-recipient reach oracle
51
        // in verifyReach() below.
52
        // Relevance floor: apiv2 returns the nearest vector neighbours regardless
53
        // of how weak the similarity is, so without a threshold poor matches (e.g.
54
        // "bicycle pump" ~ "pot pourri") surface. Only links at/above min_score are
55
        // shown; matchCache still carries the full set so reach verification below
56
        // is unaffected (reach is geography, not similarity).
57
        $minScore = (float) config('freegle.matched.match_min_score', 0.0);
12✔
58
        $links = [];   // list of ['F'=>int, 'Fowner'=>int, 'R'=>int, 'score'=>float]
12✔
59
        $ids = [];     // set of message ids to resolve
12✔
60
        $matchCache = [];
12✔
61
        foreach ($fresh as $f) {
12✔
62
            $matches = $this->api->matchesForPost((int) $f->msgid, $perPost);
12✔
63
            $matchCache[(int) $f->msgid] = array_values(array_filter(array_map(fn ($m) => (int) ($m['id'] ?? 0), $matches)));
12✔
64
            foreach ($matches as $m) {
12✔
65
                $rid = (int) ($m['id'] ?? 0);
12✔
66
                if ($rid <= 0) {
12✔
67
                    continue;
×
68
                }
69
                if ((float) ($m['score'] ?? 0) < $minScore) {
12✔
70
                    continue; // below the relevance floor — don't show it
1✔
71
                }
72
                $links[] = ['F' => (int) $f->msgid, 'Fowner' => (int) $f->fromuser, 'R' => $rid, 'score' => (float) ($m['score'] ?? 0)];
11✔
73
                $ids[(int) $f->msgid] = true;
11✔
74
                $ids[$rid] = true;
11✔
75
            }
76
        }
77
        if (! $links) {
12✔
78
            return [];
1✔
79
        }
80

81
        // Resolve the involved posts — only those still open (approved, not
82
        // deleted, not taken). A post that closed between the vector snapshot and
83
        // now simply drops out here.
84
        $messages = $this->loadOpenMessages(array_keys($ids));
11✔
85

86
        // Fan out into (recipient => matchedMsgId => [matched message, reason post, score]).
87
        $byRecipient = [];   // [recipientId => [matchedMsgId => ['message'=>Message,'reason'=>Message,'score'=>float]]]
11✔
88
        foreach ($links as $l) {
11✔
89
            $f = $messages->get($l['F']);
11✔
90
            $r = $messages->get($l['R']);
11✔
91
            if (! $f || ! $r) {
11✔
92
                continue;
×
93
            }
94
            // Direction (i): the fresh post's owner sees the matched post R,
95
            // because it matches their post F.
96
            $this->addPair($byRecipient, $l['Fowner'], $r, $f, $l['score']);
11✔
97
            // Direction (ii): the matched post's owner sees the fresh post F,
98
            // because it matches their post R.
99
            $this->addPair($byRecipient, (int) $r->fromuser, $f, $r, $l['score']);
11✔
100
        }
101
        if (! $byRecipient) {
11✔
102
            return [];
×
103
        }
104

105
        $notifications = $this->applyEligibility($byRecipient, $now);
11✔
106

107
        return $this->verifyReach($notifications, $matchCache, $perPost);
11✔
108
    }
109

110
    /**
111
     * Make every shown match reach-exact for its recipient. A recipient U is only
112
     * shown a match M if M is in apiv2's matches for U's OWN post that it matched
113
     * (`matchesForPost` reach-filters against that post's owner). Direction (i)
114
     * items pass for free — their reason post is a fresh post already in
115
     * $matchCache. Direction (ii) items (the recipient's existing post ← a new
116
     * arrival) previously relied on bbox proximity; here they are checked against
117
     * a fresh `matchesForPost` of the recipient's own post, so a match that hasn't
118
     * rippled out to the recipient is dropped.
119
     *
120
     * @param  array<int, array{user: User, items: array}>  $notifications
121
     * @param  array<int, array<int, int>>  $matchCache  postId => matched msgids
122
     * @return array<int, array{user: User, items: array}>
123
     */
124
    private function verifyReach(array $notifications, array $matchCache, int $perPost): array
11✔
125
    {
126
        $out = [];
11✔
127
        foreach ($notifications as $n) {
11✔
128
            $kept = [];
11✔
129
            foreach ($n['items'] as $item) {
11✔
130
                $reasonId = (int) $item['reason']->id;
11✔
131
                if (! array_key_exists($reasonId, $matchCache)) {
11✔
132
                    // Resolve (and cache) the recipient's own post's matches — this
133
                    // is the extra apiv2 call, made only for surviving items.
134
                    $res = $this->api->matchesForPost($reasonId, $perPost);
11✔
135
                    $matchCache[$reasonId] = array_values(array_filter(array_map(fn ($m) => (int) ($m['id'] ?? 0), $res)));
11✔
136
                }
137
                if (in_array((int) $item['message']->id, $matchCache[$reasonId], true)) {
11✔
138
                    $kept[] = $item;
11✔
139
                }
140
            }
141
            if ($kept) {
11✔
142
                $out[] = ['user' => $n['user'], 'items' => $kept];
11✔
143
            }
144
        }
145

146
        return $out;
11✔
147
    }
148

149
    /**
150
     * Recently-arrived, still-open, embedded Offer/Wanted posts — the drivers for
151
     * this run. Bounded by the arrival index; joining messages_embeddings skips
152
     * posts that can't be vector-searched yet (no wasted apiv2 call).
153
     */
154
    private function freshPosts(Carbon $since): Collection
12✔
155
    {
156
        return collect(DB::select(
12✔
157
            'SELECT m.id AS msgid, m.fromuser, m.type
12✔
158
               FROM messages_groups mg
159
               INNER JOIN messages m ON m.id = mg.msgid
160
               INNER JOIN messages_spatial ms ON ms.msgid = m.id
161
               INNER JOIN messages_embeddings me ON me.msgid = m.id
162
              WHERE mg.collection = ? AND mg.deleted = 0 AND mg.rippled_in = 0
163
                AND m.type IN (?, ?)
164
                AND mg.arrival > ?
165
                AND m.deleted IS NULL
166
                AND ms.successful = 0 AND ms.promised = 0
167
              GROUP BY m.id, m.fromuser, m.type',
12✔
168
            ['Approved', 'Offer', 'Wanted', $since->toDateTimeString()]
12✔
169
        ));
12✔
170
    }
171

172
    /**
173
     * Load the given posts as Message models, keyed by id, restricted to those
174
     * still live (approved, not deleted, no Taken/Received outcome).
175
     */
176
    private function loadOpenMessages(array $ids): Collection
11✔
177
    {
178
        if (! $ids) {
11✔
179
            return collect();
×
180
        }
181

182
        return Message::query()
11✔
183
            ->whereIn('id', $ids)
11✔
184
            ->approved()
11✔
185
            ->notDeleted()
11✔
186
            ->whereNotExists(function ($q) {
11✔
187
                $q->select(DB::raw(1))
11✔
188
                    ->from('messages_outcomes')
11✔
189
                    ->whereColumn('messages_outcomes.msgid', 'messages.id')
11✔
190
                    ->whereIn('messages_outcomes.outcome', ['Taken', 'Received']);
11✔
191
            })
11✔
192
            ->with(['attachments', 'fromUser', 'groups'])
11✔
193
            ->get()
11✔
194
            ->keyBy('id');
11✔
195
    }
196

197
    /**
198
     * Record that matched post $matched should be shown to $recipientId because it
199
     * matches their own post $reason. Dedupes on the matched post (keeps the
200
     * highest-scoring reason) and never shows a user their own post.
201
     */
202
    private function addPair(array &$byRecipient, int $recipientId, Message $matched, Message $reason, float $score): void
11✔
203
    {
204
        if ($recipientId <= 0) {
11✔
205
            return;
×
206
        }
207
        if ((int) $matched->fromuser === $recipientId) {
11✔
208
            return; // never show someone their own post
×
209
        }
210
        $mid = (int) $matched->id;
11✔
211
        $existing = $byRecipient[$recipientId][$mid] ?? null;
11✔
212
        if ($existing && $existing['score'] >= $score) {
11✔
213
            return;
1✔
214
        }
215
        $byRecipient[$recipientId][$mid] = ['message' => $matched, 'reason' => $reason, 'score' => $score];
11✔
216
    }
217

218
    /**
219
     * Drop matched posts the recipient has already clicked to view or already been
220
     * mailed, drop ineligible recipients (opted out / dormant / within cooldown),
221
     * cap and order the per-recipient list, and attach the User model.
222
     */
223
    private function applyEligibility(array $byRecipient, Carbon $now): array
11✔
224
    {
225
        $recipientIds = array_keys($byRecipient);
11✔
226

227
        // Eligible recipients: opted in, active recently, outside the cooldown.
228
        $eligible = $this->eligibleRecipients($recipientIds, $now);
11✔
229
        if ($eligible->isEmpty()) {
11✔
230
            return [];
×
231
        }
232

233
        // (userid, msgid) pairs to test against "already viewed" and "already mailed".
234
        $pairs = [];
11✔
235
        foreach ($byRecipient as $uid => $items) {
11✔
236
            if (! $eligible->has($uid)) {
11✔
237
                continue;
2✔
238
            }
239
            foreach (array_keys($items) as $mid) {
11✔
240
                $pairs[] = [$uid, $mid];
11✔
241
            }
242
        }
243
        $viewed = $this->pairSet('messages_likes', $pairs, "type = 'View' AND pageview = 1");
11✔
244
        $mailed = $this->pairSet('messages_matched_notified', $pairs);
11✔
245

246
        $cap = (int) config('freegle.matched.max_items_per_email', 10);
11✔
247

248
        $out = [];
11✔
249
        foreach ($byRecipient as $uid => $items) {
11✔
250
            if (! $eligible->has($uid)) {
11✔
251
                continue;
2✔
252
            }
253
            $kept = [];
11✔
254
            foreach ($items as $mid => $item) {
11✔
255
                if (isset($viewed["$uid:$mid"]) || isset($mailed["$uid:$mid"])) {
11✔
256
                    continue;
2✔
257
                }
258
                $kept[] = $item;
11✔
259
            }
260
            if (! $kept) {
11✔
261
                continue;
2✔
262
            }
263
            // Collapse crossposts: the same item posted to several groups is stored
264
            // as distinct messages (same poster + subject, different msgids/groups),
265
            // so without this the email shows one item as several near-identical
266
            // cards. Keep the single best-scoring copy per (poster, subject).
267
            $kept = $this->dedupeCrossposts($kept);
11✔
268
            usort($kept, fn ($a, $b) => $b['score'] <=> $a['score']);
11✔
269
            $out[] = [
11✔
270
                'user' => $eligible->get($uid),
11✔
271
                'items' => array_slice($kept, 0, $cap),
11✔
272
            ];
11✔
273
        }
274

275
        return $out;
11✔
276
    }
277

278
    /**
279
     * Collapse crossposts of one item to a single card. A crosspost is the same
280
     * poster's identical subject appearing under more than one group as separate
281
     * messages (distinct msgids); keep the highest-scoring copy.
282
     *
283
     * @param  array<int, array{message: Message, reason: Message, score: float}>  $items
284
     * @return array<int, array{message: Message, reason: Message, score: float}>
285
     */
286
    private function dedupeCrossposts(array $items): array
11✔
287
    {
288
        $best = [];
11✔
289
        foreach ($items as $item) {
11✔
290
            $msg = $item['message'];
11✔
291
            $key = (int) $msg->fromuser . '|' . trim((string) $msg->subject);
11✔
292
            if (! isset($best[$key]) || $item['score'] > $best[$key]['score']) {
11✔
293
                $best[$key] = $item;
11✔
294
            }
295
        }
296

297
        return array_values($best);
11✔
298
    }
299

300
    private function eligibleRecipients(array $ids, Carbon $now): Collection
11✔
301
    {
302
        if (! $ids) {
11✔
303
            return collect();
×
304
        }
305
        $dormantDays = (int) config('freegle.matched.min_lastaccess_days', 90);
11✔
306
        $cooldownHours = (int) config('freegle.matched.cooldown_hours', 4);
11✔
307

308
        return User::query()
11✔
309
            ->whereIn('id', $ids)
11✔
310
            ->where('relevantallowed', 1)
11✔
311
            ->where('lastaccess', '>', $now->copy()->subDays($dormantDays)->toDateTimeString())
11✔
312
            ->where(function ($q) use ($now, $cooldownHours) {
11✔
313
                $q->whereNull('lastrelevantcheck')
11✔
314
                    ->orWhere('lastrelevantcheck', '<', $now->copy()->subHours($cooldownHours)->toDateTimeString());
11✔
315
            })
11✔
316
            ->get()
11✔
317
            ->keyBy('id');
11✔
318
    }
319

320
    /**
321
     * Return a set ("userid:msgid" => true) of the given (userid, msgid) pairs
322
     * that exist in $table (optionally further constrained by $extra).
323
     *
324
     * @param  array<int, array{0:int,1:int}>  $pairs
325
     */
326
    private function pairSet(string $table, array $pairs, string $extra = ''): array
11✔
327
    {
328
        if (! $pairs) {
11✔
329
            return [];
×
330
        }
331

332
        $userIds = array_values(array_unique(array_map(fn ($p) => $p[0], $pairs)));
11✔
333
        $msgIds = array_values(array_unique(array_map(fn ($p) => $p[1], $pairs)));
11✔
334

335
        $q = DB::table($table)
11✔
336
            ->select('userid', 'msgid')
11✔
337
            ->whereIn('userid', $userIds)
11✔
338
            ->whereIn('msgid', $msgIds);
11✔
339
        if ($extra !== '') {
11✔
340
            $q->whereRaw($extra);
11✔
341
        }
342

343
        // Prune to the exact pairs we asked about (the IN×IN cross-product can
344
        // include pairs we don't care about).
345
        $wanted = [];
11✔
346
        foreach ($pairs as $p) {
11✔
347
            $wanted["{$p[0]}:{$p[1]}"] = true;
11✔
348
        }
349

350
        $set = [];
11✔
351
        foreach ($q->get() as $row) {
11✔
352
            $key = "{$row->userid}:{$row->msgid}";
2✔
353
            if (isset($wanted[$key])) {
2✔
354
                $set[$key] = true;
2✔
355
            }
356
        }
357

358
        return $set;
11✔
359
    }
360

361
    /** Item name from a message subject ("OFFER: Bike (London)" → "Bike"). */
362
    public static function itemName(Message $m): string
4✔
363
    {
364
        $subject = html_entity_decode($m->subject ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8');
4✔
365
        [, $item] = SubjectParser::parse($subject);
4✔
366
        if ($item) {
4✔
367
            return $item;
4✔
368
        }
369
        // Fallback: strip a leading TYPE: prefix and any trailing (location).
370
        $name = preg_replace('/^(OFFER|WANTED)\s*:\s*/i', '', $subject);
×
371
        $name = preg_replace('/\s*\([^)]+\)\s*$/', '', $name);
×
372

373
        return trim($name) ?: $subject;
×
374
    }
375
}
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