--- phase: 07-dkv-fleet-module plan: 04 subsystem: dkv-integration-plane tags: [dkv, nestjs, scheduler, cron, pipeline, orchestration, rest, i18n, module-registry] dependency_graph: requires: [DkvParserService (07-01), ImapProvider + ExchangeInboxProvider (07-02), DkvExportService + DkvMailService + SettingsModule (07-03), CalendarModule (CalendarCryptoService)] provides: [DkvService, DkvSchedulerService, DkvController, DkvModule, dkv.seed.ts, dkvFleet i18n namespace, settings.categorySmtp i18n keys] affects: - apps/api/src/dkv/dkv.service.ts - apps/api/src/dkv/dkv-scheduler.service.ts - apps/api/src/dkv/dkv.controller.ts - apps/api/src/dkv/dkv.seed.ts - apps/api/src/dkv/dkv.module.ts - apps/api/src/app.module.ts - apps/web/src/messages/de.json - apps/web/src/messages/en.json tech_stack: added: [] patterns: - "Single-flight guard: private processing=false in DkvService.processInbox (Pitfall 7)" - "D-10 parse retry: 3 attempts, on final failure record Fehler history row with errorMessage" - "D-16 SMTP retry: 3 attempts, exponential backoff 2s/4s, on final failure record Versand fehlgeschlagen" - "CONFIG_SAFE_SELECT const excludes encryptedInboxCreds from all API responses (T-07-12)" - "getExportFile whitelist regex ^DKV_[\\w\\-]+\\.xlsx$ blocks path traversal (T-07-09)" - "CronJob via require() workaround: cron is transitive dep of @nestjs/schedule under pnpm strict isolation" - "DkvController coordinates scheduler after PUT /dkv/config (no circular DkvService <-> DkvScheduler dep)" - "DkvModule imports CalendarModule to get CalendarCryptoService without re-declaring it" - "Invoice number extracted from email subject via regex /\\d{2}-\\d{9}-\\d{3}/; falls back to email-{uid}" key_files: created: - apps/api/src/dkv/dkv.service.ts - apps/api/src/dkv/dkv-scheduler.service.ts - apps/api/src/dkv/dkv.controller.ts - apps/api/src/dkv/dkv.seed.ts - apps/api/src/dkv/dkv.module.ts modified: - apps/api/src/app.module.ts - apps/web/src/messages/de.json - apps/web/src/messages/en.json decisions: - "DkvScheduler v1: findFirst() loads first active config (single-tenant); full per-tenant scheduling deferred" - "Circular dep avoided: DkvController injects both DkvService + DkvSchedulerService; DkvService does NOT inject scheduler" - "rechnungsnummer: extracted from email subject via /\\d{2}-\\d{9}-\\d{3}/ regex; fallback=email-{uid}" - "invoiceMonth: derived from first lieferdatum (DD.MM.YYYY→YYYY-MM); fallback=current month" - "Unknown plates export with empty Fahrer + resolveFahrzeug applied with empty marke/modell (consistent with format string)" - "cron package imported via require() not import: pnpm strict isolation prevents direct import of transitive deps" - "DkvModule imports CalendarModule (exported CalendarCryptoService) not re-declared in providers" metrics: duration: 7min completed: "2026-06-26T22:30:00Z" tasks: 3 files_created: 5 files_modified: 3 --- # Phase 07 Plan 04: DKV Integration Plane — Pipeline + REST + Module Summary DkvService orchestrates the full poll→parse→map→export→send→history pipeline with single-flight guard, 3-retry parse (D-10), and 3-retry SMTP with exponential backoff (D-16). DkvSchedulerService drives configurable polling via SchedulerRegistry dynamic cron. DkvController exposes all 12 ADMIN-only /dkv/* routes. DkvModule self-registers in the module registry. Complete dkvFleet i18n namespace and settings.smtp/categorySmtp/categoryGeneral keys added — Wave 3 frontend plans can run without touching message files. ## Tasks Completed | Task | Name | Commit | Key Files | |------|------|--------|-----------| | 1 | DkvService — pipeline orchestration + vehicle/config/history | c40a023 | dkv.service.ts (305 LOC) | | 2 | DkvSchedulerService + DkvController | c22d367 | dkv-scheduler.service.ts, dkv.controller.ts | | 3 | DkvModule + seed + AppModule + i18n | 2a3d1c1 | dkv.module.ts, dkv.seed.ts, app.module.ts, de.json, en.json | ## Architecture Notes ### Pipeline Orchestration (DkvService) ``` processInbox(tenantId) └── single-flight guard (this.processing flag) └── load raw config + decrypt inbox creds (T-05-13: never log) └── select provider by config.protocol (imap | exchange) └── provider.fetchPdfAttachments(inboxConfig) └── for each email.attachments: └── 3-retry parsePdf (D-10: Fehler row on final fail) └── batch load DkvVehicleMaster for tenant └── build ExportRow[] (resolveFahrzeug + driver lookup) └── DkvExportService.buildExcelBuffer + writeAndPrune └── 3-retry DkvMailService.sendExportEmail (D-16) └── exponential backoff: 2s, 4s └── on fail: Versand fehlgeschlagen (file stays available) └── prisma.dkvInvoiceHistory.create (D-20) ``` ### Scheduler Multi-Tenant Decision (v1) For v1, `DkvSchedulerService.onModuleInit()` calls `dkvService.loadConfig()` without a tenantId — which uses `findFirst()` to load the first active DkvModuleConfig row. One cron job is registered for that tenant's interval. **Rationale:** Single-tenant deployments are the v1 target. Multi-tenant scheduling (one `SchedulerRegistry` job per active tenant, keyed as `dkv-inbox-poll-{tenantId}`) is deferred to a future plan. **Impact:** Administrators with multiple tenants must set the schedule per-tenant; only the first active config is polled automatically. ### Circular Dependency Avoidance `DkvSchedulerService` injects `DkvService` (to call `processInbox`). If `DkvService` also injected `DkvSchedulerService` (to re-apply the interval after `saveConfig`), this would create a circular dependency. **Decision:** `DkvController` injects both services independently. After `PUT /dkv/config`, the controller calls `dkvScheduler.setInterval(...)` or `dkvScheduler.stopJob()`. Neither service injects the other. ### cron Package Resolution (pnpm strict isolation) `CronJob` is from the `cron@4.4.0` package, which is a transitive dependency of `@nestjs/schedule@6.1.3`. Under pnpm strict isolation, transitive packages are not directly importable — `import { CronJob } from 'cron'` fails TypeScript's module resolution. **Fix:** `CronJob` is resolved at runtime via `require('cron').CronJob` cast to a minimal interface `{ start(): void }`. The `schedulerRegistry.addCronJob()` call uses `as any` cast. At runtime, the object IS a full CronJob — SchedulerRegistry only calls `.stop()` on it. No new package install was needed (cron is already on disk). ### Invoice Number Extraction DKV invoice numbers follow the pattern `\d{2}-\d{9}-\d{3}` (e.g., `26-651566449-001`). DkvService extracts this from `email.subject` via regex. Fallback when not found: `email-{uid}` using the IMAP UID or EWS item ID. This fallback still produces a unique, meaningful history record and filename. ## Verification Results - `pnpm --filter @tessera/api type-check` exits 0: PASS (3x verified during execution) - `grep -q "processing"` dkv.service.ts: PASS (single-flight guard present) - `grep -q "encryptedInboxCreds"` dkv.service.ts: PASS (CONFIG_SAFE_SELECT + encryption handling) - `grep -Eq "parsePdf|buildExcelBuffer|sendExportEmail"` dkv.service.ts: PASS - `grep -q "schedulerRegistry.addCronJob"` dkv-scheduler.service.ts: PASS - `grep -q "@Controller('dkv')"` dkv.controller.ts: PASS - `grep -q "FileInterceptor"` dkv.controller.ts: PASS - 12 handlers with `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`: PASS - `grep -q "DkvModule"` app.module.ts: PASS - `grep -q "seedDkvModule"` dkv.module.ts: PASS - `grep -q "OnModuleInit"` dkv.module.ts: PASS - i18n node validation (dkvFleet + settings.categorySmtp + settings.smtp): PASS ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] cron package not directly importable under pnpm strict isolation** - **Found during:** Task 2 type-check - **Issue:** `import { CronJob } from 'cron'` failed with TS2307 because `cron@4.4.0` is a transitive dependency of `@nestjs/schedule`, not declared in `apps/api/package.json`. pnpm strict isolation prevents phantom dependency imports. - **Fix:** Replaced `import { CronJob } from 'cron'` with `require('cron').CronJob` cast to a minimal `{ start(): void }` interface. The `schedulerRegistry.addCronJob()` call uses `as any` to satisfy the `CronJob` type expected by SchedulerRegistry. Functionally identical at runtime. - **Files modified:** apps/api/src/dkv/dkv-scheduler.service.ts - **Commit:** c22d367 (included in task commit) ### Architectural Decision **DkvScheduler → saveConfig coordination:** The plan's action text said "after save calls the scheduler to (re)apply the interval". This could have been implemented as DkvService injecting DkvSchedulerService, but that creates a circular dependency (scheduler injects DkvService for processInbox). Instead, DkvController coordinates both services after PUT /dkv/config. This is a cleaner design with no NestJS `forwardRef()` workaround needed. ## Known Stubs None. All pipeline orchestration, vehicle CRUD, CSV import, history, config and export download logic is fully implemented. The frontend Wave 3 plans (05, 06) can call all /dkv/* endpoints. ## Threat Flags None. All STRIDE threats in this plan's threat register were mitigated: - T-07-12: CONFIG_SAFE_SELECT excludes encryptedInboxCreds in all loadConfig/saveConfig responses - T-05-13: Decrypted inbox credentials used only within method scope; never logged (generic error messages) - T-07-09: getExportFile validates filename against `/^DKV_[\w\-]+\.xlsx$/` before fs.readFileSync; rejects path separators and `..` - V4: All 12 DkvController handlers carry @Roles(Role.ADMIN, Role.SUPER_ADMIN) ## Self-Check: PASSED Files verified present: - apps/api/src/dkv/dkv.service.ts ✓ - apps/api/src/dkv/dkv-scheduler.service.ts ✓ - apps/api/src/dkv/dkv.controller.ts ✓ - apps/api/src/dkv/dkv.seed.ts ✓ - apps/api/src/dkv/dkv.module.ts ✓ - apps/api/src/app.module.ts (modified) ✓ - apps/web/src/messages/de.json (modified) ✓ - apps/web/src/messages/en.json (modified) ✓ Commits verified in git log: - c40a023 ✓ (feat(07-04): DkvService — pipeline orchestration + vehicle/config/history logic) - c22d367 ✓ (feat(07-04): DkvSchedulerService + DkvController — dynamic cron + REST surface) - 2a3d1c1 ✓ (feat(07-04): DkvModule + registry seed + AppModule registration + i18n keys)