Files
tessera-ctl/apps/api/src/tenders/tender-triage.service.ts
T
schalli 07fc653f52 feat(quick-260911-nke): Benutzer an 34 Aufrufstellen gesetzt, zehn Tabellen gemessen, sechs Pruefungen umgedreht
- 30 verbleibende forTenant()-Aufrufstellen in sieben Diensten (calendar 6,
  dashboard 9, favorites 5, tender-email-config 3, tender-notification-pref 2,
  tender-rss-feed 2, tender-triage 3) reichen userId als drittes Argument
  durch. tender-digest.scheduler.ts bleibt zweistellig (Hintergrunddienst,
  Etappe 3c), mit Begruendung im Kommentar. Keine Methodensignatur, kein
  Controller angefasst, keine anwendungsseitige userId-Filterung entfernt.
- rls-scratch-check.mjs: zwoelf Extraktionsstellen auf die neue Migration
  umgeleitet (TenderEmailConfig/TenderNotificationPref/TenderSavedSearch/
  TenderTriage/TenderRssFeedSource in runTendersAreaChecks, SearchProvider in
  runSearchProviderAreaChecks/runDashboardAreaChecks, DashboardLayout/
  WidgetInstance, CalendarSource/FavoriteLink samt regelstand-eindeutig-Gates).
  SearchProvider/TenderRssFeedSource jetzt mit extractAllPolicySql (4 Regeln).
  runUserDimensionChecks() um die uebrigen neun Tabellen erweitert (neue
  Routine runCommandSeparatedPersonalTableCheck fuer die zwei NULL-faehigen
  Tabellen inkl. gemeinsame-Zeile-Pruefungen).
- Sechs Loch-Pruefungen umgedreht (dashboardlayout, widgetinstance,
  searchprovider, calendarsource, favoritelink-Doppelaussage getrennt) —
  alte Messung ohne Benutzer bleibt unter neuem Namen, Umkehrung MIT
  Benutzer erwartet das Gegenteil; kein alter Name mehr als Kennung.
- Baseline: 1020/62 Tests weiterhin gruen, Typpruefung sauber, Werkzeug
  203/203 bestanden (vorher 146).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-11 17:39:17 +02:00

134 lines
5.4 KiB
TypeScript

import { ConflictException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
/**
* 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 ADDITIONALLY scoped
* by userId, exactly the `FavoritesService` convention (T-08-06). This
* stays deliberate belt-and-suspenders after binding to `forTenant()`
* (260909-laa): the delivered `tenant_isolation_policy` on TenderTriage
* has no user dimension — a foreign user of the SAME tenant is not
* excluded by the database alone (Befund E, measured for the sibling
* TenderSavedSearch policy in Aufgabe 1; all five policies of this area
* share the identical `"tenantId" = current_tenant_id()` text).
*
* Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 traegt
* die Regel auf TenderTriage die Benutzerdimension (`current_user_id() IS
* NULL OR "userId" = current_user_id()`) — alle drei `forTenant()`-Aufrufe
* unten reichen `userId` als drittes Argument durch. Die anwendungsseitige
* userId-Filterung bleibt zweites Netz, kein Ersatz.
*
* 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.
*
* Gegenrichtung der Bindung (Befund F, 260909-laa): der Eindeutigkeits-
* schluessel `@@unique([userId, tenderId])` traegt KEINE Mandanten-
* dimension. Ist die vorhandene Zeile unter dem gebundenen Kontext
* unsichtbar (veralteter Mandant in der Sitzung), findet das `upsert`
* sie nicht, versucht anzulegen und laeuft in die Eindeutigkeits-
* verletzung — aus stillem Ueberschreiben wird ein harter Fehler. Der
* `P2002`-Zweig uebersetzt das in eine verstaendliche deutsche Meldung
* statt in einen 500, wie in `tender-saved-search.service.ts`. Gemessen
* in `rls-scratch-check.mjs`, Pruefung
* `tendertriage-einfuegen-auf-unsichtbare-zeile-verletzt-eindeutigkeit`.
*/
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;
}
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
try {
return await tenantPrisma.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,
},
});
} catch (error: any) {
if (error?.code === 'P2002') {
throw new ConflictException(
'Der Bearbeitungsstand zu dieser Ausschreibung konnte nicht gespeichert werden. Bitte die Seite neu laden und es erneut versuchen.',
);
}
throw error;
}
}
/**
* 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) — deliberately BEFORE forTenant() is created, so an empty
* batch never even opens a bound client.
*/
async listForUser(userId: string, tenantId: string, tenderIds: string[]) {
if (!tenderIds.length) return [];
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
return tenantPrisma.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, tenantId: string): Promise<string[]> {
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const rows = await tenantPrisma.tenderTriage.findMany({
where: { userId, isFavorite: true },
select: { tenderId: true },
});
return rows.map((r: { tenderId: string }) => r.tenderId);
}
}