From df5c5b728b9355cc4d00317dbd66e34af676eb5a Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 9 Sep 2026 16:02:40 +0200 Subject: [PATCH] feat(laa-03): binde die Je-Treffer-Haelften der Hintergrunddienste und schliesse die Dokumente MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR --- .../tenders/tender-digest.scheduler.spec.ts | 132 +++++++++- .../src/tenders/tender-digest.scheduler.ts | 30 ++- .../tenders/tender-matching.service.spec.ts | 248 +++++++++++++----- .../src/tenders/tender-matching.service.ts | 28 +- .../tender-notifications.integration.spec.ts | 44 +++- ...andantentrennung-etappe2-fehlerrichtung.md | 25 ++ ...andantentrennung-zugriffsklassifikation.md | 62 +++-- 7 files changed, 461 insertions(+), 108 deletions(-) diff --git a/apps/api/src/tenders/tender-digest.scheduler.spec.ts b/apps/api/src/tenders/tender-digest.scheduler.spec.ts index beb3835..63141cb 100644 --- a/apps/api/src/tenders/tender-digest.scheduler.spec.ts +++ b/apps/api/src/tenders/tender-digest.scheduler.spec.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { forTenant } from '../prisma/prisma-tenant.extension'; import { TenderDigestScheduler } from './tender-digest.scheduler'; /** @@ -16,7 +17,19 @@ import { TenderDigestScheduler } from './tender-digest.scheduler'; * 2026-07-27 is a verified Monday, 2026-07-28 a verified Tuesday * (Europe/Berlin) — passed explicitly to `runDigest(now)` rather than faking * the system clock. + * + * Bindung an forTenant() je Schleifendurchlauf (260909-laa, Aufgabe 3, + * Befund C/H): `__makeBoundClient()` liefert denselben protokollierenden + * Wrapper wie in den Nutzer-CRUD-Diensten dieses Bereichs (Muster aus + * `groups.service.spec.ts`) — die uebergreifende Kandidatenabfrage + * (`tenderMatch.findMany` mit `distinct`) bleibt UNGEBUNDEN, die drei + * Zugriffe je Kandidatenzeile (`tenderNotificationPref.findUnique`, + * `tenderMatch.findMany`/`updateMany`, `user.findUnique`) binden an den + * Mandanten DIESER Zeile. */ +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), +})); const MONDAY = new Date('2026-07-27T10:00:00Z'); const TUESDAY = new Date('2026-07-28T10:00:00Z'); @@ -29,6 +42,7 @@ function makeFakePrisma(opts: { const matches = opts.matches ?? []; const prefs = opts.prefs ?? new Map(); const users = opts.users ?? new Map(); + const boundCallLog: { tenantId: string; model: string; method: string }[] = []; const tenderMatch = { findMany: vi.fn(async ({ where, distinct }: any) => { @@ -62,16 +76,35 @@ function makeFakePrisma(opts: { return { count }; }), }; + const tenderNotificationPref = { + findUnique: vi.fn(async ({ where }: any) => prefs.get(where.userId) ?? null), + }; + const user = { + findUnique: vi.fn(async ({ where }: any) => users.get(where.id) ?? null), + }; + + const modelsByName: Record = { tenderMatch, tenderNotificationPref, user }; return { tenderMatch, - tenderNotificationPref: { - findUnique: vi.fn(async ({ where }: any) => prefs.get(where.userId) ?? null), - }, - user: { - findUnique: vi.fn(async ({ where }: any) => users.get(where.id) ?? null), - }, + tenderNotificationPref, + user, __store: { matches, prefs, users }, + __boundCallLog: boundCallLog, + __makeBoundClient(tenantId: string) { + const bound: any = {}; + for (const [modelName, model] of Object.entries(modelsByName)) { + const wrapped: any = {}; + for (const method of Object.keys(model)) { + wrapped[method] = async (...args: any[]) => { + boundCallLog.push({ tenantId, model: modelName, method }); + return model[method](...args); + }; + } + bound[modelName] = wrapped; + } + return bound; + }, }; } @@ -324,3 +357,90 @@ describe('TenderDigestScheduler — Cron-Registrierung (Phase-10-Muster)', () => expect(registry.addCronJob.mock.calls[0][0]).toBe('tender-digest'); }); }); + +// --- Bindung an forTenant() je Schleifendurchlauf (260909-laa, Aufgabe 3) --- + +describe('TenderDigestScheduler — Bindung an forTenant() (260909-laa)', () => { + it('die uebergreifende Kandidatenabfrage bindet NICHT — forTenant() wird fuer sie nicht aufgerufen', async () => { + vi.mocked(forTenant).mockClear(); + const users = new Map([['user-1', { id: 'user-1', email: 'a@tenant.de', tenantId: 'tenant-1' }]]); + const prefs = new Map([['user-1', { userId: 'user-1', digestInterval: 'off' }]]); + const matches = [makeMatch({ userId: 'user-1' })]; + const prisma = makeFakePrisma({ matches, prefs, users }); + const mail = { sendDigest: vi.fn().mockResolvedValue(true) }; + const scheduler = new TenderDigestScheduler(makeSchedulerRegistry() as any, prisma as any, mail as any); + + await scheduler.runDigest(TUESDAY); + + // Die Kandidatenabfrage selbst (findMany mit distinct, VOR der Schleife) + // lief auf dem unveraenderten `this.prisma`, nie auf einem gebundenen + // Client — genau EIN forTenant()-Aufruf (fuer den einen Kandidaten, + // dessen Praeferenz 'off' vor jedem weiteren Zugriff abbricht), nicht + // zwei oder mehr fuer die Kandidatenabfrage selbst. + expect(forTenant).toHaveBeenCalledTimes(1); + }); + + it('die Zugriffe je Kandidatenzeile binden an den Mandanten DIESER Zeile — Zugriffe innerhalb der Schleife laufen auf dem gebundenen Client', async () => { + const users = new Map([['user-1', { id: 'user-1', email: 'a@tenant.de', tenantId: 'tenant-1' }]]); + const prefs = new Map([['user-1', { userId: 'user-1', digestInterval: 'daily' }]]); + const matches = [makeMatch({ userId: 'user-1', tenantId: 'tenant-1' })]; + const prisma = makeFakePrisma({ matches, prefs, users }); + const mail = { sendDigest: vi.fn().mockResolvedValue(true) }; + const scheduler = new TenderDigestScheduler(makeSchedulerRegistry() as any, prisma as any, mail as any); + + await scheduler.runDigest(TUESDAY); + + const boundModels = prisma.__boundCallLog + .filter((c: any) => c.tenantId === 'tenant-1') + .map((c: any) => `${c.model}.${c.method}`); + expect(boundModels).toEqual( + expect.arrayContaining([ + 'tenderNotificationPref.findUnique', + 'tenderMatch.findMany', + 'user.findUnique', + 'tenderMatch.updateMany', + ]), + ); + }); + + it('zwei Mandanten in einem Lauf: jeder Kandidat bindet an den Mandanten SEINER Zeile, nicht einmal global mit dem ersten', async () => { + const users = new Map([ + ['user-1', { id: 'user-1', email: 'a@tenant-a.de', tenantId: 'tenant-a' }], + ['user-2', { id: 'user-2', email: 'b@tenant-b.de', tenantId: 'tenant-b' }], + ]); + const prefs = new Map([ + ['user-1', { userId: 'user-1', digestInterval: 'daily' }], + ['user-2', { userId: 'user-2', digestInterval: 'daily' }], + ]); + const matches = [ + makeMatch({ id: 'm1', userId: 'user-1', tenantId: 'tenant-a' }), + makeMatch({ id: 'm2', userId: 'user-2', tenantId: 'tenant-b' }), + ]; + const prisma = makeFakePrisma({ matches, prefs, users }); + const mail = { sendDigest: vi.fn().mockResolvedValue(true) }; + const scheduler = new TenderDigestScheduler(makeSchedulerRegistry() as any, prisma as any, mail as any); + + await scheduler.runDigest(TUESDAY); + + const tenantsBoundForUserFindUnique = prisma.__boundCallLog + .filter((c: any) => c.model === 'user' && c.method === 'findUnique') + .map((c: any) => c.tenantId) + .sort(); + expect(tenantsBoundForUserFindUnique).toEqual(['tenant-a', 'tenant-b']); + }); + + it('lautlose Fehlerform: liefert der gebundene Lesezugriff auf den Benutzer nichts, wird nichts versendet UND notifiedAt bleibt NULL (wiederholbar)', async () => { + const users = new Map(); // kein Konto sichtbar unter dem gebundenen Kontext + const prefs = new Map([['user-1', { userId: 'user-1', digestInterval: 'daily' }]]); + const openMatch = makeMatch({ id: 'm-open', userId: 'user-1', tenantId: 'tenant-1' }); + const matches = [openMatch]; + const prisma = makeFakePrisma({ matches, prefs, users }); + const mail = { sendDigest: vi.fn().mockResolvedValue(true) }; + const scheduler = new TenderDigestScheduler(makeSchedulerRegistry() as any, prisma as any, mail as any); + + await scheduler.runDigest(TUESDAY); + + expect(mail.sendDigest).not.toHaveBeenCalled(); + expect(openMatch.notifiedAt).toBeNull(); // wiederholbar beim naechsten Lauf + }); +}); diff --git a/apps/api/src/tenders/tender-digest.scheduler.ts b/apps/api/src/tenders/tender-digest.scheduler.ts index d955ac4..52c6e94 100644 --- a/apps/api/src/tenders/tender-digest.scheduler.ts +++ b/apps/api/src/tenders/tender-digest.scheduler.ts @@ -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 { // 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' }, }); diff --git a/apps/api/src/tenders/tender-matching.service.spec.ts b/apps/api/src/tenders/tender-matching.service.spec.ts index 8984ed0..0aedd9f 100644 --- a/apps/api/src/tenders/tender-matching.service.spec.ts +++ b/apps/api/src/tenders/tender-matching.service.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import { forTenant } from '../prisma/prisma-tenant.extension'; import { TenderMatchingService } from './tender-matching.service'; /** @@ -13,7 +14,17 @@ import { TenderMatchingService } from './tender-matching.service'; * A hand-rolled Prisma-shaped mock is used (this repo's established * convention — see tender-ingestion.service.spec.ts) rather than a live * DB connection. + * + * Bindung an forTenant() je Profil (260909-laa, Aufgabe 3, Befund C/H): + * `__makeBoundClient()` liefert denselben protokollierenden Wrapper wie in + * den Nutzer-CRUD-Diensten dieses Bereichs. Die Profilabfrage + * (`tenderSavedSearch.findMany`) UND der Katalog-Lesezugriff + * (`tender.findMany`, D-03) bleiben UNGEBUNDEN; `tenderMatch.upsert/ + * findMany/updateMany` und `user.findUnique` binden je EINMAL pro Profil. */ +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), +})); const PROFILE_A = { id: 'search-a', @@ -46,73 +57,96 @@ function makeFakePrisma(opts: { const matchingTenderIds = opts.matchingTenderIds ?? new Set(); const tenders = opts.tenders ?? new Map(); const users = opts.users ?? new Map(); + const boundCallLog: { tenantId: string; model: string; method: string }[] = []; + + const tenderSavedSearch = { + findMany: vi.fn(async () => savedSearches), + }; + const tenderModel = { + // Simulates buildTenderWhere(profile.filters) AND id IN newTenderIds: + // the fake ignores the actual `where` shape (buildTenderWhere is + // unit-tested elsewhere) and just intersects the caller-provided + // `id: { in: [...] }` filter against matchingTenderIds — the set of + // tender IDs that would satisfy this profile's filters. + findMany: vi.fn(async ({ where }: any) => { + const idFilter: string[] = where.AND.find((c: any) => c.id)?.id?.in ?? []; + const hits = idFilter.filter((id) => matchingTenderIds.has(id)); + return hits.map((id) => ({ id })); + }), + }; + const tenderMatch = { + upsert: vi.fn(async ({ where, update, create }: any) => { + const key = `${where.tenderId_savedSearchId.tenderId}::${where.tenderId_savedSearchId.savedSearchId}`; + const existing = matches.get(key); + if (existing) { + matches.set(key, { ...existing, ...update }); + return matches.get(key); + } + const created = { + id: `match-${matches.size + 1}`, + notifiedAt: null, + notifiedChannel: null, + ...create, + }; + matches.set(key, created); + return created; + }), + // Instant-dispatch fresh-match lookup (Plan 12-03): savedSearchId + + // notifiedAt:null + tenderId IN newTenderIds, joined with `tender`. + findMany: vi.fn(async ({ where }: any) => { + const savedSearchId = where.savedSearchId; + const tenderIdIn: string[] | undefined = where.tenderId?.in; + const wantsUnnotified = 'notifiedAt' in where && where.notifiedAt === null; + const rows = Array.from(matches.values()).filter((m: any) => { + if (savedSearchId !== undefined && m.savedSearchId !== savedSearchId) return false; + if (wantsUnnotified && m.notifiedAt != null) return false; + if (tenderIdIn && !tenderIdIn.includes(m.tenderId)) return false; + return true; + }); + return rows.map((m: any) => ({ + ...m, + tender: tenders.get(m.tenderId) ?? { id: m.tenderId, title: m.tenderId }, + })); + }), + updateMany: vi.fn(async ({ where, data }: any) => { + const ids: string[] = where.id.in; + let count = 0; + for (const m of matches.values()) { + if (ids.includes(m.id)) { + Object.assign(m, data); + count++; + } + } + return { count }; + }), + }; + const user = { + findUnique: vi.fn(async ({ where }: any) => users.get(where.id) ?? null), + }; + + const modelsByName: Record = { tenderMatch, user }; const prisma = { - tenderSavedSearch: { - findMany: vi.fn(async () => savedSearches), - }, - tender: { - // Simulates buildTenderWhere(profile.filters) AND id IN newTenderIds: - // the fake ignores the actual `where` shape (buildTenderWhere is - // unit-tested elsewhere) and just intersects the caller-provided - // `id: { in: [...] }` filter against matchingTenderIds — the set of - // tender IDs that would satisfy this profile's filters. - findMany: vi.fn(async ({ where }: any) => { - const idFilter: string[] = where.AND.find((c: any) => c.id)?.id?.in ?? []; - const hits = idFilter.filter((id) => matchingTenderIds.has(id)); - return hits.map((id) => ({ id })); - }), - }, - tenderMatch: { - upsert: vi.fn(async ({ where, update, create }: any) => { - const key = `${where.tenderId_savedSearchId.tenderId}::${where.tenderId_savedSearchId.savedSearchId}`; - const existing = matches.get(key); - if (existing) { - matches.set(key, { ...existing, ...update }); - return matches.get(key); - } - const created = { - id: `match-${matches.size + 1}`, - notifiedAt: null, - notifiedChannel: null, - ...create, - }; - matches.set(key, created); - return created; - }), - // Instant-dispatch fresh-match lookup (Plan 12-03): savedSearchId + - // notifiedAt:null + tenderId IN newTenderIds, joined with `tender`. - findMany: vi.fn(async ({ where }: any) => { - const savedSearchId = where.savedSearchId; - const tenderIdIn: string[] | undefined = where.tenderId?.in; - const wantsUnnotified = 'notifiedAt' in where && where.notifiedAt === null; - const rows = Array.from(matches.values()).filter((m: any) => { - if (savedSearchId !== undefined && m.savedSearchId !== savedSearchId) return false; - if (wantsUnnotified && m.notifiedAt != null) return false; - if (tenderIdIn && !tenderIdIn.includes(m.tenderId)) return false; - return true; - }); - return rows.map((m: any) => ({ - ...m, - tender: tenders.get(m.tenderId) ?? { id: m.tenderId, title: m.tenderId }, - })); - }), - updateMany: vi.fn(async ({ where, data }: any) => { - const ids: string[] = where.id.in; - let count = 0; - for (const m of matches.values()) { - if (ids.includes(m.id)) { - Object.assign(m, data); - count++; - } - } - return { count }; - }), - }, - user: { - findUnique: vi.fn(async ({ where }: any) => users.get(where.id) ?? null), - }, + tenderSavedSearch, + tender: tenderModel, + tenderMatch, + user, __store: { matches, savedSearches, tenders, users }, + __boundCallLog: boundCallLog, + __makeBoundClient(tenantId: string) { + const bound: any = {}; + for (const [modelName, model] of Object.entries(modelsByName)) { + const wrapped: any = {}; + for (const method of Object.keys(model)) { + wrapped[method] = async (...args: any[]) => { + boundCallLog.push({ tenantId, model: modelName, method }); + return model[method](...args); + }; + } + bound[modelName] = wrapped; + } + return bound; + }, }; return prisma; @@ -392,3 +426,89 @@ describe('TenderMatchingService.matchDelta — nichts Neues löst keinen Alert a expect(sendInstant).not.toHaveBeenCalled(); }); }); + +// --- Bindung an forTenant() je Profil (260909-laa, Aufgabe 3) -------------- + +describe('TenderMatchingService.matchDelta — Bindung an forTenant() (260909-laa)', () => { + it('die uebergreifende Profilabfrage UND der Katalog-Lesezugriff binden NICHT', async () => { + vi.mocked(forTenant).mockClear(); + const matchingTenderIds = new Set(['new-1']); + const prisma = makeFakePrisma({ savedSearches: [PROFILE_A], matchingTenderIds }); + const service = new TenderMatchingService(prisma as any, makeFakeMail() as any); + + await service.matchDelta(['new-1']); + + // tenderSavedSearch.findMany() und tender.findMany() liefen nie ueber + // einen gebundenen Client — forTenant() wird genau EINMAL aufgerufen + // (fuer PROFILE_A's tenderMatch.upsert innerhalb der Trefferschleife). + expect(forTenant).toHaveBeenCalledTimes(1); + expect(forTenant).toHaveBeenCalledWith(prisma, PROFILE_A.tenantId); + }); + + it('die Treffer-Anlage bindet an den Mandanten DES PROFILS — EIN gebundener Client je Profil, nicht je Treffer', async () => { + vi.mocked(forTenant).mockClear(); + const matchingTenderIds = new Set(['new-1', 'new-2', 'new-3']); + const prisma = makeFakePrisma({ savedSearches: [PROFILE_A], matchingTenderIds }); + const service = new TenderMatchingService(prisma as any, makeFakeMail() as any); + + await service.matchDelta(['new-1', 'new-2', 'new-3']); + + const upsertCalls = prisma.__boundCallLog.filter( + (c: any) => c.tenantId === PROFILE_A.tenantId && c.model === 'tenderMatch' && c.method === 'upsert', + ); + expect(upsertCalls.length).toBe(3); + // forTenant() wurde genau einmal fuer dieses Profil aufgerufen, nicht + // dreimal (einmal je Treffer) — der gebundene Client wird wiederverwendet. + expect(forTenant).toHaveBeenCalledTimes(1); + }); + + it('zwei Mandanten in einem Lauf: jedes Profil bindet an SEINEN Mandanten, nicht einmal global mit dem ersten', async () => { + const matchingTenderIds = new Set(['new-1']); + const prisma = makeFakePrisma({ savedSearches: [PROFILE_A, PROFILE_C], matchingTenderIds }); + const service = new TenderMatchingService(prisma as any, makeFakeMail() as any); + + await service.matchDelta(['new-1']); + + const boundTenants = prisma.__boundCallLog + .filter((c: any) => c.model === 'tenderMatch' && c.method === 'upsert') + .map((c: any) => c.tenantId) + .sort(); + expect(boundTenants).toEqual([PROFILE_A.tenantId, PROFILE_C.tenantId].sort()); + }); + + it('Instant-Dispatch bindet fresh-Abfrage, Benutzer-Abfrage UND Stempelung an den Mandanten DES PROFILS', async () => { + const matchingTenderIds = new Set(['new-1']); + const users = new Map([['user-instant', INSTANT_USER]]); + const prisma = makeFakePrisma({ savedSearches: [INSTANT_PROFILE], matchingTenderIds, users }); + const sendInstant = vi.fn().mockResolvedValue(true); + const service = new TenderMatchingService(prisma as any, makeFakeMail({ sendInstant }) as any); + + await service.matchDelta(['new-1']); + + const boundModels = prisma.__boundCallLog + .filter((c: any) => c.tenantId === INSTANT_PROFILE.tenantId) + .map((c: any) => `${c.model}.${c.method}`); + expect(boundModels).toEqual( + expect.arrayContaining([ + 'tenderMatch.upsert', + 'tenderMatch.findMany', + 'user.findUnique', + 'tenderMatch.updateMany', + ]), + ); + }); + + it('lautlose Fehlerform: liefert der gebundene Lesezugriff auf den Benutzer nichts, wird nichts versendet UND notifiedAt bleibt NULL (wiederholbar)', async () => { + const matchingTenderIds = new Set(['new-1']); + const users = new Map(); // kein Konto sichtbar unter dem gebundenen Kontext + const prisma = makeFakePrisma({ savedSearches: [INSTANT_PROFILE], matchingTenderIds, users }); + const sendInstant = vi.fn().mockResolvedValue(true); + const service = new TenderMatchingService(prisma as any, makeFakeMail({ sendInstant }) as any); + + await service.matchDelta(['new-1']); + + expect(sendInstant).not.toHaveBeenCalled(); + const stored = prisma.__store.matches.get(`new-1::${INSTANT_PROFILE.id}`); + expect(stored.notifiedAt).toBeNull(); // wiederholbar beim naechsten Lauf + }); +}); diff --git a/apps/api/src/tenders/tender-matching.service.ts b/apps/api/src/tenders/tender-matching.service.ts index 628f03c..0d41f4f 100644 --- a/apps/api/src/tenders/tender-matching.service.ts +++ b/apps/api/src/tenders/tender-matching.service.ts @@ -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 { 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' }, }); } diff --git a/apps/api/src/tenders/tender-notifications.integration.spec.ts b/apps/api/src/tenders/tender-notifications.integration.spec.ts index 3512e98..6332473 100644 --- a/apps/api/src/tenders/tender-notifications.integration.spec.ts +++ b/apps/api/src/tenders/tender-notifications.integration.spec.ts @@ -2,6 +2,17 @@ import { describe, expect, it, vi } from 'vitest'; import { TenderMatchingService } from './tender-matching.service'; import { TenderDigestScheduler } from './tender-digest.scheduler'; +/** + * Bindung an forTenant() (260909-laa, Aufgabe 3, Befund C/H): beide + * Dienste binden jetzt ihre Je-Treffer/Je-Profil-Haelfte — `__makeBoundClient()` + * unten liefert denselben protokollierenden Wrapper wie in + * `tender-matching.service.spec.ts`/`tender-digest.scheduler.spec.ts`, damit + * dieser gemeinsame Fake fuer beide Dienste unveraendert funktioniert. + */ +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), +})); + /** * Integration spec (Plan 12-03, Task 2) — proves the NOTIFY-03 core * invariant across BOTH notification channels: a tender x saved-search @@ -102,6 +113,16 @@ function makeSharedFakePrisma(opts: { }), }; + const tenderNotificationPref = { + findUnique: vi.fn(async ({ where }: any) => prefs.get(where.userId) ?? null), + }; + const userModel = { + findUnique: vi.fn(async ({ where }: any) => users.get(where.id) ?? null), + }; + + const boundCallLog: { tenantId: string; model: string; method: string }[] = []; + const modelsByName: Record = { tenderMatch, tenderNotificationPref, user: userModel }; + return { tenderSavedSearch: { findMany: vi.fn(async () => [savedSearch]) }, tender: { @@ -111,13 +132,24 @@ function makeSharedFakePrisma(opts: { }), }, tenderMatch, - tenderNotificationPref: { - findUnique: vi.fn(async ({ where }: any) => prefs.get(where.userId) ?? null), - }, - user: { - findUnique: vi.fn(async ({ where }: any) => users.get(where.id) ?? null), - }, + tenderNotificationPref, + user: userModel, __store: { matches }, + __boundCallLog: boundCallLog, + __makeBoundClient(tenantId: string) { + const bound: any = {}; + for (const [modelName, model] of Object.entries(modelsByName)) { + const wrapped: any = {}; + for (const method of Object.keys(model)) { + wrapped[method] = async (...args: any[]) => { + boundCallLog.push({ tenantId, model: modelName, method }); + return model[method](...args); + }; + } + bound[modelName] = wrapped; + } + return bound; + }, }; } diff --git a/docs/mandantentrennung-etappe2-fehlerrichtung.md b/docs/mandantentrennung-etappe2-fehlerrichtung.md index 1cfe082..ebda997 100644 --- a/docs/mandantentrennung-etappe2-fehlerrichtung.md +++ b/docs/mandantentrennung-etappe2-fehlerrichtung.md @@ -548,6 +548,31 @@ Warnung wäre Dauerlärm und verlöre ihr Signal. über alle Mandanten hinweg ungebunden und ist als Etappe-3-Übergabe kommentiert — siehe `docs/mandantentrennung-zugriffsklassifikation.md`, Abschnitt "Der Hintergrunddienst als Falle". + + **Nachtrag (260909-laa, Aufgabe 3): Je-Treffer-Hälften GESCHLOSSEN, + übergreifende Hälften ausdrücklich an Etappe 3 übergeben.** Innerhalb der + Kandidatenschleife von `tender-digest.scheduler.ts` + (`tenderNotificationPref.findUnique`, `tenderMatch.findMany`/`updateMany`, + `user.findUnique`) und der Profilschleife von `tender-matching.service.ts` + (`tenderMatch.upsert` in der Treffer-Anlage, sowie im nachgelagerten + Instant-Dispatch `tenderMatch.findMany`/`updateMany` und + `user.findUnique`) laufen jetzt alle Zugriffe über `forTenant()`, + gebunden an den Mandanten der jeweiligen Kandidaten-/Profilzeile — je EIN + gebundener Client pro Zeile, nicht neu je Modellzugriff. Belegt durch + Bindungstests je Methode UND einen Falsifizierungsnachweis (ein + probeweiser Rückbau der `user.findUnique`-Bindung im Instant-Dispatch von + `tender-matching.service.ts` machte genau den erwarteten Test rot, danach + zurückgenommen — siehe 260909-laa-SUMMARY.md). Die übergreifenden + Kandidaten-/Profilabfragen selbst + (`tenderMatch.findMany({distinct:['userId']})` bzw. + `tenderSavedSearch.findMany()`) bleiben UNVERÄNDERT ungebunden und tragen + im Code einen Kommentar, der sie als Etappe-3-Übergabe benennt — die + Trennlinie zwischen Etappe 2 und Etappe 3 wird hier gezogen, nicht + verwischt. Der Sonderfall, dass ein Nutzer Treffer unter zwei + verschiedenen Mandanten haben könnte (der Digest wählt das + denormalisierte `tenantId` der Kandidatenzeile über `distinct`, was bei + einem Mandantenwechsel veraltet sein kann), ist NICHT gelöst, sondern im + Code und hier benannt — Gegenstand von Etappe 3. - **Befund K — die Abhängigkeit vom noch nicht umgestellten Bereich `settings`.** `tender-mail.service.ts` holt die SMTP-Angaben über `SettingsService.getDecryptedSmtpConfig(tenantId)`; `settings.service.ts` diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index 75635ad..bdc0ac3 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -96,7 +96,7 @@ autoritative Quelle. | Bereich | Ungebunden | Gebunden | Hinweis | |---|---|---|---| -| tenders | 62 | 0 | unverändert | +| tenders | 36 | 26 | **war 62/0** — Aufgabe 2/3 (260909-laa) haben die fünf Nutzer-CRUD-Dienste (vier vollständig, `tender-rss-feed.service.ts` teilweise mit im Code begründeter WINDOWS-#19-Grenze) und die Je-Treffer-Hälften der beiden Hintergrunddienste (`tender-digest.scheduler.ts`, `tender-matching.service.ts`) auf `forTenant()` umgestellt. Die 36 verbleibenden ungebundenen Treffer sind die zwölf bewusst nicht angefassten Paare (D-03-Katalog, zwei Fan-out-Adapter) plus die drei bewusst ungebundenen RSS-Pfade plus die übergreifenden Hälften der beiden Hintergrunddienste (Etappe-3-Übergabe) | | groups | 0 | 31 | **war 37/0** — Aufgabe 2/3 (260909-jts) haben `groups.service.ts` (12 Methoden) und `module-grants.service.ts` (5 Methoden) vollständig auf `forTenant()`/`withTenantTransaction()` umgestellt. Die neun zusätzlichen, über `tx` gebundenen Zugriffe innerhalb der drei Transaktionen zählt dieses einfache Muster nicht mit (siehe Methodenhinweis oben) | | ldap | 4 | 26 | **war 21/0** — Aufgabe 2/3 (260909-ipc) haben `ldap-config.service.ts` (5 Methoden) und `ldap.service.ts` (6 Methoden, 11 Abfragen) auf `forTenant()` umgestellt. Die 4 verbleibenden ungebundenen Treffer sind bewusst: `getAllActiveConfigs`/`onApplicationBootstrap` (Befund B) und `resolveEmailForWrite` (Befund A, T-IPC-04) | | dkv | 21 | 0 | unverändert | @@ -108,7 +108,7 @@ autoritative Quelle. | tenant | 8 | 0 | unverändert | | favorites | 7 | 0 | unverändert | | settings | 4 | 0 | unverändert | -| **Summe** | **173** | **62** | Ungebunden: war 210 vor dieser Etappe (260909-ipc-Stand), Delta = die 37 in Aufgabe 2/3 (260909-jts) umgestellten `groups`-Rohtreffer. Gebunden: war 31, jetzt zusätzlich 31 in `groups` (Bodensatz — siehe Methodenhinweis oben) | +| **Summe** | **147** | **88** | Ungebunden: war 173 vor dieser Etappe (260909-jts-Stand), Delta = die 26 in Aufgabe 2/3 (260909-laa) umgestellten `tenders`-Rohtreffer. Gebunden: war 62, jetzt zusätzlich 26 in `tenders` | ## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 62 Paare) @@ -124,11 +124,18 @@ Die Zahl ist der Ausgabe der Pruefung in `apps/api/src/prisma/rls-access-inventory.spec.ts` entnommen, nicht geschaetzt. +**Stand 260909-laa (Aufgabe 2):** dieselben 62 Paare, keine neue Fundstelle +hinzugekommen oder verschwunden — nur EINE Klasse hat sich verschoben: +`tender-rss-feed.service.ts`/`tenderRssFeedSource` wechselt von +`muss-mandantengebunden` auf `beides` (WINDOWS #19 — `createForUser` bindet, +`listForUser`/`createPlatform`/`remove` bleiben bewusst uebergreifend), der +exakte Praezedenzfall aus `ldapConfig` in 260909-ipc. + | Klasse | Anzahl Paare | |---|---| -| muss-mandantengebunden | 33 | +| muss-mandantengebunden | 32 | | keine-mandantengebundene-tabelle | 16 | -| beides | 10 | +| beides | 11 | | bewusst-uebergreifend | 3 | | **Summe** | **62** | @@ -160,16 +167,31 @@ betroffen: Zustand zum Zeitpunkt der ldap-Umstellung korrekt beschreibt und für spätere Etappen als Beleg dient, siehe auch `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt (e), Befund D. -- **`tender-digest.scheduler.ts`** (Ausschreibungs-Digest): liest - `tenderMatch`/`tenderNotificationPref`/`user` bewusst über ALLE Mandanten - in einem `findMany` (ein einziger globaler Cron-Job, kein Mandant im - Job selbst — so von Anfang an entworfen, Pitfall 1 in den Kommentaren der - Datei). Innerhalb der Verteilung je Treffer ist der Mandant aus der Zeile - bekannt und der Versand muss darauf gebunden laufen. -- **`tender-matching.service.ts`** (Ausschreibungs-Sofortmeldung): dieselbe - Form — `tenderMatch`/`tenderSavedSearch`/`user` werden für die - Sofort-Benachrichtigung über alle betroffenen Mandanten hinweg gelesen, - der Versand je Treffer ist mandantengebunden. +- **`tender-digest.scheduler.ts`** (Ausschreibungs-Digest) — **Stand + 260909-laa, Aufgabe 3: Je-Treffer-Hälfte geschlossen, übergreifende + Hälfte an Etappe 3 übergeben.** Liest `tenderMatch` (Kandidatenabfrage, + `findMany` mit `distinct: ['userId']`) bewusst über ALLE Mandanten in + einem einzigen `findMany` (ein einziger globaler Cron-Job, kein Mandant + im Job selbst — so von Anfang an entworfen, Pitfall 1 in den Kommentaren + der Datei) — diese Kandidatenabfrage bleibt UNGEBUNDEN und ist im Code + als Etappe-3-Übergabe kommentiert. Sie wählt zusätzlich das + denormalisierte `tenantId` der Treffer-Zeile mit aus, damit die Schleife + binden kann; der Sonderfall eines Nutzers mit Treffern unter zwei + verschiedenen Mandanten (Mandantenwechsel) ist NICHT gelöst, siehe + `docs/mandantentrennung-etappe2-fehlerrichtung.md`. Innerhalb der + Schleife laufen `tenderNotificationPref.findUnique`, + `tenderMatch.findMany`/`updateMany` und `user.findUnique` je + Kandidatenzeile über `forTenant()`, gebunden an deren Mandanten. +- **`tender-matching.service.ts`** (Ausschreibungs-Sofortmeldung) — + **Stand 260909-laa, Aufgabe 3: Je-Treffer-Hälfte geschlossen, übergreifende + Hälfte an Etappe 3 übergeben.** `tenderSavedSearch.findMany` (Profile ALLER + Mandanten) und der Lesezugriff auf den plattformweiten `tender`-Katalog + (D-03) bleiben UNGEBUNDEN, beide im Code als Etappe-3-Übergabe bzw. D-03 + kommentiert. Innerhalb der Profilschleife laufen die Treffer-Anlage + (`tenderMatch.upsert`) und — im nachgelagerten Instant-Dispatch — + `tenderMatch.findMany`/`updateMany` sowie `user.findUnique` über + `forTenant()`, EIN gebundener Client je Profil, gebunden an dessen + Mandanten. ## Bestandsaufnahme @@ -220,17 +242,17 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit | apps/api/src/tenders/adapters/rss.adapter.ts | tenderRssFeedSource | bewusst-uebergreifend | ungebunden | Fan-out über jeden aktiven Feed, plattformweit UND persönlich, in einer Abfrage (Zeilen 55–83 im Dateikopf begründet) — dieselbe Scheduler-Ebene wie beim E-Mail-Adapter. | | apps/api/src/tenders/tender-dedup.service.ts | tender | keine-mandantengebundene-tabelle | ungebunden | Explizit im Dateikopf: "platform-global, RLS-exempt tables. Never wrap these queries in forTenant()." (D-03) | | apps/api/src/tenders/tender-dedup.service.ts | tenderSource | keine-mandantengebundene-tabelle | ungebunden | Dieselbe Begründung. | -| apps/api/src/tenders/tender-digest.scheduler.ts | tenderMatch | beides | ungebunden | Ein einziger globaler Cron-Job liest über ALLE Mandanten (bewusst übergreifend, Pitfall-1-Kommentar im Dateikopf), der Versand je Treffer ist an dessen Mandanten gebunden. | -| apps/api/src/tenders/tender-digest.scheduler.ts | tenderNotificationPref | beides | ungebunden | Dieselbe Begründung — Präferenzen werden über alle Mandanten gelesen, aber je Zeile mandantenbezogen ausgewertet. | -| apps/api/src/tenders/tender-digest.scheduler.ts | user | beides | ungebunden | E-Mail-Adressen für den Versand werden über alle Mandanten gelesen, der eigentliche Versand ist je Treffer mandantengebunden. | +| apps/api/src/tenders/tender-digest.scheduler.ts | tenderMatch | beides | gemischt | Ein einziger globaler Cron-Job liest über ALLE Mandanten (bewusst übergreifend, Pitfall-1-Kommentar im Dateikopf) — seit 260909-laa (Aufgabe 3) bleibt die Kandidatenabfrage (`findMany` mit `distinct`) bewusst ungebunden, die Je-Treffer-Abfrage (`findMany` nach Mandant) und die Stempelung (`updateMany`) je Kandidatenzeile laufen über `forTenant()`. | +| apps/api/src/tenders/tender-digest.scheduler.ts | tenderNotificationPref | beides | gebunden | Präferenzen werden je Kandidatenzeile gelesen. Seit 260909-laa (Aufgabe 3) läuft der einzige Zugriff über `forTenant()`, gebunden an den Mandanten der jeweiligen Zeile. | +| apps/api/src/tenders/tender-digest.scheduler.ts | user | beides | gebunden | E-Mail-Adressen für den Versand werden je Kandidatenzeile gelesen. Seit 260909-laa (Aufgabe 3) läuft der einzige Zugriff über `forTenant()`. | | apps/api/src/tenders/tender-email-config.service.ts | tenderEmailConfig | muss-mandantengebunden | gebunden | Nutzer-CRUD für die eigene Postfachanbindung (Phase 17, D-01) — anders als der Fan-out-Adapter oben, hier ist der Mandant aus der Anfrage bekannt. Seit 260909-laa (Aufgabe 2) laufen `getConfigForApi` (beide Lesezugriffe), `saveConfig` und `testConnection` vollständig über `forTenant()`. | | apps/api/src/tenders/tender-fingerprint-backfill.service.ts | tender | keine-mandantengebundene-tabelle | ungebunden | Einmaliges Backfill-Skript über den plattformweiten `Tender`-Katalog (D-03). | | apps/api/src/tenders/tender-ingestion.service.ts | tender | keine-mandantengebundene-tabelle | ungebunden | Explizit im Dateikopf: "Multi-tenant safety (D-03, T-10-09): uses the plain, non-tenant-scoped ... queries ... these are platform-global". | | apps/api/src/tenders/tender-ingestion.service.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Plattformweiter Poll-Status, kein `tenantId` (Migration 20260909140000, Gruppe b). | | apps/api/src/tenders/tender-matching.service.ts | tender | keine-mandantengebundene-tabelle | ungebunden | Liest den plattformweiten Katalog (D-03), um Treffer zu berechnen — kein `tenantId`. | -| apps/api/src/tenders/tender-matching.service.ts | tenderMatch | beides | ungebunden | Sofortmeldung: Treffer über alle betroffenen Mandanten gelesen, Versand je Treffer mandantengebunden — siehe Abschnitt "Der Hintergrunddienst als Falle". | -| apps/api/src/tenders/tender-matching.service.ts | tenderSavedSearch | beides | ungebunden | Dieselbe Begründung — gespeicherte Suchprofile aller Mandanten werden gegen neue Treffer geprüft, der Versand ist je Profil mandantengebunden. | -| apps/api/src/tenders/tender-matching.service.ts | user | beides | ungebunden | Dieselbe Begründung — E-Mail-Adressen für die Sofortmeldung. | +| apps/api/src/tenders/tender-matching.service.ts | tenderMatch | beides | gebunden | Sofortmeldung — siehe Abschnitt "Der Hintergrunddienst als Falle". Seit 260909-laa (Aufgabe 3) laufen sowohl die Treffer-Anlage (`upsert`) als auch der Instant-Dispatch (`findMany`/`updateMany`) vollständig über `forTenant()`, EIN gebundener Client je Profil. | +| apps/api/src/tenders/tender-matching.service.ts | tenderSavedSearch | beides | ungebunden | Gespeicherte Suchprofile ALLER Mandanten werden gegen neue Treffer geprüft — bewusst übergreifend, Etappe-3-Übergabe (260909-laa, Aufgabe 3, unverändert). | +| apps/api/src/tenders/tender-matching.service.ts | user | beides | gebunden | E-Mail-Adressen für die Sofortmeldung. Seit 260909-laa (Aufgabe 3) läuft der einzige Zugriff über `forTenant()`, gebunden an den Mandanten des jeweiligen Profils. | | apps/api/src/tenders/tender-notification-pref.service.ts | tenderNotificationPref | muss-mandantengebunden | gebunden | Nutzer-CRUD für die eigenen Benachrichtigungseinstellungen — Mandant aus der Anfrage bekannt. Seit 260909-laa (Aufgabe 2) laufen `getForUser`/`setForUser` vollständig über `forTenant()`; `setForUser` übersetzt eine P2002-Verletzung (Befund F) in eine verständliche deutsche Meldung. | | apps/api/src/tenders/tender-rss-feed.service.ts | tenderRssFeedSource | beides | gemischt | Nutzer-CRUD für die eigenen RSS-Quellen (anders als der Fan-out in `adapters/rss.adapter.ts`). Seit 260909-laa (Aufgabe 2) bindet `createForUser` (Zähler + Anlage, beide ausschließlich auf persönlichen Zeilen mit gesetztem Mandanten) über `forTenant()`; `listForUser`/`createPlatform`/`remove` bleiben bewusst ungebunden, weil sie (auch) die nullbare, plattformweite Zeile berühren (WINDOWS #19, Aufgabe 1 gemessen: eine gebundene Zeile wäre unter jedem Mandanten unsichtbar bzw. ein gebundenes Einfügen ohne Mandant würde abgewiesen). Korrektur der Klasse von `muss-mandantengebunden` auf `beides`, keine Verhaltensänderung — derselbe Präzedenzfall wie `ldapConfig` in 260909-ipc. | | apps/api/src/tenders/tender-saved-search.service.ts | tenderSavedSearch | muss-mandantengebunden | gebunden | Nutzer-CRUD für die eigenen gespeicherten Suchprofile — Mandant aus der Anfrage bekannt. Seit 260909-laa (Aufgabe 2) laufen `list`/`create`/`update`/`remove` vollständig über `forTenant()`. |