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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user