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:
@@ -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([]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user