feat(11-06): saved-searches CRUD routes on TendersController (FILTER-06)

Adds GET/POST /saved-searches and PATCH/DELETE /saved-searches/:searchId,
registers TenderSavedSearchService as a module provider, and wires it into
the controller via extractTriageContext (userId/tenantId from the auth
context, never the body/query — T-11-14/V4 IDOR). Static saved-searches
routes are declared before @Get(':id') (Pitfall 5/T-11-16); mutation routes
use :searchId to avoid ambiguity with the Tender :id param.

Also fixes a Prisma InputJsonValue type mismatch in
TenderSavedSearchService (Rule 1 — caught by tsc --noEmit, same cast
convention as dashboard.service.ts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 16:48:00 +02:00
parent df94d921ef
commit 38fbf35c75
4 changed files with 294 additions and 18 deletions
@@ -1,10 +1,13 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
NotFoundException,
Param,
Patch,
Post,
Put,
Query,
Req,
@@ -14,9 +17,11 @@ 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 { 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 { 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';
@@ -52,6 +57,7 @@ export class TendersController {
private readonly prisma: PrismaService,
private readonly tenderScheduler: TenderSchedulerService,
private readonly tenderTriage: TenderTriageService,
private readonly tenderSavedSearch: TenderSavedSearchService,
) {}
/**
@@ -227,6 +233,74 @@ export class TendersController {
});
}
// ─── 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 };
}
/**
* GET /modules/tender-radar/:id — single tender detail.
* Gated by @UseModule('tender-radar'); NOT scoped by the tenant's id