From 9e7ba5353d1ff5d851268db7d2c6e4b1ea2d738c Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 22 Jul 2026 09:28:02 +0200 Subject: [PATCH] feat(12-04): tender-radar notification-pref service + routes, instantAlert passthrough - TenderNotificationPrefService: per-user digestInterval CRUD (default 'daily', upsert on @@unique userId, D-01/D-03) - UpdateNotificationPrefDto: @IsIn(['daily','weekly','off']) validation (V5) - GET/PUT /modules/tender-radar/notification-pref, declared before @Get(':id') (route-order pitfall) - instantAlert passthrough in Create/UpdateSavedSearchDto and TenderSavedSearchService.create/update (NOTIFY-02, D-04) - All pref/profile routes scoped strictly via extractTriageContext(req), never from body/query (T-12-14, IDOR) - Updated tenders.controller.spec.ts fakes for the new constructor param Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/tenders/dto/notification-pref.dto.ts | 19 ++++ apps/api/src/tenders/dto/saved-search.dto.ts | 16 +++- .../tender-notification-pref.service.spec.ts | 77 ++++++++++++++++ .../tender-notification-pref.service.ts | 53 +++++++++++ .../tender-saved-search.service.spec.ts | 41 +++++++++ .../tenders/tender-saved-search.service.ts | 2 + .../src/tenders/tenders.controller.spec.ts | 89 +++++++++++++++++++ apps/api/src/tenders/tenders.controller.ts | 38 ++++++++ apps/api/src/tenders/tenders.module.ts | 9 ++ 9 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/tenders/dto/notification-pref.dto.ts create mode 100644 apps/api/src/tenders/tender-notification-pref.service.spec.ts create mode 100644 apps/api/src/tenders/tender-notification-pref.service.ts diff --git a/apps/api/src/tenders/dto/notification-pref.dto.ts b/apps/api/src/tenders/dto/notification-pref.dto.ts new file mode 100644 index 0000000..f07fdc7 --- /dev/null +++ b/apps/api/src/tenders/dto/notification-pref.dto.ts @@ -0,0 +1,19 @@ +import { IsIn } from 'class-validator'; + +/** + * Body DTO for PUT /modules/tender-radar/notification-pref (NOTIFY-01, D-01/ + * D-03). + * + * Security (T-12-14 / V4 — IDOR): deliberately has NO userId or tenantId + * field — both are always derived server-side from the auth context + * (TendersController.extractTriageContext pattern), never trusted from the + * request body. Same convention as CreateSavedSearchDto. + * + * `digestInterval` is validated via @IsIn to exactly the three values the + * digest scheduler's due-check logic understands (T-12-15 / V5) — any other + * string is rejected by class-validator before it reaches the service. + */ +export class UpdateNotificationPrefDto { + @IsIn(['daily', 'weekly', 'off']) + digestInterval!: string; +} diff --git a/apps/api/src/tenders/dto/saved-search.dto.ts b/apps/api/src/tenders/dto/saved-search.dto.ts index 1846b0a..0e7d30a 100644 --- a/apps/api/src/tenders/dto/saved-search.dto.ts +++ b/apps/api/src/tenders/dto/saved-search.dto.ts @@ -1,4 +1,4 @@ -import { IsNotEmpty, IsObject, IsOptional, IsString, MaxLength } from 'class-validator'; +import { IsBoolean, IsNotEmpty, IsObject, IsOptional, IsString, MaxLength } from 'class-validator'; /** * Body DTO for POST /modules/tender-radar/saved-searches (FILTER-06). @@ -21,6 +21,15 @@ export class CreateSavedSearchDto { @IsObject() filters!: Record; + + /** + * Sofort-Alert-Toggle (NOTIFY-02, D-04) — default false when omitted + * (Prisma column default). Optional so existing callers that don't know + * about this field keep working unchanged. + */ + @IsOptional() + @IsBoolean() + instantAlert?: boolean; } /** @@ -38,4 +47,9 @@ export class UpdateSavedSearchDto { @IsOptional() @IsObject() filters?: Record; + + /** Sofort-Alert-Toggle (NOTIFY-02, D-04) — see CreateSavedSearchDto. */ + @IsOptional() + @IsBoolean() + instantAlert?: boolean; } diff --git a/apps/api/src/tenders/tender-notification-pref.service.spec.ts b/apps/api/src/tenders/tender-notification-pref.service.spec.ts new file mode 100644 index 0000000..f6f70f2 --- /dev/null +++ b/apps/api/src/tenders/tender-notification-pref.service.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { TenderNotificationPrefService } from './tender-notification-pref.service'; + +/** + * TenderNotificationPrefService.spec — RED-first (TDD) proof for NOTIFY-01 + * (D-01/D-03) and the V4/IDOR access-control invariant (T-12-14): + * + * - getForUser() without an existing row returns a default + * { digestInterval: 'daily' } (D-01) — no error, no implicit autowrite. + * - setForUser() upserts on the @@unique userId (D-03); a second call with + * a different value updates the SAME row rather than creating a new one. + * + * Uses the same hand-rolled prisma-shaped fake convention as + * tender-saved-search.service.spec.ts / tender-triage.service.spec.ts + * (in-memory Map, no live DB connection). + */ + +function makeFakePrisma() { + const rows = new Map(); + + return { + tenderNotificationPref: { + findUnique: async ({ where }: any) => rows.get(where.userId) ?? null, + upsert: async ({ where, create, update }: any) => { + const existing = rows.get(where.userId); + const record = existing + ? { ...existing, ...update, updatedAt: new Date() } + : { id: `pref-${rows.size + 1}`, ...create, createdAt: new Date(), updatedAt: new Date() }; + rows.set(where.userId, record); + return record; + }, + }, + }; +} + +describe('TenderNotificationPrefService', () => { + it('getForUser() returns a default digestInterval="daily" when no row exists (D-01)', async () => { + const prisma = makeFakePrisma(); + const service = new TenderNotificationPrefService(prisma as any); + + const result = await service.getForUser('u1'); + + expect(result.digestInterval).toBe('daily'); + }); + + it('setForUser() upserts on userId, creating a row scoped to (userId, tenantId) (D-03)', async () => { + const prisma = makeFakePrisma(); + const service = new TenderNotificationPrefService(prisma as any); + + const result = await service.setForUser('u1', 'tenant1', 'weekly'); + + expect(result.userId).toBe('u1'); + expect(result.tenantId).toBe('tenant1'); + expect(result.digestInterval).toBe('weekly'); + }); + + it('setForUser() called a second time updates the SAME row (@@unique userId), not a new one', async () => { + const prisma = makeFakePrisma(); + const service = new TenderNotificationPrefService(prisma as any); + + await service.setForUser('u1', 'tenant1', 'weekly'); + const second = await service.setForUser('u1', 'tenant1', 'off'); + + expect(second.digestInterval).toBe('off'); + expect(await service.getForUser('u1')).toMatchObject({ digestInterval: 'off' }); + }); + + it('getForUser() is scoped strictly by userId — a foreign userId never sees another user\'s pref (V4 / IDOR)', async () => { + const prisma = makeFakePrisma(); + const service = new TenderNotificationPrefService(prisma as any); + + await service.setForUser('u1', 'tenant1', 'weekly'); + + const foreign = await service.getForUser('u2'); + expect(foreign.digestInterval).toBe('daily'); // default, not u1's 'weekly' + }); +}); diff --git a/apps/api/src/tenders/tender-notification-pref.service.ts b/apps/api/src/tenders/tender-notification-pref.service.ts new file mode 100644 index 0000000..60bc7ec --- /dev/null +++ b/apps/api/src/tenders/tender-notification-pref.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +/** + * Service for managing the per-user Tender digest interval preference + * (NOTIFY-01, D-01/D-03). + * + * Access control (T-12-14 / V4 — IDOR): scoped by userId exactly like + * TenderSavedSearchService/TenderTriageService (T-11-14/T-08-06) — NOT + * forTenant()/RLS (Pitfall 4). userId must always be derived from the + * caller's auth context (controller), never accepted as a body/query + * parameter here. + * + * `TenderNotificationPref` has a per-user `@@unique` on `userId` (one row + * per user, D-03: the interval is a user setting, not per-profile) — this + * service upserts on that key. + */ +@Injectable() +export class TenderNotificationPrefService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Returns this user's digest interval preference. When no row exists yet + * (user has never touched the setting), returns the default + * `{ digestInterval: 'daily' }` (D-01) WITHOUT writing a row — consistent + * with the digest scheduler's own default-daily due-check semantics, no + * autowrite needed to represent "using the default". + */ + async getForUser(userId: string): Promise<{ digestInterval: string }> { + const existing = await this.prisma.tenderNotificationPref.findUnique({ + where: { userId }, + }); + + if (!existing) { + return { digestInterval: 'daily' }; + } + + return existing; + } + + /** + * Upserts this user's digest interval preference on the @@unique userId + * (D-03) — a second call for the same user updates the same row rather + * than creating a new one. + */ + async setForUser(userId: string, tenantId: string, digestInterval: string) { + return this.prisma.tenderNotificationPref.upsert({ + where: { userId }, + create: { userId, tenantId, digestInterval }, + update: { digestInterval }, + }); + } +} diff --git a/apps/api/src/tenders/tender-saved-search.service.spec.ts b/apps/api/src/tenders/tender-saved-search.service.spec.ts index 782589c..bbc931c 100644 --- a/apps/api/src/tenders/tender-saved-search.service.spec.ts +++ b/apps/api/src/tenders/tender-saved-search.service.spec.ts @@ -184,4 +184,45 @@ describe('TenderSavedSearchService', () => { expect(await service.list('u1')).toEqual([]); }); + + // --- instantAlert passthrough (NOTIFY-02, D-04) --------------------------- + + it('create() persists instantAlert=true when supplied', async () => { + const prisma = makeFakePrisma(); + const service = new TenderSavedSearchService(prisma as any); + + const result = await service.create('u1', 'tenant1', { + name: 'Bau NRW', + filters: {}, + instantAlert: true, + }); + + expect(result.instantAlert).toBe(true); + }); + + it('create() leaves instantAlert unset (falls back to the Prisma column default) when omitted', async () => { + const prisma = makeFakePrisma(); + const service = new TenderSavedSearchService(prisma as any); + + const result = await service.create('u1', 'tenant1', { + name: 'Bau NRW', + filters: {}, + }); + + expect(result.instantAlert).toBeUndefined(); + }); + + it('update() persists an instantAlert toggle for an owned profile', async () => { + const prisma = makeFakePrisma(); + const service = new TenderSavedSearchService(prisma as any); + + const created = await service.create('u1', 'tenant1', { + name: 'Alt', + filters: {}, + instantAlert: false, + }); + const updated = await service.update(created.id, 'u1', { instantAlert: true }); + + expect(updated.instantAlert).toBe(true); + }); }); diff --git a/apps/api/src/tenders/tender-saved-search.service.ts b/apps/api/src/tenders/tender-saved-search.service.ts index 13aa459..bff9a6f 100644 --- a/apps/api/src/tenders/tender-saved-search.service.ts +++ b/apps/api/src/tenders/tender-saved-search.service.ts @@ -47,6 +47,7 @@ export class TenderSavedSearchService { tenantId, name: dto.name, filters: dto.filters as unknown as Prisma.InputJsonValue, + ...(dto.instantAlert !== undefined && { instantAlert: dto.instantAlert }), }, }); } catch (error: any) { @@ -80,6 +81,7 @@ export class TenderSavedSearchService { if (dto.filters !== undefined) { data.filters = dto.filters as unknown as Prisma.InputJsonValue; } + if (dto.instantAlert !== undefined) data.instantAlert = dto.instantAlert; 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 fcd76f4..c9d41f6 100644 --- a/apps/api/src/tenders/tenders.controller.spec.ts +++ b/apps/api/src/tenders/tenders.controller.spec.ts @@ -93,6 +93,21 @@ function makeFakeSavedSearchService() { }; } +/** + * Fake TenderNotificationPrefService for controller-level wiring tests + * (Plan 12-04, NOTIFY-01/T-12-14). Default stubs return a daily default / + * echo the upserted value; individual tests override via + * `.mockResolvedValueOnce`/reassigning the mock. + */ +function makeFakeNotificationPrefService() { + return { + getForUser: vi.fn(async (_userId: string) => ({ digestInterval: 'daily' })), + setForUser: vi.fn(async (_userId: string, _tenantId: string, digestInterval: string) => ({ + digestInterval, + })), + }; +} + 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(); @@ -103,6 +118,7 @@ describe('TendersController — global read (not tenant-scoped)', () => { scheduler, triageService as any, makeFakeSavedSearchService() as any, + makeFakeNotificationPrefService() as any, ); await controller.listTenders({}); @@ -121,6 +137,7 @@ describe('TendersController — global read (not tenant-scoped)', () => { scheduler, triageService as any, makeFakeSavedSearchService() as any, + makeFakeNotificationPrefService() as any, ); const result = await controller.getTender('t1'); @@ -139,6 +156,7 @@ describe('TendersController — global read (not tenant-scoped)', () => { scheduler, triageService as any, makeFakeSavedSearchService() as any, + makeFakeNotificationPrefService() as any, ); await expect(controller.getTender('missing')).rejects.toBeInstanceOf(NotFoundException); @@ -155,6 +173,7 @@ describe('TendersController — admin source-config applies live to the schedule scheduler, triageService as any, makeFakeSavedSearchService() as any, + makeFakeNotificationPrefService() as any, ); await controller.saveSourceConfig({ isActive: true, pollIntervalMin: 30 }); @@ -173,6 +192,7 @@ describe('TendersController — admin source-config applies live to the schedule scheduler, triageService as any, makeFakeSavedSearchService() as any, + makeFakeNotificationPrefService() as any, ); await controller.saveSourceConfig({ isActive: false }); @@ -232,6 +252,60 @@ describe('TendersController — route declaration order (static route before :id expect(idIdx).toBeGreaterThanOrEqual(0); expect(listSavedSearchesIdx).toBeLessThan(idIdx); }); + + it('declares getNotificationPref/setNotificationPref before getTender so GET /:id cannot shadow "notification-pref" (Plan 12-04, Pitfall 5)', () => { + const methods = Object.getOwnPropertyNames(TendersController.prototype); + const getPrefIdx = methods.indexOf('getNotificationPref'); + const setPrefIdx = methods.indexOf('setNotificationPref'); + const idIdx = methods.indexOf('getTender'); + + expect(getPrefIdx).toBeGreaterThanOrEqual(0); + expect(setPrefIdx).toBeGreaterThanOrEqual(0); + expect(idIdx).toBeGreaterThanOrEqual(0); + expect(getPrefIdx).toBeLessThan(idIdx); + expect(setPrefIdx).toBeLessThan(idIdx); + }); +}); + +describe('TendersController — GET/PUT notification-pref (NOTIFY-01, per-user, T-12-14)', () => { + it('GET /notification-pref derives userId from req.user and delegates to tenderNotificationPref.getForUser(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 prefService = makeFakeNotificationPrefService(); + const controller = new TendersController( + prisma as any, + scheduler, + triageService as any, + makeFakeSavedSearchService() as any, + prefService as any, + ); + + await controller.getNotificationPref(makeFakeRequest('u-real')); + + expect(prefService.getForUser).toHaveBeenCalledWith('u-real'); + }); + + it('PUT /notification-pref delegates to tenderNotificationPref.setForUser 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 prefService = makeFakeNotificationPrefService(); + const controller = new TendersController( + prisma as any, + scheduler, + triageService as any, + makeFakeSavedSearchService() as any, + prefService as any, + ); + + await controller.setNotificationPref( + { digestInterval: 'weekly' } as any, + makeFakeRequest('u1', 'tenant1'), + ); + + expect(prefService.setForUser).toHaveBeenCalledWith('u1', 'tenant1', 'weekly'); + }); }); describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-14/15/16)', () => { @@ -245,6 +319,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1 scheduler, triageService as any, savedSearchService as any, + makeFakeNotificationPrefService() as any, ); await controller.listSavedSearches(makeFakeRequest('u-real')); @@ -262,6 +337,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1 scheduler, triageService as any, savedSearchService as any, + makeFakeNotificationPrefService() as any, ); await controller.createSavedSearch( @@ -285,6 +361,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1 scheduler, triageService as any, savedSearchService as any, + makeFakeNotificationPrefService() as any, ); await controller.updateSavedSearch( @@ -308,6 +385,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1 scheduler, triageService as any, savedSearchService as any, + makeFakeNotificationPrefService() as any, ); const result = await controller.removeSavedSearch('ss-1', makeFakeRequest('u1')); @@ -327,6 +405,7 @@ describe('TendersController — listTenders uses the query builder (sort whiteli scheduler, triageService as any, makeFakeSavedSearchService() as any, + makeFakeNotificationPrefService() as any, ); await controller.listTenders({ sort: 'deadline', q: 'Bau' } as any); @@ -354,6 +433,7 @@ describe('TendersController — listTenders uses the query builder (sort whiteli scheduler, triageService as any, makeFakeSavedSearchService() as any, + makeFakeNotificationPrefService() as any, ); await controller.listTenders({ sort: 'not-whitelisted' } as any); @@ -371,6 +451,7 @@ describe('TendersController — listTenders uses the query builder (sort whiteli scheduler, triageService as any, makeFakeSavedSearchService() as any, + makeFakeNotificationPrefService() as any, ); await controller.listTenders({ page: 3, limit: 10 } as any); @@ -391,6 +472,7 @@ describe('TendersController — GET /coverage', () => { scheduler, triageService as any, makeFakeSavedSearchService() as any, + makeFakeNotificationPrefService() as any, ); const result = await controller.getCoverage(); @@ -418,6 +500,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () = scheduler, triageService as any, makeFakeSavedSearchService() as any, + makeFakeNotificationPrefService() as any, ); await controller.listTriage('t1, t2 ,t3', makeFakeRequest('u1')); @@ -434,6 +517,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () = scheduler, triageService as any, makeFakeSavedSearchService() as any, + makeFakeNotificationPrefService() as any, ); await controller.listTriage('t1', makeFakeRequest('u-real')); @@ -450,6 +534,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () = scheduler, triageService as any, makeFakeSavedSearchService() as any, + makeFakeNotificationPrefService() as any, ); const manyIds = Array.from({ length: 300 }, (_, i) => `t${i}`).join(','); @@ -470,6 +555,7 @@ describe('TendersController — PUT /triage (upsert, per-user, T-11-10)', () => scheduler, triageService as any, makeFakeSavedSearchService() as any, + makeFakeNotificationPrefService() as any, ); await controller.setTriage( @@ -495,6 +581,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)', scheduler, triageService as any, makeFakeSavedSearchService() as any, + makeFakeNotificationPrefService() as any, ); await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1')); @@ -516,6 +603,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)', scheduler, triageService as any, makeFakeSavedSearchService() as any, + makeFakeNotificationPrefService() as any, ); await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1')); @@ -535,6 +623,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)', scheduler, triageService as any, makeFakeSavedSearchService() as any, + makeFakeNotificationPrefService() 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 199bea0..f82826c 100644 --- a/apps/api/src/tenders/tenders.controller.ts +++ b/apps/api/src/tenders/tenders.controller.ts @@ -17,10 +17,12 @@ 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'; @@ -58,6 +60,7 @@ export class TendersController { private readonly tenderScheduler: TenderSchedulerService, private readonly tenderTriage: TenderTriageService, private readonly tenderSavedSearch: TenderSavedSearchService, + private readonly tenderNotificationPref: TenderNotificationPrefService, ) {} /** @@ -301,6 +304,41 @@ export class TendersController { 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 diff --git a/apps/api/src/tenders/tenders.module.ts b/apps/api/src/tenders/tenders.module.ts index d493a4a..207a510 100644 --- a/apps/api/src/tenders/tenders.module.ts +++ b/apps/api/src/tenders/tenders.module.ts @@ -10,6 +10,7 @@ import { TenderIngestionService } from './tender-ingestion.service'; import { TenderMailService } from './tender-mail.service'; import { TenderMatchingService } from './tender-matching.service'; import { TenderNormalizerService } from './tender-normalizer.service'; +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'; @@ -51,6 +52,13 @@ import { TendersController } from './tenders.controller'; * TenderMailService can inject SettingsService. ScheduleModule.forRoot() * is already registered globally in AppModule — not re-imported here. * + * Phase 12, Plan 04 (NOTIFY-01/02) adds TenderNotificationPrefService: a + * per-user digestInterval CRUD service (same userId-scoping convention as + * TenderSavedSearchService, no forTenant()/RLS) backing the new GET/PUT + * notification-pref controller routes. instantAlert (NOTIFY-02) needed no + * new provider — it rides the existing TenderSavedSearchService create/ + * update path via the extended saved-search DTOs. + * * Seeds itself into the module registry on application startup via * OnModuleInit lifecycle hook — same pattern as DkvModule. */ @@ -67,6 +75,7 @@ import { TendersController } from './tenders.controller'; TenderMatchingService, TenderMailService, TenderDigestScheduler, + TenderNotificationPrefService, ], }) export class TendersModule implements OnModuleInit {