import { Injectable, Logger } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { TenderMailService } from './tender-mail.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. * * Plan 12-01 only created TenderMatch rows with `notifiedAt` left NULL. * Plan 12-03 (this) extends `matchDelta` with the Instant-Alert dispatch * (NOTIFY-02): AFTER all match upserts of this tick have been written, * profiles with `instantAlert=true` are checked for fresh (`notifiedAt` * NULL, `tenderId IN newTenderIds`) matches and — if any exist — bundled * into ONE `TenderMailService.sendInstant` call per profile per tick * (D-05, never one mail per match). `notifiedAt`/`notifiedChannel='instant'` * are stamped ONLY after a successful send — the SAME eligibility gate the * digest reads, which is what structurally guarantees a tender x profile * pair is never notified twice, neither across channels (instant + digest) * nor twice within the same channel (D-06). Instant dispatch always runs * synchronously inside the poll tick, strictly before any later digest run * — so a digest can never see a pair this tick already sent instantly. * * Each profile's instant dispatch is wrapped in its own try/catch: a send * failure/thrown error must never abort the tick (matching, or the * remaining profiles' instant dispatch) — matches this repo's established * catch-and-log-per-profile convention. On failure/skip, `notifiedAt` stays * NULL and the pair is retried on the next poll tick or the next digest run. */ @Injectable() export class TenderMatchingService { private readonly logger = new Logger(TenderMatchingService.name); constructor( private readonly prisma: PrismaService, private readonly mail: TenderMailService, ) {} 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}`, ); } } // Instant-Dispatch (NOTIFY-02, D-04/D-05) — only NOW, after every match // upsert of this tick has been written, so the fresh-match lookup below // sees this tick's rows. const instantProfiles = savedSearches.filter((search) => search.instantAlert === true); for (const profile of instantProfiles) { try { const fresh = await this.prisma.tenderMatch.findMany({ where: { savedSearchId: profile.id, notifiedAt: null, tenderId: { in: newTenderIds }, }, include: { tender: true }, }); if (!fresh.length) continue; // nothing new for this profile this tick const user = await this.prisma.user.findUnique({ where: { id: profile.userId } }); // Kein Konto, oder ein Konto ohne Adresse (WINDOWS #15, kollidierte // AD-Adresse) -- die Zugehoerigkeit funktioniert, nur der // Mailversand wird uebersprungen (zugesagtes Verhalten). if (!user || !user.email) continue; const sent = await this.mail.sendInstant( { email: user.email }, profile.tenantId, { name: profile.name }, fresh.map((match) => match.tender), ); if (sent) { await this.prisma.tenderMatch.updateMany({ where: { id: { in: fresh.map((match) => match.id) } }, data: { notifiedAt: new Date(), notifiedChannel: 'instant' }, }); } // sent === false (no SMTP config or send failure) -> notifiedAt // stays NULL for this profile's fresh matches, retried on the next // poll tick or the next digest run. } catch (err) { // A send failure/thrown error for one profile must never crash the // tick or abort dispatch for the remaining instant-alert profiles. this.logger.error( `Instant dispatch failed for savedSearch ${profile.id}: ${(err as Error).message}`, ); } } } }