From 58b0f3da50b4bf27f65c0bb8a126026c8a76d5ec Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 21 Jul 2026 16:30:09 +0200 Subject: [PATCH] feat(11-05): implement TenderTriageService (GREEN) + apply migration TenderTriageService upserts per-user read/favourite state on the @@unique([userId,tenderId]) target (idempotent), scopes every query by userId (V4/IDOR, FavoritesService pattern), and exposes favoriteIds() for the upcoming favOnly filter. Migration 20260721160000_add_tender_triage applied to the local dev DB (FK ON DELETE CASCADE verified via \d), Prisma client regenerated. All 8 spec cases green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/tenders/tender-triage.service.spec.ts | 2 +- apps/api/src/tenders/tender-triage.service.ts | 98 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/tenders/tender-triage.service.ts diff --git a/apps/api/src/tenders/tender-triage.service.spec.ts b/apps/api/src/tenders/tender-triage.service.spec.ts index 32820a1..3060d44 100644 --- a/apps/api/src/tenders/tender-triage.service.spec.ts +++ b/apps/api/src/tenders/tender-triage.service.spec.ts @@ -139,7 +139,7 @@ describe('TenderTriageService', () => { const service = new TenderTriageService(prisma as any); await service.setTriage('u1', 'tenant1', 't1', { isFavorite: true }); - (prisma as any)._simulateTenderCascadeDelete('t1'); + prisma.tenderTriage._simulateTenderCascadeDelete('t1'); expect(await service.listForUser('u1', ['t1'])).toHaveLength(0); expect(await service.favoriteIds('u1')).toEqual([]); diff --git a/apps/api/src/tenders/tender-triage.service.ts b/apps/api/src/tenders/tender-triage.service.ts new file mode 100644 index 0000000..6d34ee7 --- /dev/null +++ b/apps/api/src/tenders/tender-triage.service.ts @@ -0,0 +1,98 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +/** + * 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 scoped by userId, + * exactly the `FavoritesService` convention (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. + * + * 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. + */ + 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; + } + + return this.prisma.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, + }, + }); + } + + /** + * 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). + */ + async listForUser(userId: string, tenderIds: string[]) { + if (!tenderIds.length) return []; + return this.prisma.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): Promise { + const rows = await this.prisma.tenderTriage.findMany({ + where: { userId, isFavorite: true }, + select: { tenderId: true }, + }); + return rows.map((r) => r.tenderId); + } +}