Files
tessera-ctl/apps/api/src/tenders/tender-matching.service.ts
T
schalli 1222951af6 fix(quick-260909-ab3): kollidierende AD-Konten werden angelegt, nur ohne Adresse
- User.email auf optional gestellt (Migration geschrieben, NICHT
  ausgefuehrt); Eindeutigkeitsindex unangetastet, NULL bleibt in Postgres
  je verschieden
- Neuer Kollisionsentscheider (resolveEmailForWrite) in ldap.service.ts:
  eine bereits vergebene Adresse wird nie umgehaengt (T-Q3-01) — das
  zuerst angelegte Konto behaelt sie, jedes weitere Konto entsteht ohne
  Adresse (gesperrte Nutzerentscheidung 2026-09-09, WINDOWS #15)
- Entscheider in upsertMappedUser (Sync) UND importUsersByDn (Handimport)
  verdrahtet, damit der zweite Anlageweg nicht als Luecke bestehen bleibt
- LdapSyncResult um emailConflicts/skippedNoLogin/entryFailures erweitert;
  rohe ORM-Ausnahmetexte gehen nur noch an logger.error, nie in den
  Bericht (T-Q3-02)
- UserService.create nimmt die Adresse optional entgegen; Tender-Digest
  und Instant-Alert ueberspringen Empfaenger ohne Adresse (continue)
- Fuenf neue Testfaelle vorab gegen den unveraenderten Bestand rot
  gelaufen (erwartete Ursachen bestaetigt); 651/651 API-Tests gruen,
  prisma validate und type-check sauber

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-09 07:48:37 +02:00

159 lines
6.7 KiB
TypeScript

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<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}`,
);
}
}
// 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}`,
);
}
}
}
}