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:
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user