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:
@@ -24,9 +24,6 @@ import {
|
||||
* SORT_MAP, never from a raw user-supplied field name.
|
||||
*
|
||||
* 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 {
|
||||
/**
|
||||
@@ -166,4 +163,17 @@ export class TenderQueryDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
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', () => {
|
||||
it('sort=deadline maps to { deadlineAt: asc }', () => {
|
||||
expect(buildOrderBy('deadline')).toEqual({ deadlineAt: 'asc' });
|
||||
|
||||
@@ -13,12 +13,29 @@ import type { TenderQueryDto } from './dto/tender-query.dto';
|
||||
* 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
|
||||
* add a constraint; every non-empty field is a correctness/security
|
||||
* 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 AND: Prisma.TenderWhereInput[] = [];
|
||||
|
||||
@@ -98,6 +115,17 @@ export function buildTenderWhere(dto: TenderQueryDto): Prisma.TenderWhereInput {
|
||||
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;
|
||||
return where;
|
||||
}
|
||||
|
||||
@@ -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)', () => {
|
||||
it('GET / calls prisma.tender.findMany with a where clause that has no tenantId key', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
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({});
|
||||
|
||||
@@ -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 () => {
|
||||
const prisma = makeFakePrisma();
|
||||
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');
|
||||
|
||||
@@ -83,7 +103,8 @@ describe('TendersController — global read (not tenant-scoped)', () => {
|
||||
it('GET /:id throws NotFoundException for a missing id', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
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);
|
||||
});
|
||||
@@ -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 () => {
|
||||
const prisma = makeFakePrisma();
|
||||
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 });
|
||||
|
||||
@@ -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 () => {
|
||||
const prisma = makeFakePrisma();
|
||||
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 });
|
||||
|
||||
@@ -141,13 +164,27 @@ describe('TendersController — route declaration order (static route before :id
|
||||
expect(idIdx).toBeGreaterThanOrEqual(0);
|
||||
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)', () => {
|
||||
it('passes buildTenderWhere/buildOrderBy output through to prisma.tender.findMany', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
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);
|
||||
|
||||
@@ -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 () => {
|
||||
const prisma = makeFakePrisma();
|
||||
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);
|
||||
|
||||
@@ -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 () => {
|
||||
const prisma = makeFakePrisma();
|
||||
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);
|
||||
|
||||
@@ -193,7 +232,8 @@ describe('TendersController — GET /coverage', () => {
|
||||
it('returns distinct sourcePortal distribution and total for active tenders', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,7 @@ import { seedTendersModule } from './tenders.seed';
|
||||
import { TenderIngestionService } from './tender-ingestion.service';
|
||||
import { TenderNormalizerService } from './tender-normalizer.service';
|
||||
import { TenderSchedulerService } from './tender-scheduler.service';
|
||||
import { TenderTriageService } from './tender-triage.service';
|
||||
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
|
||||
* TendersController: the global (ModuleGuard-gated, not tenant-scoped)
|
||||
* 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).
|
||||
*
|
||||
@@ -35,6 +37,7 @@ import { TendersController } from './tenders.controller';
|
||||
TenderNormalizerService,
|
||||
TenderIngestionService,
|
||||
TenderSchedulerService,
|
||||
TenderTriageService,
|
||||
],
|
||||
})
|
||||
export class TendersModule implements OnModuleInit {
|
||||
|
||||
Reference in New Issue
Block a user