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);
|
const service = new TenderTriageService(prisma as any);
|
||||||
|
|
||||||
await service.setTriage('u1', 'tenant1', 't1', { isFavorite: true });
|
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.listForUser('u1', ['t1'])).toHaveLength(0);
|
||||||
expect(await service.favoriteIds('u1')).toEqual([]);
|
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