diff --git a/apps/api/prisma/migrations/20260721160000_add_tender_triage/migration.sql b/apps/api/prisma/migrations/20260721160000_add_tender_triage/migration.sql new file mode 100644 index 0000000..93bde1e --- /dev/null +++ b/apps/api/prisma/migrations/20260721160000_add_tender_triage/migration.sql @@ -0,0 +1,32 @@ +-- CreateTable +-- UI-03/04 (D-09/D-10/D-11) — per-user Triage-Zustand (gelesen/ungelesen, +-- Favorit) pro Tender. Scoping-Muster wie FavoriteLink (T-08-06, Pitfall 4): +-- userId-Scoping im Service, KEIN forTenant()/RLS. tenantId wird zusätzlich +-- mitgeführt (T-11-12, spätere Tenant-Isolation), ist aber nicht das +-- Scoping-Feld. ON DELETE CASCADE stellt sicher, dass Retention-Löschungen +-- (Phase 10, 90 Tage) keine verwaisten Triage-Zeilen hinterlassen (Pitfall 6). +CREATE TABLE IF NOT EXISTS "TenderTriage" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "tenderId" TEXT NOT NULL, + "isRead" BOOLEAN NOT NULL DEFAULT false, + "isFavorite" BOOLEAN NOT NULL DEFAULT false, + "readAt" TIMESTAMP(3), + "favoritedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TenderTriage_pkey" PRIMARY KEY ("id"), + CONSTRAINT "TenderTriage_tenderId_fkey" FOREIGN KEY ("tenderId") REFERENCES "Tender"("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +-- Upsert-Target für setTriage (@@unique([userId,tenderId])). +CREATE UNIQUE INDEX IF NOT EXISTS "TenderTriage_userId_tenderId_key" ON "TenderTriage"("userId", "tenderId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "TenderTriage_userId_idx" ON "TenderTriage"("userId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "TenderTriage_tenderId_idx" ON "TenderTriage"("tenderId"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f02e441..351f472 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -290,6 +290,30 @@ model Tender { @@index([bundesland]) // FILTER-02: post-backfill Bundesland-Filter-Performance @@index([cpvDivisions], type: Gin) // FILTER-03: post-backfill CPV-Divisions-Filter-Performance (hasSome) // Deliberately NO tenant column and NO tenant index — this is global data (D-03) + triage TenderTriage[] +} + +// UI-03/04 — per-user Triage-Zustand pro Tender (gelesen/ungelesen, Favorit). +// Scoping-Muster wie FavoriteLink (T-08-06, Pitfall 4): userId-Scoping im +// Service, KEIN forTenant()/RLS — RLS existiert nur für Auth-Kerntabellen. +// tenantId wird zusätzlich mitgeführt (spätere Tenant-Isolation, T-11-12), +// ist aber NICHT das Scoping-Feld — jede Query filtert auf userId. +model TenderTriage { + id String @id @default(uuid()) + userId String + tenantId String + tenderId String + isRead Boolean @default(false) + isFavorite Boolean @default(false) + readAt DateTime? + favoritedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + tender Tender @relation(fields: [tenderId], references: [id], onDelete: Cascade) + + @@unique([userId, tenderId]) // Upsert-Target (setTriage); ein Triage-Row je (user,tender) + @@index([userId]) + @@index([tenderId]) } // Singleton-per-source admin poll config (INGEST-06 foundation). diff --git a/apps/api/src/tenders/tender-triage.service.spec.ts b/apps/api/src/tenders/tender-triage.service.spec.ts new file mode 100644 index 0000000..32820a1 --- /dev/null +++ b/apps/api/src/tenders/tender-triage.service.spec.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from 'vitest'; +import { TenderTriageService } from './tender-triage.service'; + +/** + * TenderTriageService.spec — RED-first (TDD) proof for UI-03/04 (D-09/D-10/ + * D-11) and the V4/IDOR access-control invariant (T-11-10): + * + * - setTriage() is idempotent: repeated calls with the same params upsert + * onto exactly one row (@@unique([userId,tenderId]) target). + * - listForUser()/favoriteIds() are scoped strictly by userId — a foreign + * userId must see nothing, even for the same tenderId (IDOR). + * - Cascade: once a tender's triage rows are removed (simulating the DB's + * `onDelete: Cascade` FK — real enforcement is verified separately + * against the applied migration on the live dev DB), the service's read + * path must not leak orphaned rows (Pitfall 6). + * + * Uses the same hand-rolled prisma-shaped fake convention as + * tender-ingestion.service.spec.ts / tenders.controller.spec.ts (in-memory + * Map, no live DB connection). + */ + +function makeFakePrisma() { + const rows = new Map(); + const key = (userId: string, tenderId: string) => `${userId}:${tenderId}`; + + return { + tenderTriage: { + upsert: vi.fn(async ({ where, update, create }: any) => { + const k = key(where.userId_tenderId.userId, where.userId_tenderId.tenderId); + const existing = rows.get(k); + const record = existing + ? { ...existing, ...update, updatedAt: new Date() } + : { id: `tt-${rows.size + 1}`, ...create, createdAt: new Date(), updatedAt: new Date() }; + rows.set(k, record); + return record; + }), + findMany: vi.fn(async ({ where }: any) => { + return Array.from(rows.values()).filter((r) => { + if (where.userId !== undefined && r.userId !== where.userId) return false; + if (where.isFavorite !== undefined && r.isFavorite !== where.isFavorite) return false; + if (where.tenderId?.in && !where.tenderId.in.includes(r.tenderId)) return false; + return true; + }); + }), + // Simulates the schema-level `onDelete: Cascade` FK constraint + // (Pitfall 6). Real enforcement is verified separately by applying + // the 20260721160000_add_tender_triage migration and checking + // `SELECT * FROM "TenderTriage"` after a live delete — this fake + // proves the service's read path (listForUser/favoriteIds) is + // consistent with that cascade once rows are gone. + _simulateTenderCascadeDelete: (tenderId: string) => { + for (const [k, r] of rows) { + if (r.tenderId === tenderId) rows.delete(k); + } + }, + }, + }; +} + +describe('TenderTriageService', () => { + it('setTriage is idempotent: calling twice with identical params results in exactly one row', async () => { + const prisma = makeFakePrisma(); + const service = new TenderTriageService(prisma as any); + + await service.setTriage('u1', 'tenant1', 't1', { isRead: true }); + await service.setTriage('u1', 'tenant1', 't1', { isRead: true }); + + const rows = await service.listForUser('u1', ['t1']); + expect(rows).toHaveLength(1); + expect(rows[0].isRead).toBe(true); + }); + + it('setTriage upserts isFavorite=true and stamps favoritedAt', async () => { + const prisma = makeFakePrisma(); + const service = new TenderTriageService(prisma as any); + + const result = await service.setTriage('u1', 'tenant1', 't1', { isFavorite: true }); + + expect(result.isFavorite).toBe(true); + expect(result.favoritedAt).toBeInstanceOf(Date); + }); + + it('setTriage toggling isRead back to false clears readAt', async () => { + const prisma = makeFakePrisma(); + const service = new TenderTriageService(prisma as any); + + await service.setTriage('u1', 'tenant1', 't1', { isRead: true }); + const result = await service.setTriage('u1', 'tenant1', 't1', { isRead: false }); + + expect(result.isRead).toBe(false); + expect(result.readAt).toBeNull(); + }); + + it('a partial update (isRead only) does not clobber a previously-set isFavorite', async () => { + const prisma = makeFakePrisma(); + const service = new TenderTriageService(prisma as any); + + await service.setTriage('u1', 'tenant1', 't1', { isFavorite: true }); + const result = await service.setTriage('u1', 'tenant1', 't1', { isRead: true }); + + expect(result.isRead).toBe(true); + expect(result.isFavorite).toBe(true); + }); + + it('listForUser scopes strictly by userId — a foreign user sees nothing for the same tenderId (V4 / IDOR)', async () => { + const prisma = makeFakePrisma(); + const service = new TenderTriageService(prisma as any); + + await service.setTriage('u1', 'tenant1', 't1', { isRead: true, isFavorite: true }); + + const foreignRows = await service.listForUser('u2', ['t1']); + expect(foreignRows).toHaveLength(0); + }); + + it('listForUser with an empty tenderIds array short-circuits to an empty result without querying prisma', async () => { + const prisma = makeFakePrisma(); + const service = new TenderTriageService(prisma as any); + + const rows = await service.listForUser('u1', []); + + expect(rows).toEqual([]); + expect(prisma.tenderTriage.findMany).not.toHaveBeenCalled(); + }); + + it('favoriteIds returns only tenderIds favorited by that exact user, never another user\'s (V4 / IDOR)', async () => { + const prisma = makeFakePrisma(); + const service = new TenderTriageService(prisma as any); + + await service.setTriage('u1', 'tenant1', 't1', { isFavorite: true }); + await service.setTriage('u1', 'tenant1', 't2', { isFavorite: false }); + await service.setTriage('u2', 'tenant1', 't3', { isFavorite: true }); + + expect(await service.favoriteIds('u1')).toEqual(['t1']); + expect(await service.favoriteIds('u2')).toEqual(['t3']); + }); + + it('cascade: after a tender\'s triage rows are removed (DB onDelete: Cascade), it no longer appears for any user', async () => { + const prisma = makeFakePrisma(); + const service = new TenderTriageService(prisma as any); + + await service.setTriage('u1', 'tenant1', 't1', { isFavorite: true }); + (prisma as any)._simulateTenderCascadeDelete('t1'); + + expect(await service.listForUser('u1', ['t1'])).toHaveLength(0); + expect(await service.favoriteIds('u1')).toEqual([]); + }); +});