From 48e12523f34c6e2ec0432e96aeba0a4c8db8799c Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 23 Jul 2026 13:53:17 +0200 Subject: [PATCH] feat(14-03): add email-config admin routes + D-13 read-side visibility filter + EmailAlertConfigForm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/tenders/tender-query.builder.spec.ts | 20 + apps/api/src/tenders/tender-query.builder.ts | 20 + .../src/tenders/tenders.controller.spec.ts | 276 +++++++++++ apps/api/src/tenders/tenders.controller.ts | 78 +++- .../components/EmailAlertConfigForm.test.tsx | 128 +++++ .../components/EmailAlertConfigForm.tsx | 438 ++++++++++++++++++ .../modules/tender-radar/settings/page.tsx | 19 + apps/web/src/lib/tender-radar-api.ts | 65 +++ 8 files changed, 1042 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.test.tsx create mode 100644 apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.tsx diff --git a/apps/api/src/tenders/tender-query.builder.spec.ts b/apps/api/src/tenders/tender-query.builder.spec.ts index 69af103..7a277fa 100644 --- a/apps/api/src/tenders/tender-query.builder.spec.ts +++ b/apps/api/src/tenders/tender-query.builder.spec.ts @@ -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', () => { it('sort=deadline maps to { deadlineAt: asc }', () => { expect(buildOrderBy('deadline')).toEqual({ deadlineAt: 'asc' }); diff --git a/apps/api/src/tenders/tender-query.builder.ts b/apps/api/src/tenders/tender-query.builder.ts index 3188aac..d3aed5a 100644 --- a/apps/api/src/tenders/tender-query.builder.ts +++ b/apps/api/src/tenders/tender-query.builder.ts @@ -31,10 +31,20 @@ const MAX_FAV_IDS = 500; * `TenderTriageService.favoriteIds(userId)`) from the auth context — * never accepted here as user input. Only consulted when `dto.favOnly` is * 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( dto: TenderQueryDto, favIds?: string[], + ownerTenantId?: string, ): Prisma.TenderWhereInput { const where: Prisma.TenderWhereInput = {}; const AND: Prisma.TenderWhereInput[] = []; @@ -126,6 +136,16 @@ export function buildTenderWhere( 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; return where; } diff --git a/apps/api/src/tenders/tenders.controller.spec.ts b/apps/api/src/tenders/tenders.controller.spec.ts index 79bd6a6..e6fee1f 100644 --- a/apps/api/src/tenders/tenders.controller.spec.ts +++ b/apps/api/src/tenders/tenders.controller.spec.ts @@ -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 * 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, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, + makeFakeEmailConfigService() as any, ); await controller.listTenders({}); @@ -167,6 +180,7 @@ describe('TendersController — global read (not tenant-scoped)', () => { makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, + makeFakeEmailConfigService() as any, ); const result = await controller.getTender('t1'); @@ -187,6 +201,7 @@ describe('TendersController — global read (not tenant-scoped)', () => { makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, + makeFakeEmailConfigService() as any, ); const result = await controller.getTender('t1'); @@ -222,6 +237,7 @@ describe('TendersController — global read (not tenant-scoped)', () => { makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, + makeFakeEmailConfigService() as any, ); 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, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, + makeFakeEmailConfigService() as any, ); await controller.saveSourceConfig({ isActive: true, pollIntervalMin: 30 }); @@ -260,6 +277,7 @@ describe('TendersController — admin source-config applies live to the schedule makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, + makeFakeEmailConfigService() as any, ); await controller.saveSourceConfig({ isActive: false }); @@ -313,6 +331,19 @@ describe('TendersController — route declaration order (static route before :id 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)', () => { const methods = Object.getOwnPropertyNames(TendersController.prototype); const listTriageIdx = methods.indexOf('listTriage'); @@ -363,6 +394,7 @@ describe('TendersController — GET/PUT notification-pref (NOTIFY-01, per-user, makeFakeSavedSearchService() as any, prefService as any, makeFakeRssFeedService() as any, + makeFakeEmailConfigService() as any, ); await controller.getNotificationPref(makeFakeRequest('u-real')); @@ -382,6 +414,7 @@ describe('TendersController — GET/PUT notification-pref (NOTIFY-01, per-user, makeFakeSavedSearchService() as any, prefService as any, makeFakeRssFeedService() as any, + makeFakeEmailConfigService() as any, ); await controller.setNotificationPref( @@ -406,6 +439,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1 savedSearchService as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, + makeFakeEmailConfigService() as any, ); 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, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, + makeFakeEmailConfigService() as any, ); await controller.createSavedSearch( @@ -450,6 +485,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1 savedSearchService as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, + makeFakeEmailConfigService() as any, ); await controller.updateSavedSearch( @@ -475,6 +511,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1 savedSearchService as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, + makeFakeEmailConfigService() as any, ); 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, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, + makeFakeEmailConfigService() 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, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, + makeFakeEmailConfigService() 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, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, + makeFakeEmailConfigService() as any, ); await controller.listTenders({ page: 3, limit: 10 } as any); @@ -566,6 +606,7 @@ describe('TendersController — GET /coverage', () => { makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, + makeFakeEmailConfigService() as any, ); const result = await controller.getCoverage(); @@ -595,6 +636,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () = makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, + makeFakeEmailConfigService() as any, ); 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, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, + makeFakeEmailConfigService() as any, ); 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, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, + makeFakeEmailConfigService() as any, ); 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, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, + makeFakeEmailConfigService() as any, ); await controller.setTriage( @@ -680,6 +725,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)', makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, + makeFakeEmailConfigService() as any, ); 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, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, + makeFakeEmailConfigService() as any, ); 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, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, + makeFakeEmailConfigService() as any, ); 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, makeFakeNotificationPrefService() as any, rssFeedService as any, + makeFakeEmailConfigService() as any, ); 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, makeFakeNotificationPrefService() as any, rssFeedService as any, + makeFakeEmailConfigService() 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, makeFakeNotificationPrefService() as any, rssFeedService as any, + makeFakeEmailConfigService() as any, ); await expect( @@ -811,6 +862,7 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', ( makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, rssFeedService as any, + makeFakeEmailConfigService() as any, ); 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 }); }); }); + +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([ + ['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); + }); +}); diff --git a/apps/api/src/tenders/tenders.controller.ts b/apps/api/src/tenders/tenders.controller.ts index fce0b9d..26fe343 100644 --- a/apps/api/src/tenders/tenders.controller.ts +++ b/apps/api/src/tenders/tenders.controller.ts @@ -20,9 +20,11 @@ import { PrismaService } from '../prisma/prisma.service'; import { UpdateNotificationPrefDto } from './dto/notification-pref.dto'; import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.dto'; import { SourceConfigDto } from './dto/source-config.dto'; +import { TenderEmailConfigDto } from './dto/tender-email-config.dto'; import { TenderQueryDto } from './dto/tender-query.dto'; import { TenderRssFeedDto } from './dto/tender-rss-feed.dto'; import { TenderTriageDto } from './dto/tender-triage.dto'; +import { TenderEmailConfigService } from './tender-email-config.service'; import { TenderNotificationPrefService } from './tender-notification-pref.service'; import { TenderRssFeedSourceService } from './tender-rss-feed.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 * shared platform-wide poll schedule is a platform-admin action, not a * 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') export class TendersController { @@ -64,6 +76,7 @@ export class TendersController { private readonly tenderSavedSearch: TenderSavedSearchService, private readonly tenderNotificationPref: TenderNotificationPrefService, private readonly tenderRssFeedSource: TenderRssFeedSourceService, + private readonly tenderEmailConfig: TenderEmailConfigService, ) {} /** @@ -86,6 +99,18 @@ export class TendersController { 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) ──────────────────── /** @@ -123,7 +148,11 @@ export class TendersController { 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 [items, total] = await Promise.all([ @@ -202,6 +231,39 @@ export class TendersController { 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 * by sourcePortal (D-12, UI-05). Feeds the frontend CoverageBanner so a @@ -399,7 +461,7 @@ export class TendersController { */ @Get(':id') @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({ where: { id }, include: { @@ -411,6 +473,18 @@ export class TendersController { if (!tender) { 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; } diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.test.tsx new file mode 100644 index 0000000..a500881 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.tsx new file mode 100644 index 0000000..c56afd4 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.tsx @@ -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 ( + + ); +} + +/** Inline eye-off SVG (16×16) for show/hide password toggle */ +function EyeOffIcon() { + return ( + + ); +} + +/** + * 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 & { password?: string } { + const payload: Partial & { 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(DEFAULT_FORM); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [showPassword, setShowPassword] = useState(false); + + const [saveError, setSaveError] = useState(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 ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ); + } + + return ( +
+ {!configExists && ( +
+ Noch nicht gespeichert — Postfach-Konfiguration ausfüllen und + speichern. +
+ )} + + {/* Protokoll */} +
+ + +
+ + {/* Host / EWS-URL */} +
+ + update('host', e.target.value)} + /> +
+ + {/* IMAP-only: Port */} + {form.protocol === 'imap' && ( +
+ + update('port', e.target.value)} + /> +
+ )} + + {/* IMAP-only: Verschlüsselung */} + {form.protocol === 'imap' && ( +
+ + +
+ )} + + {/* Ordner / Postfach */} +
+ + update('folder', e.target.value)} + /> +
+ + {/* Exchange-only: Domain */} + {form.protocol === 'exchange' && ( +
+ + update('domain', e.target.value)} + /> +
+ )} + + {/* Absenderfilter */} +
+ + update('senderFilter', e.target.value)} + /> +
+ + {/* Benutzername */} +
+ + update('username', e.target.value)} + /> +
+ + {/* Passwort — T-07-12: never pre-filled from server */} +
+ +
+ update('password', e.target.value)} + /> + +
+
+ + {/* Aktiv — toggle switch */} +
+ + +
+ + {/* Form actions */} +
+ +
+ + {saveSuccess && ( +

+ Einstellungen gespeichert. +

+ )} + {saveError &&

{saveError}

} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx index 4138772..3b30bb9 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx @@ -6,6 +6,7 @@ import { saveNotificationPref, type NotificationPref, } from '@/lib/tender-radar-api'; +import { EmailAlertConfigForm } from './components/EmailAlertConfigForm'; import { RssFeedListForm } from './components/RssFeedListForm'; 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 * 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 * Next.js App Router route nested under the already-whitelisted * `tender-radar` module page (Plan 10-02). @@ -100,6 +106,19 @@ export default function TenderRadarSettingsPage() {
+
+

+ E-Mail-Alerts +

+

+ 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. +

+ +
+

Benachrichtigungen diff --git a/apps/web/src/lib/tender-radar-api.ts b/apps/web/src/lib/tender-radar-api.ts index 8dec559..8237960 100644 --- a/apps/web/src/lib/tender-radar-api.ts +++ b/apps/web/src/lib/tender-radar-api.ts @@ -446,3 +446,68 @@ export async function deleteRssFeed(id: string): Promise { 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 { + 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 & { password?: string }, +): Promise { + 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(); +}