Files
tessera-ctl/apps/api/src/tenders/tender-email-config.service.ts
T
schalli 05b1d293d8 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>
2026-08-12 11:19:55 +02:00

168 lines
6.5 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { CryptoService } from '../crypto/crypto.service';
import { PrismaService } from '../prisma/prisma.service';
import type { TenderEmailConfigDto } from './dto/tender-email-config.dto';
/**
* Prisma select for TenderEmailConfig — never includes encryptedInboxCreds.
* T-07-12: Encrypted credential blob is excluded from all API responses.
*/
const EMAIL_CONFIG_SAFE_SELECT = {
id: true,
userId: true,
tenantId: true,
protocol: true,
host: true,
port: true,
encryption: true,
folder: true,
senderFilter: true,
domain: true,
isActive: true,
createdAt: true,
updatedAt: true,
// encryptedInboxCreds: NEVER included — T-07-12
} as const;
/**
* 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 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 {
constructor(
private readonly prisma: PrismaService,
private readonly crypto: CryptoService,
) {}
/**
* Load config for API response: safe fields + decrypted username +
* 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(userId: string) {
const safe = await this.prisma.tenderEmailConfig.findUnique({
where: { userId },
select: EMAIL_CONFIG_SAFE_SELECT,
});
if (!safe) return null;
let username: string | null = null;
let hasPassword = false;
try {
const raw = await this.prisma.tenderEmailConfig.findUnique({ where: { userId } });
if (raw?.encryptedInboxCreds) {
const creds = JSON.parse(this.crypto.decrypt(raw.encryptedInboxCreds)) as {
username?: string;
password?: string;
};
username = creds.username ?? null;
hasPassword = Boolean(creds.password);
}
} catch {
/* ignore decrypt errors — return empty username, matches DkvService.getConfigForApi */
}
return { ...safe, username, hasPassword };
}
/**
* Upsert TenderEmailConfig for a user.
*
* Credential handling (identical semantics to DkvService.saveConfig):
* - dto.password non-empty: re-encrypt {username, password} together.
* - dto.username non-empty but dto.password empty: preserve existing
* 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(ctx: { userId: string; tenantId: string }, dto: TenderEmailConfigDto) {
const { userId, tenantId } = ctx;
let encryptedInboxCreds: string | undefined;
const credChanged =
(dto.password && dto.password.length > 0) ||
(dto.username !== undefined && dto.username !== null);
if (credChanged) {
let username: string = dto.username ?? '';
let password: string = dto.password ?? '';
if (!dto.password || !dto.username) {
try {
const existing = await this.prisma.tenderEmailConfig.findUnique({ where: { userId } });
if (existing?.encryptedInboxCreds) {
const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as {
username?: string;
password?: string;
};
if (!dto.username) username = stored.username ?? '';
if (!dto.password) password = stored.password ?? '';
}
} catch {
// Ignore decrypt errors — will overwrite with whatever was provided
}
}
encryptedInboxCreds = this.crypto.encrypt(JSON.stringify({ username, password }));
}
const data: Record<string, unknown> = {
protocol: dto.protocol,
encryption: dto.encryption,
...(dto.host !== undefined && { host: dto.host }),
...(dto.port !== undefined && { port: dto.port }),
...(dto.folder !== undefined && { folder: dto.folder }),
...(dto.senderFilter !== undefined && { senderFilter: dto.senderFilter }),
...(dto.isActive !== undefined && { isActive: dto.isActive }),
...(dto.domain !== undefined && { domain: dto.domain }),
...(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: { userId },
create: { userId, tenantId, ...data },
update: { tenantId, ...data },
select: EMAIL_CONFIG_SAFE_SELECT,
});
}
}