Files
tessera-ctl/apps/api/src/tenders/tender-matching.service.ts
T
schalli b188946e31 refactor(quick-260921-m34): Aufgabe 1 - Mandantenbindung entzaubert, 105 unnoetige any-Zusicherungen entfernt
- prisma-tenant.extension.ts: (prisma as any) und die Handannotation an
  $allOperations in forTenant()/forSystem() entfernt; Kopfkommentar
  unveraendert. .then((results: any[]) => ...) auf unknown[] umgestellt.
- 105 Aufrufstellen `const X = forTenant(...) as any` / `forSystem(...) as
  any` von der Zusicherung befreit, Zuweisungsform woertlich erhalten
  (rls-access-inventory.spec.ts bleibt scharf, 30/30 gruen einzeln
  geprueft).
- withTenantTransaction(): Prisma.TransactionClient fuer tx probiert,
  gemessen verworfen - bricht das Testdoppel in
  prisma-tenant.extension.spec.ts (TS2322 auf einem absichtlich
  unvollstaendigen Fake-Objekt). tx bleibt any, mit Begruendung am Typ.
- Gefolge des jetzt getypten Klienten entfernt: any[]-Annotationen und
  .map((x: any) => ...) in groups.service.ts, module-grants.service.ts,
  dkv.service.ts, ldap-config.service.ts, tenders.controller.ts:270.
- Befund (D-03): tender-matching.service.ts:159 trug eine Handannotation
  (match: { tender: unknown }), die den Wert nur deshalb auf unknown
  verengte, um TS7006 unter dem alten any-Klienten zu vermeiden - mit dem
  getypten Klienten war das falsch. Annotation geloescht, kein Ersatz
  durch Zusicherung.
- Zwei any bleiben gezielt in groups.service.ts (u/a in
  ensureDefaultGroup(), gefolge von tx: any) - Begruendung am Code.

noExplicitAny apps/api/src: 288 -> 149 (Schranke 155). type-check 4/4,
lint 5/5 (0 error). apps/api 72/1143 gruen, apps/web 73/531 gruen,
rls-access-inventory.spec.ts 30/30 gruen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 16:19:16 +02:00

181 lines
8.1 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
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;
// SYSTEMGEBUNDEN (Etappe 3c, 260914-eym; die Etappe-3-Uebergabe aus
// 260909-laa ist damit eingeloest): Profile ALLER Mandanten werden gegen
// neue Treffer geprueft — `forSystem()` liest TenderSavedSearch NUR
// lesend (`system_read_policy ... FOR SELECT`, Migration 20260914120000);
// ohne diese Regel saehe der Abgleich nach dem Scharfschalten 0 Profile
// und wuerde stumm. Treffer-Anlage und Sofortmeldung bleiben je Profil
// GEBUNDEN (unten); der Katalog-Lesezugriff (`tender`, D-03) bleibt
// ungebunden. Eine LEERE Profilliste ist Nichtstun (keine Treffer).
const systemPrisma = forSystem(this.prisma);
const savedSearches: Prisma.TenderSavedSearchGetPayload<Record<string, never>>[] =
await systemPrisma.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 } }],
};
// BEWUSST UNGEBUNDEN (D-03) — liest den plattformweiten
// Ausschreibungskatalog, kein tenantId.
const hits = await this.prisma.tender.findMany({
where,
select: { id: true },
});
// Gebunden an den Mandanten DIESES Profils (260909-laa, Aufgabe 3)
// — EIN gebundener Client je Profil, nicht je Treffer, sonst
// entstuende pro Zeile eine eigene Transaktion.
const tenantPrisma = forTenant(this.prisma, search.tenantId);
for (const hit of hits) {
await tenantPrisma.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 {
// Gebunden an den Mandanten DIESES Profils (260909-laa, Aufgabe 3)
// — EIN gebundener Client je Profil.
const tenantPrisma = forTenant(this.prisma, profile.tenantId);
const fresh = await tenantPrisma.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 tenantPrisma.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?.email) continue;
const sent = await this.mail.sendInstant(
{ email: user.email },
profile.tenantId,
{ name: profile.name },
fresh.map((match) => match.tender),
);
if (sent) {
await tenantPrisma.tenderMatch.updateMany({
where: { id: { in: fresh.map((match: { id: string }) => 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}`,
);
}
}
}
}