import { Body, Controller, Delete, ForbiddenException, Get, NotFoundException, Param, Patch, Post, 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 { UpdateNotificationPrefDto } from './dto/notification-pref.dto'; import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.dto'; import { SourceConfigDto } from './dto/source-config.dto'; import { TenderQueryDto } from './dto/tender-query.dto'; import { TenderTriageDto } from './dto/tender-triage.dto'; import { TenderNotificationPrefService } from './tender-notification-pref.service'; import { TenderSavedSearchService } from './tender-saved-search.service'; 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'; /** * TendersController — `/modules/tender-radar/*` routes. * * Deliberate structural divergence from DkvController (RESEARCH.md V4, * PATTERNS.md): `GET /` and `GET /:id` read the GLOBAL `Tender` catalog, * gated only by `@UseModule('tender-radar')` (module activation) — NEVER * row-scoped by the tenant's id. The tender catalog is platform-wide data * (D-03); "tenant-gated" (can this tenant see the feature at all) and * "tenant-scoped" (filter rows by tenant) are genuinely different things * here, unlike every other module in this codebase. * * Admin source-config routes (`GET`/`PUT /source-config`) are, like * DkvController, per-handler `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`-guarded * (T-10-13) and are NOT gated by @UseModule — an admin configuring the * shared platform-wide poll schedule is a platform-admin action, not a * per-tenant module feature. */ @Controller('modules/tender-radar') export class TendersController { constructor( private readonly prisma: PrismaService, private readonly tenderScheduler: TenderSchedulerService, private readonly tenderTriage: TenderTriageService, private readonly tenderSavedSearch: TenderSavedSearchService, private readonly tenderNotificationPref: TenderNotificationPrefService, ) {} /** * 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) ──────────────────── /** * GET /modules/tender-radar — paginated, filtered + sorted global * tender catalog. Gated by @UseModule('tender-radar'): only tenants * with the module active can read. Deliberately NOT filtered by the * tenant's id — the catalog is platform-global (D-03). * * Filter/sort composition is delegated to tender-query.builder.ts * (buildTenderWhere/buildOrderBy) — kept out of the controller so the * 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, @Req() req?: Request) { const page = query.page ?? 1; const limit = query.limit ?? 20; const skip = (page - 1) * limit; 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([ this.prisma.tender.findMany({ where, orderBy, skip, take: limit, }), this.prisma.tender.count({ where }), ]); return { items, total, page, limit }; } /** * GET /modules/tender-radar/source-config — read the singleton * doe-opendata poll config. * * MUST be declared before the `:id` route below — NestJS matches routes * in declaration order, so a `@Get(':id')` placed first would capture * "source-config" as an id and shadow this handler (404 on GET). */ @Get('source-config') @Roles(Role.ADMIN, Role.SUPER_ADMIN) async getSourceConfig() { const config = await this.prisma.tenderSourcePollConfig.findUnique({ where: { sourceType: DOE_SOURCE_TYPE }, }); if (!config) { throw new NotFoundException('Tender source config not yet seeded'); } return config; } /** * GET /modules/tender-radar/coverage — distribution of active tenders * by sourcePortal (D-12, UI-05). Feeds the frontend CoverageBanner so a * thin/single-source result list (currently only 'doe-opendata', * Oberschwelle-lastig) is not misread as a defect. * * MUST be declared before `@Get(':id')` below — same route-order * pitfall as `source-config` above (Pitfall 5, Phase-10 regression). * Read-surface, gated by @UseModule (not an admin-only route). */ @Get('coverage') @UseModule('tender-radar') async getCoverage() { const [bySource, total] = await Promise.all([ this.prisma.tender.groupBy({ by: ['sourcePortal'], _count: true, where: { status: 'active' }, }), this.prisma.tender.count({ where: { status: 'active' } }), ]); return { total, sources: bySource.map((s) => ({ sourcePortal: s.sourcePortal, count: s._count, })), }; } /** * GET /modules/tender-radar/triage?ids= — 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, }); } // ─── Saved Searches (per-user, FILTER-06, D-08/D-11) ─────────────────────── /** * GET /modules/tender-radar/saved-searches — list the current user's * saved search profiles (FILTER-06). Scoped strictly by userId * (T-11-14 / V4 — IDOR), derived from the auth context, never from a * query param. * * MUST be declared before `@Get(':id')` below — same route-order pitfall * as `source-config`/`coverage`/`triage` above (Pitfall 5, T-11-16). */ @Get('saved-searches') @UseModule('tender-radar') async listSavedSearches(@Req() req: Request) { const { userId } = this.extractTriageContext(req); return this.tenderSavedSearch.list(userId); } /** * POST /modules/tender-radar/saved-searches — create a new saved search * profile. userId/tenantId come exclusively from the auth context * (T-11-14 / V4 — IDOR); `dto` carries only name/filters, never a * userId field. */ @Post('saved-searches') @UseModule('tender-radar') async createSavedSearch( @Body() dto: CreateSavedSearchDto, @Req() req: Request, ) { const { userId, tenantId } = this.extractTriageContext(req); return this.tenderSavedSearch.create(userId, tenantId, dto); } /** * PATCH /modules/tender-radar/saved-searches/:searchId — rename and/or * update the filters of an existing saved search. Ownership is verified * in TenderSavedSearchService.update() (T-11-14). Uses `:searchId` * (not `:id`) so this route can never be confused with the Tender * `:id` param below (Pitfall 5). */ @Patch('saved-searches/:searchId') @UseModule('tender-radar') async updateSavedSearch( @Param('searchId') searchId: string, @Body() dto: UpdateSavedSearchDto, @Req() req: Request, ) { const { userId } = this.extractTriageContext(req); return this.tenderSavedSearch.update(searchId, userId, dto); } /** * DELETE /modules/tender-radar/saved-searches/:searchId — delete a saved * search. Ownership verified in TenderSavedSearchService.remove() * (T-11-14). */ @Delete('saved-searches/:searchId') @UseModule('tender-radar') async removeSavedSearch( @Param('searchId') searchId: string, @Req() req: Request, ) { const { userId } = this.extractTriageContext(req); await this.tenderSavedSearch.remove(searchId, userId); return { success: true }; } // ─── Notification preference (per-user, NOTIFY-01, D-01/D-03) ───────────── /** * GET /modules/tender-radar/notification-pref — this user's digest * interval preference (daily/weekly/off). Scoped strictly by userId * (T-12-14 / V4 — IDOR), derived from the auth context, never from a * query param. * * MUST be declared before `@Get(':id')` below — same route-order pitfall * as `source-config`/`coverage`/`triage`/`saved-searches` above * (Pitfall 5). */ @Get('notification-pref') @UseModule('tender-radar') async getNotificationPref(@Req() req: Request) { const { userId } = this.extractTriageContext(req); return this.tenderNotificationPref.getForUser(userId); } /** * PUT /modules/tender-radar/notification-pref — upsert this user's digest * interval preference. userId/tenantId come exclusively from the auth * context (T-12-14 / V4 — IDOR); `dto` carries only `digestInterval`, * never a userId field. */ @Put('notification-pref') @UseModule('tender-radar') async setNotificationPref( @Body() dto: UpdateNotificationPrefDto, @Req() req: Request, ) { const { userId, tenantId } = this.extractTriageContext(req); return this.tenderNotificationPref.setForUser(userId, tenantId, dto.digestInterval); } /** * GET /modules/tender-radar/:id — single tender detail. * Gated by @UseModule('tender-radar'); NOT scoped by the tenant's id * (global row). * * D-03/SCHEMA-03 (13-06): includes the `sources` relation (TenderSource * rows) so a cross-source-deduplicated tender surfaces links to ALL of * its source portals, not just the single primary `sourceUrl` column. * `select` is scoped to the three display fields the frontend needs — * no `id`/`createdAt` leak, same minimal-surface convention as * `getCoverage`'s groupBy projection. */ @Get(':id') @UseModule('tender-radar') async getTender(@Param('id') id: string) { const tender = await this.prisma.tender.findUnique({ where: { id }, include: { sources: { select: { sourcePortal: true, sourceUrl: true, sourceNoticeId: true }, }, }, }); if (!tender) { throw new NotFoundException('Tender not found'); } return tender; } // ─── Admin source-config (Roles-guarded, live scheduler apply) ──────────── // NOTE: GET /source-config is declared above the `:id` route (route-order // matters in NestJS). The PUT below is not shadowed — there is no @Put(':id'). /** * PUT /modules/tender-radar/source-config — upsert the singleton * doe-opendata poll config, then apply the change live to the scheduler * (INGEST-06: admin-configurable interval, no restart required). * * Divergence from DkvController.saveConfig: no tenant-id argument to * setInterval()/stopJob() — this config is a platform-wide singleton. */ @Put('source-config') @Roles(Role.ADMIN, Role.SUPER_ADMIN) async saveSourceConfig(@Body() dto: SourceConfigDto) { const result = await this.prisma.tenderSourcePollConfig.upsert({ where: { sourceType: DOE_SOURCE_TYPE }, update: { ...dto }, create: { sourceType: DOE_SOURCE_TYPE, pollIntervalMin: dto.pollIntervalMin ?? 60, isActive: dto.isActive ?? false, }, }); if (dto.isActive && dto.pollIntervalMin) { this.tenderScheduler.setInterval(dto.pollIntervalMin); } else if (dto.isActive === false) { this.tenderScheduler.stopJob(); } return result; } }