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:
@@ -1,4 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { TenderDigestScheduler } from './tender-digest.scheduler';
|
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
|
* 2026-07-27 is a verified Monday, 2026-07-28 a verified Tuesday
|
||||||
* (Europe/Berlin) — passed explicitly to `runDigest(now)` rather than faking
|
* (Europe/Berlin) — passed explicitly to `runDigest(now)` rather than faking
|
||||||
* the system clock.
|
* 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 MONDAY = new Date('2026-07-27T10:00:00Z');
|
||||||
const TUESDAY = new Date('2026-07-28T10:00:00Z');
|
const TUESDAY = new Date('2026-07-28T10:00:00Z');
|
||||||
@@ -29,6 +42,7 @@ function makeFakePrisma(opts: {
|
|||||||
const matches = opts.matches ?? [];
|
const matches = opts.matches ?? [];
|
||||||
const prefs = opts.prefs ?? new Map<string, any>();
|
const prefs = opts.prefs ?? new Map<string, any>();
|
||||||
const users = opts.users ?? new Map<string, any>();
|
const users = opts.users ?? new Map<string, any>();
|
||||||
|
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
|
||||||
|
|
||||||
const tenderMatch = {
|
const tenderMatch = {
|
||||||
findMany: vi.fn(async ({ where, distinct }: any) => {
|
findMany: vi.fn(async ({ where, distinct }: any) => {
|
||||||
@@ -62,16 +76,35 @@ function makeFakePrisma(opts: {
|
|||||||
return { count };
|
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 {
|
return {
|
||||||
tenderMatch,
|
tenderMatch,
|
||||||
tenderNotificationPref: {
|
tenderNotificationPref,
|
||||||
findUnique: vi.fn(async ({ where }: any) => prefs.get(where.userId) ?? null),
|
user,
|
||||||
},
|
|
||||||
user: {
|
|
||||||
findUnique: vi.fn(async ({ where }: any) => users.get(where.id) ?? null),
|
|
||||||
},
|
|
||||||
__store: { matches, prefs, users },
|
__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');
|
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 { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { TenderMailItem, TenderMailService } from './tender-mail.service';
|
import { TenderMailItem, TenderMailService } from './tender-mail.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,9 +105,21 @@ export class TenderDigestScheduler implements OnModuleInit {
|
|||||||
async runDigest(now: Date = new Date()): Promise<void> {
|
async runDigest(now: Date = new Date()): Promise<void> {
|
||||||
// Candidate users: distinct userId with at least one un-notified match,
|
// Candidate users: distinct userId with at least one un-notified match,
|
||||||
// across ALL tenants — a single findMany, never a per-tenant iteration.
|
// 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({
|
const candidates = await this.prisma.tenderMatch.findMany({
|
||||||
where: { notifiedAt: null },
|
where: { notifiedAt: null },
|
||||||
select: { userId: true },
|
select: { userId: true, tenantId: true },
|
||||||
distinct: ['userId'],
|
distinct: ['userId'],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,9 +127,14 @@ export class TenderDigestScheduler implements OnModuleInit {
|
|||||||
|
|
||||||
const weeklyDue = isMondayInBerlin(now);
|
const weeklyDue = isMondayInBerlin(now);
|
||||||
|
|
||||||
for (const { userId } of candidates) {
|
for (const { userId, tenantId } of candidates) {
|
||||||
try {
|
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 },
|
where: { userId },
|
||||||
});
|
});
|
||||||
// Missing pref row -> daily default (D-01).
|
// Missing pref row -> daily default (D-01).
|
||||||
@@ -126,14 +144,14 @@ export class TenderDigestScheduler implements OnModuleInit {
|
|||||||
if (interval === 'weekly' && !weeklyDue) continue;
|
if (interval === 'weekly' && !weeklyDue) continue;
|
||||||
// 'daily' (or any unrecognized value) -> due every run.
|
// '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 },
|
where: { userId, notifiedAt: null },
|
||||||
include: { tender: true, savedSearch: true },
|
include: { tender: true, savedSearch: true },
|
||||||
orderBy: { savedSearch: { name: 'asc' } },
|
orderBy: { savedSearch: { name: 'asc' } },
|
||||||
});
|
});
|
||||||
if (!matches.length) continue;
|
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
|
// Kein Konto, oder ein Konto ohne Adresse (WINDOWS #15, kollidierte
|
||||||
// AD-Adresse) -- die Zugehoerigkeit funktioniert, nur der
|
// AD-Adresse) -- die Zugehoerigkeit funktioniert, nur der
|
||||||
// Mailversand wird uebersprungen (zugesagtes Verhalten).
|
// 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);
|
const sent = await this.mail.sendDigest({ email: user.email }, user.tenantId, sections);
|
||||||
|
|
||||||
if (sent) {
|
if (sent) {
|
||||||
await this.prisma.tenderMatch.updateMany({
|
await tenantPrisma.tenderMatch.updateMany({
|
||||||
where: { id: { in: matches.map((m: { id: string }) => m.id) } },
|
where: { id: { in: matches.map((m: { id: string }) => m.id) } },
|
||||||
data: { notifiedAt: new Date(), notifiedChannel: 'digest' },
|
data: { notifiedAt: new Date(), notifiedChannel: 'digest' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { TenderMatchingService } from './tender-matching.service';
|
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
|
* A hand-rolled Prisma-shaped mock is used (this repo's established
|
||||||
* convention — see tender-ingestion.service.spec.ts) rather than a live
|
* convention — see tender-ingestion.service.spec.ts) rather than a live
|
||||||
* DB connection.
|
* 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 = {
|
const PROFILE_A = {
|
||||||
id: 'search-a',
|
id: 'search-a',
|
||||||
@@ -46,73 +57,96 @@ function makeFakePrisma(opts: {
|
|||||||
const matchingTenderIds = opts.matchingTenderIds ?? new Set<string>();
|
const matchingTenderIds = opts.matchingTenderIds ?? new Set<string>();
|
||||||
const tenders = opts.tenders ?? new Map<string, any>();
|
const tenders = opts.tenders ?? new Map<string, any>();
|
||||||
const users = opts.users ?? 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 = {
|
const prisma = {
|
||||||
tenderSavedSearch: {
|
tenderSavedSearch,
|
||||||
findMany: vi.fn(async () => savedSearches),
|
tender: tenderModel,
|
||||||
},
|
tenderMatch,
|
||||||
tender: {
|
user,
|
||||||
// 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),
|
|
||||||
},
|
|
||||||
__store: { matches, savedSearches, tenders, users },
|
__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;
|
return prisma;
|
||||||
@@ -392,3 +426,89 @@ describe('TenderMatchingService.matchDelta — nichts Neues löst keinen Alert a
|
|||||||
expect(sendInstant).not.toHaveBeenCalled();
|
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 { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { TenderMailService } from './tender-mail.service';
|
import { TenderMailService } from './tender-mail.service';
|
||||||
import { buildTenderWhere } from './tender-query.builder';
|
import { buildTenderWhere } from './tender-query.builder';
|
||||||
import type { TenderQueryDto } from './dto/tender-query.dto';
|
import type { TenderQueryDto } from './dto/tender-query.dto';
|
||||||
@@ -63,6 +64,10 @@ export class TenderMatchingService {
|
|||||||
async matchDelta(newTenderIds: string[]): Promise<void> {
|
async matchDelta(newTenderIds: string[]): Promise<void> {
|
||||||
if (!newTenderIds.length) return;
|
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();
|
const savedSearches = await this.prisma.tenderSavedSearch.findMany();
|
||||||
|
|
||||||
for (const search of savedSearches) {
|
for (const search of savedSearches) {
|
||||||
@@ -74,13 +79,20 @@ export class TenderMatchingService {
|
|||||||
AND: [filterWhere, { id: { in: newTenderIds } }],
|
AND: [filterWhere, { id: { in: newTenderIds } }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// BEWUSST UNGEBUNDEN (D-03) — liest den plattformweiten
|
||||||
|
// Ausschreibungskatalog, kein tenantId.
|
||||||
const hits = await this.prisma.tender.findMany({
|
const hits = await this.prisma.tender.findMany({
|
||||||
where,
|
where,
|
||||||
select: { id: true },
|
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) {
|
for (const hit of hits) {
|
||||||
await this.prisma.tenderMatch.upsert({
|
await tenantPrisma.tenderMatch.upsert({
|
||||||
where: {
|
where: {
|
||||||
tenderId_savedSearchId: {
|
tenderId_savedSearchId: {
|
||||||
tenderId: hit.id,
|
tenderId: hit.id,
|
||||||
@@ -114,7 +126,11 @@ export class TenderMatchingService {
|
|||||||
|
|
||||||
for (const profile of instantProfiles) {
|
for (const profile of instantProfiles) {
|
||||||
try {
|
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: {
|
where: {
|
||||||
savedSearchId: profile.id,
|
savedSearchId: profile.id,
|
||||||
notifiedAt: null,
|
notifiedAt: null,
|
||||||
@@ -124,7 +140,7 @@ export class TenderMatchingService {
|
|||||||
});
|
});
|
||||||
if (!fresh.length) continue; // nothing new for this profile this tick
|
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
|
// Kein Konto, oder ein Konto ohne Adresse (WINDOWS #15, kollidierte
|
||||||
// AD-Adresse) -- die Zugehoerigkeit funktioniert, nur der
|
// AD-Adresse) -- die Zugehoerigkeit funktioniert, nur der
|
||||||
// Mailversand wird uebersprungen (zugesagtes Verhalten).
|
// Mailversand wird uebersprungen (zugesagtes Verhalten).
|
||||||
@@ -134,12 +150,12 @@ export class TenderMatchingService {
|
|||||||
{ email: user.email },
|
{ email: user.email },
|
||||||
profile.tenantId,
|
profile.tenantId,
|
||||||
{ name: profile.name },
|
{ name: profile.name },
|
||||||
fresh.map((match) => match.tender),
|
fresh.map((match: { tender: unknown }) => match.tender),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (sent) {
|
if (sent) {
|
||||||
await this.prisma.tenderMatch.updateMany({
|
await tenantPrisma.tenderMatch.updateMany({
|
||||||
where: { id: { in: fresh.map((match) => match.id) } },
|
where: { id: { in: fresh.map((match: { id: string }) => match.id) } },
|
||||||
data: { notifiedAt: new Date(), notifiedChannel: 'instant' },
|
data: { notifiedAt: new Date(), notifiedChannel: 'instant' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,17 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
import { TenderMatchingService } from './tender-matching.service';
|
import { TenderMatchingService } from './tender-matching.service';
|
||||||
import { TenderDigestScheduler } from './tender-digest.scheduler';
|
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
|
* Integration spec (Plan 12-03, Task 2) — proves the NOTIFY-03 core
|
||||||
* invariant across BOTH notification channels: a tender x saved-search
|
* 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 {
|
return {
|
||||||
tenderSavedSearch: { findMany: vi.fn(async () => [savedSearch]) },
|
tenderSavedSearch: { findMany: vi.fn(async () => [savedSearch]) },
|
||||||
tender: {
|
tender: {
|
||||||
@@ -111,13 +132,24 @@ function makeSharedFakePrisma(opts: {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
tenderMatch,
|
tenderMatch,
|
||||||
tenderNotificationPref: {
|
tenderNotificationPref,
|
||||||
findUnique: vi.fn(async ({ where }: any) => prefs.get(where.userId) ?? null),
|
user: userModel,
|
||||||
},
|
|
||||||
user: {
|
|
||||||
findUnique: vi.fn(async ({ where }: any) => users.get(where.id) ?? null),
|
|
||||||
},
|
|
||||||
__store: { matches },
|
__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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
über alle Mandanten hinweg ungebunden und ist als Etappe-3-Übergabe
|
||||||
kommentiert — siehe `docs/mandantentrennung-zugriffsklassifikation.md`,
|
kommentiert — siehe `docs/mandantentrennung-zugriffsklassifikation.md`,
|
||||||
Abschnitt "Der Hintergrunddienst als Falle".
|
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
|
- **Befund K — die Abhängigkeit vom noch nicht umgestellten Bereich
|
||||||
`settings`.** `tender-mail.service.ts` holt die SMTP-Angaben über
|
`settings`.** `tender-mail.service.ts` holt die SMTP-Angaben über
|
||||||
`SettingsService.getDecryptedSmtpConfig(tenantId)`; `settings.service.ts`
|
`SettingsService.getDecryptedSmtpConfig(tenantId)`; `settings.service.ts`
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ autoritative Quelle.
|
|||||||
|
|
||||||
| Bereich | Ungebunden | Gebunden | Hinweis |
|
| 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) |
|
| 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) |
|
| 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 |
|
| dkv | 21 | 0 | unverändert |
|
||||||
@@ -108,7 +108,7 @@ autoritative Quelle.
|
|||||||
| tenant | 8 | 0 | unverändert |
|
| tenant | 8 | 0 | unverändert |
|
||||||
| favorites | 7 | 0 | unverändert |
|
| favorites | 7 | 0 | unverändert |
|
||||||
| settings | 4 | 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)
|
## 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
|
`apps/api/src/prisma/rls-access-inventory.spec.ts` entnommen, nicht
|
||||||
geschaetzt.
|
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 |
|
| Klasse | Anzahl Paare |
|
||||||
|---|---|
|
|---|---|
|
||||||
| muss-mandantengebunden | 33 |
|
| muss-mandantengebunden | 32 |
|
||||||
| keine-mandantengebundene-tabelle | 16 |
|
| keine-mandantengebundene-tabelle | 16 |
|
||||||
| beides | 10 |
|
| beides | 11 |
|
||||||
| bewusst-uebergreifend | 3 |
|
| bewusst-uebergreifend | 3 |
|
||||||
| **Summe** | **62** |
|
| **Summe** | **62** |
|
||||||
|
|
||||||
@@ -160,16 +167,31 @@ betroffen:
|
|||||||
Zustand zum Zeitpunkt der ldap-Umstellung korrekt beschreibt und für
|
Zustand zum Zeitpunkt der ldap-Umstellung korrekt beschreibt und für
|
||||||
spätere Etappen als Beleg dient, siehe auch
|
spätere Etappen als Beleg dient, siehe auch
|
||||||
`docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt (e), Befund D.
|
`docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt (e), Befund D.
|
||||||
- **`tender-digest.scheduler.ts`** (Ausschreibungs-Digest): liest
|
- **`tender-digest.scheduler.ts`** (Ausschreibungs-Digest) — **Stand
|
||||||
`tenderMatch`/`tenderNotificationPref`/`user` bewusst über ALLE Mandanten
|
260909-laa, Aufgabe 3: Je-Treffer-Hälfte geschlossen, übergreifende
|
||||||
in einem `findMany` (ein einziger globaler Cron-Job, kein Mandant im
|
Hälfte an Etappe 3 übergeben.** Liest `tenderMatch` (Kandidatenabfrage,
|
||||||
Job selbst — so von Anfang an entworfen, Pitfall 1 in den Kommentaren der
|
`findMany` mit `distinct: ['userId']`) bewusst über ALLE Mandanten in
|
||||||
Datei). Innerhalb der Verteilung je Treffer ist der Mandant aus der Zeile
|
einem einzigen `findMany` (ein einziger globaler Cron-Job, kein Mandant
|
||||||
bekannt und der Versand muss darauf gebunden laufen.
|
im Job selbst — so von Anfang an entworfen, Pitfall 1 in den Kommentaren
|
||||||
- **`tender-matching.service.ts`** (Ausschreibungs-Sofortmeldung): dieselbe
|
der Datei) — diese Kandidatenabfrage bleibt UNGEBUNDEN und ist im Code
|
||||||
Form — `tenderMatch`/`tenderSavedSearch`/`user` werden für die
|
als Etappe-3-Übergabe kommentiert. Sie wählt zusätzlich das
|
||||||
Sofort-Benachrichtigung über alle betroffenen Mandanten hinweg gelesen,
|
denormalisierte `tenantId` der Treffer-Zeile mit aus, damit die Schleife
|
||||||
der Versand je Treffer ist mandantengebunden.
|
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
|
## 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/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 | 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-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 | 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 | ungebunden | Dieselbe Begründung — Präferenzen werden über alle Mandanten gelesen, aber je Zeile mandantenbezogen ausgewertet. |
|
| 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 | 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 | 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-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-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 | 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-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 | 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 | 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 | 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 | 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 | ungebunden | Dieselbe Begründung — E-Mail-Adressen für die Sofortmeldung. |
|
| 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-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-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()`. |
|
| 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()`. |
|
||||||
|
|||||||
Reference in New Issue
Block a user