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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
|
||||
/** Sofort-Alert-Toggle (NOTIFY-02, D-04) — see CreateSavedSearchDto. */
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
instantAlert?: boolean;
|
||||
}
|
||||
|
||||
@@ -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<string, any>();
|
||||
|
||||
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'
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user