1222951af6
- 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
159 lines
6.7 KiB
TypeScript
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}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|