From 38fbf35c754657203e9171d6aadd2f3059100716 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 21 Jul 2026 16:48:00 +0200 Subject: [PATCH] feat(11-06): saved-searches CRUD routes on TendersController (FILTER-06) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../tenders/tender-saved-search.service.ts | 7 +- .../src/tenders/tenders.controller.spec.ts | 225 ++++++++++++++++-- apps/api/src/tenders/tenders.controller.ts | 74 ++++++ apps/api/src/tenders/tenders.module.ts | 6 + 4 files changed, 294 insertions(+), 18 deletions(-) diff --git a/apps/api/src/tenders/tender-saved-search.service.ts b/apps/api/src/tenders/tender-saved-search.service.ts index 721b459..13aa459 100644 --- a/apps/api/src/tenders/tender-saved-search.service.ts +++ b/apps/api/src/tenders/tender-saved-search.service.ts @@ -1,4 +1,5 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.dto'; @@ -45,7 +46,7 @@ export class TenderSavedSearchService { userId, tenantId, name: dto.name, - filters: dto.filters, + filters: dto.filters as unknown as Prisma.InputJsonValue, }, }); } catch (error: any) { @@ -76,7 +77,9 @@ export class TenderSavedSearchService { const data: Record = {}; if (dto.name !== undefined) data.name = dto.name; - if (dto.filters !== undefined) data.filters = dto.filters; + if (dto.filters !== undefined) { + data.filters = dto.filters as unknown as Prisma.InputJsonValue; + } try { return await this.prisma.tenderSavedSearch.update({ diff --git a/apps/api/src/tenders/tenders.controller.spec.ts b/apps/api/src/tenders/tenders.controller.spec.ts index 2fd8816..fcd76f4 100644 --- a/apps/api/src/tenders/tenders.controller.spec.ts +++ b/apps/api/src/tenders/tenders.controller.spec.ts @@ -73,12 +73,37 @@ function makeFakeRequest(userId = 'u1', tenantId = 'tenant1') { return { user: { id: userId, tenantId }, tenantId } as any; } +/** + * Fake TenderSavedSearchService for controller-level wiring tests (Plan + * 11-06, Task 2). Default stubs return empty/echo results — individual + * tests override via `.mockResolvedValueOnce`/reassigning the mock. + */ +function makeFakeSavedSearchService() { + return { + list: vi.fn(async (_userId: string) => [] as any[]), + create: vi.fn(async (_userId: string, _tenantId: string, dto: any) => ({ + id: 'ss-1', + ...dto, + })), + update: vi.fn(async (_id: string, _userId: string, dto: any) => ({ + id: 'ss-1', + ...dto, + })), + remove: vi.fn(async (_id: string, _userId: string) => undefined), + }; +} + 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 triageService = makeFakeTriageService(); - const controller = new TendersController(prisma as any, scheduler, triageService as any); + const controller = new TendersController( + prisma as any, + scheduler, + triageService as any, + makeFakeSavedSearchService() as any, + ); await controller.listTenders({}); @@ -91,7 +116,12 @@ describe('TendersController — global read (not tenant-scoped)', () => { 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 controller = new TendersController( + prisma as any, + scheduler, + triageService as any, + makeFakeSavedSearchService() as any, + ); const result = await controller.getTender('t1'); @@ -104,7 +134,12 @@ describe('TendersController — global read (not tenant-scoped)', () => { 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 controller = new TendersController( + prisma as any, + scheduler, + triageService as any, + makeFakeSavedSearchService() as any, + ); await expect(controller.getTender('missing')).rejects.toBeInstanceOf(NotFoundException); }); @@ -115,7 +150,12 @@ describe('TendersController — admin source-config applies live to the schedule 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 controller = new TendersController( + prisma as any, + scheduler, + triageService as any, + makeFakeSavedSearchService() as any, + ); await controller.saveSourceConfig({ isActive: true, pollIntervalMin: 30 }); @@ -128,7 +168,12 @@ describe('TendersController — admin source-config applies live to the schedule 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 controller = new TendersController( + prisma as any, + scheduler, + triageService as any, + makeFakeSavedSearchService() as any, + ); await controller.saveSourceConfig({ isActive: false }); @@ -177,6 +222,99 @@ describe('TendersController — route declaration order (static route before :id expect(listTriageIdx).toBeLessThan(idIdx); expect(setTriageIdx).toBeLessThan(idIdx); }); + + it('declares listSavedSearches before getTender so GET /:id cannot shadow "saved-searches" (Plan 11-06, Pitfall 5/T-11-16)', () => { + const methods = Object.getOwnPropertyNames(TendersController.prototype); + const listSavedSearchesIdx = methods.indexOf('listSavedSearches'); + const idIdx = methods.indexOf('getTender'); + + expect(listSavedSearchesIdx).toBeGreaterThanOrEqual(0); + expect(idIdx).toBeGreaterThanOrEqual(0); + expect(listSavedSearchesIdx).toBeLessThan(idIdx); + }); +}); + +describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-14/15/16)', () => { + it('GET /saved-searches derives userId from req.user and delegates to tenderSavedSearch.list(userId) — never from a query param (V4 / IDOR)', async () => { + const prisma = makeFakePrisma(); + const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; + const triageService = makeFakeTriageService(); + const savedSearchService = makeFakeSavedSearchService(); + const controller = new TendersController( + prisma as any, + scheduler, + triageService as any, + savedSearchService as any, + ); + + await controller.listSavedSearches(makeFakeRequest('u-real')); + + expect(savedSearchService.list).toHaveBeenCalledWith('u-real'); + }); + + it('POST /saved-searches delegates to tenderSavedSearch.create with userId/tenantId from the auth context, not the body', async () => { + const prisma = makeFakePrisma(); + const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; + const triageService = makeFakeTriageService(); + const savedSearchService = makeFakeSavedSearchService(); + const controller = new TendersController( + prisma as any, + scheduler, + triageService as any, + savedSearchService as any, + ); + + await controller.createSavedSearch( + { name: 'Bau NRW', filters: { q: 'Bau' } } as any, + makeFakeRequest('u1', 'tenant1'), + ); + + expect(savedSearchService.create).toHaveBeenCalledWith('u1', 'tenant1', { + name: 'Bau NRW', + filters: { q: 'Bau' }, + }); + }); + + it('PATCH /saved-searches/:searchId delegates to tenderSavedSearch.update(searchId, userId, dto) — userId from the auth context', async () => { + const prisma = makeFakePrisma(); + const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; + const triageService = makeFakeTriageService(); + const savedSearchService = makeFakeSavedSearchService(); + const controller = new TendersController( + prisma as any, + scheduler, + triageService as any, + savedSearchService as any, + ); + + await controller.updateSavedSearch( + 'ss-1', + { name: 'Neuer Name' } as any, + makeFakeRequest('u1'), + ); + + expect(savedSearchService.update).toHaveBeenCalledWith('ss-1', 'u1', { + name: 'Neuer Name', + }); + }); + + it('DELETE /saved-searches/:searchId delegates to tenderSavedSearch.remove(searchId, userId) — userId from the auth context', async () => { + const prisma = makeFakePrisma(); + const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; + const triageService = makeFakeTriageService(); + const savedSearchService = makeFakeSavedSearchService(); + const controller = new TendersController( + prisma as any, + scheduler, + triageService as any, + savedSearchService as any, + ); + + const result = await controller.removeSavedSearch('ss-1', makeFakeRequest('u1')); + + expect(savedSearchService.remove).toHaveBeenCalledWith('ss-1', 'u1'); + expect(result).toEqual({ success: true }); + }); }); describe('TendersController — listTenders uses the query builder (sort whitelist + pagination bounds)', () => { @@ -184,7 +322,12 @@ describe('TendersController — listTenders uses the query builder (sort whiteli 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 controller = new TendersController( + prisma as any, + scheduler, + triageService as any, + makeFakeSavedSearchService() as any, + ); await controller.listTenders({ sort: 'deadline', q: 'Bau' } as any); @@ -206,7 +349,12 @@ describe('TendersController — listTenders uses the query builder (sort whiteli 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 controller = new TendersController( + prisma as any, + scheduler, + triageService as any, + makeFakeSavedSearchService() as any, + ); await controller.listTenders({ sort: 'not-whitelisted' } as any); @@ -218,7 +366,12 @@ describe('TendersController — listTenders uses the query builder (sort whiteli 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 controller = new TendersController( + prisma as any, + scheduler, + triageService as any, + makeFakeSavedSearchService() as any, + ); await controller.listTenders({ page: 3, limit: 10 } as any); @@ -233,7 +386,12 @@ describe('TendersController — GET /coverage', () => { 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 controller = new TendersController( + prisma as any, + scheduler, + triageService as any, + makeFakeSavedSearchService() as any, + ); const result = await controller.getCoverage(); @@ -255,7 +413,12 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () = 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 controller = new TendersController( + prisma as any, + scheduler, + triageService as any, + makeFakeSavedSearchService() as any, + ); await controller.listTriage('t1, t2 ,t3', makeFakeRequest('u1')); @@ -266,7 +429,12 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () = 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 controller = new TendersController( + prisma as any, + scheduler, + triageService as any, + makeFakeSavedSearchService() as any, + ); await controller.listTriage('t1', makeFakeRequest('u-real')); @@ -277,7 +445,12 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () = 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 controller = new TendersController( + prisma as any, + scheduler, + triageService as any, + makeFakeSavedSearchService() as any, + ); const manyIds = Array.from({ length: 300 }, (_, i) => `t${i}`).join(','); await controller.listTriage(manyIds, makeFakeRequest('u1')); @@ -292,7 +465,12 @@ describe('TendersController — PUT /triage (upsert, per-user, T-11-10)', () => 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 controller = new TendersController( + prisma as any, + scheduler, + triageService as any, + makeFakeSavedSearchService() as any, + ); await controller.setTriage( { tenderId: 't1', isRead: true } as any, @@ -312,7 +490,12 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)', 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); + const controller = new TendersController( + prisma as any, + scheduler, + triageService as any, + makeFakeSavedSearchService() as any, + ); await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1')); @@ -328,7 +511,12 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)', 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); + const controller = new TendersController( + prisma as any, + scheduler, + triageService as any, + makeFakeSavedSearchService() as any, + ); await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1')); @@ -342,7 +530,12 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)', 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 controller = new TendersController( + prisma as any, + scheduler, + triageService as any, + makeFakeSavedSearchService() as any, + ); await controller.listTenders({} as any, makeFakeRequest('u1')); diff --git a/apps/api/src/tenders/tenders.controller.ts b/apps/api/src/tenders/tenders.controller.ts index e5cb898..199bea0 100644 --- a/apps/api/src/tenders/tenders.controller.ts +++ b/apps/api/src/tenders/tenders.controller.ts @@ -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 diff --git a/apps/api/src/tenders/tenders.module.ts b/apps/api/src/tenders/tenders.module.ts index 5cb873d..a1bc9d1 100644 --- a/apps/api/src/tenders/tenders.module.ts +++ b/apps/api/src/tenders/tenders.module.ts @@ -6,6 +6,7 @@ import { DoeOpenDataAdapter } from './adapters/doe-opendata.adapter'; import { seedTendersModule } from './tenders.seed'; import { TenderIngestionService } from './tender-ingestion.service'; import { TenderNormalizerService } from './tender-normalizer.service'; +import { TenderSavedSearchService } from './tender-saved-search.service'; import { TenderSchedulerService } from './tender-scheduler.service'; import { TenderTriageService } from './tender-triage.service'; import { TendersController } from './tenders.controller'; @@ -26,6 +27,10 @@ import { TendersController } from './tenders.controller'; * * PrismaModule is global (no explicit import needed). * + * Plan 06 adds TenderSavedSearchService (per-user Suchprofile, FILTER-06) + * as a further provider — same userId-scoping convention as + * TenderTriageService, no forTenant()/RLS (Pitfall 4). + * * Seeds itself into the module registry on application startup via * OnModuleInit lifecycle hook — same pattern as DkvModule. */ @@ -38,6 +43,7 @@ import { TendersController } from './tenders.controller'; TenderIngestionService, TenderSchedulerService, TenderTriageService, + TenderSavedSearchService, ], }) export class TendersModule implements OnModuleInit {