feat(11-05): GET/PUT /modules/tender-radar/triage routes + favOnly filter

Adds the batch-triage read/write routes (declared before @Get(':id') per
the route-order pitfall, T-11-13) and wires them through
TenderTriageService with userId/tenantId always derived from the request
context, never the body (T-11-10 / V4 IDOR). Extends TenderQueryDto/
buildTenderWhere with favOnly (UI-04): the controller resolves the
current user's favorited tenderIds server-side before building the
where-clause, and an empty favorites list yields zero matches instead of
the unfiltered catalog. Both batch-ids and favIds in-lists are bounded
(T-11-11 DoS). tenders.controller.spec.ts constructor calls updated for
the new TenderTriageService dependency (Rule 3 — required to keep the
existing suite compiling/passing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 16:33:40 +02:00
parent 58b0f3da50
commit 5f97eca804
7 changed files with 371 additions and 16 deletions
+99 -2
View File
@@ -1,21 +1,32 @@
import {
Body,
Controller,
ForbiddenException,
Get,
NotFoundException,
Param,
Put,
Query,
Req,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Request } from 'express';
import { Roles } from '../auth/decorators/roles.decorator';
import { UseModule } from '../module-registry/module.guard';
import { PrismaService } from '../prisma/prisma.service';
import { SourceConfigDto } from './dto/source-config.dto';
import { TenderQueryDto } from './dto/tender-query.dto';
import { TenderTriageDto } from './dto/tender-triage.dto';
import { TenderSchedulerService } from './tender-scheduler.service';
import { TenderTriageService } from './tender-triage.service';
import { buildOrderBy, buildTenderWhere } from './tender-query.builder';
/**
* T-11-11 (DoS): bounds the `ids` batch-triage query param — same
* defensive intent as MAX_FAV_IDS in tender-query.builder.ts.
*/
const MAX_TRIAGE_BATCH_IDS = 200;
const DOE_SOURCE_TYPE = 'doe-opendata';
/**
@@ -40,8 +51,29 @@ export class TendersController {
constructor(
private readonly prisma: PrismaService,
private readonly tenderScheduler: TenderSchedulerService,
private readonly tenderTriage: TenderTriageService,
) {}
/**
* Extracts (userId, tenantId) for the per-user Triage routes — same
* pattern as FavoritesController.extractContext (T-08-06): userId/
* tenantId are ALWAYS read from the authenticated request context, never
* from a client-supplied body/query field (T-11-10 / V4 — IDOR).
*/
private extractTriageContext(req: Request) {
const userId = (req as any).user?.id;
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
}
if (!userId) {
throw new ForbiddenException('No user context');
}
return { userId, tenantId };
}
// ─── Global read (ModuleGuard-gated, NOT tenant-scoped) ────────────────────
/**
@@ -55,15 +87,31 @@ export class TendersController {
* where/orderBy logic is independently unit-testable (T-11-01/03).
* Pagination bounds (limit @Max(100), page @Min(1)) are unchanged
* (T-10-15, Don't Hand-Roll).
*
* favOnly (UI-04, T-11-10): when set, this user's favorited tenderIds
* are resolved server-side via TenderTriageService.favoriteIds(userId)
* — derived from the auth context, NOT from the query string — and
* passed into buildTenderWhere so an empty favorites list yields zero
* matches rather than the unfiltered catalog.
*/
@Get()
@UseModule('tender-radar')
async listTenders(@Query() query: TenderQueryDto) {
async listTenders(@Query() query: TenderQueryDto, @Req() req?: Request) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const where = buildTenderWhere(query);
let favIds: string[] | undefined;
if (query.favOnly) {
// req is always present in production (NestJS @Req() DI) — the
// optional type only accommodates unit tests that call this method
// directly without favOnly set (T-11-10: extractTriageContext
// throws ForbiddenException if req/user context is genuinely absent).
const { userId } = this.extractTriageContext(req as Request);
favIds = await this.tenderTriage.favoriteIds(userId);
}
const where = buildTenderWhere(query, favIds);
const orderBy = buildOrderBy(query.sort);
const [items, total] = await Promise.all([
@@ -130,6 +178,55 @@ export class TendersController {
};
}
/**
* GET /modules/tender-radar/triage?ids=<csv> — batch-fetch the current
* user's triage state (gelesen/ungelesen, Favorit) for the given
* tenderIds (UI-03/04). Used by the Trefferliste to merge triage state
* into the visible page in one round-trip instead of per-row requests.
*
* MUST be declared before `@Get(':id')` below — same route-order
* pitfall as `source-config`/`coverage` above (Pitfall 5).
*
* Scoped strictly by userId (T-11-10 / V4 — IDOR): userId is derived
* from the auth context, never from `ids`. `ids` is a client-supplied
* comma-separated list of tenderIds to look up — bounded to
* MAX_TRIAGE_BATCH_IDS entries (T-11-11, DoS).
*/
@Get('triage')
@UseModule('tender-radar')
async listTriage(@Query('ids') ids: string | undefined, @Req() req: Request) {
const { userId } = this.extractTriageContext(req);
const tenderIds = (ids ?? '')
.split(',')
.map((id) => id.trim())
.filter(Boolean)
.slice(0, MAX_TRIAGE_BATCH_IDS);
return this.tenderTriage.listForUser(userId, tenderIds);
}
/**
* PUT /modules/tender-radar/triage — upsert the current user's triage
* state (isRead/isFavorite) for one tender (UI-03/04). Idempotent
* (TenderTriageService.setTriage upserts on @@unique([userId,tenderId])).
*
* MUST be declared before `@Get(':id')` below (Pitfall 5).
*
* userId/tenantId come exclusively from the auth context — `dto` (body)
* carries only `tenderId`/`isRead`/`isFavorite`, never a userId field
* (T-11-10 / V4 — IDOR).
*/
@Put('triage')
@UseModule('tender-radar')
async setTriage(@Body() dto: TenderTriageDto, @Req() req: Request) {
const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderTriage.setTriage(userId, tenantId, dto.tenderId, {
isRead: dto.isRead,
isFavorite: dto.isFavorite,
});
}
/**
* GET /modules/tender-radar/:id — single tender detail.
* Gated by @UseModule('tender-radar'); NOT scoped by the tenant's id