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 } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { 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';
@@ -63,6 +64,10 @@ export class TenderMatchingService {
async matchDelta(newTenderIds: string[]): Promise<void> {
if (!newTenderIds.length) return;
// BEWUSST UNGEBUNDEN (260909-laa, Aufgabe 3) — Profile aller Mandanten
// werden gegen neue Treffer geprueft, der bewusste Fan-out dieses
// Bereichs; Etappe-3-Uebergabe (Systemkontext fuer Hintergrundlaeufe
// wird dort entschieden, hier NICHT vorweggenommen).
const savedSearches = await this.prisma.tenderSavedSearch.findMany();
for (const search of savedSearches) {
@@ -74,13 +79,20 @@ export class TenderMatchingService {
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) as any;
for (const hit of hits) {
await this.prisma.tenderMatch.upsert({
await tenantPrisma.tenderMatch.upsert({
where: {
tenderId_savedSearchId: {
tenderId: hit.id,
@@ -114,7 +126,11 @@ export class TenderMatchingService {
for (const profile of instantProfiles) {
try {
const fresh = await this.prisma.tenderMatch.findMany({
// Gebunden an den Mandanten DIESES Profils (260909-laa, Aufgabe 3)
// — EIN gebundener Client je Profil.
const tenantPrisma = forTenant(this.prisma, profile.tenantId) as any;
const fresh = await tenantPrisma.tenderMatch.findMany({
where: {
savedSearchId: profile.id,
notifiedAt: null,
@@ -124,7 +140,7 @@ export class TenderMatchingService {
});
if (!fresh.length) continue; // nothing new for this profile this tick
const user = await this.prisma.user.findUnique({ where: { id: profile.userId } });
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).
@@ -134,12 +150,12 @@ export class TenderMatchingService {
{ email: user.email },
profile.tenantId,
{ name: profile.name },
fresh.map((match) => match.tender),
fresh.map((match: { tender: unknown }) => match.tender),
);
if (sent) {
await this.prisma.tenderMatch.updateMany({
where: { id: { in: fresh.map((match) => match.id) } },
await tenantPrisma.tenderMatch.updateMany({
where: { id: { in: fresh.map((match: { id: string }) => match.id) } },
data: { notifiedAt: new Date(), notifiedChannel: 'instant' },
});
}