diff --git a/apps/api/src/tenders/dto/tender-query.dto.ts b/apps/api/src/tenders/dto/tender-query.dto.ts index 5349f49..921e4a7 100644 --- a/apps/api/src/tenders/dto/tender-query.dto.ts +++ b/apps/api/src/tenders/dto/tender-query.dto.ts @@ -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; } diff --git a/apps/api/src/tenders/dto/tender-triage.dto.ts b/apps/api/src/tenders/dto/tender-triage.dto.ts new file mode 100644 index 0000000..5ead089 --- /dev/null +++ b/apps/api/src/tenders/dto/tender-triage.dto.ts @@ -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; +} diff --git a/apps/api/src/tenders/tender-query.builder.spec.ts b/apps/api/src/tenders/tender-query.builder.spec.ts index 8c44651..69af103 100644 --- a/apps/api/src/tenders/tender-query.builder.spec.ts +++ b/apps/api/src/tenders/tender-query.builder.spec.ts @@ -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), + ); + 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>) ?? []; + 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' }); diff --git a/apps/api/src/tenders/tender-query.builder.ts b/apps/api/src/tenders/tender-query.builder.ts index 49a37f0..3188aac 100644 --- a/apps/api/src/tenders/tender-query.builder.ts +++ b/apps/api/src/tenders/tender-query.builder.ts @@ -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; } diff --git a/apps/api/src/tenders/tenders.controller.spec.ts b/apps/api/src/tenders/tenders.controller.spec.ts index a50f933..2fd8816 100644 --- a/apps/api/src/tenders/tenders.controller.spec.ts +++ b/apps/api/src/tenders/tenders.controller.spec.ts @@ -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(); + }); +}); diff --git a/apps/api/src/tenders/tenders.controller.ts b/apps/api/src/tenders/tenders.controller.ts index 6f094ca..e5cb898 100644 --- a/apps/api/src/tenders/tenders.controller.ts +++ b/apps/api/src/tenders/tenders.controller.ts @@ -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= — 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 diff --git a/apps/api/src/tenders/tenders.module.ts b/apps/api/src/tenders/tenders.module.ts index 10b2422..5cb873d 100644 --- a/apps/api/src/tenders/tenders.module.ts +++ b/apps/api/src/tenders/tenders.module.ts @@ -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 {