feat(12-01): implement TenderMatchingService.matchDelta (GREEN)

matchDelta(newTenderIds) loads all active TenderSavedSearch profiles,
reuses buildTenderWhere(profile.filters) AND-ed with id IN newTenderIds
(delta-only boundary, D-07), and upserts TenderMatch on
@@unique([tenderId, savedSearchId]) with update:{} — idempotent, so a
re-match never resets an already-set notifiedAt (D-06). Per-profile
catch-and-log so one broken filters JSON never aborts the whole delta
(matches pollDueSources' existing catch-and-log convention).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 09:03:36 +02:00
parent 57d22a034f
commit 92d4c969de
@@ -0,0 +1,90 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { buildTenderWhere } from './tender-query.builder';
import type { TenderQueryDto } from './dto/tender-query.dto';
/**
* TenderMatchingService.matchDelta — NOTIFY-03 core: evaluates ONLY the
* tender IDs newly upserted in the current `pollDueSources` tick
* (`newTenderIds`) against every active `TenderSavedSearch` profile
* (across all users/tenants — the Tender catalog is global, D-03).
*
* Delta-only is the structural backfill-flood prevention (D-07): matching
* NEVER runs against the full `Tender` table or historical rows. A newly
* created profile therefore collects zero un-notified matches against the
* existing ~2188-row catalog — it only starts matching from the next poll
* tick onward. There is deliberately NO historical rescan and NO backfill/
* suppression table.
*
* Filter reuse (Don't Hand-Roll): the exact same `buildTenderWhere` used
* by the Phase-11 read path (`TendersController.listTenders`) is reused
* here — a profile's `filters` JSON blob is cast to `TenderQueryDto` and
* compiled to a parametrized Prisma `where`, AND-ed with the delta
* boundary `{ id: { in: newTenderIds } }`.
*
* Match persistence is an idempotent upsert on
* `@@unique([tenderId, savedSearchId])` with `update: {}` (D-06) — a
* re-match of an already-matched pair leaves any previously-set
* `notifiedAt` untouched, so the matched-vs-notified invariant holds
* structurally even if `matchDelta` is ever called twice for the same
* tender ID.
*
* This plan (12-01) only creates TenderMatch rows with `notifiedAt` left
* NULL — instant dispatch is wired in Plan 12-03, which extends this
* service. No TenderMailService import here yet.
*/
@Injectable()
export class TenderMatchingService {
private readonly logger = new Logger(TenderMatchingService.name);
constructor(private readonly prisma: PrismaService) {}
async matchDelta(newTenderIds: string[]): Promise<void> {
if (!newTenderIds.length) return;
const savedSearches = await this.prisma.tenderSavedSearch.findMany();
for (const search of savedSearches) {
try {
const filterWhere = buildTenderWhere(
search.filters as unknown as TenderQueryDto,
);
const where: Prisma.TenderWhereInput = {
AND: [filterWhere, { id: { in: newTenderIds } }],
};
const hits = await this.prisma.tender.findMany({
where,
select: { id: true },
});
for (const hit of hits) {
await this.prisma.tenderMatch.upsert({
where: {
tenderId_savedSearchId: {
tenderId: hit.id,
savedSearchId: search.id,
},
},
create: {
tenderId: hit.id,
savedSearchId: search.id,
userId: search.userId,
tenantId: search.tenantId,
// notifiedAt intentionally omitted -> stays NULL (Eligibility-Gate, D-06)
},
update: {}, // idempotent — preserves a previously-set notifiedAt on re-match
});
}
} catch (err) {
// A single broken profile (e.g. malformed filters JSON) must never
// abort matching for the remaining profiles — catch-and-log per
// profile (DKV/pollDueSources pattern).
this.logger.error(
`matchDelta failed for savedSearch ${search.id}: ${(err as Error).message}`,
);
}
}
}
}