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
+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
* 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);
}
/**