feat(17-01): move TenderEmailConfig ownership from tenant to user

Alert-Postfach gehoert jetzt dem einzelnen Nutzer (userId @unique) statt
dem Mandanten (D-01) — ein zweiter Kollege desselben Mandanten kann sein
eigenes Postfach anbinden. tenantId bleibt denormalisiert (SMTP-Aufloesung,
Herkunftsmarkierung), wird auf create UND update mitgeschrieben.

- Handgeschriebene Migration (prisma migrate dev verweigert die
  nicht-interaktive Shell): befuellt Bestandszeilen mit dem aeltesten
  aktiven Administrator ihres Mandanten, entfernt verwaiste Zeilen ohne
  Administrator, ersetzt die tenantId-Eindeutigkeit durch userId.
  Lokal getestet (0 Bestandszeilen lokal und auf alpha — Zaehlung im
  Task-1-Checkpoint), Index-Ergebnis verifiziert.
- TenderEmailConfigService.getConfigForApi/saveConfig auf userId als
  Schluessel umgestellt; saveConfig nimmt {userId, tenantId}.
- TendersController: email-config-Routen von @Roles(ADMIN,SUPER_ADMIN)
  auf @UseModule('tender-radar') umgestellt (Postfach ist jetzt
  Nutzereinstellung); Route-Reihenfolge vor @Get(':id') unveraendert.
- Neue Seite /modules/tender-radar/my-sources ("Meine Quellen") mit dem
  unveraenderten EmailAlertConfigForm; Hinweistext benennt D-05 (Tender
  bleibt plattform-global — nur wer Quellen einspeist aendert sich).
- tenders.controller.spec.ts an neue Service-Signatur angepasst (Rule 3,
  nicht im Plan gelistet, aber zum Kompilieren/Bestehen erforderlich).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 11:19:55 +02:00
parent 42a2c7703f
commit 05b1d293d8
9 changed files with 363 additions and 115 deletions
@@ -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");
+8 -1
View File
@@ -275,9 +275,15 @@ model DkvModuleConfig {
// DKV invoice inbox). Credentials are encrypted via CalendarCryptoService // DKV invoice inbox). Credentials are encrypted via CalendarCryptoService
// (same AES-256-GCM iv:authTag:ciphertext format as DkvModuleConfig/ // (same AES-256-GCM iv:authTag:ciphertext format as DkvModuleConfig/
// SmtpConfig) and excluded from every API response (Safe-Select, T-07-12). // 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 { model TenderEmailConfig {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String @unique userId String @unique
tenantId String
protocol String @default("imap") // 'imap' | 'exchange' protocol String @default("imap") // 'imap' | 'exchange'
host String? host String?
port Int? port Int?
@@ -291,6 +297,7 @@ model TenderEmailConfig {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([tenantId]) @@index([tenantId])
@@index([userId])
} }
model DkvVehicleMaster { model DkvVehicleMaster {
@@ -7,6 +7,12 @@ import { TenderEmailConfigService } from './tender-email-config.service';
* (deterministic reversible encode, NOT real AES) — same convention as * (deterministic reversible encode, NOT real AES) — same convention as
* tender-dedup.service.spec.ts: no live DB/crypto dependency, just proving * tender-dedup.service.spec.ts: no live DB/crypto dependency, just proving
* this service's own encrypt-preserve-empty / safe-select contract. * 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() { function makeFakeCrypto() {
@@ -24,7 +30,7 @@ function makeFakePrisma() {
return { return {
tenderEmailConfig: { tenderEmailConfig: {
findUnique: vi.fn(async ({ where, select }: any) => { findUnique: vi.fn(async ({ where, select }: any) => {
const row = configs.get(where.tenantId); const row = configs.get(where.userId);
if (!row) return null; if (!row) return null;
if (!select) return row; if (!select) return row;
const out: any = {}; const out: any = {};
@@ -32,9 +38,9 @@ function makeFakePrisma() {
return out; return out;
}), }),
upsert: vi.fn(async ({ where, update, create, select }: any) => { upsert: vi.fn(async ({ where, update, create, select }: any) => {
const existing = configs.get(where.tenantId); const existing = configs.get(where.userId);
const row = existing ? { ...existing, ...update } : { id: 'cfg-1', ...create }; const row = existing ? { ...existing, ...update } : { id: `cfg-${configs.size + 1}`, ...create };
configs.set(where.tenantId, row); configs.set(where.userId, row);
if (!select) return row; if (!select) return row;
const out: any = {}; const out: any = {};
for (const k of Object.keys(select)) out[k] = row[k]; for (const k of Object.keys(select)) out[k] = row[k];
@@ -46,12 +52,12 @@ function makeFakePrisma() {
} }
describe('TenderEmailConfigService', () => { 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 prisma = makeFakePrisma();
const crypto = makeFakeCrypto(); const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any); 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(); expect(result).toBeNull();
}); });
@@ -61,18 +67,21 @@ describe('TenderEmailConfigService', () => {
const crypto = makeFakeCrypto(); const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any); const service = new TenderEmailConfigService(prisma as any, crypto as any);
await service.saveConfig('tenant-a', { await service.saveConfig(
protocol: 'imap', { userId: 'user-a', tenantId: 'tenant-a' },
encryption: 'ssl-tls', {
host: 'imap.example.test', protocol: 'imap',
port: 993, encryption: 'ssl-tls',
folder: 'INBOX', host: 'imap.example.test',
username: 'alerts@example.test', port: 993,
password: 'super-secret', folder: 'INBOX',
isActive: true, username: 'alerts@example.test',
} as any); 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.toBeNull();
expect(apiResult).not.toHaveProperty('password'); expect(apiResult).not.toHaveProperty('password');
@@ -86,16 +95,19 @@ describe('TenderEmailConfigService', () => {
const crypto = makeFakeCrypto(); const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any); const service = new TenderEmailConfigService(prisma as any, crypto as any);
await service.saveConfig('tenant-b', { await service.saveConfig(
protocol: 'imap', { userId: 'user-b', tenantId: 'tenant-b' },
encryption: 'ssl-tls', {
host: 'imap.example.test', protocol: 'imap',
port: 993, encryption: 'ssl-tls',
folder: 'INBOX', host: 'imap.example.test',
isActive: false, port: 993,
} as any); 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!.hasPassword).toBe(false);
expect(apiResult!.username).toBeNull(); expect(apiResult!.username).toBeNull();
@@ -107,21 +119,27 @@ describe('TenderEmailConfigService', () => {
const crypto = makeFakeCrypto(); const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any); const service = new TenderEmailConfigService(prisma as any, crypto as any);
await service.saveConfig('tenant-c', { await service.saveConfig(
protocol: 'imap', { userId: 'user-c', tenantId: 'tenant-c' },
encryption: 'ssl-tls', {
username: 'old@example.test', protocol: 'imap',
password: 'original-secret', encryption: 'ssl-tls',
} as any); username: 'old@example.test',
password: 'original-secret',
} as any,
);
// Re-save with a new username, password left blank (T-07-12 UI convention) // Re-save with a new username, password left blank (T-07-12 UI convention)
await service.saveConfig('tenant-c', { await service.saveConfig(
protocol: 'imap', { userId: 'user-c', tenantId: 'tenant-c' },
encryption: 'ssl-tls', {
username: 'new@example.test', protocol: 'imap',
} as any); 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)); const decrypted = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds));
expect(decrypted.username).toBe('new@example.test'); expect(decrypted.username).toBe('new@example.test');
expect(decrypted.password).toBe('original-secret'); expect(decrypted.password).toBe('original-secret');
@@ -132,20 +150,26 @@ describe('TenderEmailConfigService', () => {
const crypto = makeFakeCrypto(); const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any); const service = new TenderEmailConfigService(prisma as any, crypto as any);
await service.saveConfig('tenant-d', { await service.saveConfig(
protocol: 'imap', { userId: 'user-d', tenantId: 'tenant-d' },
encryption: 'ssl-tls', {
username: 'stable@example.test', protocol: 'imap',
password: 'first-secret', encryption: 'ssl-tls',
} as any); username: 'stable@example.test',
password: 'first-secret',
} as any,
);
await service.saveConfig('tenant-d', { await service.saveConfig(
protocol: 'imap', { userId: 'user-d', tenantId: 'tenant-d' },
encryption: 'ssl-tls', {
password: 'rotated-secret', protocol: 'imap',
} as any); 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)); const decrypted = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds));
expect(decrypted.username).toBe('stable@example.test'); expect(decrypted.username).toBe('stable@example.test');
expect(decrypted.password).toBe('rotated-secret'); expect(decrypted.password).toBe('rotated-secret');
@@ -156,14 +180,54 @@ describe('TenderEmailConfigService', () => {
const crypto = makeFakeCrypto(); const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any); const service = new TenderEmailConfigService(prisma as any, crypto as any);
const result = await service.saveConfig('tenant-e', { const result = await service.saveConfig(
protocol: 'imap', { userId: 'user-e', tenantId: 'tenant-e' },
encryption: 'ssl-tls', {
username: 'x@example.test', protocol: 'imap',
password: 'y', encryption: 'ssl-tls',
} as any); username: 'x@example.test',
password: 'y',
} as any,
);
expect(result).not.toHaveProperty('encryptedInboxCreds'); expect(result).not.toHaveProperty('encryptedInboxCreds');
expect(result).not.toHaveProperty('password'); 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);
});
}); });
@@ -10,6 +10,7 @@ import type { TenderEmailConfigDto } from './dto/tender-email-config.dto';
*/ */
const EMAIL_CONFIG_SAFE_SELECT = { const EMAIL_CONFIG_SAFE_SELECT = {
id: true, id: true,
userId: true,
tenantId: true, tenantId: true,
protocol: true, protocol: true,
host: true, host: true,
@@ -25,25 +26,36 @@ const EMAIL_CONFIG_SAFE_SELECT = {
} as const; } as const;
/** /**
* TenderEmailConfigService — per-tenant admin CRUD for the portal-alert * TenderEmailConfigService — per-USER CRUD for the portal-alert mailbox
* mailbox config (Phase 14, Plan 03, INGEST-05/CONFIG-02, D-06/D-07). * config (Phase 14, Plan 03, INGEST-05/CONFIG-02, D-06/D-07; ownership
* Structural clone of DkvService's config half (safe-select + encrypt- * moved from tenant to user in Phase 17, Plan 01, D-01). Structural clone
* preserve-empty semantics), mirroring the exact same pattern already * of DkvService's config half (safe-select + encrypt-preserve-empty
* proven for DKV's own (separate, D-03) mailbox config. * 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: * Security:
* - T-07-12: encryptedInboxCreds is excluded from every read-path select; * - T-07-12: encryptedInboxCreds is excluded from every read-path select;
* getConfigForApi returns `hasPassword: boolean` instead of the password. * getConfigForApi returns `hasPassword: boolean` instead of the password.
* - T-05-13: decrypted credentials only ever exist within a method's local * - T-05-13: decrypted credentials only ever exist within a method's local
* scope — never logged. * 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 * This service is used ONLY by the GET/PUT /email-config routes
* (TendersController). EmailAlertAdapter's own per-tenant poll-time fan-out * (TendersController). EmailAlertAdapter's own poll-time fan-out decrypts
* decrypts credentials independently via a direct CryptoService * credentials independently via a direct CryptoService injection
* injection (RESEARCH.md Pattern 1) — it does NOT go through this service, * (RESEARCH.md Pattern 1) — it does NOT go through this service, since the
* since the adapter's cross-tenant `findMany({where:{isActive:true}})` read * adapter's cross-tenant `findMany({where:{isActive:true}})` read is a
* is a deliberate platform-scheduler exception (see EmailAlertAdapter's * deliberate platform-scheduler exception (see EmailAlertAdapter's
* docstring), structurally different from this service's tenant-scoped CRUD. * docstring), structurally different from this service's per-user CRUD.
*/ */
@Injectable() @Injectable()
export class TenderEmailConfigService { export class TenderEmailConfigService {
@@ -54,11 +66,12 @@ export class TenderEmailConfigService {
/** /**
* Load config for API response: safe fields + decrypted username + * 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({ const safe = await this.prisma.tenderEmailConfig.findUnique({
where: { tenantId }, where: { userId },
select: EMAIL_CONFIG_SAFE_SELECT, select: EMAIL_CONFIG_SAFE_SELECT,
}); });
if (!safe) return null; if (!safe) return null;
@@ -66,7 +79,7 @@ export class TenderEmailConfigService {
let username: string | null = null; let username: string | null = null;
let hasPassword = false; let hasPassword = false;
try { try {
const raw = await this.prisma.tenderEmailConfig.findUnique({ where: { tenantId } }); const raw = await this.prisma.tenderEmailConfig.findUnique({ where: { userId } });
if (raw?.encryptedInboxCreds) { if (raw?.encryptedInboxCreds) {
const creds = JSON.parse(this.crypto.decrypt(raw.encryptedInboxCreds)) as { const creds = JSON.parse(this.crypto.decrypt(raw.encryptedInboxCreds)) as {
username?: string; 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): * Credential handling (identical semantics to DkvService.saveConfig):
* - dto.password non-empty: re-encrypt {username, password} together. * - dto.password non-empty: re-encrypt {username, password} together.
@@ -91,10 +104,15 @@ export class TenderEmailConfigService {
* password, re-encrypt with the new username. * password, re-encrypt with the new username.
* - both empty/undefined: preserve existing encryptedInboxCreds entirely. * - 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-07-12: Returns safe select (no encryptedInboxCreds).
* T-05-13: Never logs decrypted credentials. * 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; let encryptedInboxCreds: string | undefined;
const credChanged = const credChanged =
@@ -107,7 +125,7 @@ export class TenderEmailConfigService {
if (!dto.password || !dto.username) { if (!dto.password || !dto.username) {
try { try {
const existing = await this.prisma.tenderEmailConfig.findUnique({ where: { tenantId } }); const existing = await this.prisma.tenderEmailConfig.findUnique({ where: { userId } });
if (existing?.encryptedInboxCreds) { if (existing?.encryptedInboxCreds) {
const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as { const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as {
username?: string; username?: string;
@@ -136,10 +154,13 @@ export class TenderEmailConfigService {
...(encryptedInboxCreds !== undefined && { encryptedInboxCreds }), ...(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({ return this.prisma.tenderEmailConfig.upsert({
where: { tenantId }, where: { userId },
create: { tenantId, ...data }, create: { userId, tenantId, ...data },
update: data, update: { tenantId, ...data },
select: EMAIL_CONFIG_SAFE_SELECT, select: EMAIL_CONFIG_SAFE_SELECT,
}); });
} }
+44 -10
View File
@@ -104,13 +104,17 @@ function makeFakeRssFeedService() {
/** /**
* Fake TenderEmailConfigService for controller-level wiring tests (Plan * Fake TenderEmailConfigService for controller-level wiring tests (Plan
* 14-03, D-06/D-07/CONFIG-02). Default stubs echo/return null; individual * 14-03, D-06/D-07/CONFIG-02; per-user ownership since Phase 17, Plan 01,
* tests override via `.mockResolvedValueOnce`/reassigning the mock. * D-01). Default stubs echo/return null; individual tests override via
* `.mockResolvedValueOnce`/reassigning the mock.
*/ */
function makeFakeEmailConfigService() { function makeFakeEmailConfigService() {
return { return {
getConfigForApi: vi.fn(async (_tenantId: string) => null as any), getConfigForApi: vi.fn(async (_userId: string) => null as any),
saveConfig: vi.fn(async (_tenantId: string, dto: any) => ({ id: 'ec-1', ...dto })), 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)', () => { 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 tenantId from the auth context and delegates to tenderEmailConfig.getConfigForApi(tenantId)', async () => { it('GET /email-config resolves userId from the auth context and delegates to tenderEmailConfig.getConfigForApi(userId)', async () => {
const prisma = makeFakePrisma(); const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const emailConfigService = makeFakeEmailConfigService(); const emailConfigService = makeFakeEmailConfigService();
emailConfigService.getConfigForApi.mockResolvedValueOnce({ emailConfigService.getConfigForApi.mockResolvedValueOnce({
userId: 'u1',
tenantId: 'tenant1', tenantId: 'tenant1',
protocol: 'imap', protocol: 'imap',
hasPassword: true, 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')); const result = await controller.getEmailConfig(makeFakeRequest('u1', 'tenant1'));
expect(emailConfigService.getConfigForApi).toHaveBeenCalledWith('tenant1'); expect(emailConfigService.getConfigForApi).toHaveBeenCalledWith('u1');
expect(result).toEqual({ tenantId: 'tenant1', protocol: 'imap', hasPassword: true }); 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 prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const emailConfigService = makeFakeEmailConfigService(); 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; const dto = { protocol: 'imap', encryption: 'ssl-tls', host: 'imap.example.test' } as any;
await controller.saveEmailConfig(dto, makeFakeRequest('u1', 'tenant1')); 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');
}); });
}); });
+31 -26
View File
@@ -59,15 +59,17 @@ const DOE_SOURCE_TYPE = 'doe-opendata';
* 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 * Phase 14, Plan 03 (INGEST-05, D-13) originally made `GET`/`PUT
* `source-config`/`rss-feeds`, per-handler `@Roles(ADMIN, SUPER_ADMIN)`- * /email-config` per-handler `@Roles(ADMIN, SUPER_ADMIN)`-guarded and
* guarded — but UNLIKE those (platform-wide singletons/lists), the email * per-TENANT. Phase 17, Plan 01 (D-01) changed this: the mailbox is now
* mailbox config is per-TENANT: tenantId is resolved from the auth context, * per-USER — every user with module access connects their own inbox, so
* never the body (T-14-03-05/IDOR). This is also the plan that breaks the * these two routes are `@UseModule('tender-radar')`-gated like the other
* "GET/GET :id are never row-scoped" invariant above, narrowly: `listTenders`/ * per-user routes below, and `userId` (not `tenantId`) is the ownership
* `getTender` now resolve the requesting tenant to apply the D-13 OR[global, * key, resolved from the auth context, never the body (T-14-03-05/T-17-01/
* mine] visibility filter for PRIVATE (email-alert) tenders only — public * IDOR). `listTenders`/`getTender` still resolve the requesting tenant to
* tenders (D-03) remain visible to every tenant exactly as before. * 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') @Controller('modules/tender-radar')
export class TendersController { export class TendersController {
@@ -268,37 +270,40 @@ 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) ────── // ─── E-Mail-Alerts config (ModuleGuard-gated, PER-USER, Phase 17 D-01) ─────
/** /**
* GET /modules/tender-radar/email-config — this tenant's portal-alert * GET /modules/tender-radar/email-config — the requesting USER's own
* mailbox config (safe-select — never the password, T-07-12). Unlike * portal-alert mailbox config (safe-select — never the password,
* `source-config`/`rss-feeds` (platform-wide), this is PER-TENANT: * T-07-12). Phase 17 (D-01): ownership moved from tenant to user — every
* tenantId is resolved from the auth context, never a query/body field * user with module access manages their own mailbox, so the Roles guard
* (T-14-03-05 / V4 — IDOR). * (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 * MUST be declared before `@Get(':id')` below — same route-order pitfall
* as `source-config`/`coverage`/`triage`/`rss-feeds`/... above (Pitfall 5). * as `source-config`/`coverage`/`triage`/`rss-feeds`/... above (Pitfall 5).
*/ */
@Get('email-config') @Get('email-config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @UseModule('tender-radar')
async getEmailConfig(@Req() req: Request) { async getEmailConfig(@Req() req: Request) {
const { tenantId } = this.extractTriageContext(req); const { userId } = this.extractTriageContext(req);
return this.tenderEmailConfig.getConfigForApi(tenantId); return this.tenderEmailConfig.getConfigForApi(userId);
} }
/** /**
* PUT /modules/tender-radar/email-config — upsert this tenant's mailbox * PUT /modules/tender-radar/email-config — upsert the requesting USER's
* config. tenantId comes exclusively from the auth context — `dto` never * own mailbox config. userId/tenantId come exclusively from the auth
* carries a tenantId field (T-14-03-05 / V4 — IDOR). Credential * context — `dto` never carries either field (T-14-03-05 / T-17-01 / V4
* encrypt-preserve-empty semantics live in TenderEmailConfigService * — IDOR). Credential encrypt-preserve-empty semantics live in
* (mirrors DkvService.saveConfig / T-07-12). * TenderEmailConfigService (mirrors DkvService.saveConfig / T-07-12).
*/ */
@Put('email-config') @Put('email-config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @UseModule('tender-radar')
async saveEmailConfig(@Body() dto: TenderEmailConfigDto, @Req() req: Request) { async saveEmailConfig(@Body() dto: TenderEmailConfigDto, @Req() req: Request) {
const { tenantId } = this.extractTriageContext(req); const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderEmailConfig.saveConfig(tenantId, dto); return this.tenderEmailConfig.saveConfig({ userId, tenantId }, dto);
} }
/** /**
@@ -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 (
<div className="mx-auto max-w-2xl p-6">
<h1 className="text-2xl font-semibold tracking-tight mb-2">
{t('mySources.title')}
</h1>
<p className="mb-6 text-sm text-muted-foreground">
{t('mySources.intro')}
</p>
<div>
<h2 className="text-lg font-semibold text-foreground mb-4">
{t('mySources.mailboxSectionTitle')}
</h2>
<EmailAlertConfigForm />
</div>
</div>
);
}
+5
View File
@@ -933,6 +933,11 @@
"save": "Speichern", "save": "Speichern",
"saveSuccess": "Einstellungen gespeichert.", "saveSuccess": "Einstellungen gespeichert.",
"errorSave": "Einstellungen konnten nicht gespeichert werden" "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"
} }
} }
} }
+5
View File
@@ -933,6 +933,11 @@
"save": "Save", "save": "Save",
"saveSuccess": "Settings saved.", "saveSuccess": "Settings saved.",
"errorSave": "Could not save settings" "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"
} }
} }
} }