b188946e31
- 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
181 lines
8.1 KiB
TypeScript
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}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|