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
+13 -3
View File
@@ -24,9 +24,6 @@ import {
* SORT_MAP, never from a raw user-supplied field name. * SORT_MAP, never from a raw user-supplied field name.
* *
* Used for: GET /modules/tender-radar?page=1&limit=20&status=active&q=... * Used for: GET /modules/tender-radar?page=1&limit=20&status=active&q=...
*
* favOnly filter is added in a later Phase-11 plan (11-05) — deliberately
* NOT added here.
*/ */
export class TenderQueryDto { export class TenderQueryDto {
/** /**
@@ -166,4 +163,17 @@ export class TenderQueryDto {
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
cpv?: string[]; cpv?: string[];
/**
* Merklisten-Filter (UI-04, D-10, T-11-10/11). When true, the controller
* resolves the current user's favorited tenderIds via
* `TenderTriageService.favoriteIds(userId)` — derived from the auth
* context, never from this DTO — and passes them into
* `buildTenderWhere(dto, favIds)`. userId itself never appears here
* (V4 / IDOR).
*/
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
favOnly?: boolean;
} }
@@ -0,0 +1,27 @@
import { Type } from 'class-transformer';
import { IsBoolean, IsOptional, IsUUID } from 'class-validator';
/**
* Body DTO for PUT /modules/tender-radar/triage.
*
* Security (T-11-10 / V4 — IDOR): this DTO deliberately has NO userId or
* tenantId field — both are always derived server-side from the auth
* context (@CurrentUser-equivalent `req.user`/`req.tenantId`, same
* FavoritesController.extractContext pattern) in TendersController, never
* trusted from the request body.
*/
export class TenderTriageDto {
/** Target tender's id — Tender.id is a `@default(uuid())` string. */
@IsUUID()
tenderId!: string;
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
isRead?: boolean;
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
isFavorite?: boolean;
}
@@ -206,6 +206,56 @@ describe('buildTenderWhere', () => {
}); });
}); });
describe('buildTenderWhere — favOnly (UI-04, D-10, T-11-10/11)', () => {
it('favOnly=true with favIds supplied: adds an id-in constraint containing exactly those ids', () => {
const where = buildTenderWhere(dto({ favOnly: true }), ['t1', 't2']);
expect(where.AND).toEqual(
expect.arrayContaining([{ id: { in: ['t1', 't2'] } }]),
);
});
it('favOnly=true with empty favIds: yields a guaranteed-empty match, never "all tenders"', () => {
const where = buildTenderWhere(dto({ favOnly: true }), []);
expect(where.AND).toEqual(
expect.arrayContaining([{ id: { in: ['__none__'] } }]),
);
});
it('favOnly=true with favIds omitted entirely: also yields a guaranteed-empty match', () => {
const where = buildTenderWhere(dto({ favOnly: true }));
expect(where.AND).toEqual(
expect.arrayContaining([{ id: { in: ['__none__'] } }]),
);
});
it('favOnly unset: never adds an id-in constraint, regardless of favIds', () => {
const where = buildTenderWhere(dto(), ['t1', 't2']);
const and = (where.AND as unknown[]) ?? [];
const hasFavClause = and.some(
(clause) =>
typeof clause === 'object' &&
clause !== null &&
'id' in (clause as Record<string, unknown>),
);
expect(hasFavClause).toBe(false);
});
it('favOnly=true with more favIds than MAX_FAV_IDS: the in-list is capped (T-11-11 DoS)', () => {
const manyIds = Array.from({ length: 600 }, (_, i) => `t${i}`);
const where = buildTenderWhere(dto({ favOnly: true }), manyIds);
const and = (where.AND as Array<Record<string, unknown>>) ?? [];
const favClause = and.find((c) => 'id' in c) as
| { id: { in: string[] } }
| undefined;
expect(favClause?.id.in.length).toBeLessThanOrEqual(500);
});
});
describe('buildOrderBy', () => { describe('buildOrderBy', () => {
it('sort=deadline maps to { deadlineAt: asc }', () => { it('sort=deadline maps to { deadlineAt: asc }', () => {
expect(buildOrderBy('deadline')).toEqual({ deadlineAt: 'asc' }); expect(buildOrderBy('deadline')).toEqual({ deadlineAt: 'asc' });
+29 -1
View File
@@ -13,12 +13,29 @@ import type { TenderQueryDto } from './dto/tender-query.dto';
* orderBy key. * orderBy key.
*/ */
/**
* T-11-11 (DoS): bounds the favOnly `id: { in: [...] }` list — a user's
* own favorites are already implicitly bounded by their behavior, but a
* hard cap keeps the generated query's IN-list size predictable
* regardless of how many rows accumulate over time.
*/
const MAX_FAV_IDS = 500;
/** /**
* buildTenderWhere — conditional AND-composition. Empty DTO fields never * buildTenderWhere — conditional AND-composition. Empty DTO fields never
* add a constraint; every non-empty field is a correctness/security * add a constraint; every non-empty field is a correctness/security
* requirement documented inline (D-01/04/05). * requirement documented inline (D-01/04/05).
*
* `favIds` (UI-04, T-11-10): the current user's favorited tenderIds,
* resolved by the CALLER (TendersController, via
* `TenderTriageService.favoriteIds(userId)`) from the auth context —
* never accepted here as user input. Only consulted when `dto.favOnly` is
* true.
*/ */
export function buildTenderWhere(dto: TenderQueryDto): Prisma.TenderWhereInput { export function buildTenderWhere(
dto: TenderQueryDto,
favIds?: string[],
): Prisma.TenderWhereInput {
const where: Prisma.TenderWhereInput = {}; const where: Prisma.TenderWhereInput = {};
const AND: Prisma.TenderWhereInput[] = []; const AND: Prisma.TenderWhereInput[] = [];
@@ -98,6 +115,17 @@ export function buildTenderWhere(dto: TenderQueryDto): Prisma.TenderWhereInput {
AND.push({ cpvDivisions: { hasSome: dto.cpv } }); AND.push({ cpvDivisions: { hasSome: dto.cpv } });
} }
// UI-04 / D-10 (Merklisten-Filter, Pflichtkriterium): favOnly restricts
// the result to the current user's favorited tenders. Empty favIds (no
// favorites yet, or favIds not supplied) MUST yield ZERO matches, never
// "all tenders" — `'__none__'` is a sentinel that can never equal a real
// Tender.id (uuid), so `{ in: ['__none__'] }` is a guaranteed-empty
// match rather than an accidentally-unconstrained query.
if (dto.favOnly) {
const ids = (favIds ?? []).slice(0, MAX_FAV_IDS);
AND.push({ id: { in: ids.length ? ids : ['__none__'] } });
}
if (AND.length) where.AND = AND; if (AND.length) where.AND = AND;
return where; return where;
} }
+149 -9
View File
@@ -55,11 +55,30 @@ function makeFakePrisma() {
}; };
} }
/**
* Fake TenderTriageService for controller-level wiring tests (Plan 11-05,
* Task 2). Default stubs return empty results — individual tests override
* via `.mockResolvedValueOnce`/reassigning the mock as needed.
*/
function makeFakeTriageService() {
return {
favoriteIds: vi.fn(async (_userId: string) => [] as string[]),
listForUser: vi.fn(async (_userId: string, _tenderIds: string[]) => [] as any[]),
setTriage: vi.fn(async (_userId: string, _tenantId: string, _tenderId: string, _dto: any) => ({})),
};
}
/** Minimal authenticated Express Request fake (userId/tenantId only). */
function makeFakeRequest(userId = 'u1', tenantId = 'tenant1') {
return { user: { id: userId, tenantId }, tenantId } as any;
}
describe('TendersController — global read (not tenant-scoped)', () => { describe('TendersController — global read (not tenant-scoped)', () => {
it('GET / calls prisma.tender.findMany with a where clause that has no tenantId key', async () => { it('GET / calls prisma.tender.findMany with a where clause that has no tenantId key', async () => {
const prisma = makeFakePrisma(); const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const controller = new TendersController(prisma as any, scheduler); const triageService = makeFakeTriageService();
const controller = new TendersController(prisma as any, scheduler, triageService as any);
await controller.listTenders({}); await controller.listTenders({});
@@ -71,7 +90,8 @@ describe('TendersController — global read (not tenant-scoped)', () => {
it('GET /:id returns the tender when found and never scopes by tenant', async () => { it('GET /:id returns the tender when found and never scopes by tenant', async () => {
const prisma = makeFakePrisma(); const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const controller = new TendersController(prisma as any, scheduler); const triageService = makeFakeTriageService();
const controller = new TendersController(prisma as any, scheduler, triageService as any);
const result = await controller.getTender('t1'); const result = await controller.getTender('t1');
@@ -83,7 +103,8 @@ describe('TendersController — global read (not tenant-scoped)', () => {
it('GET /:id throws NotFoundException for a missing id', async () => { it('GET /:id throws NotFoundException for a missing id', async () => {
const prisma = makeFakePrisma(); const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const controller = new TendersController(prisma as any, scheduler); const triageService = makeFakeTriageService();
const controller = new TendersController(prisma as any, scheduler, triageService as any);
await expect(controller.getTender('missing')).rejects.toBeInstanceOf(NotFoundException); await expect(controller.getTender('missing')).rejects.toBeInstanceOf(NotFoundException);
}); });
@@ -93,7 +114,8 @@ describe('TendersController — admin source-config applies live to the schedule
it('PUT /source-config with isActive=true + pollIntervalMin=30 calls scheduler.setInterval(30) with a single argument', async () => { it('PUT /source-config with isActive=true + pollIntervalMin=30 calls scheduler.setInterval(30) with a single argument', async () => {
const prisma = makeFakePrisma(); const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const controller = new TendersController(prisma as any, scheduler); const triageService = makeFakeTriageService();
const controller = new TendersController(prisma as any, scheduler, triageService as any);
await controller.saveSourceConfig({ isActive: true, pollIntervalMin: 30 }); await controller.saveSourceConfig({ isActive: true, pollIntervalMin: 30 });
@@ -105,7 +127,8 @@ describe('TendersController — admin source-config applies live to the schedule
it('PUT /source-config with isActive=false calls scheduler.stopJob()', async () => { it('PUT /source-config with isActive=false calls scheduler.stopJob()', async () => {
const prisma = makeFakePrisma(); const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const controller = new TendersController(prisma as any, scheduler); const triageService = makeFakeTriageService();
const controller = new TendersController(prisma as any, scheduler, triageService as any);
await controller.saveSourceConfig({ isActive: false }); await controller.saveSourceConfig({ isActive: false });
@@ -141,13 +164,27 @@ describe('TendersController — route declaration order (static route before :id
expect(idIdx).toBeGreaterThanOrEqual(0); expect(idIdx).toBeGreaterThanOrEqual(0);
expect(coverageIdx).toBeLessThan(idIdx); expect(coverageIdx).toBeLessThan(idIdx);
}); });
it('declares listTriage and setTriage before getTender so GET /:id cannot shadow "triage" (Plan 11-05, Pitfall 5)', () => {
const methods = Object.getOwnPropertyNames(TendersController.prototype);
const listTriageIdx = methods.indexOf('listTriage');
const setTriageIdx = methods.indexOf('setTriage');
const idIdx = methods.indexOf('getTender');
expect(listTriageIdx).toBeGreaterThanOrEqual(0);
expect(setTriageIdx).toBeGreaterThanOrEqual(0);
expect(idIdx).toBeGreaterThanOrEqual(0);
expect(listTriageIdx).toBeLessThan(idIdx);
expect(setTriageIdx).toBeLessThan(idIdx);
});
}); });
describe('TendersController — listTenders uses the query builder (sort whitelist + pagination bounds)', () => { describe('TendersController — listTenders uses the query builder (sort whitelist + pagination bounds)', () => {
it('passes buildTenderWhere/buildOrderBy output through to prisma.tender.findMany', async () => { it('passes buildTenderWhere/buildOrderBy output through to prisma.tender.findMany', async () => {
const prisma = makeFakePrisma(); const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const controller = new TendersController(prisma as any, scheduler); const triageService = makeFakeTriageService();
const controller = new TendersController(prisma as any, scheduler, triageService as any);
await controller.listTenders({ sort: 'deadline', q: 'Bau' } as any); await controller.listTenders({ sort: 'deadline', q: 'Bau' } as any);
@@ -168,7 +205,8 @@ describe('TendersController — listTenders uses the query builder (sort whiteli
it('an unknown sort key falls back to the publishedAt-desc default via buildOrderBy', async () => { it('an unknown sort key falls back to the publishedAt-desc default via buildOrderBy', async () => {
const prisma = makeFakePrisma(); const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const controller = new TendersController(prisma as any, scheduler); const triageService = makeFakeTriageService();
const controller = new TendersController(prisma as any, scheduler, triageService as any);
await controller.listTenders({ sort: 'not-whitelisted' } as any); await controller.listTenders({ sort: 'not-whitelisted' } as any);
@@ -179,7 +217,8 @@ describe('TendersController — listTenders uses the query builder (sort whiteli
it('respects page/limit for skip/take (pagination bounds unchanged, T-10-15)', async () => { it('respects page/limit for skip/take (pagination bounds unchanged, T-10-15)', async () => {
const prisma = makeFakePrisma(); const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const controller = new TendersController(prisma as any, scheduler); const triageService = makeFakeTriageService();
const controller = new TendersController(prisma as any, scheduler, triageService as any);
await controller.listTenders({ page: 3, limit: 10 } as any); await controller.listTenders({ page: 3, limit: 10 } as any);
@@ -193,7 +232,8 @@ describe('TendersController — GET /coverage', () => {
it('returns distinct sourcePortal distribution and total for active tenders', async () => { it('returns distinct sourcePortal distribution and total for active tenders', async () => {
const prisma = makeFakePrisma(); const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const controller = new TendersController(prisma as any, scheduler); const triageService = makeFakeTriageService();
const controller = new TendersController(prisma as any, scheduler, triageService as any);
const result = await controller.getCoverage(); const result = await controller.getCoverage();
@@ -209,3 +249,103 @@ describe('TendersController — GET /coverage', () => {
}); });
}); });
}); });
describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () => {
it('parses the comma-separated ids param and delegates to tenderTriage.listForUser(userId, ids)', async () => {
const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const triageService = makeFakeTriageService();
const controller = new TendersController(prisma as any, scheduler, triageService as any);
await controller.listTriage('t1, t2 ,t3', makeFakeRequest('u1'));
expect(triageService.listForUser).toHaveBeenCalledWith('u1', ['t1', 't2', 't3']);
});
it('derives userId from req.user, never from the query string (V4 / IDOR)', async () => {
const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const triageService = makeFakeTriageService();
const controller = new TendersController(prisma as any, scheduler, triageService as any);
await controller.listTriage('t1', makeFakeRequest('u-real'));
expect(triageService.listForUser).toHaveBeenCalledWith('u-real', ['t1']);
});
it('caps the ids batch at MAX_TRIAGE_BATCH_IDS (T-11-11 DoS)', async () => {
const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const triageService = makeFakeTriageService();
const controller = new TendersController(prisma as any, scheduler, triageService as any);
const manyIds = Array.from({ length: 300 }, (_, i) => `t${i}`).join(',');
await controller.listTriage(manyIds, makeFakeRequest('u1'));
const calledIds = triageService.listForUser.mock.calls[0][1];
expect(calledIds.length).toBeLessThanOrEqual(200);
});
});
describe('TendersController — PUT /triage (upsert, per-user, T-11-10)', () => {
it('delegates to tenderTriage.setTriage with userId/tenantId from the request context, not the body', async () => {
const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const triageService = makeFakeTriageService();
const controller = new TendersController(prisma as any, scheduler, triageService as any);
await controller.setTriage(
{ tenderId: 't1', isRead: true } as any,
makeFakeRequest('u1', 'tenant1'),
);
expect(triageService.setTriage).toHaveBeenCalledWith('u1', 'tenant1', 't1', {
isRead: true,
isFavorite: undefined,
});
});
});
describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)', () => {
it('favOnly=true resolves favoriteIds(userId) from the auth context and passes them into the where-builder', async () => {
const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const triageService = makeFakeTriageService();
triageService.favoriteIds.mockResolvedValueOnce(['t1']);
const controller = new TendersController(prisma as any, scheduler, triageService as any);
await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1'));
expect(triageService.favoriteIds).toHaveBeenCalledWith('u1');
const callArgs = prisma.tender.findMany.mock.calls[0][0];
expect(callArgs.where.AND).toEqual(
expect.arrayContaining([{ id: { in: ['t1'] } }]),
);
});
it('favOnly=true with no favorites yields a zero-match query, never the unfiltered catalog', async () => {
const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const triageService = makeFakeTriageService();
triageService.favoriteIds.mockResolvedValueOnce([]);
const controller = new TendersController(prisma as any, scheduler, triageService as any);
await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1'));
const callArgs = prisma.tender.findMany.mock.calls[0][0];
expect(callArgs.where.AND).toEqual(
expect.arrayContaining([{ id: { in: ['__none__'] } }]),
);
});
it('favOnly unset never calls tenderTriage.favoriteIds', async () => {
const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const triageService = makeFakeTriageService();
const controller = new TendersController(prisma as any, scheduler, triageService as any);
await controller.listTenders({} as any, makeFakeRequest('u1'));
expect(triageService.favoriteIds).not.toHaveBeenCalled();
});
});
+99 -2
View File
@@ -1,21 +1,32 @@
import { import {
Body, Body,
Controller, Controller,
ForbiddenException,
Get, Get,
NotFoundException, NotFoundException,
Param, Param,
Put, Put,
Query, Query,
Req,
} from '@nestjs/common'; } from '@nestjs/common';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
import { Request } from 'express';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { UseModule } from '../module-registry/module.guard'; import { UseModule } from '../module-registry/module.guard';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SourceConfigDto } from './dto/source-config.dto'; import { SourceConfigDto } from './dto/source-config.dto';
import { TenderQueryDto } from './dto/tender-query.dto'; import { TenderQueryDto } from './dto/tender-query.dto';
import { TenderTriageDto } from './dto/tender-triage.dto';
import { TenderSchedulerService } from './tender-scheduler.service'; import { TenderSchedulerService } from './tender-scheduler.service';
import { TenderTriageService } from './tender-triage.service';
import { buildOrderBy, buildTenderWhere } from './tender-query.builder'; 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'; const DOE_SOURCE_TYPE = 'doe-opendata';
/** /**
@@ -40,8 +51,29 @@ export class TendersController {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly tenderScheduler: TenderSchedulerService, 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) ──────────────────── // ─── 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). * where/orderBy logic is independently unit-testable (T-11-01/03).
* Pagination bounds (limit @Max(100), page @Min(1)) are unchanged * Pagination bounds (limit @Max(100), page @Min(1)) are unchanged
* (T-10-15, Don't Hand-Roll). * (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() @Get()
@UseModule('tender-radar') @UseModule('tender-radar')
async listTenders(@Query() query: TenderQueryDto) { async listTenders(@Query() query: TenderQueryDto, @Req() req?: Request) {
const page = query.page ?? 1; const page = query.page ?? 1;
const limit = query.limit ?? 20; const limit = query.limit ?? 20;
const skip = (page - 1) * limit; 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 orderBy = buildOrderBy(query.sort);
const [items, total] = await Promise.all([ 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. * GET /modules/tender-radar/:id — single tender detail.
* Gated by @UseModule('tender-radar'); NOT scoped by the tenant's id * Gated by @UseModule('tender-radar'); NOT scoped by the tenant's id
+4 -1
View File
@@ -7,6 +7,7 @@ import { seedTendersModule } from './tenders.seed';
import { TenderIngestionService } from './tender-ingestion.service'; import { TenderIngestionService } from './tender-ingestion.service';
import { TenderNormalizerService } from './tender-normalizer.service'; import { TenderNormalizerService } from './tender-normalizer.service';
import { TenderSchedulerService } from './tender-scheduler.service'; import { TenderSchedulerService } from './tender-scheduler.service';
import { TenderTriageService } from './tender-triage.service';
import { TendersController } from './tenders.controller'; import { TendersController } from './tenders.controller';
/** /**
@@ -20,7 +21,8 @@ import { TendersController } from './tenders.controller';
* D-05 retention) and the shared global scheduler. This plan (05) wires * D-05 retention) and the shared global scheduler. This plan (05) wires
* TendersController: the global (ModuleGuard-gated, not tenant-scoped) * TendersController: the global (ModuleGuard-gated, not tenant-scoped)
* read surface plus the Roles-guarded admin source-config routes that * read surface plus the Roles-guarded admin source-config routes that
* live-apply to TenderSchedulerService. * live-apply to TenderSchedulerService. Plan 05 adds TenderTriageService
* (per-user gelesen/ungelesen + Favorit, UI-03/04) as a further provider.
* *
* PrismaModule is global (no explicit import needed). * PrismaModule is global (no explicit import needed).
* *
@@ -35,6 +37,7 @@ import { TendersController } from './tenders.controller';
TenderNormalizerService, TenderNormalizerService,
TenderIngestionService, TenderIngestionService,
TenderSchedulerService, TenderSchedulerService,
TenderTriageService,
], ],
}) })
export class TendersModule implements OnModuleInit { export class TendersModule implements OnModuleInit {