diff --git a/apps/api/prisma/migrations/20260812100000_tender_email_config_per_user/migration.sql b/apps/api/prisma/migrations/20260812100000_tender_email_config_per_user/migration.sql new file mode 100644 index 0000000..fdabde6 --- /dev/null +++ b/apps/api/prisma/migrations/20260812100000_tender_email_config_per_user/migration.sql @@ -0,0 +1,53 @@ +-- Phase 17 (D-01): das Alert-Postfach ("TenderEmailConfig") gehoerte bisher +-- dem MANDANTEN (tenantId eindeutig) -- ein zweiter Kollege mit eigenem +-- Portal-Konto konnte sein Postfach nicht anbinden, weil es bereits eines +-- fuer den ganzen Mandanten gab. Ab dieser Migration gehoert das Postfach +-- dem NUTZER (userId eindeutig); tenantId bleibt als gewoehnliches, +-- denormalisiertes Feld erhalten (SMTP-Aufloesung, Herkunftsmarkierung der +-- eingelesenen Ausschreibungen -- gleiche Rolle wie in TenderMatch). +-- +-- Eine bereits vorhandene Postfach-Zeile bekommt dabei den AELTESTEN +-- AKTIVEN Administrator (ADMIN oder SUPER_ADMIN) ihres Mandanten als +-- Besitzer zugeordnet, nicht geloescht: die Zeile enthaelt verschluesselte +-- Zugangsdaten und wurde seinerzeit von genau dieser Rolle angelegt: ein +-- Loeschen wuerde den laufenden Abruf ohne Vorwarnung stilllegen. Nur wenn +-- ein Mandant ueberhaupt keinen aktiven Administrator hat, wird die Zeile +-- entfernt -- sonst waere sie fuer niemanden mehr bearbeitbar, wuerde aber +-- im Hintergrund weiter abgefragt. + +-- 1. Neue Spalte zunaechst ohne Pflicht, damit Bestandszeilen befuellt +-- werden koennen, bevor NOT NULL erzwungen wird. +ALTER TABLE "TenderEmailConfig" ADD COLUMN "userId" TEXT; + +-- 2. Bestandszeilen: aeltester aktiver Administrator desselben Mandanten +-- (sortiert nach createdAt, bei Gleichstand nach id, genau einer). +UPDATE "TenderEmailConfig" AS tec +SET "userId" = ( + SELECT u."id" + FROM "User" u + WHERE u."tenantId" = tec."tenantId" + AND u."isActive" = true + AND u."role" IN ('ADMIN', 'SUPER_ADMIN') + ORDER BY u."createdAt" ASC, u."id" ASC + LIMIT 1 +) +WHERE tec."userId" IS NULL; + +-- 3. Zeilen ohne gefundenen Administrator entfernen (kein Mandanten-Admin +-- vorhanden -- siehe Begruendung oben). +DELETE FROM "TenderEmailConfig" WHERE "userId" IS NULL; + +-- 4. Alte Eindeutigkeitsregel "ein Postfach pro Mandant" aufheben. Der +-- gewoehnliche Index auf tenantId (TenderEmailConfig_tenantId_idx) +-- bleibt bestehen -- tenantId wird weiterhin gefiltert (SMTP-Aufloesung). +DROP INDEX "TenderEmailConfig_tenantId_key"; + +-- 5. Neue Eindeutigkeitsregel "ein Postfach pro Nutzer" -- erst NOT NULL +-- setzen, nachdem jede Zeile einen Besitzer hat (Schritte 2/3). +ALTER TABLE "TenderEmailConfig" ALTER COLUMN "userId" SET NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "TenderEmailConfig_userId_key" ON "TenderEmailConfig"("userId"); + +-- CreateIndex +CREATE INDEX "TenderEmailConfig_userId_idx" ON "TenderEmailConfig"("userId"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index d47f1f2..4cbb475 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -275,9 +275,15 @@ model DkvModuleConfig { // DKV invoice inbox). Credentials are encrypted via CalendarCryptoService // (same AES-256-GCM iv:authTag:ciphertext format as DkvModuleConfig/ // SmtpConfig) and excluded from every API response (Safe-Select, T-07-12). +// Phase 17 (D-01): ownership lives at the USER, not the tenant — each user +// connects their own alert mailbox, so two colleagues at the same tenant can +// each run their own inbox in parallel. `tenantId` stays as a denormalized +// field (SMTP resolution, ownerTenantId tagging on ingested Tender rows) — +// same role as `tenantId` on TenderMatch, not the ownership key anymore. model TenderEmailConfig { id String @id @default(uuid()) - tenantId String @unique + userId String @unique + tenantId String protocol String @default("imap") // 'imap' | 'exchange' host String? port Int? @@ -291,6 +297,7 @@ model TenderEmailConfig { updatedAt DateTime @updatedAt @@index([tenantId]) + @@index([userId]) } model DkvVehicleMaster { diff --git a/apps/api/src/tenders/tender-email-config.service.spec.ts b/apps/api/src/tenders/tender-email-config.service.spec.ts index 3828117..a37cf70 100644 --- a/apps/api/src/tenders/tender-email-config.service.spec.ts +++ b/apps/api/src/tenders/tender-email-config.service.spec.ts @@ -7,6 +7,12 @@ import { TenderEmailConfigService } from './tender-email-config.service'; * (deterministic reversible encode, NOT real AES) — same convention as * tender-dedup.service.spec.ts: no live DB/crypto dependency, just proving * this service's own encrypt-preserve-empty / safe-select contract. + * + * Phase 17, Plan 01 (D-01): ownership moved from tenantId to userId — the + * fake prisma below is now keyed by userId (matches the real + * `where: { userId }` upsert target), and two new cases prove the actual + * new capability: two users of the SAME tenant get two independent rows, + * and tenantId is written on create (denormalized, D-01). */ function makeFakeCrypto() { @@ -24,7 +30,7 @@ function makeFakePrisma() { return { tenderEmailConfig: { findUnique: vi.fn(async ({ where, select }: any) => { - const row = configs.get(where.tenantId); + const row = configs.get(where.userId); if (!row) return null; if (!select) return row; const out: any = {}; @@ -32,9 +38,9 @@ function makeFakePrisma() { return out; }), upsert: vi.fn(async ({ where, update, create, select }: any) => { - const existing = configs.get(where.tenantId); - const row = existing ? { ...existing, ...update } : { id: 'cfg-1', ...create }; - configs.set(where.tenantId, row); + const existing = configs.get(where.userId); + const row = existing ? { ...existing, ...update } : { id: `cfg-${configs.size + 1}`, ...create }; + configs.set(where.userId, row); if (!select) return row; const out: any = {}; for (const k of Object.keys(select)) out[k] = row[k]; @@ -46,12 +52,12 @@ function makeFakePrisma() { } describe('TenderEmailConfigService', () => { - it('getConfigForApi returns null when no config exists for the tenant', async () => { + it('getConfigForApi returns null when no config exists for the user', async () => { const prisma = makeFakePrisma(); const crypto = makeFakeCrypto(); const service = new TenderEmailConfigService(prisma as any, crypto as any); - const result = await service.getConfigForApi('tenant-missing'); + const result = await service.getConfigForApi('user-missing'); expect(result).toBeNull(); }); @@ -61,18 +67,21 @@ describe('TenderEmailConfigService', () => { const crypto = makeFakeCrypto(); const service = new TenderEmailConfigService(prisma as any, crypto as any); - await service.saveConfig('tenant-a', { - protocol: 'imap', - encryption: 'ssl-tls', - host: 'imap.example.test', - port: 993, - folder: 'INBOX', - username: 'alerts@example.test', - password: 'super-secret', - isActive: true, - } as any); + await service.saveConfig( + { userId: 'user-a', tenantId: 'tenant-a' }, + { + protocol: 'imap', + encryption: 'ssl-tls', + host: 'imap.example.test', + port: 993, + folder: 'INBOX', + username: 'alerts@example.test', + password: 'super-secret', + isActive: true, + } as any, + ); - const apiResult = await service.getConfigForApi('tenant-a'); + const apiResult = await service.getConfigForApi('user-a'); expect(apiResult).not.toBeNull(); expect(apiResult).not.toHaveProperty('password'); @@ -86,16 +95,19 @@ describe('TenderEmailConfigService', () => { const crypto = makeFakeCrypto(); const service = new TenderEmailConfigService(prisma as any, crypto as any); - await service.saveConfig('tenant-b', { - protocol: 'imap', - encryption: 'ssl-tls', - host: 'imap.example.test', - port: 993, - folder: 'INBOX', - isActive: false, - } as any); + await service.saveConfig( + { userId: 'user-b', tenantId: 'tenant-b' }, + { + protocol: 'imap', + encryption: 'ssl-tls', + host: 'imap.example.test', + port: 993, + folder: 'INBOX', + isActive: false, + } as any, + ); - const apiResult = await service.getConfigForApi('tenant-b'); + const apiResult = await service.getConfigForApi('user-b'); expect(apiResult!.hasPassword).toBe(false); expect(apiResult!.username).toBeNull(); @@ -107,21 +119,27 @@ describe('TenderEmailConfigService', () => { const crypto = makeFakeCrypto(); const service = new TenderEmailConfigService(prisma as any, crypto as any); - await service.saveConfig('tenant-c', { - protocol: 'imap', - encryption: 'ssl-tls', - username: 'old@example.test', - password: 'original-secret', - } as any); + await service.saveConfig( + { userId: 'user-c', tenantId: 'tenant-c' }, + { + protocol: 'imap', + encryption: 'ssl-tls', + username: 'old@example.test', + password: 'original-secret', + } as any, + ); // Re-save with a new username, password left blank (T-07-12 UI convention) - await service.saveConfig('tenant-c', { - protocol: 'imap', - encryption: 'ssl-tls', - username: 'new@example.test', - } as any); + await service.saveConfig( + { userId: 'user-c', tenantId: 'tenant-c' }, + { + protocol: 'imap', + encryption: 'ssl-tls', + username: 'new@example.test', + } as any, + ); - const raw = prisma.__store.get('tenant-c'); + const raw = prisma.__store.get('user-c'); const decrypted = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds)); expect(decrypted.username).toBe('new@example.test'); expect(decrypted.password).toBe('original-secret'); @@ -132,20 +150,26 @@ describe('TenderEmailConfigService', () => { const crypto = makeFakeCrypto(); const service = new TenderEmailConfigService(prisma as any, crypto as any); - await service.saveConfig('tenant-d', { - protocol: 'imap', - encryption: 'ssl-tls', - username: 'stable@example.test', - password: 'first-secret', - } as any); + await service.saveConfig( + { userId: 'user-d', tenantId: 'tenant-d' }, + { + protocol: 'imap', + encryption: 'ssl-tls', + username: 'stable@example.test', + password: 'first-secret', + } as any, + ); - await service.saveConfig('tenant-d', { - protocol: 'imap', - encryption: 'ssl-tls', - password: 'rotated-secret', - } as any); + await service.saveConfig( + { userId: 'user-d', tenantId: 'tenant-d' }, + { + protocol: 'imap', + encryption: 'ssl-tls', + password: 'rotated-secret', + } as any, + ); - const raw = prisma.__store.get('tenant-d'); + const raw = prisma.__store.get('user-d'); const decrypted = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds)); expect(decrypted.username).toBe('stable@example.test'); expect(decrypted.password).toBe('rotated-secret'); @@ -156,14 +180,54 @@ describe('TenderEmailConfigService', () => { const crypto = makeFakeCrypto(); const service = new TenderEmailConfigService(prisma as any, crypto as any); - const result = await service.saveConfig('tenant-e', { - protocol: 'imap', - encryption: 'ssl-tls', - username: 'x@example.test', - password: 'y', - } as any); + const result = await service.saveConfig( + { userId: 'user-e', tenantId: 'tenant-e' }, + { + protocol: 'imap', + encryption: 'ssl-tls', + username: 'x@example.test', + password: 'y', + } as any, + ); expect(result).not.toHaveProperty('encryptedInboxCreds'); expect(result).not.toHaveProperty('password'); }); + + it('saveConfig writes BOTH userId and tenantId on create (Phase 17, D-01: tenantId stays denormalized)', async () => { + const prisma = makeFakePrisma(); + const crypto = makeFakeCrypto(); + const service = new TenderEmailConfigService(prisma as any, crypto as any); + + await service.saveConfig( + { userId: 'user-f', tenantId: 'tenant-f' }, + { protocol: 'imap', encryption: 'ssl-tls' } as any, + ); + + const raw = prisma.__store.get('user-f'); + expect(raw.userId).toBe('user-f'); + expect(raw.tenantId).toBe('tenant-f'); + }); + + it('two users of the SAME tenant each get their own row — the second save never overwrites the first (Phase 17, D-01)', async () => { + const prisma = makeFakePrisma(); + const crypto = makeFakeCrypto(); + const service = new TenderEmailConfigService(prisma as any, crypto as any); + + await service.saveConfig( + { userId: 'user-g1', tenantId: 'tenant-shared' }, + { protocol: 'imap', encryption: 'ssl-tls', host: 'imap.user-g1.test' } as any, + ); + await service.saveConfig( + { userId: 'user-g2', tenantId: 'tenant-shared' }, + { protocol: 'imap', encryption: 'ssl-tls', host: 'imap.user-g2.test' } as any, + ); + + const configG1 = await service.getConfigForApi('user-g1'); + const configG2 = await service.getConfigForApi('user-g2'); + + expect(configG1!.host).toBe('imap.user-g1.test'); + expect(configG2!.host).toBe('imap.user-g2.test'); + expect(prisma.__store.size).toBe(2); + }); }); diff --git a/apps/api/src/tenders/tender-email-config.service.ts b/apps/api/src/tenders/tender-email-config.service.ts index 8d63c91..bc17c24 100644 --- a/apps/api/src/tenders/tender-email-config.service.ts +++ b/apps/api/src/tenders/tender-email-config.service.ts @@ -10,6 +10,7 @@ import type { TenderEmailConfigDto } from './dto/tender-email-config.dto'; */ const EMAIL_CONFIG_SAFE_SELECT = { id: true, + userId: true, tenantId: true, protocol: true, host: true, @@ -25,25 +26,36 @@ const EMAIL_CONFIG_SAFE_SELECT = { } as const; /** - * TenderEmailConfigService — per-tenant admin CRUD for the portal-alert - * mailbox config (Phase 14, Plan 03, INGEST-05/CONFIG-02, D-06/D-07). - * Structural clone of DkvService's config half (safe-select + encrypt- - * preserve-empty semantics), mirroring the exact same pattern already - * proven for DKV's own (separate, D-03) mailbox config. + * TenderEmailConfigService — per-USER CRUD for the portal-alert mailbox + * config (Phase 14, Plan 03, INGEST-05/CONFIG-02, D-06/D-07; ownership + * moved from tenant to user in Phase 17, Plan 01, D-01). Structural clone + * of DkvService's config half (safe-select + encrypt-preserve-empty + * semantics), mirroring the exact same pattern already proven for DKV's + * own (separate, D-03) mailbox config. + * + * Ownership (Phase 17, D-01): a mailbox belongs to exactly one user + * (`userId @unique`) — two users at the same tenant each connect their own + * inbox independently, neither can read or overwrite the other's config. + * `tenantId` stays denormalized on every row (SMTP resolution, same role as + * `tenantId` on TenderMatch) and is written on both create and update, + * since a user's tenant can in principle change. * * Security: * - T-07-12: encryptedInboxCreds is excluded from every read-path select; * getConfigForApi returns `hasPassword: boolean` instead of the password. * - T-05-13: decrypted credentials only ever exist within a method's local * scope — never logged. + * - T-17-01: userId/tenantId are supplied by the caller from the auth + * context (TendersController.extractTriageContext) — this service never + * derives ownership from anything the DTO carries. * - * This service is used ONLY by the admin GET/PUT /email-config routes - * (TendersController). EmailAlertAdapter's own per-tenant poll-time fan-out - * decrypts credentials independently via a direct CryptoService - * injection (RESEARCH.md Pattern 1) — it does NOT go through this service, - * since the adapter's cross-tenant `findMany({where:{isActive:true}})` read - * is a deliberate platform-scheduler exception (see EmailAlertAdapter's - * docstring), structurally different from this service's tenant-scoped CRUD. + * This service is used ONLY by the GET/PUT /email-config routes + * (TendersController). EmailAlertAdapter's own poll-time fan-out decrypts + * credentials independently via a direct CryptoService injection + * (RESEARCH.md Pattern 1) — it does NOT go through this service, since the + * adapter's cross-tenant `findMany({where:{isActive:true}})` read is a + * deliberate platform-scheduler exception (see EmailAlertAdapter's + * docstring), structurally different from this service's per-user CRUD. */ @Injectable() export class TenderEmailConfigService { @@ -54,11 +66,12 @@ export class TenderEmailConfigService { /** * Load config for API response: safe fields + decrypted username + - * hasPassword flag. T-07-12: password is NEVER returned. + * hasPassword flag. T-07-12: password is NEVER returned. Scoped strictly + * by userId (T-17-01) — a user only ever reads their own mailbox. */ - async getConfigForApi(tenantId: string) { + async getConfigForApi(userId: string) { const safe = await this.prisma.tenderEmailConfig.findUnique({ - where: { tenantId }, + where: { userId }, select: EMAIL_CONFIG_SAFE_SELECT, }); if (!safe) return null; @@ -66,7 +79,7 @@ export class TenderEmailConfigService { let username: string | null = null; let hasPassword = false; try { - const raw = await this.prisma.tenderEmailConfig.findUnique({ where: { tenantId } }); + const raw = await this.prisma.tenderEmailConfig.findUnique({ where: { userId } }); if (raw?.encryptedInboxCreds) { const creds = JSON.parse(this.crypto.decrypt(raw.encryptedInboxCreds)) as { username?: string; @@ -83,7 +96,7 @@ export class TenderEmailConfigService { } /** - * Upsert TenderEmailConfig for a tenant. + * Upsert TenderEmailConfig for a user. * * Credential handling (identical semantics to DkvService.saveConfig): * - dto.password non-empty: re-encrypt {username, password} together. @@ -91,10 +104,15 @@ export class TenderEmailConfigService { * password, re-encrypt with the new username. * - both empty/undefined: preserve existing encryptedInboxCreds entirely. * + * tenantId is written on both create AND update (Phase 17, D-01): it is + * denormalized, so if the resolved tenant for this user ever changes the + * stored value must follow. + * * T-07-12: Returns safe select (no encryptedInboxCreds). * T-05-13: Never logs decrypted credentials. */ - async saveConfig(tenantId: string, dto: TenderEmailConfigDto) { + async saveConfig(ctx: { userId: string; tenantId: string }, dto: TenderEmailConfigDto) { + const { userId, tenantId } = ctx; let encryptedInboxCreds: string | undefined; const credChanged = @@ -107,7 +125,7 @@ export class TenderEmailConfigService { if (!dto.password || !dto.username) { try { - const existing = await this.prisma.tenderEmailConfig.findUnique({ where: { tenantId } }); + const existing = await this.prisma.tenderEmailConfig.findUnique({ where: { userId } }); if (existing?.encryptedInboxCreds) { const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as { username?: string; @@ -136,10 +154,13 @@ export class TenderEmailConfigService { ...(encryptedInboxCreds !== undefined && { encryptedInboxCreds }), }; + // tenantId is written on BOTH create and update — it is denormalized + // (Phase 17, D-01), so a user's resolved tenant must always overwrite + // whatever was stored previously, not just be set once on create. return this.prisma.tenderEmailConfig.upsert({ - where: { tenantId }, - create: { tenantId, ...data }, - update: data, + where: { userId }, + create: { userId, tenantId, ...data }, + update: { tenantId, ...data }, select: EMAIL_CONFIG_SAFE_SELECT, }); } diff --git a/apps/api/src/tenders/tenders.controller.spec.ts b/apps/api/src/tenders/tenders.controller.spec.ts index d8931da..979fe84 100644 --- a/apps/api/src/tenders/tenders.controller.spec.ts +++ b/apps/api/src/tenders/tenders.controller.spec.ts @@ -104,13 +104,17 @@ 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. + * 14-03, D-06/D-07/CONFIG-02; per-user ownership since Phase 17, Plan 01, + * D-01). 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 })), + getConfigForApi: vi.fn(async (_userId: string) => null as any), + saveConfig: vi.fn(async (_ctx: { userId: string; tenantId: string }, dto: any) => ({ + id: 'ec-1', + ...dto, + })), }; } @@ -959,12 +963,13 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', ( }); }); -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 () => { +describe('TendersController — email-config (Plan 14-03, per-user since Phase 17 Plan 01 D-01, T-14-03-05/T-17-01)', () => { + it('GET /email-config resolves userId from the auth context and delegates to tenderEmailConfig.getConfigForApi(userId)', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const emailConfigService = makeFakeEmailConfigService(); emailConfigService.getConfigForApi.mockResolvedValueOnce({ + userId: 'u1', tenantId: 'tenant1', protocol: 'imap', hasPassword: true, @@ -981,11 +986,16 @@ describe('TendersController — email-config (Plan 14-03, per-tenant, D-06/D-07/ const result = await controller.getEmailConfig(makeFakeRequest('u1', 'tenant1')); - expect(emailConfigService.getConfigForApi).toHaveBeenCalledWith('tenant1'); - expect(result).toEqual({ tenantId: 'tenant1', protocol: 'imap', hasPassword: true }); + expect(emailConfigService.getConfigForApi).toHaveBeenCalledWith('u1'); + expect(result).toEqual({ + userId: 'u1', + tenantId: 'tenant1', + protocol: 'imap', + hasPassword: true, + }); }); - it('PUT /email-config delegates to tenderEmailConfig.saveConfig with tenantId from the auth context, never the body', async () => { + it('PUT /email-config delegates to tenderEmailConfig.saveConfig with {userId, 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(); @@ -1002,7 +1012,31 @@ describe('TendersController — email-config (Plan 14-03, per-tenant, D-06/D-07/ 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); + expect(emailConfigService.saveConfig).toHaveBeenCalledWith( + { userId: 'u1', tenantId: 'tenant1' }, + dto, + ); + }); + + it('two different users of the same tenant each resolve their own userId — never the other user\'s (T-17-01, IDOR)', 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, + ); + + await controller.getEmailConfig(makeFakeRequest('user-a', 'tenant1')); + await controller.getEmailConfig(makeFakeRequest('user-b', 'tenant1')); + + expect(emailConfigService.getConfigForApi).toHaveBeenNthCalledWith(1, 'user-a'); + expect(emailConfigService.getConfigForApi).toHaveBeenNthCalledWith(2, 'user-b'); }); }); diff --git a/apps/api/src/tenders/tenders.controller.ts b/apps/api/src/tenders/tenders.controller.ts index e3861bc..23fb63c 100644 --- a/apps/api/src/tenders/tenders.controller.ts +++ b/apps/api/src/tenders/tenders.controller.ts @@ -59,15 +59,17 @@ const DOE_SOURCE_TYPE = 'doe-opendata'; * 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. + * Phase 14, Plan 03 (INGEST-05, D-13) originally made `GET`/`PUT + * /email-config` per-handler `@Roles(ADMIN, SUPER_ADMIN)`-guarded and + * per-TENANT. Phase 17, Plan 01 (D-01) changed this: the mailbox is now + * per-USER — every user with module access connects their own inbox, so + * these two routes are `@UseModule('tender-radar')`-gated like the other + * per-user routes below, and `userId` (not `tenantId`) is the ownership + * key, resolved from the auth context, never the body (T-14-03-05/T-17-01/ + * IDOR). `listTenders`/`getTender` still resolve the requesting tenant to + * apply the D-13 OR[global, mine] visibility filter for PRIVATE + * (email-alert) tenders — public tenders (D-03) remain visible to every + * tenant exactly as before; that part of D-13 is unaffected by D-01. */ @Controller('modules/tender-radar') export class TendersController { @@ -268,37 +270,40 @@ export class TendersController { return this.tenderRssFeedSource.remove(feedId); } - // ─── E-Mail-Alerts config (Roles-guarded, PER-TENANT, D-06/D-07/D-13) ────── + // ─── E-Mail-Alerts config (ModuleGuard-gated, PER-USER, Phase 17 D-01) ───── /** - * 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). + * GET /modules/tender-radar/email-config — the requesting USER's own + * portal-alert mailbox config (safe-select — never the password, + * T-07-12). Phase 17 (D-01): ownership moved from tenant to user — every + * user with module access manages their own mailbox, so the Roles guard + * (previously ADMIN/SUPER_ADMIN only) is replaced with `@UseModule`, the + * same access gate as every other per-user route below. `userId` comes + * exclusively from the auth context, never a query/body field + * (T-14-03-05 / T-17-01 / 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) + @UseModule('tender-radar') async getEmailConfig(@Req() req: Request) { - const { tenantId } = this.extractTriageContext(req); - return this.tenderEmailConfig.getConfigForApi(tenantId); + const { userId } = this.extractTriageContext(req); + return this.tenderEmailConfig.getConfigForApi(userId); } /** - * 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 /modules/tender-radar/email-config — upsert the requesting USER's + * own mailbox config. userId/tenantId come exclusively from the auth + * context — `dto` never carries either field (T-14-03-05 / T-17-01 / 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) + @UseModule('tender-radar') async saveEmailConfig(@Body() dto: TenderEmailConfigDto, @Req() req: Request) { - const { tenantId } = this.extractTriageContext(req); - return this.tenderEmailConfig.saveConfig(tenantId, dto); + const { userId, tenantId } = this.extractTriageContext(req); + return this.tenderEmailConfig.saveConfig({ userId, tenantId }, dto); } /** diff --git a/apps/web/src/app/(portal)/modules/tender-radar/my-sources/page.tsx b/apps/web/src/app/(portal)/modules/tender-radar/my-sources/page.tsx new file mode 100644 index 0000000..5ba8258 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/tender-radar/my-sources/page.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { EmailAlertConfigForm } from '../settings/components/EmailAlertConfigForm'; + +/** + * "Meine Quellen" — the per-user Ausschreibungs-Radar module page (Phase + * 17, Plan 01, D-01/D-05). + * + * Until this plan, the alert mailbox lived on the admin-only + * `/modules/tender-radar/settings` page, one config per TENANT — a second + * colleague with their own portal account could not connect their own + * inbox. Phase 17 moves ownership to the USER (TenderEmailConfig.userId). + * This page is where every user with module access manages their own + * mailbox, reusing the existing `EmailAlertConfigForm` UNCHANGED — it + * already talks to the same `GET`/`PUT /modules/tender-radar/email-config` + * endpoints, which now resolve ownership by userId instead of tenantId + * (TendersController.getEmailConfig/saveEmailConfig). + * + * Deliberately a SEPARATE page from `/settings` (D-01 open point 4), not a + * new section on `/settings/general`: module-specific settings stay with + * the module rather than accumulating on a generic account page as more + * modules adopt the same per-user pattern (DKV-Fleet, future modules). + * + * D-05 (harte Grenze): `Tender` stays platform-global — connecting a + * mailbox here only changes WHO feeds sources in, not WHO sees hits. The + * intro text below says so explicitly, so nobody is surprised that a + * colleague's inbox produces results everyone at the tenant can see. + * + * No module-loader whitelist change needed — nested route under the + * already-whitelisted `tender-radar` module page (same reasoning as + * `/settings`, Plan 10-02). + */ +export default function TenderRadarMySourcesPage() { + const t = useTranslations('tenderRadar'); + + return ( +
+

+ {t('mySources.title')} +

+

+ {t('mySources.intro')} +

+ +
+

+ {t('mySources.mailboxSectionTitle')} +

+ +
+
+ ); +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index cb3d77b..6f53af6 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -933,6 +933,11 @@ "save": "Speichern", "saveSuccess": "Einstellungen gespeichert.", "errorSave": "Einstellungen konnten nicht gespeichert werden" + }, + "mySources": { + "title": "Meine Quellen", + "intro": "Ausschreibungen, die aus Ihrem Postfach hereinkommen, erscheinen danach in der Trefferliste aller Kolleginnen und Kollegen Ihres Mandanten — es gibt keine getrennte Sichtbarkeit je Postfach.", + "mailboxSectionTitle": "Mein Postfach" } } } diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 4c723ee..48939d6 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -933,6 +933,11 @@ "save": "Save", "saveSuccess": "Settings saved.", "errorSave": "Could not save settings" + }, + "mySources": { + "title": "My sources", + "intro": "Tenders that arrive through your mailbox will then appear in the results list for every colleague at your organization — there is no separate visibility per mailbox.", + "mailboxSectionTitle": "My mailbox" } } }