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:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user