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,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<string, any>();
const users = opts.users ?? new Map<string, any>();
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<string, any> = { 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<string, any>(); // 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
});
});
@@ -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' },
});
@@ -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<string>();
const tenders = opts.tenders ?? new Map<string, any>();
const users = opts.users ?? new Map<string, any>();
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<string, any> = { 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<string, any>(); // 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
});
});
@@ -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' },
});
}
@@ -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<string, any> = { 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;
},
};
}