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,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<string, unknown> = {};
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({
+209 -16
View File
@@ -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'));
@@ -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
+6
View File
@@ -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 {