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:
2026-07-23 13:53:17 +02:00
parent 1be6b15249
commit 48e12523f3
8 changed files with 1042 additions and 2 deletions
@@ -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);
});
});
+76 -2
View File
@@ -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;
} }
@@ -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();
});
});
@@ -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
+65
View File
@@ -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();
}