import { ConflictException, Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; import { prismaErrorCode } from '../prisma/prisma-error'; /** * Partial triage update accepted by setTriage(). Both fields are optional * so a caller can flip just isRead or just isFavorite without clobbering * the other (see the "partial update" spec case). */ export interface SetTriageInput { isRead?: boolean; isFavorite?: boolean; } /** * Service for managing per-user Tender triage state (gelesen/ungelesen, * Favorit — UI-03/04, D-09/D-10/D-11). * * Access control (T-11-10 / V4 — IDOR): every query is ADDITIONALLY scoped * by userId, exactly the `FavoritesService` convention (T-08-06). This * stays deliberate belt-and-suspenders after binding to `forTenant()` * (260909-laa): the delivered `tenant_isolation_policy` on TenderTriage * has no user dimension — a foreign user of the SAME tenant is not * excluded by the database alone (Befund E, measured for the sibling * TenderSavedSearch policy in Aufgabe 1; all five policies of this area * share the identical `"tenantId" = current_tenant_id()` text). * * Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 traegt * die Regel auf TenderTriage die Benutzerdimension (`current_user_id() IS * NULL OR "userId" = current_user_id()`) — alle drei `forTenant()`-Aufrufe * unten reichen `userId` als drittes Argument durch. Die anwendungsseitige * userId-Filterung bleibt zweites Netz, kein Ersatz. * * Cascade (Pitfall 6): the schema's `Tender @relation(..., onDelete: * Cascade)` removes a tender's triage rows automatically when Phase 10's * retention job deletes the tender — no manual cleanup needed here. */ @Injectable() export class TenderTriageService { constructor(private readonly prisma: PrismaService) {} /** * Upserts the triage row for (userId, tenderId) on the * `@@unique([userId, tenderId])` target — idempotent: calling this twice * with the same params never creates a second row. Only the fields * present in `dto` are touched; the other flag (and its timestamp) is * left as-is on both the update and create branches. * * Gegenrichtung der Bindung (Befund F, 260909-laa): der Eindeutigkeits- * schluessel `@@unique([userId, tenderId])` traegt KEINE Mandanten- * dimension. Ist die vorhandene Zeile unter dem gebundenen Kontext * unsichtbar (veralteter Mandant in der Sitzung), findet das `upsert` * sie nicht, versucht anzulegen und laeuft in die Eindeutigkeits- * verletzung — aus stillem Ueberschreiben wird ein harter Fehler. Der * `P2002`-Zweig uebersetzt das in eine verstaendliche deutsche Meldung * statt in einen 500, wie in `tender-saved-search.service.ts`. Gemessen * in `rls-scratch-check.mjs`, Pruefung * `tendertriage-einfuegen-auf-unsichtbare-zeile-verletzt-eindeutigkeit`. */ async setTriage( userId: string, tenantId: string, tenderId: string, dto: SetTriageInput, ) { const now = new Date(); const update: Record = {}; if (dto.isRead !== undefined) { update.isRead = dto.isRead; update.readAt = dto.isRead ? now : null; } if (dto.isFavorite !== undefined) { update.isFavorite = dto.isFavorite; update.favoritedAt = dto.isFavorite ? now : null; } const tenantPrisma = forTenant(this.prisma, tenantId, userId); try { return await tenantPrisma.tenderTriage.upsert({ where: { userId_tenderId: { userId, tenderId } }, update, create: { userId, tenantId, tenderId, isRead: dto.isRead ?? false, isFavorite: dto.isFavorite ?? false, readAt: dto.isRead ? now : null, favoritedAt: dto.isFavorite ? now : null, }, }); } catch (error: unknown) { if (prismaErrorCode(error) === 'P2002') { throw new ConflictException( 'Der Bearbeitungsstand zu dieser Ausschreibung konnte nicht gespeichert werden. Bitte die Seite neu laden und es erneut versuchen.', ); } throw error; } } /** * Batch-fetch this user's triage rows for a set of tenderIds (used by * the Trefferliste to merge read/favorite state into the visible page). * Scoped by userId (V4/IDOR) — a foreign userId never sees another * user's rows, even for the same tenderId. Returns [] without querying * prisma when tenderIds is empty (avoids an unbounded `in: []` no-op * round-trip) — deliberately BEFORE forTenant() is created, so an empty * batch never even opens a bound client. */ async listForUser(userId: string, tenantId: string, tenderIds: string[]) { if (!tenderIds.length) return []; const tenantPrisma = forTenant(this.prisma, tenantId, userId); return tenantPrisma.tenderTriage.findMany({ where: { userId, tenderId: { in: tenderIds } }, }); } /** * Returns the tenderIds this user has marked as favorite (UI-04 * Merklisten-Filter). Scoped by userId — feeds the favOnly branch of * tender-query.builder.ts's buildTenderWhere. */ async favoriteIds(userId: string, tenantId: string): Promise { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const rows = await tenantPrisma.tenderTriage.findMany({ where: { userId, isFavorite: true }, select: { tenderId: true }, }); return rows.map((r: { tenderId: string }) => r.tenderId); } }