diff --git a/apps/api/src/tenders/tender-matching.service.ts b/apps/api/src/tenders/tender-matching.service.ts new file mode 100644 index 0000000..e15c53f --- /dev/null +++ b/apps/api/src/tenders/tender-matching.service.ts @@ -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 { + 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}`, + ); + } + } + } +}