From 1cd2fcfa01dba8497ff72f22c7309ed7d4ddcf58 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 23 Jul 2026 08:48:12 +0200 Subject: [PATCH] feat(13-03): implement TenderDedupService three-tier resolver (D-04/D-05) resolve(n, {dedupActive}) matches OCID -> source:noticeId -> fingerprint (fingerprint tier hard-gated by dedupActive, D-05). On any match the existing Tender gets an additional TenderSource attached (D-03 merge) instead of a new Tender row; SCHEMA-02 change-detection is preserved inline (matched Tender's mutable fields refresh when contentHash differs, exactly as the old direct tender.upsert UPDATE branch did). No match -> tender.create (with computed fingerprint) + tenderSource.create. Plain PrismaService, no forTenant()/RLS (T-10-09). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/tenders/tender-dedup.service.ts | 190 +++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 apps/api/src/tenders/tender-dedup.service.ts diff --git a/apps/api/src/tenders/tender-dedup.service.ts b/apps/api/src/tenders/tender-dedup.service.ts new file mode 100644 index 0000000..d9a19f0 --- /dev/null +++ b/apps/api/src/tenders/tender-dedup.service.ts @@ -0,0 +1,190 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { tenderFingerprint } from './tender-fingerprint'; +import type { NormalizedTenderFields } from './tender.types'; + +export interface DedupResolveOptions { + /** + * D-05 hard gate: the fingerprint tier only runs when true. Callers + * (pollDueSources, Plan 13-03 Task 2) compute this as + * `activePortalCount >= 2` — with only DÖE active, dedupActive is always + * false and the fingerprint tier NEVER runs (Erfolgskriterium 3, inert). + */ + dedupActive: boolean; +} + +export interface DedupResolveResult { + tenderId: string; + created: boolean; +} + +/** Minimal shape needed from a matched Tender row across all three tiers. */ +interface TenderMatchRow { + id: string; + contentHash: string; +} + +/** + * TenderDedupService — SCHEMA-03 three-tier cross-source dedup resolver + * (D-04): OCID -> source:noticeId -> fingerprint (13-RESEARCH Pattern 5). + * The fingerprint tier is hard-gated by `opts.dedupActive` (D-05) — with + * only one active source it is structurally never reached, so two + * fingerprint-equal DÖE tenders stay two separate Tender rows + * (Erfolgskriterium 3, inert). + * + * On a match (tiers 1-3): attaches an additional `TenderSource` to the + * EXISTING Tender (D-03 merge) instead of creating a new Tender row. + * + * SCHEMA-02 preservation (no Phase-10 regress): the old direct + * `tender.upsert` UPDATE branch in `pollDueSources` refreshed the mutable + * Tender fields (title, buyerName, cpvCodes, cpvDivisions, region, plz, + * bundesland, deadlineAt, estimatedValue, procedureType, sourceUrl, + * contentHash) whenever the contentHash changed on re-poll. That logic now + * lives HERE: on any match, if the incoming `contentHash` differs from the + * matched Tender's stored `contentHash`, the same mutable-field set is + * refreshed via `tender.update`. Only the create-vs-update DECISION moved + * into this resolver — the SCHEMA-02 semantics are unchanged. + * + * Plain, non-tenant-scoped PrismaService (T-10-09 pattern): Tender/ + * TenderSource are platform-global, RLS-exempt tables. Never wrap these + * queries in forTenant(). + */ +@Injectable() +export class TenderDedupService { + constructor(private readonly prisma: PrismaService) {} + + async resolve( + n: NormalizedTenderFields, + opts: DedupResolveOptions, + ): Promise { + const match = await this.findMatch(n, opts); + + if (match) { + await this.prisma.tenderSource.upsert({ + where: { + sourcePortal_sourceNoticeId: { + sourcePortal: n.sourcePortal, + sourceNoticeId: n.sourceNoticeId, + }, + }, + create: { + tenderId: match.id, + sourcePortal: n.sourcePortal, + sourceNoticeId: n.sourceNoticeId, + sourceUrl: n.sourceUrl, + ocid: n.ocid, + }, + update: { + sourceUrl: n.sourceUrl, + }, + }); + + // SCHEMA-02 change-detection preservation — see class doc. + if (n.contentHash !== match.contentHash) { + await this.prisma.tender.update({ + where: { id: match.id }, + data: { + title: n.title, + buyerName: n.buyerName, + cpvCodes: n.cpvCodes, + cpvDivisions: n.cpvDivisions, + region: n.region, + plz: n.plz, + bundesland: n.bundesland, + deadlineAt: n.deadlineAt, + estimatedValue: n.estimatedValue, + procedureType: n.procedureType, + sourceUrl: n.sourceUrl, + contentHash: n.contentHash, + }, + }); + } + + return { tenderId: match.id, created: false }; + } + + const fingerprint = n.fingerprint ?? tenderFingerprint(n); + const created = await this.prisma.tender.create({ + data: { + sourcePortal: n.sourcePortal, + sourceNoticeId: n.sourceNoticeId, + ocid: n.ocid, + dedupKey: n.dedupKey, + title: n.title, + buyerName: n.buyerName, + cpvCodes: n.cpvCodes, + cpvDivisions: n.cpvDivisions, + region: n.region, + plz: n.plz, + bundesland: n.bundesland, + deadlineAt: n.deadlineAt, + estimatedValue: n.estimatedValue, + procedureType: n.procedureType, + status: n.status, + sourceUrl: n.sourceUrl, + contentHash: n.contentHash, + publishedAt: n.publishedAt, + fingerprint, + }, + }); + + await this.prisma.tenderSource.create({ + data: { + tenderId: created.id, + sourcePortal: n.sourcePortal, + sourceNoticeId: n.sourceNoticeId, + sourceUrl: n.sourceUrl, + ocid: n.ocid, + }, + }); + + return { tenderId: created.id, created: true }; + } + + private async findMatch( + n: NormalizedTenderFields, + opts: DedupResolveOptions, + ): Promise { + // Tier 1: OCID (D-04 primary key, present for DÖE — 0% NULL live). + if (n.ocid) { + const byOcid = await this.prisma.tender.findFirst({ + where: { ocid: n.ocid }, + select: { id: true, contentHash: true }, + }); + if (byOcid) return byOcid; + } + + // Tier 2: source:noticeId — exact same source re-seen (idempotent + // re-poll). NOT gated by dedupActive — this is the same-source + // identity check, independent of cross-source merging. + const existingSource = await this.prisma.tenderSource.findUnique({ + where: { + sourcePortal_sourceNoticeId: { + sourcePortal: n.sourcePortal, + sourceNoticeId: n.sourceNoticeId, + }, + }, + select: { tenderId: true }, + }); + if (existingSource) { + const byNoticeId = await this.prisma.tender.findUnique({ + where: { id: existingSource.tenderId }, + select: { id: true, contentHash: true }, + }); + if (byNoticeId) return byNoticeId; + } + + // Tier 3: fingerprint — ONLY when dedupActive (D-05). With a single + // active source this branch is structurally never reached. + if (opts.dedupActive) { + const fingerprint = n.fingerprint ?? tenderFingerprint(n); + const byFingerprint = await this.prisma.tender.findFirst({ + where: { fingerprint }, + select: { id: true, contentHash: true }, + }); + if (byFingerprint) return byFingerprint; + } + + return null; + } +}