feat(14-03): add per-tenant encrypted TenderEmailConfig + ownerTenantId write-side (D-13)

Prisma: new TenderEmailConfig model (per-tenant, tenantId @unique, mirrors
DkvModuleConfig) + Tender.ownerTenantId nullable column + index (D-13:
null = global/platform-wide, unchanged for all existing rows and every
public source; set = visible only to that tenant). Migration
20260723113917_tender_email_config_owner_tenant_id applied locally.

TenderEmailConfigService: safe-select admin CRUD (GET never returns the
password, only hasPassword — T-07-12) with DkvService's encrypt-preserve-
empty semantics, via CalendarCryptoService (AES-256-GCM).

RawTenderRecord/NormalizedTenderFields gain optional ownerTenantId,
threaded through TenderNormalizerService.assemble() unchanged.
TenderDedupService's CREATE branch writes ownerTenantId (defaulting to
null); the UPDATE branch deliberately never references it, so a tender
later also seen on a public source is never retroactively hidden.

EmailAlertAdapter.fetchTenders() now does the real per-tenant fan-out:
findMany({isActive:true}) across ALL tenants (deliberate, documented
cross-tenant platform-scheduler read, never forTenant()/RLS), decrypts
each tenant's credentials, picks imap/exchange provider, and tags every
extracted candidate with ownerTenantId — catch-per-tenant so one broken
mailbox never blocks the others.

tenders.module.ts: imports CalendarModule/InboxModule, registers
EmailAlertAdapter + TenderEmailConfigService, seeds an 'email-alert'
TenderSourcePollConfig row (pollGranularity='tick', isActive=false —
no default mailbox to activate yet, D-02 framework-ready stance).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 13:45:11 +02:00
parent 8983231196
commit 1be6b15249
12 changed files with 994 additions and 21 deletions
@@ -0,0 +1,30 @@
-- AlterTable
ALTER TABLE "Tender" ADD COLUMN "ownerTenantId" TEXT;
-- CreateTable
CREATE TABLE "TenderEmailConfig" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"protocol" TEXT NOT NULL DEFAULT 'imap',
"host" TEXT,
"port" INTEGER,
"encryption" TEXT NOT NULL DEFAULT 'ssl-tls',
"folder" TEXT NOT NULL DEFAULT 'INBOX',
"senderFilter" TEXT,
"domain" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT false,
"encryptedInboxCreds" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TenderEmailConfig_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TenderEmailConfig_tenantId_key" ON "TenderEmailConfig"("tenantId");
-- CreateIndex
CREATE INDEX "TenderEmailConfig_tenantId_idx" ON "TenderEmailConfig"("tenantId");
-- CreateIndex
CREATE INDEX "Tender_ownerTenantId_idx" ON "Tender"("ownerTenantId");
+38 -1
View File
@@ -197,6 +197,31 @@ model DkvModuleConfig {
@@index([tenantId])
}
// Phase 14, Plan 03 (INGEST-05, CONFIG-02, D-06/D-07) — per-tenant portal-
// alert mailbox config, mirroring DkvModuleConfig's shape/pattern exactly
// (own tenantId @unique row, own encrypted creds — D-03: each module keeps
// its own independent mailbox config, this is a SEPARATE mailbox from the
// 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).
model TenderEmailConfig {
id String @id @default(uuid())
tenantId String @unique
protocol String @default("imap") // 'imap' | 'exchange'
host String?
port Int?
encryption String @default("ssl-tls") // 'none' | 'starttls' | 'ssl-tls'
folder String @default("INBOX")
senderFilter String?
domain String? // Exchange only: Windows domain (optional)
isActive Boolean @default(false)
encryptedInboxCreds String? // AES-256-GCM: JSON { username, password } encrypted
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId])
}
model DkvVehicleMaster {
id String @id @default(uuid())
tenantId String
@@ -285,6 +310,15 @@ model Tender {
// for pre-existing rows by backfill-tender-source.ts (Plan 13-01 Task 2).
// dedupKey above stays the SCHEMA-02 upsert target — NOT replaced here.
fingerprint String?
// Phase 14, Plan 03 (INGEST-05, D-13) — per-tenant visibility for PRIVATE
// sources only. null = global/platform-wide (D-03, unchanged for all
// public sources: DÖE/NetServer/cosinex/RSS — existing rows stay null,
// no backfill). Set = visible ONLY to that tenant (email-alert tenders,
// since an alert mailbox reflects one tenant's private subscription).
// Read filter: buildTenderWhere OR[{ownerTenantId:null},{ownerTenantId:tenant}].
// Write: dedup CREATE only sets this — the UPDATE branch never touches it,
// so a source later also seen globally is never retroactively hidden.
ownerTenantId String?
publishedAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -295,7 +329,10 @@ model Tender {
@@index([bundesland]) // FILTER-02: post-backfill Bundesland-Filter-Performance
@@index([cpvDivisions], type: Gin) // FILTER-03: post-backfill CPV-Divisions-Filter-Performance (hasSome)
@@index([fingerprint]) // SCHEMA-03: dedup resolver fingerprint-tier lookup
// Deliberately NO tenant column and NO tenant index — this is global data (D-03)
@@index([ownerTenantId]) // D-13: read-filter lookup for private (email-alert) tenders
// Deliberately NO tenant column/index for the platform-wide default (D-03)
// — ownerTenantId above is the sole, additive, nullable exception for
// privately-sourced tenders (D-13).
triage TenderTriage[]
matches TenderMatch[]
sources TenderSource[] // SCHEMA-03/D-03 — all source portals this tender was seen on