feat(laa-03): binde die Je-Treffer-Haelften der Hintergrunddienste und schliesse die Dokumente

tender-digest.scheduler.ts: die uebergreifende Kandidatenabfrage
(tenderMatch.findMany mit distinct:['userId']) bleibt bewusst ungebunden
und waehlt zusaetzlich das denormalisierte tenantId der Treffer-Zeile mit
aus; innerhalb der Schleife binden tenderNotificationPref.findUnique,
tenderMatch.findMany/updateMany und user.findUnique an den Mandanten
DIESER Kandidatenzeile.

tender-matching.service.ts: die Profilabfrage (tenderSavedSearch.findMany)
und der Lesezugriff auf den plattformweiten Tender-Katalog (D-03) bleiben
ungebunden; innerhalb der Profilschleife bindet die Treffer-Anlage
(tenderMatch.upsert), im nachgelagerten Instant-Dispatch binden
tenderMatch.findMany/updateMany und user.findUnique — je EIN gebundener
Client pro Profil, nicht neu je Treffer.

Beide Dateien tragen Codekommentare, die die uebergreifenden Abfragen
ausdruecklich als Etappe-3-Uebergabe benennen — die Trennlinie zwischen
Etappe 2 und Etappe 3 wird hier gezogen, nicht verwischt.

Alle drei betroffenen Testdateien (inkl. der gemeinsamen
Integrationsdatei) bekommen den Zwei-Client-Nachweis, Tests fuer
Zwei-Mandanten-Laeufe und die lautlose Fehlerform (kein Versand,
notifiedAt bleibt NULL). Falsifiziert: ein probeweiser Rueckbau der
user.findUnique-Bindung im Instant-Dispatch von tender-matching.service.ts
machte genau den erwarteten Test rot, danach zurueckgenommen.

rls-access-inventory.spec.ts gemessen und docs/mandantentrennung-zugriffsklassifikation.md
nachgezogen: Stand der fuenf betroffenen Paare (tender-digest.scheduler.ts/
tenderMatch,tenderNotificationPref,user; tender-matching.service.ts/
tenderMatch,user) auf gemischt bzw. gebunden; Bereichsuebersicht und
Klassen-Verteilung neu gemessen (tenders jetzt 36 ungebunden/26 gebunden);
"Der Hintergrunddienst als Falle" um den Abschluss beider Dateien ergaenzt.

docs/mandantentrennung-etappe2-fehlerrichtung.md: (t4) um den Nachtrag
ergaenzt, dass die Je-Treffer-Haelften geschlossen sind und die
uebergreifenden Haelften an Etappe 3 uebergeben bleiben.

770 Tests gruen, Typpruefung sauber, Wegwerf-Werkzeug 32/32, kein
Schema-/Migrations-/Compose-/Umgebungsdatei-Diff.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-09 16:02:40 +02:00
parent 3336a6e419
commit df5c5b728b
7 changed files with 461 additions and 108 deletions
@@ -1,6 +1,7 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { TenderMailItem, TenderMailService } from './tender-mail.service';
/**
@@ -104,9 +105,21 @@ export class TenderDigestScheduler implements OnModuleInit {
async runDigest(now: Date = new Date()): Promise<void> {
// Candidate users: distinct userId with at least one un-notified match,
// across ALL tenants — a single findMany, never a per-tenant iteration.
// BEWUSST UNGEBUNDEN (260909-laa, Aufgabe 3) — der bewusste Fan-out
// über alle Mandanten dieses Bereichs; Etappe-3-Uebergabe (Systemkontext
// für Hintergrundläufe wird dort entschieden, hier NICHT vorweggenommen).
//
// Zusaetzlich das denormalisierte tenantId der Treffer-Zeile mit
// ausgewaehlt (nicht Teil von `distinct`), damit die Schleife unten
// ueberhaupt an einen Mandanten binden KANN. Sonderfall, NICHT geloest:
// ein Nutzer koennte Treffer unter zwei verschiedenen Mandanten haben
// (der denormalisierte Wert kann bei einem Mandantenwechsel veralten) —
// `distinct(['userId'])` liefert dann nur EINE der moeglichen
// tenantId-Werte je Nutzer, welche ist von der internen Zeilenreihenfolge
// abhaengig. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md.
const candidates = await this.prisma.tenderMatch.findMany({
where: { notifiedAt: null },
select: { userId: true },
select: { userId: true, tenantId: true },
distinct: ['userId'],
});
@@ -114,9 +127,14 @@ export class TenderDigestScheduler implements OnModuleInit {
const weeklyDue = isMondayInBerlin(now);
for (const { userId } of candidates) {
for (const { userId, tenantId } of candidates) {
try {
const pref = await this.prisma.tenderNotificationPref.findUnique({
// Je-Treffer-Haelfte, gebunden an den Mandanten DIESER
// Kandidatenzeile (260909-laa, Aufgabe 3) — ein einziger gebundener
// Client fuer alle Zugriffe dieses Schleifendurchlaufs.
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const pref = await tenantPrisma.tenderNotificationPref.findUnique({
where: { userId },
});
// Missing pref row -> daily default (D-01).
@@ -126,14 +144,14 @@ export class TenderDigestScheduler implements OnModuleInit {
if (interval === 'weekly' && !weeklyDue) continue;
// 'daily' (or any unrecognized value) -> due every run.
const matches = await this.prisma.tenderMatch.findMany({
const matches = await tenantPrisma.tenderMatch.findMany({
where: { userId, notifiedAt: null },
include: { tender: true, savedSearch: true },
orderBy: { savedSearch: { name: 'asc' } },
});
if (!matches.length) continue;
const user = await this.prisma.user.findUnique({ where: { id: userId } });
const user = await tenantPrisma.user.findUnique({ where: { id: userId } });
// Kein Konto, oder ein Konto ohne Adresse (WINDOWS #15, kollidierte
// AD-Adresse) -- die Zugehoerigkeit funktioniert, nur der
// Mailversand wird uebersprungen (zugesagtes Verhalten).
@@ -143,7 +161,7 @@ export class TenderDigestScheduler implements OnModuleInit {
const sent = await this.mail.sendDigest({ email: user.email }, user.tenantId, sections);
if (sent) {
await this.prisma.tenderMatch.updateMany({
await tenantPrisma.tenderMatch.updateMany({
where: { id: { in: matches.map((m: { id: string }) => m.id) } },
data: { notifiedAt: new Date(), notifiedChannel: 'digest' },
});