feat(14-03): add email-config admin routes + D-13 read-side visibility filter + EmailAlertConfigForm
buildTenderWhere gains an optional ownerTenantId param: a resolved
requesting tenant sees global tenders (null) plus its own private ones
(OR[global, mine]); an unresolved requester fails CLOSED to global-only —
never an accidental cross-tenant leak.
TendersController: listTenders/getTender resolve the requesting tenant
leniently from the auth context (resolveRequestingTenantId, never throws)
and apply the D-13 filter; getTender 404s (not a distinct "forbidden") when
a tender's non-null ownerTenantId doesn't match the requester, so no
cross-tenant detail leak. New GET/PUT /modules/tender-radar/email-config
routes (Roles ADMIN/SUPER_ADMIN, tenantId from auth context, never the
body) delegate to TenderEmailConfigService — declared before @Get(':id')
per the project's NestJS route-order convention.
Web: EmailAlertConfig type + fetchEmailConfig/saveEmailConfig client
functions; EmailAlertConfigForm mirrors the DKV InboxConfigForm (password
blank on load, only sent when typed — T-07-12), added as a new
"E-Mail-Alerts" section on the existing tender-radar settings page.
Hardcoded German strings — i18n is Plan 14-05.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -256,6 +256,26 @@ describe('buildTenderWhere — favOnly (UI-04, D-10, T-11-10/11)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('buildTenderWhere — D-13 ownerTenantId visibility (Phase 14, Plan 03)', () => {
|
||||||
|
it('a resolved ownerTenantId adds an OR[global, mine] clause', () => {
|
||||||
|
const where = buildTenderWhere(dto(), undefined, 'tenant-a');
|
||||||
|
|
||||||
|
expect(where.AND).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ OR: [{ ownerTenantId: null }, { ownerTenantId: 'tenant-a' }] },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unresolved ownerTenantId (no auth context) fails closed to global-only tenders', () => {
|
||||||
|
const where = buildTenderWhere(dto());
|
||||||
|
|
||||||
|
expect(where.AND).toEqual(
|
||||||
|
expect.arrayContaining([{ ownerTenantId: null }]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('buildOrderBy', () => {
|
describe('buildOrderBy', () => {
|
||||||
it('sort=deadline maps to { deadlineAt: asc }', () => {
|
it('sort=deadline maps to { deadlineAt: asc }', () => {
|
||||||
expect(buildOrderBy('deadline')).toEqual({ deadlineAt: 'asc' });
|
expect(buildOrderBy('deadline')).toEqual({ deadlineAt: 'asc' });
|
||||||
|
|||||||
@@ -31,10 +31,20 @@ const MAX_FAV_IDS = 500;
|
|||||||
* `TenderTriageService.favoriteIds(userId)`) from the auth context —
|
* `TenderTriageService.favoriteIds(userId)`) from the auth context —
|
||||||
* never accepted here as user input. Only consulted when `dto.favOnly` is
|
* never accepted here as user input. Only consulted when `dto.favOnly` is
|
||||||
* true.
|
* true.
|
||||||
|
*
|
||||||
|
* `ownerTenantId` (Phase 14, Plan 03, D-13): the requesting tenant's id,
|
||||||
|
* resolved by the CALLER (TendersController) from the auth context — never
|
||||||
|
* from `dto`. Applies the OR[global, mine] visibility rule for privately-
|
||||||
|
* sourced (email-alert) tenders: `{ OR: [{ownerTenantId:null},
|
||||||
|
* {ownerTenantId}] }`. When the caller cannot resolve a requesting tenant
|
||||||
|
* (no auth context), this fails CLOSED — only globally-visible
|
||||||
|
* (`ownerTenantId: null`) tenders are returned, never a private tenant's
|
||||||
|
* rows leaked to an unidentified requester.
|
||||||
*/
|
*/
|
||||||
export function buildTenderWhere(
|
export function buildTenderWhere(
|
||||||
dto: TenderQueryDto,
|
dto: TenderQueryDto,
|
||||||
favIds?: string[],
|
favIds?: string[],
|
||||||
|
ownerTenantId?: string,
|
||||||
): Prisma.TenderWhereInput {
|
): Prisma.TenderWhereInput {
|
||||||
const where: Prisma.TenderWhereInput = {};
|
const where: Prisma.TenderWhereInput = {};
|
||||||
const AND: Prisma.TenderWhereInput[] = [];
|
const AND: Prisma.TenderWhereInput[] = [];
|
||||||
@@ -126,6 +136,16 @@ export function buildTenderWhere(
|
|||||||
AND.push({ id: { in: ids.length ? ids : ['__none__'] } });
|
AND.push({ id: { in: ids.length ? ids : ['__none__'] } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// D-13 (Phase 14, Plan 03): private (email-alert) tender visibility.
|
||||||
|
// A resolved requesting tenant sees global tenders (null) PLUS its own;
|
||||||
|
// an unidentified requester (ownerTenantId undefined) sees ONLY global
|
||||||
|
// tenders — fail-closed, never an accidental cross-tenant leak.
|
||||||
|
AND.push(
|
||||||
|
ownerTenantId
|
||||||
|
? { OR: [{ ownerTenantId: null }, { ownerTenantId }] }
|
||||||
|
: { ownerTenantId: null },
|
||||||
|
);
|
||||||
|
|
||||||
if (AND.length) where.AND = AND;
|
if (AND.length) where.AND = AND;
|
||||||
return where;
|
return where;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,18 @@ function makeFakeRssFeedService() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fake TenderEmailConfigService for controller-level wiring tests (Plan
|
||||||
|
* 14-03, D-06/D-07/CONFIG-02). Default stubs echo/return null; individual
|
||||||
|
* tests override via `.mockResolvedValueOnce`/reassigning the mock.
|
||||||
|
*/
|
||||||
|
function makeFakeEmailConfigService() {
|
||||||
|
return {
|
||||||
|
getConfigForApi: vi.fn(async (_tenantId: string) => null as any),
|
||||||
|
saveConfig: vi.fn(async (_tenantId: string, dto: any) => ({ id: 'ec-1', ...dto })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fake TenderSavedSearchService for controller-level wiring tests (Plan
|
* Fake TenderSavedSearchService for controller-level wiring tests (Plan
|
||||||
* 11-06, Task 2). Default stubs return empty/echo results — individual
|
* 11-06, Task 2). Default stubs return empty/echo results — individual
|
||||||
@@ -147,6 +159,7 @@ describe('TendersController — global read (not tenant-scoped)', () => {
|
|||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
makeFakeRssFeedService() as any,
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTenders({});
|
await controller.listTenders({});
|
||||||
@@ -167,6 +180,7 @@ describe('TendersController — global read (not tenant-scoped)', () => {
|
|||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
makeFakeRssFeedService() as any,
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await controller.getTender('t1');
|
const result = await controller.getTender('t1');
|
||||||
@@ -187,6 +201,7 @@ describe('TendersController — global read (not tenant-scoped)', () => {
|
|||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
makeFakeRssFeedService() as any,
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await controller.getTender('t1');
|
const result = await controller.getTender('t1');
|
||||||
@@ -222,6 +237,7 @@ describe('TendersController — global read (not tenant-scoped)', () => {
|
|||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
makeFakeRssFeedService() as any,
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(controller.getTender('missing')).rejects.toBeInstanceOf(NotFoundException);
|
await expect(controller.getTender('missing')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
@@ -240,6 +256,7 @@ describe('TendersController — admin source-config applies live to the schedule
|
|||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
makeFakeRssFeedService() as any,
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.saveSourceConfig({ isActive: true, pollIntervalMin: 30 });
|
await controller.saveSourceConfig({ isActive: true, pollIntervalMin: 30 });
|
||||||
@@ -260,6 +277,7 @@ describe('TendersController — admin source-config applies live to the schedule
|
|||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
makeFakeRssFeedService() as any,
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.saveSourceConfig({ isActive: false });
|
await controller.saveSourceConfig({ isActive: false });
|
||||||
@@ -313,6 +331,19 @@ describe('TendersController — route declaration order (static route before :id
|
|||||||
expect(removeIdx).toBeLessThan(idIdx);
|
expect(removeIdx).toBeLessThan(idIdx);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('declares getEmailConfig/saveEmailConfig before getTender so GET /:id cannot shadow "email-config" (Plan 14-03, Pitfall 5)', () => {
|
||||||
|
const methods = Object.getOwnPropertyNames(TendersController.prototype);
|
||||||
|
const getIdx = methods.indexOf('getEmailConfig');
|
||||||
|
const saveIdx = methods.indexOf('saveEmailConfig');
|
||||||
|
const idIdx = methods.indexOf('getTender');
|
||||||
|
|
||||||
|
expect(getIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(saveIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(idIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(getIdx).toBeLessThan(idIdx);
|
||||||
|
expect(saveIdx).toBeLessThan(idIdx);
|
||||||
|
});
|
||||||
|
|
||||||
it('declares listTriage and setTriage before getTender so GET /:id cannot shadow "triage" (Plan 11-05, Pitfall 5)', () => {
|
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 methods = Object.getOwnPropertyNames(TendersController.prototype);
|
||||||
const listTriageIdx = methods.indexOf('listTriage');
|
const listTriageIdx = methods.indexOf('listTriage');
|
||||||
@@ -363,6 +394,7 @@ describe('TendersController — GET/PUT notification-pref (NOTIFY-01, per-user,
|
|||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
prefService as any,
|
prefService as any,
|
||||||
makeFakeRssFeedService() as any,
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.getNotificationPref(makeFakeRequest('u-real'));
|
await controller.getNotificationPref(makeFakeRequest('u-real'));
|
||||||
@@ -382,6 +414,7 @@ describe('TendersController — GET/PUT notification-pref (NOTIFY-01, per-user,
|
|||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
prefService as any,
|
prefService as any,
|
||||||
makeFakeRssFeedService() as any,
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.setNotificationPref(
|
await controller.setNotificationPref(
|
||||||
@@ -406,6 +439,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1
|
|||||||
savedSearchService as any,
|
savedSearchService as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
makeFakeRssFeedService() as any,
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listSavedSearches(makeFakeRequest('u-real'));
|
await controller.listSavedSearches(makeFakeRequest('u-real'));
|
||||||
@@ -425,6 +459,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1
|
|||||||
savedSearchService as any,
|
savedSearchService as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
makeFakeRssFeedService() as any,
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.createSavedSearch(
|
await controller.createSavedSearch(
|
||||||
@@ -450,6 +485,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1
|
|||||||
savedSearchService as any,
|
savedSearchService as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
makeFakeRssFeedService() as any,
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.updateSavedSearch(
|
await controller.updateSavedSearch(
|
||||||
@@ -475,6 +511,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1
|
|||||||
savedSearchService as any,
|
savedSearchService as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
makeFakeRssFeedService() as any,
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await controller.removeSavedSearch('ss-1', makeFakeRequest('u1'));
|
const result = await controller.removeSavedSearch('ss-1', makeFakeRequest('u1'));
|
||||||
@@ -496,6 +533,7 @@ describe('TendersController — listTenders uses the query builder (sort whiteli
|
|||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
makeFakeRssFeedService() as any,
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTenders({ sort: 'deadline', q: 'Bau' } as any);
|
await controller.listTenders({ sort: 'deadline', q: 'Bau' } as any);
|
||||||
@@ -525,6 +563,7 @@ describe('TendersController — listTenders uses the query builder (sort whiteli
|
|||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
makeFakeRssFeedService() as any,
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTenders({ sort: 'not-whitelisted' } as any);
|
await controller.listTenders({ sort: 'not-whitelisted' } as any);
|
||||||
@@ -544,6 +583,7 @@ describe('TendersController — listTenders uses the query builder (sort whiteli
|
|||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
makeFakeRssFeedService() as any,
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTenders({ page: 3, limit: 10 } as any);
|
await controller.listTenders({ page: 3, limit: 10 } as any);
|
||||||
@@ -566,6 +606,7 @@ describe('TendersController — GET /coverage', () => {
|
|||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
makeFakeRssFeedService() as any,
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await controller.getCoverage();
|
const result = await controller.getCoverage();
|
||||||
@@ -595,6 +636,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () =
|
|||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
makeFakeRssFeedService() as any,
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTriage('t1, t2 ,t3', makeFakeRequest('u1'));
|
await controller.listTriage('t1, t2 ,t3', makeFakeRequest('u1'));
|
||||||
@@ -613,6 +655,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () =
|
|||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
makeFakeRssFeedService() as any,
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTriage('t1', makeFakeRequest('u-real'));
|
await controller.listTriage('t1', makeFakeRequest('u-real'));
|
||||||
@@ -631,6 +674,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () =
|
|||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
makeFakeRssFeedService() as any,
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
const manyIds = Array.from({ length: 300 }, (_, i) => `t${i}`).join(',');
|
const manyIds = Array.from({ length: 300 }, (_, i) => `t${i}`).join(',');
|
||||||
@@ -653,6 +697,7 @@ describe('TendersController — PUT /triage (upsert, per-user, T-11-10)', () =>
|
|||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
makeFakeRssFeedService() as any,
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.setTriage(
|
await controller.setTriage(
|
||||||
@@ -680,6 +725,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)',
|
|||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
makeFakeRssFeedService() as any,
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1'));
|
await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1'));
|
||||||
@@ -703,6 +749,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)',
|
|||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
makeFakeRssFeedService() as any,
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1'));
|
await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1'));
|
||||||
@@ -724,6 +771,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)',
|
|||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
makeFakeRssFeedService() as any,
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTenders({} as any, makeFakeRequest('u1'));
|
await controller.listTenders({} as any, makeFakeRequest('u1'));
|
||||||
@@ -747,6 +795,7 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', (
|
|||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
rssFeedService as any,
|
rssFeedService as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await controller.listRssFeeds();
|
const result = await controller.listRssFeeds();
|
||||||
@@ -768,6 +817,7 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', (
|
|||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
rssFeedService as any,
|
rssFeedService as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
const dto = { url: 'https://service.bund.de/rss.xml', label: 'service-bund' } as any;
|
const dto = { url: 'https://service.bund.de/rss.xml', label: 'service-bund' } as any;
|
||||||
@@ -790,6 +840,7 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', (
|
|||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
rssFeedService as any,
|
rssFeedService as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -811,6 +862,7 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', (
|
|||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
rssFeedService as any,
|
rssFeedService as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await controller.removeRssFeed('feed-1');
|
const result = await controller.removeRssFeed('feed-1');
|
||||||
@@ -819,3 +871,227 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', (
|
|||||||
expect(result).toEqual({ success: true });
|
expect(result).toEqual({ success: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('TendersController — email-config (Plan 14-03, per-tenant, D-06/D-07/T-14-03-05)', () => {
|
||||||
|
it('GET /email-config resolves tenantId from the auth context and delegates to tenderEmailConfig.getConfigForApi(tenantId)', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||||
|
const emailConfigService = makeFakeEmailConfigService();
|
||||||
|
emailConfigService.getConfigForApi.mockResolvedValueOnce({
|
||||||
|
tenantId: 'tenant1',
|
||||||
|
protocol: 'imap',
|
||||||
|
hasPassword: true,
|
||||||
|
} as any);
|
||||||
|
const controller = new TendersController(
|
||||||
|
prisma as any,
|
||||||
|
scheduler,
|
||||||
|
makeFakeTriageService() as any,
|
||||||
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
|
emailConfigService as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await controller.getEmailConfig(makeFakeRequest('u1', 'tenant1'));
|
||||||
|
|
||||||
|
expect(emailConfigService.getConfigForApi).toHaveBeenCalledWith('tenant1');
|
||||||
|
expect(result).toEqual({ tenantId: 'tenant1', protocol: 'imap', hasPassword: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT /email-config delegates to tenderEmailConfig.saveConfig with tenantId from the auth context, never the body', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||||
|
const emailConfigService = makeFakeEmailConfigService();
|
||||||
|
const controller = new TendersController(
|
||||||
|
prisma as any,
|
||||||
|
scheduler,
|
||||||
|
makeFakeTriageService() as any,
|
||||||
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
|
emailConfigService as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dto = { protocol: 'imap', encryption: 'ssl-tls', host: 'imap.example.test' } as any;
|
||||||
|
await controller.saveEmailConfig(dto, makeFakeRequest('u1', 'tenant1'));
|
||||||
|
|
||||||
|
expect(emailConfigService.saveConfig).toHaveBeenCalledWith('tenant1', dto);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TendersController — D-13 private (email-alert) tender visibility (Phase 14, Plan 03)', () => {
|
||||||
|
/**
|
||||||
|
* Bespoke fake prisma for these tests: unlike makeFakePrisma() (which
|
||||||
|
* ignores `where` entirely), this one evaluates the ownerTenantId-related
|
||||||
|
* AND clauses buildTenderWhere produces — proving the visibility filter
|
||||||
|
* actually excludes/includes rows, not just that the where clause "looks
|
||||||
|
* right" in isolation (tender-query.builder.spec.ts already covers that).
|
||||||
|
*/
|
||||||
|
function makeFakeVisibilityPrisma() {
|
||||||
|
const tenders = new Map<string, any>([
|
||||||
|
['global-1', { id: 'global-1', title: 'Global Tender', ownerTenantId: null, sources: [] }],
|
||||||
|
['private-a', { id: 'private-a', title: 'Private Tender (Tenant A)', ownerTenantId: 'tenant-a', sources: [] }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function matchesOwnerTenantClause(tender: any, where: any): boolean {
|
||||||
|
const and = (where?.AND ?? []) as any[];
|
||||||
|
for (const clause of and) {
|
||||||
|
if (clause && typeof clause === 'object' && 'ownerTenantId' in clause) {
|
||||||
|
if (clause.ownerTenantId !== tender.ownerTenantId) return false;
|
||||||
|
} else if (clause && typeof clause === 'object' && 'OR' in clause) {
|
||||||
|
const or = clause.OR as any[];
|
||||||
|
const isOwnerOrClause = or.every(
|
||||||
|
(c) => c && typeof c === 'object' && 'ownerTenantId' in c,
|
||||||
|
);
|
||||||
|
if (isOwnerOrClause) {
|
||||||
|
const matches = or.some((c) => c.ownerTenantId === tender.ownerTenantId);
|
||||||
|
if (!matches) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tender: {
|
||||||
|
findMany: vi.fn(async ({ where }: any) =>
|
||||||
|
Array.from(tenders.values()).filter((t) => matchesOwnerTenantClause(t, where)),
|
||||||
|
),
|
||||||
|
count: vi.fn(async ({ where }: any) =>
|
||||||
|
Array.from(tenders.values()).filter((t) => matchesOwnerTenantClause(t, where)).length,
|
||||||
|
),
|
||||||
|
findUnique: vi.fn(async ({ where }: any) => tenders.get(where.id) ?? null),
|
||||||
|
},
|
||||||
|
tenderSourcePollConfig: { findUnique: vi.fn(), upsert: vi.fn() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('GET / for tenant-a includes both the global tender and its own private tender', async () => {
|
||||||
|
const prisma = makeFakeVisibilityPrisma();
|
||||||
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||||
|
const controller = new TendersController(
|
||||||
|
prisma as any,
|
||||||
|
scheduler,
|
||||||
|
makeFakeTriageService() as any,
|
||||||
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await controller.listTenders({} as any, makeFakeRequest('u-a', 'tenant-a'));
|
||||||
|
|
||||||
|
const ids = result.items.map((t: any) => t.id).sort();
|
||||||
|
expect(ids).toEqual(['global-1', 'private-a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET / for tenant-b (a different tenant) excludes tenant-a\'s private tender, includes the global one', async () => {
|
||||||
|
const prisma = makeFakeVisibilityPrisma();
|
||||||
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||||
|
const controller = new TendersController(
|
||||||
|
prisma as any,
|
||||||
|
scheduler,
|
||||||
|
makeFakeTriageService() as any,
|
||||||
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await controller.listTenders({} as any, makeFakeRequest('u-b', 'tenant-b'));
|
||||||
|
|
||||||
|
const ids = result.items.map((t: any) => t.id).sort();
|
||||||
|
expect(ids).toEqual(['global-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET / with no resolvable tenant context (no req) fails closed to only the global tender', async () => {
|
||||||
|
const prisma = makeFakeVisibilityPrisma();
|
||||||
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||||
|
const controller = new TendersController(
|
||||||
|
prisma as any,
|
||||||
|
scheduler,
|
||||||
|
makeFakeTriageService() as any,
|
||||||
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await controller.listTenders({} as any);
|
||||||
|
|
||||||
|
const ids = result.items.map((t: any) => t.id).sort();
|
||||||
|
expect(ids).toEqual(['global-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /:id returns the null-owner tender to both tenant-a and tenant-b', async () => {
|
||||||
|
const prisma = makeFakeVisibilityPrisma();
|
||||||
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||||
|
const controller = new TendersController(
|
||||||
|
prisma as any,
|
||||||
|
scheduler,
|
||||||
|
makeFakeTriageService() as any,
|
||||||
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const forA = await controller.getTender('global-1', makeFakeRequest('u-a', 'tenant-a'));
|
||||||
|
const forB = await controller.getTender('global-1', makeFakeRequest('u-b', 'tenant-b'));
|
||||||
|
|
||||||
|
expect(forA.id).toBe('global-1');
|
||||||
|
expect(forB.id).toBe('global-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /:id returns tenant-a\'s private tender to tenant-a', async () => {
|
||||||
|
const prisma = makeFakeVisibilityPrisma();
|
||||||
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||||
|
const controller = new TendersController(
|
||||||
|
prisma as any,
|
||||||
|
scheduler,
|
||||||
|
makeFakeTriageService() as any,
|
||||||
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await controller.getTender('private-a', makeFakeRequest('u-a', 'tenant-a'));
|
||||||
|
|
||||||
|
expect(result.id).toBe('private-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /:id 404s for tenant-b requesting tenant-a\'s private tender (no cross-tenant detail leak)', async () => {
|
||||||
|
const prisma = makeFakeVisibilityPrisma();
|
||||||
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||||
|
const controller = new TendersController(
|
||||||
|
prisma as any,
|
||||||
|
scheduler,
|
||||||
|
makeFakeTriageService() as any,
|
||||||
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.getTender('private-a', makeFakeRequest('u-b', 'tenant-b')),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /:id 404s for an unauthenticated request (no req) to a private tender', async () => {
|
||||||
|
const prisma = makeFakeVisibilityPrisma();
|
||||||
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||||
|
const controller = new TendersController(
|
||||||
|
prisma as any,
|
||||||
|
scheduler,
|
||||||
|
makeFakeTriageService() as any,
|
||||||
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(controller.getTender('private-a')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { UpdateNotificationPrefDto } from './dto/notification-pref.dto';
|
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 { TenderEmailConfigDto } from './dto/tender-email-config.dto';
|
||||||
import { TenderQueryDto } from './dto/tender-query.dto';
|
import { TenderQueryDto } from './dto/tender-query.dto';
|
||||||
import { TenderRssFeedDto } from './dto/tender-rss-feed.dto';
|
import { TenderRssFeedDto } from './dto/tender-rss-feed.dto';
|
||||||
import { TenderTriageDto } from './dto/tender-triage.dto';
|
import { TenderTriageDto } from './dto/tender-triage.dto';
|
||||||
|
import { TenderEmailConfigService } from './tender-email-config.service';
|
||||||
import { TenderNotificationPrefService } from './tender-notification-pref.service';
|
import { TenderNotificationPrefService } from './tender-notification-pref.service';
|
||||||
import { TenderRssFeedSourceService } from './tender-rss-feed.service';
|
import { TenderRssFeedSourceService } from './tender-rss-feed.service';
|
||||||
import { TenderSavedSearchService } from './tender-saved-search.service';
|
import { TenderSavedSearchService } from './tender-saved-search.service';
|
||||||
@@ -54,6 +56,16 @@ const DOE_SOURCE_TYPE = 'doe-opendata';
|
|||||||
* (T-10-13) and are NOT gated by @UseModule — an admin configuring the
|
* (T-10-13) and are NOT gated by @UseModule — an admin configuring the
|
||||||
* shared platform-wide poll schedule is a platform-admin action, not a
|
* shared platform-wide poll schedule is a platform-admin action, not a
|
||||||
* per-tenant module feature.
|
* per-tenant module feature.
|
||||||
|
*
|
||||||
|
* Phase 14, Plan 03 (INGEST-05, D-13): `GET`/`PUT /email-config` are, like
|
||||||
|
* `source-config`/`rss-feeds`, per-handler `@Roles(ADMIN, SUPER_ADMIN)`-
|
||||||
|
* guarded — but UNLIKE those (platform-wide singletons/lists), the email
|
||||||
|
* mailbox config is per-TENANT: tenantId is resolved from the auth context,
|
||||||
|
* never the body (T-14-03-05/IDOR). This is also the plan that breaks the
|
||||||
|
* "GET/GET :id are never row-scoped" invariant above, narrowly: `listTenders`/
|
||||||
|
* `getTender` now resolve the requesting tenant to apply the D-13 OR[global,
|
||||||
|
* mine] visibility filter for PRIVATE (email-alert) tenders only — public
|
||||||
|
* tenders (D-03) remain visible to every tenant exactly as before.
|
||||||
*/
|
*/
|
||||||
@Controller('modules/tender-radar')
|
@Controller('modules/tender-radar')
|
||||||
export class TendersController {
|
export class TendersController {
|
||||||
@@ -64,6 +76,7 @@ export class TendersController {
|
|||||||
private readonly tenderSavedSearch: TenderSavedSearchService,
|
private readonly tenderSavedSearch: TenderSavedSearchService,
|
||||||
private readonly tenderNotificationPref: TenderNotificationPrefService,
|
private readonly tenderNotificationPref: TenderNotificationPrefService,
|
||||||
private readonly tenderRssFeedSource: TenderRssFeedSourceService,
|
private readonly tenderRssFeedSource: TenderRssFeedSourceService,
|
||||||
|
private readonly tenderEmailConfig: TenderEmailConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,6 +99,18 @@ export class TendersController {
|
|||||||
return { userId, tenantId };
|
return { userId, tenantId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leniently resolves the requesting tenant's id for the D-13 visibility
|
||||||
|
* filter — unlike extractTriageContext, this NEVER throws when the
|
||||||
|
* context is missing: `listTenders`/`getTender` are gated only by
|
||||||
|
* `@UseModule`, not per-user auth, and must degrade to "global tenders
|
||||||
|
* only" (fail-closed, tender-query.builder.ts) rather than 403 when no
|
||||||
|
* tenant context is present.
|
||||||
|
*/
|
||||||
|
private resolveRequestingTenantId(req?: Request): string | undefined {
|
||||||
|
return (req as any)?.user?.tenantId ?? (req as any)?.tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Global read (ModuleGuard-gated, NOT tenant-scoped) ────────────────────
|
// ─── Global read (ModuleGuard-gated, NOT tenant-scoped) ────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,7 +148,11 @@ export class TendersController {
|
|||||||
favIds = await this.tenderTriage.favoriteIds(userId);
|
favIds = await this.tenderTriage.favoriteIds(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const where = buildTenderWhere(query, favIds);
|
// D-13: resolve the requesting tenant for the private-tender visibility
|
||||||
|
// filter — leniently (never throws); see resolveRequestingTenantId doc.
|
||||||
|
const ownerTenantId = this.resolveRequestingTenantId(req);
|
||||||
|
|
||||||
|
const where = buildTenderWhere(query, favIds, ownerTenantId);
|
||||||
const orderBy = buildOrderBy(query.sort);
|
const orderBy = buildOrderBy(query.sort);
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
@@ -202,6 +231,39 @@ export class TendersController {
|
|||||||
return this.tenderRssFeedSource.remove(feedId);
|
return this.tenderRssFeedSource.remove(feedId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── E-Mail-Alerts config (Roles-guarded, PER-TENANT, D-06/D-07/D-13) ──────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /modules/tender-radar/email-config — this tenant's portal-alert
|
||||||
|
* mailbox config (safe-select — never the password, T-07-12). Unlike
|
||||||
|
* `source-config`/`rss-feeds` (platform-wide), this is PER-TENANT:
|
||||||
|
* tenantId is resolved from the auth context, never a query/body field
|
||||||
|
* (T-14-03-05 / V4 — IDOR).
|
||||||
|
*
|
||||||
|
* MUST be declared before `@Get(':id')` below — same route-order pitfall
|
||||||
|
* as `source-config`/`coverage`/`triage`/`rss-feeds`/... above (Pitfall 5).
|
||||||
|
*/
|
||||||
|
@Get('email-config')
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async getEmailConfig(@Req() req: Request) {
|
||||||
|
const { tenantId } = this.extractTriageContext(req);
|
||||||
|
return this.tenderEmailConfig.getConfigForApi(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /modules/tender-radar/email-config — upsert this tenant's mailbox
|
||||||
|
* config. tenantId comes exclusively from the auth context — `dto` never
|
||||||
|
* carries a tenantId field (T-14-03-05 / V4 — IDOR). Credential
|
||||||
|
* encrypt-preserve-empty semantics live in TenderEmailConfigService
|
||||||
|
* (mirrors DkvService.saveConfig / T-07-12).
|
||||||
|
*/
|
||||||
|
@Put('email-config')
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async saveEmailConfig(@Body() dto: TenderEmailConfigDto, @Req() req: Request) {
|
||||||
|
const { tenantId } = this.extractTriageContext(req);
|
||||||
|
return this.tenderEmailConfig.saveConfig(tenantId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /modules/tender-radar/coverage — distribution of active tenders
|
* GET /modules/tender-radar/coverage — distribution of active tenders
|
||||||
* by sourcePortal (D-12, UI-05). Feeds the frontend CoverageBanner so a
|
* by sourcePortal (D-12, UI-05). Feeds the frontend CoverageBanner so a
|
||||||
@@ -399,7 +461,7 @@ export class TendersController {
|
|||||||
*/
|
*/
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@UseModule('tender-radar')
|
@UseModule('tender-radar')
|
||||||
async getTender(@Param('id') id: string) {
|
async getTender(@Param('id') id: string, @Req() req?: Request) {
|
||||||
const tender = await this.prisma.tender.findUnique({
|
const tender = await this.prisma.tender.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
@@ -411,6 +473,18 @@ export class TendersController {
|
|||||||
if (!tender) {
|
if (!tender) {
|
||||||
throw new NotFoundException('Tender not found');
|
throw new NotFoundException('Tender not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// D-13: a privately-owned (email-alert) tender is invisible to every
|
||||||
|
// OTHER tenant — surfaced as the SAME NotFoundException as a genuinely
|
||||||
|
// missing id, never a distinct "forbidden" response (no cross-tenant
|
||||||
|
// detail leak, e.g. confirming the id exists at all).
|
||||||
|
if ((tender as { ownerTenantId?: string | null }).ownerTenantId) {
|
||||||
|
const requestingTenantId = this.resolveRequestingTenantId(req);
|
||||||
|
if ((tender as { ownerTenantId?: string | null }).ownerTenantId !== requestingTenantId) {
|
||||||
|
throw new NotFoundException('Tender not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return tender;
|
return tender;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
import { cleanup, render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock @/lib/tender-radar-api
|
||||||
|
const mockFetchEmailConfig = vi.fn();
|
||||||
|
const mockSaveEmailConfig = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/tender-radar-api', () => ({
|
||||||
|
fetchEmailConfig: (...args: unknown[]) => mockFetchEmailConfig(...args),
|
||||||
|
saveEmailConfig: (...args: unknown[]) => mockSaveEmailConfig(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
mockFetchEmailConfig.mockReset();
|
||||||
|
mockSaveEmailConfig.mockReset();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const EXISTING_CONFIG = {
|
||||||
|
protocol: 'imap' as const,
|
||||||
|
host: 'imap.example.test',
|
||||||
|
port: 993,
|
||||||
|
encryption: 'ssl-tls' as const,
|
||||||
|
folder: 'INBOX',
|
||||||
|
senderFilter: null,
|
||||||
|
domain: null,
|
||||||
|
isActive: true,
|
||||||
|
username: 'alerts@example.test',
|
||||||
|
hasPassword: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('EmailAlertConfigForm', () => {
|
||||||
|
it('shows the "not yet saved" banner and defaults when no config exists (fresh tenant)', async () => {
|
||||||
|
mockFetchEmailConfig.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const { EmailAlertConfigForm } = await import('./EmailAlertConfigForm');
|
||||||
|
render(<EmailAlertConfigForm />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Noch nicht gespeichert/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(mockFetchEmailConfig).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads an existing config and never pre-fills the password field (T-07-12)', async () => {
|
||||||
|
mockFetchEmailConfig.mockResolvedValue(EXISTING_CONFIG);
|
||||||
|
|
||||||
|
const { EmailAlertConfigForm } = await import('./EmailAlertConfigForm');
|
||||||
|
render(<EmailAlertConfigForm />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByDisplayValue('imap.example.test')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByDisplayValue('alerts@example.test')).toBeInTheDocument();
|
||||||
|
const passwordInput = screen.getByLabelText(/^Passwort$/i) as HTMLInputElement;
|
||||||
|
expect(passwordInput.value).toBe('');
|
||||||
|
expect(screen.queryByText(/Noch nicht gespeichert/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking "Speichern" calls saveEmailConfig WITHOUT a password field when none was typed', async () => {
|
||||||
|
mockFetchEmailConfig.mockResolvedValue(null);
|
||||||
|
mockSaveEmailConfig.mockResolvedValue({ ...EXISTING_CONFIG, hasPassword: false, username: null });
|
||||||
|
|
||||||
|
const { EmailAlertConfigForm } = await import('./EmailAlertConfigForm');
|
||||||
|
render(<EmailAlertConfigForm />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Noch nicht gespeichert/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/IMAP-Server/i), {
|
||||||
|
target: { value: 'imap.example.test' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Speichern/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSaveEmailConfig).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
const payload = mockSaveEmailConfig.mock.calls[0][0];
|
||||||
|
expect(payload).not.toHaveProperty('password');
|
||||||
|
expect(payload.host).toBe('imap.example.test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking "Speichern" includes the password field only when a new one was typed (T-07-12)', async () => {
|
||||||
|
mockFetchEmailConfig.mockResolvedValue(EXISTING_CONFIG);
|
||||||
|
mockSaveEmailConfig.mockResolvedValue(EXISTING_CONFIG);
|
||||||
|
|
||||||
|
const { EmailAlertConfigForm } = await import('./EmailAlertConfigForm');
|
||||||
|
render(<EmailAlertConfigForm />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByDisplayValue('imap.example.test')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/^Passwort$/i), {
|
||||||
|
target: { value: 'new-secret' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Speichern/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSaveEmailConfig).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
const payload = mockSaveEmailConfig.mock.calls[0][0];
|
||||||
|
expect(payload.password).toBe('new-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switching protocol to exchange shows the EWS/domain fields and hides port/encryption', async () => {
|
||||||
|
mockFetchEmailConfig.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const { EmailAlertConfigForm } = await import('./EmailAlertConfigForm');
|
||||||
|
render(<EmailAlertConfigForm />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Noch nicht gespeichert/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/Protokoll/i), {
|
||||||
|
target: { value: 'exchange' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/EWS-Endpunkt-URL/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/Windows-Domäne/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText(/^Port \*$/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
+438
@@ -0,0 +1,438 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
type EmailAlertConfig,
|
||||||
|
fetchEmailConfig,
|
||||||
|
saveEmailConfig,
|
||||||
|
} from '@/lib/tender-radar-api';
|
||||||
|
|
||||||
|
/** Inline eye SVG (16×16) for show/hide password toggle */
|
||||||
|
function EyeIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<ellipse cx="8" cy="8" rx="6" ry="4" />
|
||||||
|
<circle cx="8" cy="8" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inline eye-off SVG (16×16) for show/hide password toggle */
|
||||||
|
function EyeOffIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<line x1="2" y1="2" x2="14" y2="14" />
|
||||||
|
<path d="M6.5 5.2A6 6 0 0114 8a9 9 0 01-1.2 2M4.5 4.5A6 6 0 002 8a6 6 0 008 4.5" />
|
||||||
|
<circle cx="8" cy="8" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form state — includes the password field (optional, only sent when the
|
||||||
|
* admin types a new one). T-07-12: password is never pre-filled from server
|
||||||
|
* data; the server only returns hasPassword.
|
||||||
|
*/
|
||||||
|
interface FormState {
|
||||||
|
protocol: 'imap' | 'exchange';
|
||||||
|
host: string;
|
||||||
|
port: string;
|
||||||
|
encryption: 'none' | 'starttls' | 'ssl-tls';
|
||||||
|
folder: string;
|
||||||
|
senderFilter: string;
|
||||||
|
username: string;
|
||||||
|
password: string; // intentionally blank on load (T-07-12)
|
||||||
|
domain: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FORM: FormState = {
|
||||||
|
protocol: 'imap',
|
||||||
|
host: '',
|
||||||
|
port: '993',
|
||||||
|
encryption: 'ssl-tls',
|
||||||
|
folder: 'INBOX',
|
||||||
|
senderFilter: '',
|
||||||
|
username: '',
|
||||||
|
password: '', // blank — T-07-12
|
||||||
|
domain: '',
|
||||||
|
isActive: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function configToForm(config: EmailAlertConfig): FormState {
|
||||||
|
return {
|
||||||
|
protocol: config.protocol,
|
||||||
|
host: config.host ?? '',
|
||||||
|
port: config.port !== null && config.port !== undefined ? String(config.port) : '993',
|
||||||
|
encryption: config.encryption,
|
||||||
|
folder: config.folder,
|
||||||
|
senderFilter: config.senderFilter ?? '',
|
||||||
|
username: config.username ?? '',
|
||||||
|
password: '', // NEVER pre-fill from server — T-07-12
|
||||||
|
domain: config.domain ?? '',
|
||||||
|
isActive: config.isActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formToPayload(
|
||||||
|
form: FormState,
|
||||||
|
): Partial<EmailAlertConfig> & { password?: string } {
|
||||||
|
const payload: Partial<EmailAlertConfig> & { password?: string } = {
|
||||||
|
protocol: form.protocol,
|
||||||
|
host: form.host || undefined,
|
||||||
|
port: form.port ? Number(form.port) : undefined,
|
||||||
|
encryption: form.encryption,
|
||||||
|
folder: form.folder,
|
||||||
|
senderFilter: form.senderFilter || undefined,
|
||||||
|
username: form.username || undefined,
|
||||||
|
domain: form.domain || undefined,
|
||||||
|
isActive: form.isActive,
|
||||||
|
};
|
||||||
|
// Only include password when the admin typed a new one (T-07-12)
|
||||||
|
if (form.password) {
|
||||||
|
payload.password = form.password;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EmailAlertConfigForm — per-tenant portal-alert mailbox config (Plan
|
||||||
|
* 14-03, INGEST-05/CONFIG-02, D-06/D-07/D-13). Mirrors the DKV Fleet
|
||||||
|
* `InboxConfigForm` (protocol/host/port/encryption/folder/senderFilter/
|
||||||
|
* domain/username/password/isActive) — password blank on load, only sent
|
||||||
|
* when the admin types a new one (T-07-12).
|
||||||
|
*
|
||||||
|
* Deliberately does NOT include a "Test Connection" button or an
|
||||||
|
* Abrufintervall field: this module's poll cadence is governed by the
|
||||||
|
* shared, platform-wide 'email-alert' TenderSourcePollConfig (D-15,
|
||||||
|
* pollGranularity='tick'), not a per-tenant setting, and no
|
||||||
|
* test-connection endpoint exists for this config (out of this plan's
|
||||||
|
* scope).
|
||||||
|
*
|
||||||
|
* Hardcoded German strings — full i18n is CONFIG-03 (Plan 14-05), matching
|
||||||
|
* the same intentional convention already used by SourceConfigForm/
|
||||||
|
* RssFeedListForm.
|
||||||
|
*/
|
||||||
|
export function EmailAlertConfigForm() {
|
||||||
|
const [form, setForm] = useState<FormState>(DEFAULT_FORM);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
|
|
||||||
|
// Whether config exists in DB (null = not yet saved)
|
||||||
|
const [configExists, setConfigExists] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEmailConfig()
|
||||||
|
.then((config) => {
|
||||||
|
if (config) {
|
||||||
|
setForm(configToForm(config));
|
||||||
|
setConfigExists(true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Keep defaults if fetch failed
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const update = useCallback(
|
||||||
|
(key: keyof FormState, value: string | boolean) => {
|
||||||
|
setForm((f) => ({ ...f, [key]: value }));
|
||||||
|
setSaveError(null);
|
||||||
|
setSaveSuccess(false);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
setSaveError(null);
|
||||||
|
setSaveSuccess(false);
|
||||||
|
try {
|
||||||
|
const result = await saveEmailConfig(formToPayload(form));
|
||||||
|
// configToForm() always blanks the password field (T-07-12)
|
||||||
|
setForm(configToForm(result));
|
||||||
|
setSaveSuccess(true);
|
||||||
|
setConfigExists(true);
|
||||||
|
} catch (err) {
|
||||||
|
setSaveError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Einstellungen konnten nicht gespeichert werden',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
'h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm text-foreground';
|
||||||
|
const labelCls = 'mb-1 block text-sm text-foreground';
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="mb-1 h-4 w-32 rounded bg-muted animate-pulse" />
|
||||||
|
<div className="h-9 max-w-md rounded bg-muted animate-pulse" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!configExists && (
|
||||||
|
<div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
|
||||||
|
Noch nicht gespeichert — Postfach-Konfiguration ausfüllen und
|
||||||
|
speichern.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Protokoll */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email-alert-protocol" className={labelCls}>
|
||||||
|
Protokoll *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="email-alert-protocol"
|
||||||
|
className={inputCls}
|
||||||
|
value={form.protocol}
|
||||||
|
onChange={(e) =>
|
||||||
|
update('protocol', e.target.value as 'imap' | 'exchange')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="imap">IMAP</option>
|
||||||
|
<option value="exchange">Exchange</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Host / EWS-URL */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email-alert-host" className={labelCls}>
|
||||||
|
{form.protocol === 'exchange' ? 'EWS-Endpunkt-URL' : 'IMAP-Server'} *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email-alert-host"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder={
|
||||||
|
form.protocol === 'exchange'
|
||||||
|
? 'https://mail.firma.de/EWS/Exchange.asmx'
|
||||||
|
: 'imap.firma.de'
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
value={form.host}
|
||||||
|
onChange={(e) => update('host', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* IMAP-only: Port */}
|
||||||
|
{form.protocol === 'imap' && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email-alert-port" className={labelCls}>
|
||||||
|
Port *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email-alert-port"
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
className={inputCls}
|
||||||
|
value={form.port}
|
||||||
|
onChange={(e) => update('port', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* IMAP-only: Verschlüsselung */}
|
||||||
|
{form.protocol === 'imap' && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email-alert-encryption" className={labelCls}>
|
||||||
|
Verschlüsselung *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="email-alert-encryption"
|
||||||
|
className={inputCls}
|
||||||
|
value={form.encryption}
|
||||||
|
onChange={(e) =>
|
||||||
|
update(
|
||||||
|
'encryption',
|
||||||
|
e.target.value as 'none' | 'starttls' | 'ssl-tls',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="none">Keine</option>
|
||||||
|
<option value="starttls">STARTTLS</option>
|
||||||
|
<option value="ssl-tls">SSL-TLS</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ordner / Postfach */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email-alert-folder" className={labelCls}>
|
||||||
|
Ordner
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email-alert-folder"
|
||||||
|
type="text"
|
||||||
|
placeholder={form.protocol === 'exchange' ? 'Inbox' : 'INBOX'}
|
||||||
|
className={inputCls}
|
||||||
|
value={form.folder}
|
||||||
|
onChange={(e) => update('folder', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Exchange-only: Domain */}
|
||||||
|
{form.protocol === 'exchange' && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email-alert-domain" className={labelCls}>
|
||||||
|
Windows-Domäne
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email-alert-domain"
|
||||||
|
type="text"
|
||||||
|
placeholder="CONTOSO"
|
||||||
|
className={inputCls}
|
||||||
|
value={form.domain}
|
||||||
|
onChange={(e) => update('domain', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Absenderfilter */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email-alert-sender-filter" className={labelCls}>
|
||||||
|
Absenderfilter
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email-alert-sender-filter"
|
||||||
|
type="email"
|
||||||
|
placeholder="alerts@vergabeportal.de"
|
||||||
|
className={inputCls}
|
||||||
|
value={form.senderFilter}
|
||||||
|
onChange={(e) => update('senderFilter', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Benutzername */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email-alert-username" className={labelCls}>
|
||||||
|
Benutzername
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email-alert-username"
|
||||||
|
type="text"
|
||||||
|
className={inputCls}
|
||||||
|
value={form.username}
|
||||||
|
onChange={(e) => update('username', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Passwort — T-07-12: never pre-filled from server */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email-alert-password" className={labelCls}>
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
<div className="relative max-w-md">
|
||||||
|
<input
|
||||||
|
id="email-alert-password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
className="h-9 w-full rounded border border-border bg-background px-3 pr-10 text-sm text-foreground"
|
||||||
|
value={form.password}
|
||||||
|
placeholder="••••••••"
|
||||||
|
onChange={(e) => update('password', e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aktiv — toggle switch */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label
|
||||||
|
htmlFor="email-alert-active"
|
||||||
|
className="text-sm text-foreground cursor-pointer"
|
||||||
|
>
|
||||||
|
Aktiv
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
id="email-alert-active"
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={form.isActive}
|
||||||
|
onClick={() => update('isActive', !form.isActive)}
|
||||||
|
className="relative flex-shrink-0 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 22,
|
||||||
|
background: form.isActive ? 'var(--primary)' : 'var(--muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute top-0.5 rounded-full bg-white shadow transition-transform"
|
||||||
|
style={{
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
left: 2,
|
||||||
|
transform: form.isActive ? 'translateX(18px)' : 'translateX(0)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form actions */}
|
||||||
|
<div className="flex gap-3 pt-4 border-t border-border mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Wird gespeichert...' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveSuccess && (
|
||||||
|
<p className="text-sm" style={{ color: 'oklch(0.40 0.15 148)' }}>
|
||||||
|
Einstellungen gespeichert.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{saveError && <p className="text-sm text-destructive">{saveError}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
saveNotificationPref,
|
saveNotificationPref,
|
||||||
type NotificationPref,
|
type NotificationPref,
|
||||||
} from '@/lib/tender-radar-api';
|
} from '@/lib/tender-radar-api';
|
||||||
|
import { EmailAlertConfigForm } from './components/EmailAlertConfigForm';
|
||||||
import { RssFeedListForm } from './components/RssFeedListForm';
|
import { RssFeedListForm } from './components/RssFeedListForm';
|
||||||
import { SourceConfigForm } from './components/SourceConfigForm';
|
import { SourceConfigForm } from './components/SourceConfigForm';
|
||||||
|
|
||||||
@@ -30,6 +31,11 @@ import { SourceConfigForm } from './components/SourceConfigForm';
|
|||||||
* (not per-tenant) RSS feed sources. Extends this existing settings page
|
* (not per-tenant) RSS feed sources. Extends this existing settings page
|
||||||
* rather than building a new one (D-09).
|
* rather than building a new one (D-09).
|
||||||
*
|
*
|
||||||
|
* Plan 14-03 (INGEST-05, D-06/D-07/D-09/D-13) adds an "E-Mail-Alerts"
|
||||||
|
* section: EmailAlertConfigForm, the PER-TENANT portal-alert mailbox
|
||||||
|
* config (separate from RSS's global feed list and DKV's own, separate
|
||||||
|
* invoice mailbox, D-03). Same "extend, don't rebuild" stance as RSS-Feeds.
|
||||||
|
*
|
||||||
* No module-loader whitelist change is needed here: this is a standard
|
* No module-loader whitelist change is needed here: this is a standard
|
||||||
* Next.js App Router route nested under the already-whitelisted
|
* Next.js App Router route nested under the already-whitelisted
|
||||||
* `tender-radar` module page (Plan 10-02).
|
* `tender-radar` module page (Plan 10-02).
|
||||||
@@ -100,6 +106,19 @@ export default function TenderRadarSettingsPage() {
|
|||||||
<RssFeedListForm />
|
<RssFeedListForm />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 border-t border-border pt-6">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||||
|
E-Mail-Alerts
|
||||||
|
</h2>
|
||||||
|
<p className="mb-4 text-xs text-muted-foreground">
|
||||||
|
Postfach, in das Vergabeportale Ihre Benachrichtigungsmails
|
||||||
|
schicken — pro Mandant konfigurierbar, getrennt vom
|
||||||
|
DKV-Rechnungspostfach. Aus diesen Mails erkannte Ausschreibungen
|
||||||
|
sind nur für Ihren Mandanten sichtbar.
|
||||||
|
</p>
|
||||||
|
<EmailAlertConfigForm />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 border-t border-border pt-6">
|
<div className="mt-8 border-t border-border pt-6">
|
||||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||||
Benachrichtigungen
|
Benachrichtigungen
|
||||||
|
|||||||
@@ -446,3 +446,68 @@ export async function deleteRssFeed(id: string): Promise<void> {
|
|||||||
throw new Error(await extractErrorMessage(res, 'Failed to delete RSS feed'));
|
throw new Error(await extractErrorMessage(res, 'Failed to delete RSS feed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This tenant's portal-alert mailbox config (Plan 14-03, INGEST-05/
|
||||||
|
* CONFIG-02, D-06/D-07). Unlike SourceConfig/RssFeedSource (platform-wide),
|
||||||
|
* this is PER-TENANT — the backend derives tenantId from the auth cookie,
|
||||||
|
* this client never sends a tenantId.
|
||||||
|
*
|
||||||
|
* Security (T-07-12): the password is NEVER returned — only `hasPassword`.
|
||||||
|
* The password field is only sent in `saveEmailConfig`'s payload when the
|
||||||
|
* admin has typed a new one (same InboxConfigForm/DKV convention).
|
||||||
|
*/
|
||||||
|
export interface EmailAlertConfig {
|
||||||
|
protocol: 'imap' | 'exchange';
|
||||||
|
host: string | null;
|
||||||
|
port: number | null;
|
||||||
|
encryption: 'none' | 'starttls' | 'ssl-tls';
|
||||||
|
folder: string;
|
||||||
|
senderFilter?: string | null;
|
||||||
|
domain?: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
username?: string | null;
|
||||||
|
/** true if a password is stored server-side — never the actual secret */
|
||||||
|
hasPassword: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch this tenant's email-alert mailbox config. Returns null when no
|
||||||
|
* config has been saved yet (fresh tenant — mirrors DkvConfig's fetchConfig
|
||||||
|
* convention).
|
||||||
|
* GET /modules/tender-radar/email-config
|
||||||
|
*/
|
||||||
|
export async function fetchEmailConfig(): Promise<EmailAlertConfig | null> {
|
||||||
|
const res = await fetch(`${API_URL}/modules/tender-radar/email-config`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
await extractErrorMessage(res, 'Failed to fetch email-alert config'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save this tenant's email-alert mailbox config.
|
||||||
|
* PUT /modules/tender-radar/email-config
|
||||||
|
* Only include `password` in the payload when the admin typed a new one
|
||||||
|
* (T-07-12 — blank-on-load convention).
|
||||||
|
*/
|
||||||
|
export async function saveEmailConfig(
|
||||||
|
payload: Partial<EmailAlertConfig> & { password?: string },
|
||||||
|
): Promise<EmailAlertConfig> {
|
||||||
|
const res = await fetch(`${API_URL}/modules/tender-radar/email-config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
await extractErrorMessage(res, 'Failed to save email-alert config'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user