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). 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. * * Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 traegt * die Regel auf TenderNotificationPref die Benutzerdimension * (`current_user_id() IS NULL OR "userId" = current_user_id()`) — beide * `forTenant()`-Aufrufe unten reichen `userId` als drittes Argument durch. * Die anwendungsseitige userId-Filterung bleibt zweites Netz, kein Ersatz. */ @Injectable() export class TenderNotificationPrefService { constructor(private readonly prisma: PrismaService) {} /** * Returns this user's digest interval preference. When no row exists yet * (user has never touched the setting), returns the default * `{ digestInterval: 'daily' }` (D-01) WITHOUT writing a row — consistent * with the digest scheduler's own default-daily due-check semantics, no * autowrite needed to represent "using the default". */ async getForUser(userId: string, tenantId: string): Promise<{ digestInterval: string }> { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const existing = await tenantPrisma.tenderNotificationPref.findUnique({ where: { userId }, }); if (!existing) { return { digestInterval: 'daily' }; } return existing; } /** * Upserts this user's digest interval preference on the @@unique userId * (D-03) — a second call for the same user updates the same row rather * than creating a new one. */ async setForUser(userId: string, tenantId: string, digestInterval: string) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); 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; } } }