feat(laa-02): binde die fuenf Nutzer-CRUD-Dienste des Bereichs tenders an forTenant()

tender-saved-search.service.ts, tender-triage.service.ts,
tender-notification-pref.service.ts und tender-email-config.service.ts
laufen jetzt vollstaendig ueber forTenant() — vier neue Parameter (list,
update, remove, listForUser, favoriteIds, getForUser, getConfigForApi,
testConnection bekommen tenantId), die anwendungsseitige userId-Filterung
bleibt unveraendert (Befund E: die Policies haben keine Benutzerdimension).

tender-rss-feed.service.ts bindet nur createForUser (Zaehler + Anlage,
beide ausschliesslich auf persoenlichen Zeilen); listForUser, createPlatform
und remove bleiben mit Codekommentar bewusst ungebunden (WINDOWS #19 —
eine gebundene plattformweite Zeile waere unter jedem Mandanten unsichtbar,
ein gebundenes Einfuegen ohne Mandant wuerde abgewiesen).

tender-notification-pref.service.ts und tender-email-config.service.ts
uebersetzen eine P2002-Verletzung auf dem tenantlosen upsert-Schluessel
(Befund F) in eine verstaendliche deutsche Meldung statt eines rohen
Fehlers.

tenders.controller.ts reicht tenantId an den acht betroffenen
Aufrufstellen durch extractTriageContext() durch (kein neuer
Aufloesungsweg); die drei RSS-Aufrufstellen bleiben unveraendert, da ihre
Dienstmethoden nicht binden.

Alle sieben angefassten Testdateien bekommen den Zwei-Client-Nachweis
(__makeBoundClient ueber demselben Speicher) und Bindungstests je
umgestellter Methode; tender-rss-feed.service.spec.ts zusaetzlich den
Gegentest, dass die drei unveraendert bleibenden Pfade forTenant() NICHT
aufrufen. Falsifiziert: ein probeweiser Rueckbau der list()-Bindung in
tender-saved-search.service.ts machte genau den erwarteten Bindungstest
rot, danach zurueckgenommen.

docs/mandantentrennung-zugriffsklassifikation.md: Stand der fuenf Paare
auf gebunden bzw. gemischt nachgezogen; tenderRssFeedSource von
muss-mandantengebunden auf beides umklassifiziert (derselbe Praezedenzfall
wie ldapConfig in 260909-ipc).

761 Tests gruen (743 + 18 neue Bindungsnachweise), 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 15:53:53 +02:00
parent 349814747e
commit 3336a6e419
13 changed files with 790 additions and 229 deletions
@@ -1,19 +1,31 @@
import { Injectable } from '@nestjs/common';
import { ConflictException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
/**
* Service for managing the per-user Tender digest interval preference
* (NOTIFY-01, D-01/D-03).
*
* Access control (T-12-14 / V4 — IDOR): scoped by userId exactly like
* TenderSavedSearchService/TenderTriageService (T-11-14/T-08-06) — NOT
* forTenant()/RLS (Pitfall 4). userId must always be derived from the
* caller's auth context (controller), never accepted as a body/query
* parameter here.
* TenderSavedSearchService/TenderTriageService (T-11-14/T-08-06). This
* stays deliberate belt-and-suspenders after binding to `forTenant()`
* (260909-laa) — the delivered policy on TenderNotificationPref has no
* user dimension (Befund E, Aufgabe 1).
*
* `TenderNotificationPref` has a per-user `@@unique` on `userId` (one row
* per user, D-03: the interval is a user setting, not per-profile) — this
* service upserts on that key.
*
* T-LAA-07 (260909-laa, Befund F): `userId` has no tenant dimension. If a
* user's stored `tenantId` has gone stale (their resolved tenant changed)
* the existing row can become invisible under the now-bound context — a
* bound `upsert` then falls into the create branch and hits the
* platform-wide uniqueness constraint on `userId`. Aufgabe 1 measured this
* exact shape for the sibling `TenderTriage` upsert
* (`tendertriage-einfuegen-auf-unsichtbare-zeile-verletzt-eindeutigkeit`):
* the failure is a P2002 unique-constraint violation, not an RLS
* rejection. Translated below into a German message, same pattern as
* `tender-saved-search.service.ts`, instead of surfacing as a raw 500.
*/
@Injectable()
export class TenderNotificationPrefService {
@@ -26,8 +38,9 @@ export class TenderNotificationPrefService {
* with the digest scheduler's own default-daily due-check semantics, no
* autowrite needed to represent "using the default".
*/
async getForUser(userId: string): Promise<{ digestInterval: string }> {
const existing = await this.prisma.tenderNotificationPref.findUnique({
async getForUser(userId: string, tenantId: string): Promise<{ digestInterval: string }> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const existing = await tenantPrisma.tenderNotificationPref.findUnique({
where: { userId },
});
@@ -44,10 +57,20 @@ export class TenderNotificationPrefService {
* than creating a new one.
*/
async setForUser(userId: string, tenantId: string, digestInterval: string) {
return this.prisma.tenderNotificationPref.upsert({
where: { userId },
create: { userId, tenantId, digestInterval },
update: { digestInterval },
});
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
try {
return await tenantPrisma.tenderNotificationPref.upsert({
where: { userId },
create: { userId, tenantId, digestInterval },
update: { digestInterval },
});
} catch (error: any) {
if (error?.code === 'P2002') {
throw new ConflictException(
'Die Benachrichtigungseinstellung konnte nicht gespeichert werden, weil bereits ein widersprüchlicher Eintrag existiert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.',
);
}
throw error;
}
}
}