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) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 16:30:09 +02:00
parent 7bb695d37a
commit 58b0f3da50
2 changed files with 99 additions and 1 deletions
@@ -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<string, unknown> = {};
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<string[]> {
const rows = await this.prisma.tenderTriage.findMany({
where: { userId, isFavorite: true },
select: { tenderId: true },
});
return rows.map((r) => r.tenderId);
}
}