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