feat(12-03): instant-alert dispatch at end of matchDelta

GREEN: after all match upserts of a poll tick are written, profiles with
instantAlert=true are checked for fresh (notifiedAt=NULL, tenderId IN
newTenderIds) matches. If any exist they are bundled into one
TenderMailService.sendInstant call per profile per tick (D-05). notifiedAt
is stamped 'instant' only on a successful send (D-06) -- the same
eligibility gate the digest reads, so a tender x profile pair can never be
notified twice across instant and digest. Instant dispatch runs
synchronously in the tick, before any later digest run.

Each profile's dispatch is wrapped in its own try/catch so a send
failure/thrown error never aborts the tick or the remaining profiles'
dispatch; notifiedAt stays NULL on failure and is retried next tick/digest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 09:20:49 +02:00
parent 86c184f8d5
commit ec032ad89d
@@ -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<void> {
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}`,
);
}
}
}
}