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).
|
* Body DTO for POST /modules/tender-radar/saved-searches (FILTER-06).
|
||||||
@@ -21,6 +21,15 @@ export class CreateSavedSearchDto {
|
|||||||
|
|
||||||
@IsObject()
|
@IsObject()
|
||||||
filters!: Record<string, unknown>;
|
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()
|
@IsOptional()
|
||||||
@IsObject()
|
@IsObject()
|
||||||
filters?: Record<string, unknown>;
|
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([]);
|
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,
|
tenantId,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
filters: dto.filters as unknown as Prisma.InputJsonValue,
|
filters: dto.filters as unknown as Prisma.InputJsonValue,
|
||||||
|
...(dto.instantAlert !== undefined && { instantAlert: dto.instantAlert }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -80,6 +81,7 @@ export class TenderSavedSearchService {
|
|||||||
if (dto.filters !== undefined) {
|
if (dto.filters !== undefined) {
|
||||||
data.filters = dto.filters as unknown as Prisma.InputJsonValue;
|
data.filters = dto.filters as unknown as Prisma.InputJsonValue;
|
||||||
}
|
}
|
||||||
|
if (dto.instantAlert !== undefined) data.instantAlert = dto.instantAlert;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.prisma.tenderSavedSearch.update({
|
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)', () => {
|
describe('TendersController — global read (not tenant-scoped)', () => {
|
||||||
it('GET / calls prisma.tender.findMany with a where clause that has no tenantId key', async () => {
|
it('GET / calls prisma.tender.findMany with a where clause that has no tenantId key', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
@@ -103,6 +118,7 @@ describe('TendersController — global read (not tenant-scoped)', () => {
|
|||||||
scheduler,
|
scheduler,
|
||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTenders({});
|
await controller.listTenders({});
|
||||||
@@ -121,6 +137,7 @@ describe('TendersController — global read (not tenant-scoped)', () => {
|
|||||||
scheduler,
|
scheduler,
|
||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await controller.getTender('t1');
|
const result = await controller.getTender('t1');
|
||||||
@@ -139,6 +156,7 @@ describe('TendersController — global read (not tenant-scoped)', () => {
|
|||||||
scheduler,
|
scheduler,
|
||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(controller.getTender('missing')).rejects.toBeInstanceOf(NotFoundException);
|
await expect(controller.getTender('missing')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
@@ -155,6 +173,7 @@ describe('TendersController — admin source-config applies live to the schedule
|
|||||||
scheduler,
|
scheduler,
|
||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.saveSourceConfig({ isActive: true, pollIntervalMin: 30 });
|
await controller.saveSourceConfig({ isActive: true, pollIntervalMin: 30 });
|
||||||
@@ -173,6 +192,7 @@ describe('TendersController — admin source-config applies live to the schedule
|
|||||||
scheduler,
|
scheduler,
|
||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.saveSourceConfig({ isActive: false });
|
await controller.saveSourceConfig({ isActive: false });
|
||||||
@@ -232,6 +252,60 @@ describe('TendersController — route declaration order (static route before :id
|
|||||||
expect(idIdx).toBeGreaterThanOrEqual(0);
|
expect(idIdx).toBeGreaterThanOrEqual(0);
|
||||||
expect(listSavedSearchesIdx).toBeLessThan(idIdx);
|
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)', () => {
|
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,
|
scheduler,
|
||||||
triageService as any,
|
triageService as any,
|
||||||
savedSearchService as any,
|
savedSearchService as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listSavedSearches(makeFakeRequest('u-real'));
|
await controller.listSavedSearches(makeFakeRequest('u-real'));
|
||||||
@@ -262,6 +337,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1
|
|||||||
scheduler,
|
scheduler,
|
||||||
triageService as any,
|
triageService as any,
|
||||||
savedSearchService as any,
|
savedSearchService as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.createSavedSearch(
|
await controller.createSavedSearch(
|
||||||
@@ -285,6 +361,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1
|
|||||||
scheduler,
|
scheduler,
|
||||||
triageService as any,
|
triageService as any,
|
||||||
savedSearchService as any,
|
savedSearchService as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.updateSavedSearch(
|
await controller.updateSavedSearch(
|
||||||
@@ -308,6 +385,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1
|
|||||||
scheduler,
|
scheduler,
|
||||||
triageService as any,
|
triageService as any,
|
||||||
savedSearchService as any,
|
savedSearchService as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await controller.removeSavedSearch('ss-1', makeFakeRequest('u1'));
|
const result = await controller.removeSavedSearch('ss-1', makeFakeRequest('u1'));
|
||||||
@@ -327,6 +405,7 @@ describe('TendersController — listTenders uses the query builder (sort whiteli
|
|||||||
scheduler,
|
scheduler,
|
||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTenders({ sort: 'deadline', q: 'Bau' } 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,
|
scheduler,
|
||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTenders({ sort: 'not-whitelisted' } as any);
|
await controller.listTenders({ sort: 'not-whitelisted' } as any);
|
||||||
@@ -371,6 +451,7 @@ describe('TendersController — listTenders uses the query builder (sort whiteli
|
|||||||
scheduler,
|
scheduler,
|
||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTenders({ page: 3, limit: 10 } as any);
|
await controller.listTenders({ page: 3, limit: 10 } as any);
|
||||||
@@ -391,6 +472,7 @@ describe('TendersController — GET /coverage', () => {
|
|||||||
scheduler,
|
scheduler,
|
||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await controller.getCoverage();
|
const result = await controller.getCoverage();
|
||||||
@@ -418,6 +500,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () =
|
|||||||
scheduler,
|
scheduler,
|
||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTriage('t1, t2 ,t3', makeFakeRequest('u1'));
|
await controller.listTriage('t1, t2 ,t3', makeFakeRequest('u1'));
|
||||||
@@ -434,6 +517,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () =
|
|||||||
scheduler,
|
scheduler,
|
||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTriage('t1', makeFakeRequest('u-real'));
|
await controller.listTriage('t1', makeFakeRequest('u-real'));
|
||||||
@@ -450,6 +534,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () =
|
|||||||
scheduler,
|
scheduler,
|
||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
const manyIds = Array.from({ length: 300 }, (_, i) => `t${i}`).join(',');
|
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,
|
scheduler,
|
||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.setTriage(
|
await controller.setTriage(
|
||||||
@@ -495,6 +581,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)',
|
|||||||
scheduler,
|
scheduler,
|
||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1'));
|
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,
|
scheduler,
|
||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1'));
|
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,
|
scheduler,
|
||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTenders({} as any, makeFakeRequest('u1'));
|
await controller.listTenders({} as any, makeFakeRequest('u1'));
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ import { Request } from 'express';
|
|||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import { UseModule } from '../module-registry/module.guard';
|
import { UseModule } from '../module-registry/module.guard';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { UpdateNotificationPrefDto } from './dto/notification-pref.dto';
|
||||||
import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.dto';
|
import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.dto';
|
||||||
import { SourceConfigDto } from './dto/source-config.dto';
|
import { SourceConfigDto } from './dto/source-config.dto';
|
||||||
import { TenderQueryDto } from './dto/tender-query.dto';
|
import { TenderQueryDto } from './dto/tender-query.dto';
|
||||||
import { TenderTriageDto } from './dto/tender-triage.dto';
|
import { TenderTriageDto } from './dto/tender-triage.dto';
|
||||||
|
import { TenderNotificationPrefService } from './tender-notification-pref.service';
|
||||||
import { TenderSavedSearchService } from './tender-saved-search.service';
|
import { TenderSavedSearchService } from './tender-saved-search.service';
|
||||||
import { TenderSchedulerService } from './tender-scheduler.service';
|
import { TenderSchedulerService } from './tender-scheduler.service';
|
||||||
import { TenderTriageService } from './tender-triage.service';
|
import { TenderTriageService } from './tender-triage.service';
|
||||||
@@ -58,6 +60,7 @@ export class TendersController {
|
|||||||
private readonly tenderScheduler: TenderSchedulerService,
|
private readonly tenderScheduler: TenderSchedulerService,
|
||||||
private readonly tenderTriage: TenderTriageService,
|
private readonly tenderTriage: TenderTriageService,
|
||||||
private readonly tenderSavedSearch: TenderSavedSearchService,
|
private readonly tenderSavedSearch: TenderSavedSearchService,
|
||||||
|
private readonly tenderNotificationPref: TenderNotificationPrefService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -301,6 +304,41 @@ export class TendersController {
|
|||||||
return { success: true };
|
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.
|
* GET /modules/tender-radar/:id — single tender detail.
|
||||||
* Gated by @UseModule('tender-radar'); NOT scoped by the tenant's id
|
* 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 { TenderMailService } from './tender-mail.service';
|
||||||
import { TenderMatchingService } from './tender-matching.service';
|
import { TenderMatchingService } from './tender-matching.service';
|
||||||
import { TenderNormalizerService } from './tender-normalizer.service';
|
import { TenderNormalizerService } from './tender-normalizer.service';
|
||||||
|
import { TenderNotificationPrefService } from './tender-notification-pref.service';
|
||||||
import { TenderSavedSearchService } from './tender-saved-search.service';
|
import { TenderSavedSearchService } from './tender-saved-search.service';
|
||||||
import { TenderSchedulerService } from './tender-scheduler.service';
|
import { TenderSchedulerService } from './tender-scheduler.service';
|
||||||
import { TenderTriageService } from './tender-triage.service';
|
import { TenderTriageService } from './tender-triage.service';
|
||||||
@@ -51,6 +52,13 @@ import { TendersController } from './tenders.controller';
|
|||||||
* TenderMailService can inject SettingsService. ScheduleModule.forRoot()
|
* TenderMailService can inject SettingsService. ScheduleModule.forRoot()
|
||||||
* is already registered globally in AppModule — not re-imported here.
|
* 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
|
* Seeds itself into the module registry on application startup via
|
||||||
* OnModuleInit lifecycle hook — same pattern as DkvModule.
|
* OnModuleInit lifecycle hook — same pattern as DkvModule.
|
||||||
*/
|
*/
|
||||||
@@ -67,6 +75,7 @@ import { TendersController } from './tenders.controller';
|
|||||||
TenderMatchingService,
|
TenderMatchingService,
|
||||||
TenderMailService,
|
TenderMailService,
|
||||||
TenderDigestScheduler,
|
TenderDigestScheduler,
|
||||||
|
TenderNotificationPrefService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TendersModule implements OnModuleInit {
|
export class TendersModule implements OnModuleInit {
|
||||||
|
|||||||
Reference in New Issue
Block a user