diff --git a/apps/api/src/tenders/tender-matching.service.ts b/apps/api/src/tenders/tender-matching.service.ts index e15c53f..b7dd804 100644 --- a/apps/api/src/tenders/tender-matching.service.ts +++ b/apps/api/src/tenders/tender-matching.service.ts @@ -1,6 +1,7 @@ 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'; @@ -30,15 +31,34 @@ import type { TenderQueryDto } from './dto/tender-query.dto'; * 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. + * 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) {} + constructor( + private readonly prisma: PrismaService, + private readonly mail: TenderMailService, + ) {} async matchDelta(newTenderIds: string[]): Promise { if (!newTenderIds.length) return; @@ -86,5 +106,50 @@ export class TenderMatchingService { ); } } + + // 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 } }); + if (!user) 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}`, + ); + } + } } }