docs(07): create phase 7 execution plans for DKV fleet module
Tessera CI/CD / Build & Deploy (push) Blocked by required conditions
Tessera CI/CD / Lint & Type Check (push) Successful in 38s
Tessera CI/CD / Tests (push) Waiting to run

6 plans covering full pipeline: PDF parsing foundation (Wave 0),
inbox providers + export/SMTP services (Wave 1), pipeline
orchestration + frontend pages + settings UI (Wave 2). Includes
D-06 MailModule DB-config migration and Nyquist validation strategy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 18:56:59 +02:00
parent e9f4f2dcad
commit de06794e67
9 changed files with 2180 additions and 19 deletions
+17 -2
View File
@@ -224,7 +224,22 @@ Decimal phases appear between their surrounding integers in numeric order.
5. SMTP settings are configurable in general settings (shared with other modules)
6. Module configuration (inbox, sender filter, folder, recipient) is editable in the module settings UI
**Plans**: TBD
**Plans**: 6 plans
**Wave 0**
- [ ] 07-01-PLAN.md -- Backend foundation: deps install, Prisma models, ScheduleModule, crypto export, validated DKV PDF parser (DKV-02)
**Wave 1** *(blocked on Wave 0 completion)*
- [ ] 07-02-PLAN.md -- Inbox-access layer: InboxProvider interface, IMAP + Exchange providers, config/vehicle/history DTOs (DKV-01)
- [ ] 07-03-PLAN.md -- Export + delivery + SMTP backend: SettingsModule (SMTP CRUD + test), DkvExportService (xlsx + prune), DkvMailService, MailModule DB-SMTP migration (DKV-04/05, D-06)
**Wave 2** *(blocked on Wave 1 completion)*
- [ ] 07-04-PLAN.md -- Pipeline integration: DkvService orchestration, dynamic scheduler, DkvController REST, module registry seed, i18n keys (DKV-01/03/04/05)
- [ ] 07-05-PLAN.md -- Module frontend: dkv-api client, main page (history + export download + "Jetzt prüfen"), inbox settings form, vehicle CRUD + CSV import (DKV-01/03/04)
- [ ] 07-06-PLAN.md -- SMTP settings frontend: settings-api client, SMTP config form + connection test, settings-sidebar "Allgemein" category (DKV-05)
**UI hint**: yes
@@ -241,4 +256,4 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7
| 4. Marketplace & Portal Navigation | 0/4 | Not started | - |
| 5. Dashboard & Calendar | 5/5 | Complete | 2026-06-24 |
| 6. Desktop Client & CI/CD | 2/3 | In Progress| |
| 7. DKV Fleet Module | 0/? | Not started | - |
| 7. DKV Fleet Module | 0/6 | Not started | - |
@@ -0,0 +1,221 @@
---
phase: 07-dkv-fleet-module
plan: 01
type: execute
wave: 0
depends_on: []
files_modified:
- apps/api/package.json
- apps/api/prisma/schema.prisma
- apps/api/src/app.module.ts
- apps/api/src/calendar/calendar.module.ts
- apps/api/src/dkv/dkv.types.ts
- apps/api/src/dkv/dkv-parser.validate.ts
- apps/api/src/dkv/dkv-parser.service.ts
autonomous: false
requirements: [DKV-02]
user_setup: []
must_haves:
truths:
- "The four DKV tables exist in the live PostgreSQL database (DkvModuleConfig, DkvVehicleMaster, DkvInvoiceHistory, SmtpConfig)"
- "ScheduleModule is registered in AppModule so dynamic cron jobs can be created"
- "The DKV parser extracts at least one vehicle block from user-files/invoice.pdf"
- "CalendarCryptoService is importable by other modules (exported from CalendarModule)"
artifacts:
- path: "apps/api/prisma/schema.prisma"
provides: "DkvModuleConfig, DkvVehicleMaster, DkvInvoiceHistory, SmtpConfig models"
contains: "model DkvModuleConfig"
- path: "apps/api/src/dkv/dkv-parser.service.ts"
provides: "DkvParserService.parsePdf(buffer) -> DkvVehicleBlock[]"
min_lines: 40
- path: "apps/api/src/dkv/dkv-parser.validate.ts"
provides: "Wave 0 validation script asserting >=1 vehicle block from invoice.pdf"
- path: "apps/api/src/dkv/dkv.types.ts"
provides: "DkvVehicleBlock, DkvTransaction, InboxConfig, ExportRow shared types"
key_links:
- from: "apps/api/src/dkv/dkv-parser.service.ts"
to: "pdf-parse PDFParse class"
via: "new PDFParse({ data: buffer }).getText()"
pattern: "new PDFParse"
- from: "apps/api/src/app.module.ts"
to: "@nestjs/schedule ScheduleModule"
via: "ScheduleModule.forRoot() in imports array"
pattern: "ScheduleModule.forRoot"
---
<objective>
Establish the backend foundation for the DKV Fleet Module: install the three new npm packages, add the four Prisma data models, push the schema to the live database, register ScheduleModule, export the shared crypto service, and validate the highest-risk component — the DKV PDF parser — against the real reference invoice before any production code consumes it.
Purpose: The PDF parsing regex is the single LOW-confidence element of this phase (Research A2). Validating it in Wave 0 against user-files/invoice.pdf de-risks every downstream plan. The schema + ScheduleModule + crypto export are hard prerequisites for all backend plans.
Output: Installed deps, four live DB tables, registered ScheduleModule, exported CalendarCryptoService, a validated DkvParserService and a runnable validation script.
</objective>
## Phase Goal
**Als** Administrator **möchte ich** DKV-Tankkarten-Rechnungen automatisch aus einem E-Mail-Postfach verarbeiten lassen, **damit** Flotten-Tankdaten ohne manuelle Eingabe als Excel-Datei exportiert und per SMTP zugestellt werden.
This plan delivers the foundation that proves the riskiest leg of that pipeline (PDF → structured data) works against the real DKV invoice.
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/07-dkv-fleet-module/07-CONTEXT.md
@.planning/phases/07-dkv-fleet-module/07-RESEARCH.md
@.planning/phases/07-dkv-fleet-module/07-PATTERNS.md
</context>
## Artifacts this phase produces (Plan 01 portion)
New symbols introduced here (exclude from drift verification):
- Prisma models: `DkvModuleConfig`, `DkvVehicleMaster`, `DkvInvoiceHistory`, `SmtpConfig`
- File `apps/api/src/dkv/dkv.types.ts`: interfaces `DkvTransaction`, `DkvVehicleBlock`, `InboxConfig`, `InboxEmail`, `InboxAttachment`, `ExportRow`
- Class `DkvParserService` with method `parsePdf(buffer: Buffer): Promise<DkvVehicleBlock[]>`
- Validation script `apps/api/src/dkv/dkv-parser.validate.ts`
- New deps in apps/api/package.json: `imapflow`, `pdf-parse`, `xlsx`
- `CalendarCryptoService` added to `CalendarModule` `exports`
<tasks>
<task type="checkpoint:human-verify" gate="blocking-human">
<name>Task 1: Verify imapflow package legitimacy before install</name>
<what-built>Nothing yet — this gate runs BEFORE any package install.</what-built>
<how-to-verify>
The package legitimacy audit in 07-RESEARCH.md flags `imapflow` as SUS (a too-new version was published the same day as research). The audit disposition is "Approved — false positive" because the package has existed since 2019 with ~1.1M weekly downloads from github.com/postalsys/imapflow.
Confirm before install:
1. Open https://www.npmjs.com/package/imapflow — confirm repository is `postalsys/imapflow`, weekly downloads are in the ~1M range, and the package is not a recent typosquat.
2. Confirm `pdf-parse` (https://www.npmjs.com/package/pdf-parse, repo mehmet-kozan/pdf-parse) and `xlsx` (https://www.npmjs.com/package/xlsx, repo SheetJS/sheetjs) match the audit table.
</how-to-verify>
<resume-signal>Type "approved" to proceed with install, or describe a concern to halt.</resume-signal>
</task>
<task type="auto">
<name>Task 2: Install deps, add Prisma models, wire ScheduleModule + crypto export</name>
<files>apps/api/package.json, apps/api/prisma/schema.prisma, apps/api/src/app.module.ts, apps/api/src/calendar/calendar.module.ts, apps/api/src/dkv/dkv.types.ts</files>
<read_first>
- apps/api/prisma/schema.prisma — current schema end (CalendarSource is the last model; append after it, do not edit existing models)
- apps/api/src/app.module.ts — current imports array (ScheduleModule is NOT present; CalendarModule, DashboardModule etc. are)
- apps/api/src/calendar/calendar.module.ts — current `exports: [CalendarService]` line (must become `exports: [CalendarService, CalendarCryptoService]`)
- 07-RESEARCH.md "Prisma Schema (new tables)" — exact field definitions for all four models
- 07-RESEARCH.md "Pattern 8: ScheduleModule Registration"
</read_first>
<action>
Install the three new packages into the api workspace: run `pnpm --filter @tessera/api add imapflow pdf-parse xlsx`. Do NOT add @types/* — all three ship their own types.
Append exactly the four Prisma models from 07-RESEARCH.md "Prisma Schema (new tables)" to apps/api/prisma/schema.prisma: `DkvModuleConfig` (tenantId @unique, protocol default "imap", encryption default "ssl-tls", folder default "INBOX", pollIntervalMin default 60, isActive default false, vehicleFormatString default "{Marke}/{Modell}/{Kennzeichen}", encryptedInboxCreds String?), `DkvVehicleMaster` (@@unique([tenantId, kennzeichen])), `DkvInvoiceHistory` (status string, errorMessage String?, exportFilename String?, indices on tenantId and datumZeit), `SmtpConfig` (tenantId @unique, port default 587, encryption default "starttls", encryptedPassword String?, fromAddress required). Use the exact field names and defaults — downstream code depends on these identifiers.
In apps/api/src/app.module.ts add `import { ScheduleModule } from '@nestjs/schedule';` and add `ScheduleModule.forRoot()` to the imports array (DkvModule and SettingsModule are registered in later plans — do NOT add them here, they do not exist yet).
In apps/api/src/calendar/calendar.module.ts change the `exports` array to also export `CalendarCryptoService` so DkvModule and SettingsModule can inject it (per PATTERNS.md note).
Create apps/api/src/dkv/dkv.types.ts containing the shared interfaces `DkvTransaction` (lieferdatum string, ort string, kilometerstand number, produkt string, menge number, einheit string, netto number, brutto number), `DkvVehicleBlock` (kennzeichen string, cardNumber string, transactions DkvTransaction[]), `InboxConfig` (protocol, host, port, username?, password?, encryption, folder, senderFilter?), `InboxAttachment` (filename, contentType, buffer Buffer), `InboxEmail` (uid, messageId, subject, from, date, attachments InboxAttachment[]), and `ExportRow` (lieferdatum, fahrzeug, fahrer, ort string, kilometerstand number) — field shapes taken from 07-RESEARCH.md interfaces.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec node -e "require('imapflow');require('pdf-parse');require('xlsx');console.log('deps ok')" && grep -q "model DkvModuleConfig" apps/api/prisma/schema.prisma && grep -q "model SmtpConfig" apps/api/prisma/schema.prisma && grep -q "ScheduleModule.forRoot" apps/api/src/app.module.ts && grep -q "CalendarCryptoService" apps/api/src/calendar/calendar.module.ts</automated>
</verify>
<acceptance_criteria>
- `apps/api/package.json` dependencies include `imapflow`, `pdf-parse`, and `xlsx`
- `grep -c "^model " apps/api/prisma/schema.prisma` increased by exactly 4 versus before the change
- `apps/api/prisma/schema.prisma` contains `model DkvModuleConfig`, `model DkvVehicleMaster`, `model DkvInvoiceHistory`, `model SmtpConfig`
- `apps/api/src/app.module.ts` contains `ScheduleModule.forRoot()` inside the imports array
- `apps/api/src/calendar/calendar.module.ts` exports array includes `CalendarCryptoService`
- `apps/api/src/dkv/dkv.types.ts` exports interfaces `DkvVehicleBlock`, `DkvTransaction`, `InboxConfig`, `InboxEmail`, `InboxAttachment`, `ExportRow`
</acceptance_criteria>
<done>Deps installed, four models present, ScheduleModule registered, crypto exported, shared types file created.</done>
</task>
<task type="auto">
<name>Task 3 [BLOCKING]: Push schema to live database</name>
<files>apps/api/prisma/schema.prisma</files>
<read_first>
- .planning/phases/05-dashboard-calendar/05-03-PLAN.md (db push task) — established push pattern for this project (DB port not exposed to host; push from container or via container IP)
- .planning/phases/05-dashboard-calendar/05-01-SUMMARY.md — note "Prisma db push via docker exec" workaround
</read_first>
<action>
This is the MANDATORY schema-push task required by the phase planning contract. Build and type checks pass WITHOUT the push (types come from the generated client, not the live DB), so this is a false-positive risk if skipped.
Push the new tables to the running PostgreSQL container following the project's established method from Phase 05 (the DB port is not exposed to the host — push from inside the api container via `docker compose exec` or via the container IP, whichever Phase 05 SUMMARYs recorded as working). Command core: `prisma db push --skip-generate` then `prisma generate`. Only four NEW tables are added — no existing table changes. If the push reports it would cause data loss, STOP and flag for manual review; do NOT blindly pass `--accept-data-loss` (the non-TTY workaround is only acceptable if the sole reported change is the four additive tables).
</action>
<verify>
<automated>cd apps/api && npx prisma db push --skip-generate 2>&1 | grep -Eq "already in sync|now in sync|in sync with" && npx prisma generate >/dev/null 2>&1 && echo "schema pushed"</automated>
</verify>
<acceptance_criteria>
- `prisma db push` exits 0
- A second `prisma db push` run reports the database is already in sync
- `prisma generate` regenerates the client without error
- No data-loss warning was bypassed with `--accept-data-loss`
</acceptance_criteria>
<done>The four DKV tables exist in the live database and the Prisma client is regenerated.</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: DKV PDF parser — validate against real invoice, then implement service</name>
<files>apps/api/src/dkv/dkv-parser.validate.ts, apps/api/src/dkv/dkv-parser.service.ts</files>
<read_first>
- user-files/invoice.pdf — the real DKV E-Rechnung (April 2026, 4 pages, 27 vehicles) — parse target
- 07-RESEARCH.md "Pattern 3: pdf-parse v2 Text Extraction" — class-based API `new PDFParse({ data: buffer })`, must call `destroy()`
- 07-RESEARCH.md "Pattern 4: DKV PDF Regex" — vehicle block + transaction row regex (ASSUMED, must be validated/adjusted against real output)
- 07-RESEARCH.md "Pitfall 2" (v1 vs v2 API break) and "Pitfall 5" (German number format: strip dots, replace comma with dot)
- apps/api/src/dkv/dkv.types.ts — DkvVehicleBlock / DkvTransaction shapes to return
</read_first>
<behavior>
- extractPdfText(buffer): returns concatenated text from all pages of invoice.pdf (non-empty string)
- parseDkvText(text): returns >= 1 DkvVehicleBlock; for invoice.pdf it should approach 27 vehicle blocks (the reference invoice has 27 vehicles)
- German number parsing: "19.234,56" parses to 19234.56 (not 19.234)
- Each block has a non-empty `kennzeichen` and `transactions` array
</behavior>
<action>
First write apps/api/src/dkv/dkv-parser.validate.ts: a standalone script that reads `user-files/invoice.pdf` via fs.readFileSync, runs the pdf-parse v2 extraction (`new PDFParse({ data: buffer })`, `getText()`, `destroy()`), then runs the vehicle-block regex from Research Pattern 4. It MUST print the parsed structure (vehicle count, first block's kennzeichen + transaction count) AND assert: if zero vehicle blocks are parsed, print the raw extracted text to stdout and `process.exit(1)`. This script is the empirical source of truth — adjust the regex in this script until it parses the real invoice, THEN copy the proven logic into the service.
Then implement apps/api/src/dkv/dkv-parser.service.ts as an `@Injectable()` NestJS service (Logger via `new Logger(DkvParserService.name)`) with `async parsePdf(buffer: Buffer): Promise<DkvVehicleBlock[]>` that wraps the validated extract+parse logic. Apply German number parsing `parseFloat(raw.replace(/\./g, '').replace(',', '.'))` to kilometerstand and menge. Never log decrypted credentials (not applicable here, but follow T-05-13 generic-error convention for any catch). If extraction yields zero vehicles, throw an Error with a generic message (caller records it as parse failure per D-10).
Do NOT use the v1 `pdfParse(buffer)` call — it does not exist in v2.
</action>
<verify>
<automated>pnpm --filter @tessera/api type-check && cd apps/api && (node --experimental-strip-types src/dkv/dkv-parser.validate.ts || node --no-warnings --experimental-strip-types src/dkv/dkv-parser.validate.ts)</automated>
</verify>
<acceptance_criteria>
- `apps/api/src/dkv/dkv-parser.validate.ts` exits 0 and prints a vehicle count >= 1 when run against user-files/invoice.pdf
- On zero parsed vehicles the script exits 1 (proves the assertion is wired)
- `apps/api/src/dkv/dkv-parser.service.ts` exports class `DkvParserService` with method `parsePdf`
- Source contains `new PDFParse(` and does NOT contain a bare `pdfParse(` v1 call
- German-number handling present: `replace(/\\./g, '').replace(',', '.')` appears in the parse path
- `pnpm --filter @tessera/api type-check` exits 0
</acceptance_criteria>
<done>The parser is empirically validated against the real DKV invoice and wrapped in an injectable service returning typed vehicle blocks.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| npm registry → build | Third-party packages enter the supply chain at install time |
| PDF bytes → parser | Untrusted binary attachment content is parsed server-side |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-07-SC | Tampering | npm installs (imapflow/pdf-parse/xlsx) | mitigate | Package Legitimacy Audit (07-RESEARCH.md) + blocking-human checkpoint (Task 1) verifies imapflow SUS flag is a false positive against npmjs.com before install |
| T-07-01 | Denial of Service | dkv-parser.service.ts | mitigate | Parser operates only on attachments fetched by the inbox provider, which enforces a max attachment size (Plan 02). Parser calls `destroy()` to free memory per pdf-parse guidance |
| T-07-02 | Information Disclosure | dkv-parser.service.ts | mitigate | Generic error messages only on parse failure (T-05-13 convention); no PDF content echoed in production logs |
</threat_model>
<verification>
- `pnpm --filter @tessera/api type-check` exits 0
- `grep -q "model DkvModuleConfig" apps/api/prisma/schema.prisma` succeeds for all four models
- `prisma db push` reports in sync on second run
- Validation script parses >= 1 vehicle block from user-files/invoice.pdf
</verification>
<success_criteria>
- Three packages installed; four tables live in PostgreSQL; ScheduleModule registered; CalendarCryptoService exported
- DkvParserService validated against the real invoice and returns typed vehicle blocks
- DKV-02 parsing risk (Research A2) retired before downstream plans build on it
</success_criteria>
<output>
Create `.planning/phases/07-dkv-fleet-module/07-01-SUMMARY.md` when done. Record: actual vehicle count parsed from invoice.pdf, any regex adjustments made versus Research Pattern 4, and the exact db push method used.
</output>
@@ -0,0 +1,198 @@
---
phase: 07-dkv-fleet-module
plan: 02
type: execute
wave: 1
depends_on: ["07-01"]
files_modified:
- apps/api/src/dkv/providers/inbox-provider.interface.ts
- apps/api/src/dkv/providers/imap.provider.ts
- apps/api/src/dkv/providers/exchange-inbox.provider.ts
- apps/api/src/dkv/dto/dkv-config.dto.ts
- apps/api/src/dkv/dto/dkv-vehicle.dto.ts
- apps/api/src/dkv/dto/dkv-history.dto.ts
autonomous: true
requirements: [DKV-01]
user_setup: []
must_haves:
truths:
- "An IMAP inbox can be polled for PDF attachments filtered by sender"
- "An Exchange inbox can be polled for PDF attachments via EWS"
- "Both providers implement the same InboxProvider contract so they are interchangeable"
- "Invalid module-config input is rejected by class-validator DTOs"
artifacts:
- path: "apps/api/src/dkv/providers/inbox-provider.interface.ts"
provides: "InboxProvider interface contract"
contains: "fetchPdfAttachments"
- path: "apps/api/src/dkv/providers/imap.provider.ts"
provides: "ImapProvider (imapflow implementation)"
min_lines: 40
- path: "apps/api/src/dkv/providers/exchange-inbox.provider.ts"
provides: "ExchangeInboxProvider (ews-javascript-api implementation)"
min_lines: 40
- path: "apps/api/src/dkv/dto/dkv-config.dto.ts"
provides: "DkvConfigDto with class-validator decorators"
contains: "IsEmail"
key_links:
- from: "apps/api/src/dkv/providers/imap.provider.ts"
to: "imapflow ImapFlow"
via: "fetchAll then download (never download inside fetch iterator)"
pattern: "fetchAll"
- from: "apps/api/src/dkv/providers/exchange-inbox.provider.ts"
to: "ews-javascript-api"
via: "WellKnownFolderName.Inbox + FindItems"
pattern: "WellKnownFolderName.Inbox"
---
<objective>
Build the interchangeable inbox-access layer: an `InboxProvider` interface and two implementations (IMAP via imapflow, Exchange via ews-javascript-api) that connect to a configured inbox, search for emails from the DKV sender, and download PDF attachments. Also define the class-validator DTOs that the controller (Plan 04) will use to validate module config and vehicle input.
Purpose: This is the first half of the DKV-01 "detect invoices from inbox" capability. Defining the provider contract first (interface-first ordering) lets Plan 04's orchestration code be written against a stable contract.
Output: Inbox interface + IMAP provider + Exchange provider + three DTOs.
</objective>
## Phase Goal
**Als** Administrator **möchte ich** DKV-Tankkarten-Rechnungen automatisch aus einem E-Mail-Postfach verarbeiten lassen, **damit** Flotten-Tankdaten ohne manuelle Eingabe als Excel-Datei exportiert und per SMTP zugestellt werden.
This plan delivers the inbox-reading capability — the entry point of the pipeline.
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/phases/07-dkv-fleet-module/07-CONTEXT.md
@.planning/phases/07-dkv-fleet-module/07-RESEARCH.md
@.planning/phases/07-dkv-fleet-module/07-PATTERNS.md
@apps/api/src/dkv/dkv.types.ts
@apps/api/src/calendar/providers/exchange.provider.ts
</context>
## Artifacts this phase produces (Plan 02 portion)
New symbols (exclude from drift verification):
- File `apps/api/src/dkv/providers/inbox-provider.interface.ts`: `InboxProvider` interface (`fetchPdfAttachments`, `testConnection`)
- Class `ImapProvider implements InboxProvider`
- Class `ExchangeInboxProvider implements InboxProvider`
- DTO classes: `DkvConfigDto`, `CreateVehicleDto`, `UpdateVehicleDto`, `DkvHistoryQueryDto`
<tasks>
<task type="auto">
<name>Task 1: InboxProvider interface + config/vehicle/history DTOs</name>
<files>apps/api/src/dkv/providers/inbox-provider.interface.ts, apps/api/src/dkv/dto/dkv-config.dto.ts, apps/api/src/dkv/dto/dkv-vehicle.dto.ts, apps/api/src/dkv/dto/dkv-history.dto.ts</files>
<read_first>
- apps/api/src/dkv/dkv.types.ts — InboxConfig, InboxEmail, InboxAttachment already defined in Plan 01; interface re-uses these
- apps/api/src/calendar/dto/create-calendar-source.dto.ts — class-validator decorator conventions (IsString, IsInt, IsIn, IsEmail, IsOptional, Min, Max)
- 07-PATTERNS.md "dkv-config.dto.ts" — exact validator set per field
- 07-RESEARCH.md Security Domain V5 — senderFilter must use IsEmail(); folder must be constrained
</read_first>
<action>
Create inbox-provider.interface.ts exporting the `InboxProvider` interface with `fetchPdfAttachments(config: InboxConfig): Promise<InboxEmail[]>` and `testConnection(config: InboxConfig): Promise<boolean>`. Import InboxConfig/InboxEmail/InboxAttachment from `../dkv.types` (do not redeclare them).
Create dkv-config.dto.ts exporting `DkvConfigDto` with these validators: protocol `@IsIn(['imap','exchange'])`; host optional string; port optional `@IsInt() @Min(1) @Max(65535)`; encryption `@IsIn(['none','starttls','ssl-tls'])`; folder optional string; senderFilter optional `@IsEmail()` (mitigates injection per V5); exportRecipient optional `@IsEmail()`; pollIntervalMin optional `@IsInt() @Min(5)` (UI-SPEC min=5); isActive optional `@IsBoolean()`; vehicleFormatString optional string; username optional string; password optional string.
Create dkv-vehicle.dto.ts exporting `CreateVehicleDto` (kennzeichen, marke, modell, fahrer — all `@IsString() @IsNotEmpty()` except validate kennzeichen non-empty) and `UpdateVehicleDto` (all four optional).
Create dkv-history.dto.ts exporting `DkvHistoryQueryDto` with `page` and `limit` optional `@IsInt() @Min(1)` (pagination mitigates the history-accumulation DoS, Research Security Domain).
</action>
<verify>
<automated>pnpm --filter @tessera/api type-check && grep -q "IsEmail" apps/api/src/dkv/dto/dkv-config.dto.ts && grep -q "InboxProvider" apps/api/src/dkv/providers/inbox-provider.interface.ts</automated>
</verify>
<acceptance_criteria>
- `apps/api/src/dkv/providers/inbox-provider.interface.ts` exports `InboxProvider` with both `fetchPdfAttachments` and `testConnection`
- `DkvConfigDto` decorates `senderFilter` and `exportRecipient` with `@IsEmail()`
- `DkvConfigDto` decorates `pollIntervalMin` with `@Min(5)`
- `DkvHistoryQueryDto` exposes `page` and `limit` with `@IsInt()`
- `pnpm --filter @tessera/api type-check` exits 0
</acceptance_criteria>
<done>Provider contract and all input DTOs exist and type-check.</done>
</task>
<task type="auto">
<name>Task 2: ImapProvider (imapflow)</name>
<files>apps/api/src/dkv/providers/imap.provider.ts</files>
<read_first>
- 07-RESEARCH.md "Pattern 2: imapflow IMAP Provider" — connect, search by sender, fetchAll then download, streamToBuffer
- 07-RESEARCH.md "Pitfall 1" — NEVER call download() inside a fetch() async iterator (deadlock); always fetchAll() first, then loop and download()
- 07-PATTERNS.md "imap.provider.ts" — class shell, `logger: false` to suppress credential logging
- apps/api/src/dkv/providers/inbox-provider.interface.ts — contract to implement
- apps/api/src/dkv/dkv.types.ts — InboxEmail / InboxAttachment shapes to return
</read_first>
<action>
Implement `@Injectable() ImapProvider implements InboxProvider` with `new Logger(ImapProvider.name)`. Construct `ImapFlow` with `host`, `port`, `secure: encryption === 'ssl-tls'`, `requireTLS: encryption === 'starttls'`, `auth` only when username present, and `logger: false` (T-07 credential safety). In `fetchPdfAttachments`: connect, acquire mailbox lock on `config.folder`, `client.search({ from: config.senderFilter }, { uid: true })`, then `client.fetchAll(uids, { envelope: true, bodyStructure: true }, { uid: true })`, then loop messages and call `client.download(...)` separately (never inside the fetch iterator). Collect only parts whose content type is application/pdf. Enforce a max attachment size guard (skip/raise on attachments larger than a sane limit, e.g. 25 MB — mitigates the PDF-bomb DoS) before buffering. Always release the lock and `logout()` in finally. `testConnection`: connect + logout, return boolean. Wrap errors in generic log messages (T-05-13) and never include credentials.
</action>
<verify>
<automated>pnpm --filter @tessera/api type-check && grep -q "fetchAll" apps/api/src/dkv/providers/imap.provider.ts && grep -q "logger: false" apps/api/src/dkv/providers/imap.provider.ts</automated>
</verify>
<acceptance_criteria>
- `ImapProvider` implements `InboxProvider` (type-check enforces the contract)
- Source contains `fetchAll(` and does NOT call `download(` inside a `for await` / `client.fetch(` iterator
- `ImapFlow` is constructed with `logger: false`
- An attachment size guard (numeric byte limit) is present before buffering
- `pnpm --filter @tessera/api type-check` exits 0
</acceptance_criteria>
<done>IMAP provider fetches sender-filtered PDF attachments without the documented deadlock and without logging credentials.</done>
</task>
<task type="auto">
<name>Task 3: ExchangeInboxProvider (ews-javascript-api)</name>
<files>apps/api/src/dkv/providers/exchange-inbox.provider.ts</files>
<read_first>
- apps/api/src/calendar/providers/exchange.provider.ts — full EWS pattern: dynamic `await import('ews-javascript-api')`, ExchangeService, WebCredentials, error handling lines 42-48
- 07-RESEARCH.md "Pitfall 6" — use `WellKnownFolderName.Inbox` + `FindItems` + `EmailMessage.Bind`, NOT FindAppointments/CalendarView
- 07-PATTERNS.md "exchange-inbox.provider.ts" — class shell mirroring ExchangeProvider
- apps/api/src/dkv/providers/inbox-provider.interface.ts — contract to implement
</read_first>
<action>
Implement `@Injectable() ExchangeInboxProvider implements InboxProvider` mirroring the existing ExchangeProvider structure. Use the dynamic import `const ews: any = await import('ews-javascript-api')`, construct `ExchangeService`, set `Url` from config.host and `Credentials = new ews.WebCredentials(username, password)`. Differ from the calendar provider per Pitfall 6: bind to `ews.WellKnownFolderName.Inbox`, use `service.FindItems(...)` with an item view, filter by sender (config.senderFilter), then `EmailMessage.Bind` to load each message and read its attachments; download FileAttachment content for application/pdf parts into a Buffer. Apply the same max attachment size guard as the IMAP provider. `testConnection`: attempt a minimal FindItems on the Inbox, return boolean. On error, log a generic message only (T-05-13), never credentials, and return `[]` for fetch (consistent with calendar exchange.provider error path).
</action>
<verify>
<automated>pnpm --filter @tessera/api type-check && grep -q "WellKnownFolderName.Inbox" apps/api/src/dkv/providers/exchange-inbox.provider.ts && grep -q "import('ews-javascript-api')" apps/api/src/dkv/providers/exchange-inbox.provider.ts</automated>
</verify>
<acceptance_criteria>
- `ExchangeInboxProvider` implements `InboxProvider`
- Source references `WellKnownFolderName.Inbox` and `FindItems` (not `FindAppointments`)
- Uses dynamic `import('ews-javascript-api')` (matches calendar provider lazy-load convention)
- Error path logs generic message and does not include credential values
- `pnpm --filter @tessera/api type-check` exits 0
</acceptance_criteria>
<done>Exchange provider reads PDF attachments from the Inbox folder using the email (not calendar) EWS item type.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| external mail server → API | Untrusted IMAP/EWS responses and attachments cross into the server |
| admin form input → DTO | User-supplied config (host, sender, credentials) enters via DTO |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-07-03 | Information Disclosure | imap.provider.ts / exchange-inbox.provider.ts | mitigate | `logger: false` on ImapFlow; generic error logging (T-05-13); credentials never serialized into log strings |
| T-07-04 | Tampering | dkv-config.dto.ts | mitigate | senderFilter validated with `@IsEmail()`, port with `@Min(1)@Max(65535)`, encryption with `@IsIn(...)` — prevents injection/malformed config |
| T-07-05 | Denial of Service | imap.provider.ts / exchange-inbox.provider.ts | mitigate | Max attachment byte-size guard before buffering/parsing (PDF-bomb defense) |
| T-07-06 | Denial of Service | dkv-history.dto.ts | mitigate | Pagination params (page/limit) on history query |
</threat_model>
<verification>
- `pnpm --filter @tessera/api type-check` exits 0
- Both providers structurally implement the InboxProvider contract (type-check enforced)
- imapflow deadlock pattern avoided (fetchAll before download)
</verification>
<success_criteria>
- InboxProvider contract defined and implemented by both IMAP and Exchange providers
- DTOs validate all module-config and vehicle inputs with the security constraints from Research V5
- DKV-01 inbox-access layer ready for orchestration in Plan 04
</success_criteria>
<output>
Create `.planning/phases/07-dkv-fleet-module/07-02-SUMMARY.md` when done. Record the chosen max attachment size limit and any EWS API adjustments made versus the calendar ExchangeProvider.
</output>
@@ -0,0 +1,283 @@
---
phase: 07-dkv-fleet-module
plan: 03
type: execute
wave: 1
depends_on: ["07-01"]
files_modified:
- apps/api/src/settings/settings.module.ts
- apps/api/src/settings/settings.controller.ts
- apps/api/src/settings/settings.service.ts
- apps/api/src/settings/dto/smtp-config.dto.ts
- apps/api/src/dkv/dkv-mail.service.ts
- apps/api/src/dkv/dkv-export.service.ts
- apps/api/src/mail/mail.module.ts
- apps/api/src/mail/mail.service.ts
- apps/api/src/app.module.ts
autonomous: true
requirements: [DKV-04, DKV-05]
user_setup: []
must_haves:
truths:
- "SMTP config (host, port, encryption, username, encrypted password, from address) can be saved and read per tenant"
- "GET /settings/smtp never returns the decrypted or encrypted password to the client"
- "An SMTP connection can be verified via POST /settings/smtp/test"
- "An xlsx buffer with exactly 5 columns can be generated from export rows"
- "Export files are written to user-files/ and pruned to the last 10"
- "An email with an xlsx attachment can be sent via a runtime nodemailer transport built from DB SMTP config"
- "The existing MailModule reads its SMTP transport from the DB SmtpConfig (priority 1) and falls back to env vars when no DB config exists (D-06)"
artifacts:
- path: "apps/api/src/settings/settings.service.ts"
provides: "SmtpConfig CRUD + encryption/decryption + test + startup transport accessor"
contains: "getSmtpConfig"
- path: "apps/api/src/dkv/dkv-export.service.ts"
provides: "buildExcelBuffer + resolveFahrzeug + 10-file prune"
min_lines: 40
- path: "apps/api/src/dkv/dkv-mail.service.ts"
provides: "dynamic nodemailer transport send with attachment"
contains: "createTransport"
- path: "apps/api/src/mail/mail.module.ts"
provides: "MailerModule.forRootAsync factory sourcing SMTP from DB SmtpConfig with env fallback (D-06)"
contains: "forRootAsync"
key_links:
- from: "apps/api/src/dkv/dkv-mail.service.ts"
to: "nodemailer.createTransport"
via: "transport built per-send from SmtpConfig (NOT @nestjs-modules/mailer)"
pattern: "createTransport"
- from: "apps/api/src/settings/settings.service.ts"
to: "CalendarCryptoService"
via: "encrypt/decrypt SMTP password"
pattern: "crypto.(encrypt|decrypt)"
- from: "apps/api/src/mail/mail.module.ts"
to: "SettingsService"
via: "MailerModule.forRootAsync factory injects SettingsService to read DB SMTP config (D-06)"
pattern: "forRootAsync"
- from: "apps/api/src/app.module.ts"
to: "SettingsModule"
via: "imports array registration"
pattern: "SettingsModule"
---
<objective>
Build the export-and-deliver half of the pipeline plus shared SMTP configuration: the SettingsModule (SmtpConfig CRUD with encryption + connection test, ADMIN-only), the DkvExportService (xlsx generation + user-files/ management with 10-file prune), and the DkvMailService (runtime nodemailer transport that reads SMTP config from DB so config changes take effect immediately). Register SettingsModule in AppModule. Finally, migrate the existing MailModule (`apps/api/src/mail/`) so its transport is sourced from the DB SmtpConfig with an env-var fallback — this satisfies D-06 ("existing mail module must read SMTP config from DB instead of hardcoded env vars").
Purpose: Delivers DKV-04 (Excel + SMTP send) backend and DKV-05 (SMTP settings storage), and closes D-06 by removing the hardcoded env-only SMTP transport from the existing MailModule. The runtime-transport approach for DkvMailService is mandatory because @nestjs-modules/mailer cannot change its transport after startup (Research Pitfall 3); the existing MailModule keeps @nestjs-modules/mailer but builds its startup transport from the DB default SmtpConfig.
Output: SettingsModule (SMTP backend + test), DkvExportService, DkvMailService, migrated MailModule.
</objective>
## Phase Goal
**Als** Administrator **möchte ich** DKV-Tankkarten-Rechnungen automatisch aus einem E-Mail-Postfach verarbeiten lassen, **damit** Flotten-Tankdaten ohne manuelle Eingabe als Excel-Datei exportiert und per SMTP zugestellt werden.
This plan delivers the export + delivery + SMTP-config capability — the exit point of the pipeline.
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/phases/07-dkv-fleet-module/07-CONTEXT.md
@.planning/phases/07-dkv-fleet-module/07-RESEARCH.md
@.planning/phases/07-dkv-fleet-module/07-PATTERNS.md
@apps/api/src/dkv/dkv.types.ts
@apps/api/src/calendar/crypto.service.ts
@apps/api/src/calendar/calendar.service.ts
@apps/api/src/ldap/ldap.controller.ts
@apps/api/src/mail/mail.service.ts
@apps/api/src/mail/mail.module.ts
</context>
## Artifacts this phase produces (Plan 03 portion)
New symbols (exclude from drift verification):
- Classes `SettingsModule`, `SettingsController`, `SettingsService`
- DTO `SmtpConfigDto`
- Class `DkvExportService` (`buildExcelBuffer`, `resolveFahrzeug`, `writeAndPrune`)
- Class `DkvMailService` (`sendExportEmail`)
- `SettingsModule` registered in `apps/api/src/app.module.ts`
- New `SettingsService` accessors `testSmtpConfig()` and `getStartupSmtpConfig()` (tenant-agnostic, used by the MailModule factory)
Modified (existing) symbols:
- `apps/api/src/mail/mail.module.ts` — `MailerModule.forRootAsync` factory rewired to source the transport from the DB default SmtpConfig with env fallback (D-06)
- `apps/api/src/mail/mail.service.ts` — unchanged logic; verified to still compile against the rewired MailerModule
<tasks>
<task type="auto">
<name>Task 1: SettingsModule — SMTP config backend + connection test (DKV-05)</name>
<files>apps/api/src/settings/settings.module.ts, apps/api/src/settings/settings.controller.ts, apps/api/src/settings/settings.service.ts, apps/api/src/settings/dto/smtp-config.dto.ts, apps/api/src/app.module.ts</files>
<read_first>
- apps/api/src/ldap/ldap.controller.ts — controller shell, `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`, tenant extraction `req.tenantId` + BadRequestException
- apps/api/src/calendar/calendar.service.ts lines 50-67 — SAFE_SELECT pattern excluding encrypted fields
- apps/api/src/calendar/crypto.service.ts — encrypt/decrypt API (CalendarCryptoService, exported from CalendarModule in Plan 01)
- apps/api/src/calendar/calendar.module.ts — module wiring reference (how CalendarCryptoService is provided/imported)
- apps/api/src/app.module.ts — imports array (add SettingsModule; ScheduleModule already added in Plan 01)
- 07-PATTERNS.md "settings.controller.ts" — GET/PUT /settings/smtp routes, hasPassword boolean response
- 07-RESEARCH.md Prisma SmtpConfig model field names + "Pattern 6: Dynamic SMTP via Nodemailer" (transport build / verify)
</read_first>
<action>
Create the SettingsModule (controller + service + dto). Import CalendarModule (or provide CalendarCryptoService) so `CalendarCryptoService` can be injected; PrismaModule is global. The module MUST list `SettingsService` in its `exports` array (Task 4 wires it into MailModule).
`SmtpConfigDto`: host `@IsString() @IsNotEmpty()`; port `@IsInt() @Min(1) @Max(65535)`; encryption `@IsIn(['none','starttls','ssl-tls'])`; username optional string; password optional string; fromAddress `@IsEmail()`.
`SettingsController` with `@Controller('settings')`, every handler `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`, tenant extracted from `req.tenantId` (throw BadRequestException 'No tenant context' when absent):
- `GET /settings/smtp` → returns config with `encryptedPassword` stripped and a `hasPassword` boolean added; returns null when none.
- `PUT /settings/smtp` → upsert via service.
- `POST /settings/smtp/test` (body via `SmtpConfigDto`) → calls `service.testSmtpConfig(tenantId, dto)` and returns `{ success: boolean }`. This backs the UI-SPEC Surface C "Verbindung testen" button consumed by Plan 07-06.
`SettingsService`: `getSmtpConfig(tenantId)` using a `SMTP_SAFE_SELECT` that excludes `encryptedPassword`; `saveSmtpConfig(tenantId, dto)` that encrypts `dto.password` with `crypto.encrypt(...)` only when a new password is provided (preserve existing on empty), upserts on `tenantId @unique`; `getDecryptedSmtpConfig(tenantId)` (internal, used by DkvMailService) returning host/port/encryption/username/fromAddress plus `decryptedPassword`; `testSmtpConfig(tenantId, dto)` that builds a `nodemailer.createTransport(...)` from the submitted dto (use `dto.password` when provided, else the stored decrypted password) and runs `transport.verify()`, returning a boolean (generic error on failure, never logging credentials — T-05-13). Never log decrypted values (T-05-13).
Register `SettingsModule` in apps/api/src/app.module.ts imports array.
</action>
<verify>
<automated>pnpm --filter @tessera/api type-check && grep -q "getSmtpConfig" apps/api/src/settings/settings.service.ts && grep -q "testSmtpConfig" apps/api/src/settings/settings.service.ts && grep -q "SettingsModule" apps/api/src/app.module.ts && grep -q "Roles(Role.ADMIN" apps/api/src/settings/settings.controller.ts && grep -q "exports" apps/api/src/settings/settings.module.ts</automated>
</verify>
<acceptance_criteria>
- `GET /settings/smtp` handler strips `encryptedPassword` and adds `hasPassword` boolean
- All controller handlers (GET, PUT, POST test) carry `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` (V4 access control)
- `SettingsService.saveSmtpConfig` calls `crypto.encrypt` for the password
- `SettingsService` exposes an internal decrypt method returning `decryptedPassword`
- `SettingsService.testSmtpConfig` builds a transport and calls `transport.verify()`, returning a boolean; `POST /settings/smtp/test` returns `{ success: boolean }`
- `SettingsModule` exports `SettingsService` and appears in `apps/api/src/app.module.ts` imports
- `pnpm --filter @tessera/api type-check` exits 0
</acceptance_criteria>
<done>Per-tenant SMTP config can be stored encrypted, read back safely, and connection-tested; module registered and exports SettingsService.</done>
</task>
<task type="auto">
<name>Task 2: DkvExportService — xlsx generation + user-files/ prune (DKV-04)</name>
<files>apps/api/src/dkv/dkv-export.service.ts</files>
<read_first>
- 07-RESEARCH.md "Pattern 5: SheetJS Excel Generation" — aoa_to_sheet, book_new, XLSX.write buffer
- 07-RESEARCH.md "Fahrzeug Format String Resolution" — resolveFahrzeug template replacement
- 07-RESEARCH.md "Pitfall 7" — 10-file prune race condition; D-12 filename, D-13 columns, D-14 one file per invoice, D-15 keep last 10
- 07-PATTERNS.md "dkv-export.service.ts" — service shell, buildExcelBuffer, processing-lock note
- apps/api/src/dkv/dkv.types.ts — ExportRow, DkvVehicleMaster shape
</read_first>
<action>
Implement `@Injectable() DkvExportService` with `new Logger(...)`.
`resolveFahrzeug(vehicle, formatString)`: replace `{Marke}`,`{Modell}`,`{Kennzeichen}`,`{Fahrer}` tokens (default format `{Marke}/{Modell}/{Kennzeichen}` per D-19).
`buildExcelBuffer(rows: ExportRow[]): Buffer`: header row exactly `['Lieferdatum','Fahrzeug','Fahrer','Ort','Kilometerstand']` (D-13 order), write Lieferdatum as the already-formatted German date STRING (never a JS Date — Research anti-pattern), Kilometerstand as number; `XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })`.
`writeAndPrune(buffer, rechnungsnummer, invoiceMonth): string`: compute filename `DKV_YYYY-MM_<Rechnungsnummer>.xlsx` (D-12) server-side only (never user-supplied — path-traversal mitigation), write into the `user-files/` directory (create if missing), then list DKV_*.xlsx files sorted by mtime and delete all but the newest 10 (D-15). Return the filename.
Guard against the prune race (Pitfall 7): expose the write+prune as a single method so the orchestrator (Plan 04) serializes processing; do not interleave count-check and write.
Resolve the user-files/ path relative to the repo/app root, not from request input.
</action>
<verify>
<automated>pnpm --filter @tessera/api type-check && grep -q "aoa_to_sheet" apps/api/src/dkv/dkv-export.service.ts && grep -q "Lieferdatum" apps/api/src/dkv/dkv-export.service.ts</automated>
</verify>
<acceptance_criteria>
- `buildExcelBuffer` produces a sheet whose first row is exactly the 5 headers in D-13 order
- Lieferdatum is written as a string (no `new Date(` wrapping of the Lieferdatum value)
- `writeAndPrune` builds the filename server-side as `DKV_YYYY-MM_<nr>.xlsx` and never from request input
- Prune logic keeps at most 10 `DKV_*.xlsx` files
- `resolveFahrzeug` replaces all four placeholder tokens
- `pnpm --filter @tessera/api type-check` exits 0
</acceptance_criteria>
<done>Excel buffers with the exact 5-column contract are generated and export files are pruned to the last 10.</done>
</task>
<task type="auto">
<name>Task 3: DkvMailService — runtime SMTP transport with attachment (DKV-04)</name>
<files>apps/api/src/dkv/dkv-mail.service.ts</files>
<read_first>
- 07-RESEARCH.md "Pattern 6: Dynamic SMTP via Nodemailer" — createTransport at send time, secure/requireTLS mapping
- 07-RESEARCH.md "Pitfall 3" — @nestjs-modules/mailer cannot change transport at runtime; use nodemailer directly
- 07-PATTERNS.md "dkv-mail.service.ts" — imports, error handling (log + rethrow for retry), do NOT use MailerService
- apps/api/src/mail/mail.service.ts — existing MailService (its logic is left unchanged in Task 3; this DkvMailService is a separate service)
</read_first>
<action>
Implement `@Injectable() DkvMailService` with `new Logger(...)`. Inject SettingsService (or PrismaService + CalendarCryptoService) to load decrypted SMTP config.
`sendExportEmail(tenantId, recipient, attachmentBuffer, filename)`: load decrypted SMTP config for the tenant; build `nodemailer.createTransport({ host, port, secure: encryption==='ssl-tls', requireTLS: encryption==='starttls', auth: username ? { user, pass: decryptedPassword } : undefined })`; `sendMail({ from: fromAddress, to: recipient, subject: 'DKV Flottenabrechnung: '+filename, text, attachments: [{ filename, content: attachmentBuffer, contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }] })`.
On failure: log a generic message (never credentials, T-05-13) and RETHROW so the orchestrator (Plan 04) can run the 3-retry exponential backoff per D-16. Do NOT swallow the error (unlike MailService).
Do NOT import or use `MailerService` from @nestjs-modules/mailer.
</action>
<verify>
<automated>pnpm --filter @tessera/api type-check && grep -q "createTransport" apps/api/src/dkv/dkv-mail.service.ts && ! grep -q "MailerService" apps/api/src/dkv/dkv-mail.service.ts</automated>
</verify>
<acceptance_criteria>
- Source uses `nodemailer.createTransport(` built from DB config at send time
- Source does NOT reference `MailerService`
- Attachment contentType is the xlsx OOXML mime type
- Error path rethrows (does not swallow) so retries can run
- `pnpm --filter @tessera/api type-check` exits 0
</acceptance_criteria>
<done>DKV exports can be emailed with the xlsx attachment via a per-send transport that reflects current DB SMTP config.</done>
</task>
<task type="auto">
<name>Task 4: Migrate MailModule to DB-sourced SMTP transport with env fallback (D-06)</name>
<files>apps/api/src/mail/mail.module.ts, apps/api/src/mail/mail.service.ts, apps/api/src/settings/settings.service.ts</files>
<read_first>
- apps/api/src/mail/mail.module.ts — current `MailerModule.forRootAsync` factory reading TESSERA_SMTP_* env vars only (this is what D-06 forbids; it must read DB config first)
- apps/api/src/mail/mail.service.ts — consumes `MailerService`; verify it still compiles after the factory change (no logic change expected)
- apps/api/src/settings/settings.service.ts — add a tenant-agnostic startup accessor here (Task 1 created this file)
- apps/api/src/calendar/crypto.service.ts — decrypt API for the stored SMTP password
- 07-CONTEXT.md D-05 (SMTP lives in general settings) and D-06 (existing mail module must read SMTP from DB)
</read_first>
<action>
This task closes the D-06 violation. User decision: Option 2 — migrate the existing `mail.service.ts`/`mail.module.ts` to DB-config + env-fallback rather than building a parallel service.
1. In `apps/api/src/settings/settings.service.ts`, add a tenant-agnostic accessor `async getStartupSmtpConfig()`: `prisma.smtpConfig.findFirst()`; if a row exists, decrypt its password via `CalendarCryptoService` and return `{ host, port, secure: encryption==='ssl-tls', requireTLS: encryption==='starttls', username, password: decryptedPassword, fromAddress }`; return `null` when no row exists. Never log the decrypted password (T-05-13). This is the DB default transport used for system mail (e.g. password-reset) — document in the SUMMARY that single-tenant deployments use the first SmtpConfig row.
2. Rewrite `apps/api/src/mail/mail.module.ts` to keep `MailerModule.forRootAsync(...)` but change the factory to source the transport from the DB SmtpConfig (priority 1) with an env-var fallback (priority 2):
- `imports: [SettingsModule]` and `inject: [SettingsService, ConfigService]`.
- Factory body: `const db = await settingsService.getStartupSmtpConfig();` Build `transport`/`defaults` from `db` when non-null. When `db` is null, fall back to env vars per the checker contract: `MAIL_HOST`, `MAIL_PORT`, `MAIL_USER`, `MAIL_PASS` (read via `configService.get`). Preserve backward compatibility by chaining to the existing `TESSERA_SMTP_*` names and current defaults (`localhost:1025`) as the final fallback so the password-reset flow keeps working: e.g. `host = MAIL_HOST ?? TESSERA_SMTP_HOST ?? 'localhost'`, `port = MAIL_PORT ?? TESSERA_SMTP_PORT ?? 1025`, `user = MAIL_USER ?? TESSERA_SMTP_USER ?? ''`, `pass = MAIL_PASS ?? TESSERA_SMTP_PASSWORD ?? ''`. Map `secure`: from DB use `encryption==='ssl-tls'`; from env keep `TESSERA_SMTP_SECURE==='true'`. `defaults.from` from DB `fromAddress` else `TESSERA_SMTP_FROM` default.
- This is an async factory; `useFactory` must be `async`.
3. `apps/api/src/mail/mail.service.ts`: no behavioral change is required (it injects `MailerService`, which still exists). Confirm it compiles against the rewired module. Do not alter the password-reset / welcome-email logic.
Beware circular imports: the chain `AuthModule → MailModule → SettingsModule → CalendarModule` has no cycle; do NOT make SettingsModule import MailModule.
</action>
<verify>
<automated>pnpm --filter @tessera/api type-check && grep -q "forRootAsync" apps/api/src/mail/mail.module.ts && grep -q "getStartupSmtpConfig" apps/api/src/settings/settings.service.ts && grep -q "getStartupSmtpConfig" apps/api/src/mail/mail.module.ts && grep -Eq "MAIL_HOST" apps/api/src/mail/mail.module.ts && grep -q "SettingsModule" apps/api/src/mail/mail.module.ts</automated>
</verify>
<acceptance_criteria>
- `apps/api/src/mail/mail.module.ts` still calls `MailerModule.forRootAsync(`
- The factory injects `SettingsService` and calls `getStartupSmtpConfig()` (DB priority 1)
- When no DB SmtpConfig row exists, the factory falls back to env vars `MAIL_HOST`/`MAIL_PORT`/`MAIL_USER`/`MAIL_PASS` (with existing `TESSERA_SMTP_*` preserved as secondary fallback)
- `SettingsService.getStartupSmtpConfig()` decrypts the stored password and never logs it
- `apps/api/src/mail/mail.module.ts` imports `SettingsModule`
- `pnpm --filter @tessera/api type-check` exits 0
</acceptance_criteria>
<done>The existing MailModule sources its SMTP transport from the DB SmtpConfig with an env-var fallback — D-06 satisfied without a parallel mail service.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → /settings/smtp | Admin supplies SMTP host/credentials |
| API → external SMTP server | Outbound mail with attachment + connection verify |
| API → user-files/ filesystem | Export file writes |
| DB SmtpConfig → MailModule factory | Stored encrypted SMTP password decrypted at startup |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-07-07 | Information Disclosure | settings.service/controller | mitigate | SMTP_SAFE_SELECT excludes encryptedPassword; GET returns hasPassword boolean only; password encrypted via CalendarCryptoService (V6) |
| T-07-08 | Tampering | settings.controller.ts | mitigate | All SMTP endpoints (GET/PUT/test) are `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` only (V4); host/port/encryption validated via SmtpConfigDto (open-relay defense) |
| T-07-09 | Tampering | dkv-export.service.ts | mitigate | Export filename generated server-side (`DKV_YYYY-MM_<nr>.xlsx`), never from request — path-traversal prevented |
| T-07-10 | Information Disclosure | dkv-mail.service.ts | mitigate | Generic error logging; decrypted SMTP password never logged (T-05-13) |
| T-07-11 | Information Disclosure | mail.module.ts / settings.service.getStartupSmtpConfig | mitigate | Decrypted SMTP password used only to build the transport at startup; never logged (T-05-13); env fallback values are not echoed |
| T-07-16 | Information Disclosure | settings.service.testSmtpConfig | mitigate | Connection test returns only a boolean; verify() failures logged generically without credentials (T-05-13) |
</threat_model>
<verification>
- `pnpm --filter @tessera/api type-check` exits 0
- SettingsModule registered + exports SettingsService; SMTP CRUD never leaks password; test endpoint returns boolean
- Export buffer has exact 5-column contract; DkvMailService uses runtime transport
- MailModule factory reads DB SmtpConfig first, env fallback second (D-06)
</verification>
<success_criteria>
- DKV-05 SMTP storage backend complete (encrypted, ADMIN-only, password never returned, connection-testable)
- DKV-04 export + delivery primitives complete (xlsx 5-column buffer, 10-file prune, runtime SMTP send)
- D-06 closed: existing MailModule sources its SMTP transport from the DB with env fallback
</success_criteria>
<output>
Create `.planning/phases/07-dkv-fleet-module/07-03-SUMMARY.md` when done. Record how the user-files/ path is resolved, the exact prune ordering used, and how the MailModule factory resolves the DB-vs-env transport (including the tenant chosen by getStartupSmtpConfig).
</output>
@@ -0,0 +1,187 @@
---
phase: 07-dkv-fleet-module
plan: 04
type: execute
wave: 2
depends_on: ["07-01", "07-02", "07-03"]
files_modified:
- 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
autonomous: true
requirements: [DKV-01, DKV-03, DKV-04, DKV-05]
user_setup: []
must_haves:
truths:
- "Triggering an inbox check runs the full pipeline: poll -> parse -> map drivers -> export xlsx -> send SMTP -> record history"
- "The poll interval is driven by a dynamic cron job sourced from DB config"
- "Concurrent processing is prevented by a single-flight guard"
- "All /dkv/* REST routes exist and are ADMIN-only"
- "Vehicle master CRUD and CSV import work end-to-end"
- "Export files are downloadable; history is paginated"
- "DKV module is registered in the module registry"
- "All dkvFleet and settings i18n keys exist in de.json and en.json"
artifacts:
- path: "apps/api/src/dkv/dkv.service.ts"
provides: "processInbox orchestration + vehicle CRUD + CSV import + config + history + driver mapping"
min_lines: 80
- path: "apps/api/src/dkv/dkv-scheduler.service.ts"
provides: "SchedulerRegistry dynamic cron lifecycle"
contains: "addCronJob"
- path: "apps/api/src/dkv/dkv.controller.ts"
provides: "all /dkv/* routes, ADMIN-only"
contains: "@Controller('dkv')"
- path: "apps/api/src/dkv/dkv.module.ts"
provides: "DkvModule wiring + registry self-seed"
contains: "OnModuleInit"
key_links:
- from: "apps/api/src/dkv/dkv.service.ts"
to: "DkvParserService / DkvExportService / DkvMailService / InboxProvider"
via: "pipeline orchestration"
pattern: "parsePdf|buildExcelBuffer|sendExportEmail"
- from: "apps/api/src/dkv/dkv-scheduler.service.ts"
to: "SchedulerRegistry"
via: "addCronJob with */N cron expression"
pattern: "schedulerRegistry.addCronJob"
- from: "apps/api/src/dkv/dkv.module.ts"
to: "ModuleRegistryService"
via: "seedDkvModule on init"
pattern: "seedDkvModule"
---
<objective>
Wire the full DKV processing pipeline together and expose it over REST. DkvService orchestrates poll → parse → driver-map → export → send → history with a single-flight guard and per-D-10/D-16 retry logic. DkvSchedulerService drives the configurable poll interval via SchedulerRegistry. DkvController exposes all /dkv/* routes (config, vehicles CRUD, CSV import, check-now, history, export download), ADMIN-only. DkvModule self-registers in the module registry. Finally, add all phase i18n keys so the Wave 3 frontend plans can run in parallel without touching the message files.
Purpose: This is the integration plane that turns the Plan 02/03 primitives into the end-to-end DKV-01/03/04/05 capability and exposes it to the frontend.
Output: DkvService, DkvSchedulerService, DkvController, DkvModule (+seed), AppModule registration, complete i18n keys.
</objective>
## Phase Goal
**Als** Administrator **möchte ich** DKV-Tankkarten-Rechnungen automatisch aus einem E-Mail-Postfach verarbeiten lassen, **damit** Flotten-Tankdaten ohne manuelle Eingabe als Excel-Datei exportiert und per SMTP zugestellt werden.
After this plan, the entire backend pipeline runs end-to-end via a single "check inbox" trigger.
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/phases/07-dkv-fleet-module/07-CONTEXT.md
@.planning/phases/07-dkv-fleet-module/07-RESEARCH.md
@.planning/phases/07-dkv-fleet-module/07-PATTERNS.md
@.planning/phases/07-dkv-fleet-module/07-UI-SPEC.md
@apps/api/src/dkv/dkv.types.ts
@apps/api/src/ldap/ldap.controller.ts
@apps/api/src/ldap/ldap-sync.scheduler.ts
@apps/api/src/domaincheck/domaincheck.module.ts
@apps/api/src/domaincheck/domaincheck.seed.ts
@apps/api/src/calendar/calendar.service.ts
</context>
## Artifacts this phase produces (Plan 04 portion)
New symbols (exclude from drift verification):
- Classes `DkvService`, `DkvSchedulerService`, `DkvController`, `DkvModule`
- Function `seedDkvModule` (registry seed)
- DkvService methods: `processInbox`, `checkNow`, `loadConfig`, `saveConfig`, `getHistory`, `listVehicles`, `createVehicle`, `updateVehicle`, `deleteVehicle`, `importVehiclesCsv`, `getExportFile`
- `DkvModule` registered in `apps/api/src/app.module.ts`
- i18n namespaces `dkvFleet` (new) and `settings` keys (categoryGeneral, categorySmtp, smtp.*)
<tasks>
<task type="auto">
<name>Task 1: DkvService pipeline orchestration + vehicle/config/history logic</name>
<files>apps/api/src/dkv/dkv.service.ts</files>
<read_first>
- apps/api/src/calendar/calendar.service.ts — orchestration service shell, CONFIG SAFE_SELECT pattern (exclude encryptedInboxCreds), crypto encrypt/decrypt usage
- apps/api/src/dkv/dkv.types.ts — DkvVehicleBlock, ExportRow, InboxConfig
- apps/api/src/dkv/providers/inbox-provider.interface.ts — InboxProvider contract (Plan 02)
- apps/api/src/dkv/dkv-parser.service.ts (Plan 01), dkv-export.service.ts + dkv-mail.service.ts (Plan 03) — methods to call
- 07-RESEARCH.md "Pitfall 7" (single-flight guard), "CSV Vehicle Import Pattern", D-09/D-10/D-13/D-16 decisions
- 07-CONTEXT.md D-12 filename, D-13 columns, D-19 vehicleFormatString
</read_first>
<action>
Implement `@Injectable() DkvService` injecting PrismaService, CalendarCryptoService, DkvParserService, DkvExportService, DkvMailService, ImapProvider, ExchangeInboxProvider.
Config: `loadConfig(tenantId?)` returns DkvModuleConfig via a CONFIG_SAFE_SELECT that excludes `encryptedInboxCreds`; `saveConfig(tenantId, dto)` encrypts `{username,password}` to `encryptedInboxCreds` via `crypto.encrypt(JSON.stringify(...))` (only when provided; preserve existing on empty), upserts on tenantId @unique, and after save calls the scheduler to (re)apply the interval.
Pipeline `processInbox(tenantId)`: single-flight guard (`private processing` flag per Pitfall 7 — return early if busy). Select provider by `config.protocol` ('imap'→ImapProvider, 'exchange'→ExchangeInboxProvider). Decrypt inbox creds, build InboxConfig, fetch PDF attachments. For each invoice PDF: parse via DkvParserService (D-10: up to 3 parse retries, then record a `Fehler` history row with errorMessage and continue); resolve each vehicle's driver by looking up DkvVehicleMaster by (tenantId, kennzeichen) — unknown plates still export with empty Fahrer; build ExportRow[] (Lieferdatum string TT.MM.JJJJ, Fahrzeug via export.resolveFahrzeug + config.vehicleFormatString, Fahrer, Ort, Kilometerstand number); generate xlsx buffer + writeAndPrune to get filename; send via DkvMailService with D-16 retry (3 attempts, exponential backoff) — on final failure record status `Versand fehlgeschlagen` (file stays available); on success record `Verarbeitet`. Each history row records datumZeit, rechnungsnummer, anzahlFahrzeuge, anzahlTransaktionen, status, exportFilename (D-20).
`checkNow(tenantId)`: invoke processInbox and return a summary; update a lastCheckedAt notion (store on config or return now()).
Vehicle CRUD: `listVehicles`, `createVehicle`, `updateVehicle`, `deleteVehicle` scoped by tenantId (unique on tenantId+kennzeichen). `importVehiclesCsv(tenantId, csvText, mode)`: semicolon-delimited parse (header Kennzeichen;Marke;Modell;Fahrer per Research); mode 'merge' upserts, mode 'replace' deletes all tenant vehicles then inserts (returns count). `getHistory(tenantId, page, limit)` paginated, ordered by datumZeit desc. `getExportFile(tenantId, filename)`: validate filename matches the `DKV_*.xlsx` server-format (reject anything with path separators — traversal guard) before reading from user-files/.
Never log decrypted credentials (T-05-13).
</action>
<verify>
<automated>pnpm --filter @tessera/api type-check && grep -q "processing" apps/api/src/dkv/dkv.service.ts && grep -q "encryptedInboxCreds" apps/api/src/dkv/dkv.service.ts && grep -Eq "parsePdf|buildExcelBuffer|sendExportEmail" apps/api/src/dkv/dkv.service.ts</automated>
</verify>
<acceptance_criteria>
- `processInbox` has a single-flight guard (early return when already processing)
- Pipeline calls parser, export builder, and mail sender in sequence
- Parse failures record a `Fehler` history row with errorMessage (D-10); SMTP final failure records `Versand fehlgeschlagen` (D-16)
- CONFIG_SAFE_SELECT excludes `encryptedInboxCreds`
- `getExportFile` rejects filenames containing path separators
- CSV import supports merge and replace modes
- `pnpm --filter @tessera/api type-check` exits 0
</acceptance_criteria>
<done>The full poll→parse→map→export→send→history pipeline plus vehicle/config/history logic is implemented with safety guards.</done>
</task>
<task type="auto">
<name>Task 2: DkvSchedulerService + DkvController</name>
<files>apps/api/src/dkv/dkv-scheduler.service.ts, apps/api/src/dkv/dkv.controller.ts</files>
<read_first>
- apps/api/src/ldap/ldap-sync.scheduler.ts — scheduler service shape (this one DIFFERS: dynamic SchedulerRegistry not static @Cron)
- 07-RESEARCH.md "Pattern 7: Dynamic Cron Job (SchedulerRegistry)" + "Pitfall 4" (ScheduleModule.forRoot already added in Plan 01)
- apps/api/src/ldap/ldap.controller.ts — controller shell, @Roles, tenant extraction, FileInterceptor usage pattern
- 07-PATTERNS.md "dkv.controller.ts" — full route list, FileInterceptor for CSV import
- apps/api/src/dkv/dto/dkv-config.dto.ts + dkv-vehicle.dto.ts + dkv-history.dto.ts (Plan 02)
</read_first>
<action>
DkvSchedulerService: `@Injectable() implements OnModuleInit`, inject SchedulerRegistry + DkvService. Constant `JOB_NAME='dkv-inbox-poll'`. `onModuleInit` loads config and, if isActive, calls `setInterval(pollIntervalMin)`. `setInterval(min)`: remove existing job (getCronJob/stop/deleteCronJob wrapped in try/catch), create `new CronJob('*/${min} * * * *', () => this.dkvService.processInbox(tenantId).catch(log))`, addCronJob + start. Expose a method DkvService can call after config save to re-apply the interval. (Multi-tenant note: if only one tenant config exists today, key the job by tenant or process all active tenant configs — document the choice in the SUMMARY.)
DkvController: `@Controller('dkv')`, EVERY handler `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`, tenant from `req.tenantId` (BadRequestException when absent). Routes:
- `GET /dkv/config`, `PUT /dkv/config` (DkvConfigDto)
- `POST /dkv/check-now` → dkvService.checkNow
- `GET /dkv/history` (DkvHistoryQueryDto page/limit)
- `GET /dkv/exports/:filename` → stream the file (Content-Disposition attachment); rely on service traversal guard
- `GET /dkv/vehicles`, `POST /dkv/vehicles` (CreateVehicleDto), `PUT /dkv/vehicles/:id` (UpdateVehicleDto), `DELETE /dkv/vehicles/:id`
- `POST /dkv/vehicles/import` with `@UseInterceptors(FileInterceptor('file'))` + a `mode` body field ('merge'|'replace'); read uploaded buffer to utf8 and pass to dkvService.importVehiclesCsv
- `POST /dkv/test-connection` (DkvConfigDto) → provider.testConnection for the inbox config form
Follow the ldap.controller error-handling convention.
</action>
<verify>
<automated>pnpm --filter @tessera/api type-check && grep -q "schedulerRegistry.addCronJob" apps/api/src/dkv/dkv-scheduler.service.ts && grep -q "@Controller('dkv')" apps/api/src/dkv/dkv.controller.ts && grep -q "FileInterceptor" apps/api/src/dkv/dkv.controller.ts</automated>
</verify>
<acceptance_criteria>
- Scheduler uses `schedulerRegistry.addCronJob` (not a static `@Cron` decorator)
- All DkvController handlers carry `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` (V4)
- Routes exist for config GET/PUT, check-now, history, exports/:filename, vehicles CRUD, vehicles/import, test-connection
- CSV import route uses `FileInterceptor`
- `pnpm --filter @tessera/api type-check` exits 0
</acceptance_criteria>
<done>Dynamic cron scheduling and the complete ADMIN-only REST surface are in place.</done>
</task>
<task type="auto">
<name>Task 3: DkvModule + registry seed + AppModule registration + i18n keys</name>
<files>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</files>
<read_first>
- apps/api/src/domaincheck/domaincheck.module.ts — module + OnModuleInit + ModuleRegistryModule import + seed call
- apps/api/src/domaincheck/domaincheck.seed.ts — seedModule manifest shape (slug, name, version, category, description {de,en}, isSystem)
- 07-PATTERNS.md "dkv.module.ts" (provider list incl. CalendarCryptoService) + "AppModule Modification"
- 07-UI-SPEC.md "i18n Translation Keys" — full dkvFleet namespace + settings namespace additions (DE values given; provide EN equivalents)
- apps/web/src/messages/de.json + en.json — existing structure to extend (do not remove existing keys)
</read_first>
<action>
Create dkv.seed.ts exporting `seedDkvModule(moduleRegistryService)` calling `seedModule({ slug: 'dkv-fleet', name: 'DKV Flotte', version: '1.0.0', category: 'fleet', description: { de: 'DKV-Tankkartenrechnungen automatisch verarbeiten', en: 'Automatically process DKV fuel card invoices' }, isSystem: true })`.
Create dkv.module.ts: `@Module` importing `ModuleRegistryModule` (for the registry seed) and providing DkvService, DkvSchedulerService, DkvParserService, DkvExportService, DkvMailService, ImapProvider, ExchangeInboxProvider, CalendarCryptoService; controllers [DkvController]; exports [DkvService]. Implement `OnModuleInit` to call `seedDkvModule(this.moduleRegistryService)` in a try/catch with logger (mirror DomaincheckModule).
Register `DkvModule` in apps/api/src/app.module.ts imports array (ScheduleModule.forRoot and SettingsModule were added by Plans 01 and 03 respectively — only add DkvModule here).
Add the complete `dkvFleet` namespace to both apps/web/src/messages/de.json and en.json using the exact German strings from 07-UI-SPEC.md "i18n Translation Keys", and provide accurate English translations for en.json. Extend the existing `settings` namespace with `categoryGeneral`, `categorySmtp`, and the `smtp` sub-object (both files). Preserve all existing keys; valid JSON in both files.
</action>
<verify>
<automated>pnpm --filter @tessera/api type-check && grep -q "DkvModule" apps/api/src/app.module.ts && grep -q "seedDkvModule" apps/api/src/dkv/dkv.module.ts && node -e "const d=require('./apps/web/src/messages/de.json');const e=require('./apps/web/src/messages/en.json');if(!d.dkvFleet||!e.dkvFleet||!d.settings.categorySmtp||!e.settings.categorySmtp)process.exit(1);console.log('i18n ok')"
@@ -0,0 +1,244 @@
---
phase: 07-dkv-fleet-module
plan: 05
type: execute
wave: 2
depends_on: ["07-01", "07-02", "07-03"]
files_modified:
- apps/web/src/lib/dkv-api.ts
- apps/web/src/app/(portal)/modules/dkv-fleet/page.tsx
- apps/web/src/app/(portal)/modules/dkv-fleet/components/StatusBadge.tsx
- apps/web/src/app/(portal)/modules/dkv-fleet/components/InvoiceHistoryTable.tsx
- apps/web/src/app/(portal)/modules/dkv-fleet/components/ExportFileList.tsx
- apps/web/src/app/(portal)/modules/dkv-fleet/settings/page.tsx
- apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/InboxConfigForm.tsx
- apps/web/src/app/(portal)/modules/dkv-fleet/vehicles/page.tsx
- apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx
- apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/CsvImportButton.tsx
- apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx
autonomous: true
requirements: [DKV-01, DKV-03, DKV-04]
user_setup: []
must_haves:
truths:
- "User can open /modules/dkv-fleet and see the processing history (Verarbeitungshistorie) and the last export files"
- "User can click 'Jetzt prüfen' to trigger an inbox poll and the history refetches afterward"
- "User can download an export file from the history row / export list"
- "User can configure the inbox (protocol, host, port, encryption, folder, sender filter, interval, credentials, recipient, format string, active) and save it"
- "User can test the inbox connection and see inline success/error feedback"
- "User can list, add, inline-edit, and delete vehicles in the vehicle master table"
- "User can import a CSV of vehicles in merge or replace mode (replace asks for destructive confirmation)"
artifacts:
- path: "apps/web/src/lib/dkv-api.ts"
provides: "Typed fetch client for all /dkv/* routes (config, check-now, history, exports, vehicles CRUD, CSV import, test-connection)"
contains: "credentials: 'include'"
- path: "apps/web/src/app/(portal)/modules/dkv-fleet/page.tsx"
provides: "Surface A — module main page (history + export list + Jetzt prüfen)"
min_lines: 30
- path: "apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/InboxConfigForm.tsx"
provides: "Inbox config form (Surface B inbox tab)"
min_lines: 40
- path: "apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx"
provides: "Vehicle master CRUD table with inline edit"
min_lines: 40
- path: "apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx"
provides: "Vitest coverage for vehicle CRUD table (Wave 0 / VALIDATION DKV-03)"
contains: "vi.mock"
key_links:
- from: "apps/web/src/app/(portal)/modules/dkv-fleet/page.tsx"
to: "apps/web/src/lib/dkv-api.ts (checkNow + fetchHistory)"
via: "Jetzt prüfen button calls checkNow then refetches history"
pattern: "checkNow|fetchHistory"
- from: "apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/InboxConfigForm.tsx"
to: "apps/web/src/lib/dkv-api.ts (fetchConfig/saveConfig/testConnection)"
via: "form load + save + test"
pattern: "saveConfig|testConnection"
- from: "apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx"
to: "apps/web/src/lib/dkv-api.ts (vehicles CRUD)"
via: "list/create/update/delete"
pattern: "createVehicle|updateVehicle|deleteVehicle"
---
<objective>
Deliver the DKV Fleet Module frontend (Surfaces A and B from 07-UI-SPEC.md): the typed API client, the module main page (processing history + export download list + "Jetzt prüfen" trigger), the module settings inbox-config form, and the vehicle master CRUD table with CSV import. Consumes the `/dkv/*` REST surface built in Plan 04.
Purpose: This is the user-facing half of DKV-01 (inbox config + manual check), DKV-03 (vehicle mapping CSV + manual edit), and DKV-04 (export download + processing history). Each task is a vertical slice: after it, a user can do something concrete in the browser.
Output: dkv-api client, main page (+ StatusBadge, InvoiceHistoryTable, ExportFileList), settings page (+ InboxConfigForm), vehicles page (+ VehicleTable, CsvImportButton) and a VehicleTable test.
</objective>
## Phase Goal
**Als** Administrator **möchte ich** DKV-Tankkarten-Rechnungen automatisch aus einem E-Mail-Postfach verarbeiten lassen, **damit** Flotten-Tankdaten ohne manuelle Eingabe als Excel-Datei exportiert und per SMTP zugestellt werden.
This plan makes the pipeline operable from the browser: configure the inbox, manage vehicle→driver mapping, trigger checks, and download exports.
> **Requirement mapping note:** The checker brief listed `DKV-03, DKV-05` for this plan. DKV-05 (SMTP in general settings) has no UI here — it is delivered by Plan 07-06. This plan's tasks address DKV-01 (inbox config + check-now UI), DKV-03 (vehicle CRUD + CSV), and DKV-04 (export download + history UI), which is the accurate mapping.
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/phases/07-dkv-fleet-module/07-CONTEXT.md
@.planning/phases/07-dkv-fleet-module/07-UI-SPEC.md
@.planning/phases/07-dkv-fleet-module/07-PATTERNS.md
@apps/web/src/lib/calendar-api.ts
@apps/web/src/components/settings/calendar-source-form.tsx
@apps/web/src/app/(portal)/settings/dashboard/calendar/calendar-settings.test.tsx
@apps/web/src/app/(portal)/modules/domaincheck/page.tsx
</context>
## Artifacts this phase produces (Plan 05 portion)
New symbols (exclude from drift verification):
- File `apps/web/src/lib/dkv-api.ts`: types `DkvConfig`, `DkvHistoryEntry`, `DkvVehicle`, `CreateVehiclePayload`, `UpdateVehiclePayload`; functions `fetchConfig`, `saveConfig`, `testConnection`, `checkNow`, `fetchHistory`, `fetchVehicles`, `createVehicle`, `updateVehicle`, `deleteVehicle`, `importVehiclesCsv`, `exportFileUrl`
- Components `DkvFleetPage` (default), `StatusBadge`, `InvoiceHistoryTable`, `ExportFileList`, `DkvFleetSettingsPage` (default), `InboxConfigForm`, `DkvFleetVehiclesPage` (default), `VehicleTable`, `CsvImportButton`
> **Path note:** The existing API-client convention in this repo is `apps/web/src/lib/<domain>-api.ts` (see `apps/web/src/lib/calendar-api.ts`). The checker brief referenced `lib/api/dkv.ts` with analog `lib/api/calendar.ts`, but that path does not exist in the repo. This plan uses `apps/web/src/lib/dkv-api.ts` to match the real, existing analog. The `@` import alias maps to `apps/web/src`.
<tasks>
<task type="auto">
<name>Task 1: dkv-api client + StatusBadge + module main page (Surface A — DKV-04/DKV-01)</name>
<files>apps/web/src/lib/dkv-api.ts, apps/web/src/app/(portal)/modules/dkv-fleet/components/StatusBadge.tsx, apps/web/src/app/(portal)/modules/dkv-fleet/components/InvoiceHistoryTable.tsx, apps/web/src/app/(portal)/modules/dkv-fleet/components/ExportFileList.tsx, apps/web/src/app/(portal)/modules/dkv-fleet/page.tsx</files>
<read_first>
- apps/web/src/lib/calendar-api.ts — API-client convention: `API_URL` const, `credentials: 'include'`, exported typed functions, throw on non-ok (mirror this file exactly for structure)
- apps/web/src/app/(portal)/modules/domaincheck/page.tsx — page shell: `'use client'`, `useTranslations('dkvFleet')`, `mx-auto max-w-5xl space-y-6 p-6`, card panel
- 07-UI-SPEC.md "Surface A: Module Main Page" — full layout, InvoiceHistoryTable column spec (6 columns + widths), ExportFileList spec, "Jetzt prüfen" button states, skeleton rows, pagination > 25 rows
- 07-UI-SPEC.md "StatusBadge Component" — exact OKLCH inline styles per status, rounded-full px-2 py-0.5 text-xs font-medium
- 07-UI-SPEC.md "Color" + "Typography" + "Spacing Scale" — token usage; accent reserved list
- 07-PATTERNS.md "dkv-fleet/page.tsx" — analog domaincheck/page.tsx; layout note (max-w-5xl)
- 07-CONTEXT.md D-20 (history columns), D-15 (last 10 export files)
</read_first>
<action>
Create `apps/web/src/lib/dkv-api.ts` following the calendar-api.ts structure: `const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';`, all calls use `credentials: 'include'` and throw `new Error(...)` on non-ok. Export TypeScript types mirroring the Plan 04 backend contract: `DkvConfig` (protocol, host, port, encryption, folder, senderFilter, pollIntervalMin, isActive, exportRecipient, vehicleFormatString, username, hasPassword boolean — never a password), `DkvHistoryEntry` (id, datumZeit string, rechnungsnummer, anzahlFahrzeuge number, anzahlTransaktionen number, status 'Verarbeitet'|'Fehler'|'Versand fehlgeschlagen', errorMessage?, exportFilename?), `DkvVehicle` (id, kennzeichen, marke, modell, fahrer), `CreateVehiclePayload`, `UpdateVehiclePayload`. Export functions: `fetchConfig()` GET /dkv/config; `saveConfig(payload)` PUT /dkv/config; `testConnection(payload)` POST /dkv/test-connection → `{ success: boolean }`; `checkNow()` POST /dkv/check-now; `fetchHistory(page?, limit?)` GET /dkv/history with URLSearchParams; `fetchVehicles()` GET /dkv/vehicles; `createVehicle(payload)` POST /dkv/vehicles; `updateVehicle(id, payload)` PUT /dkv/vehicles/:id; `deleteVehicle(id)` DELETE /dkv/vehicles/:id; `importVehiclesCsv(file, mode)` POST /dkv/vehicles/import as `multipart/form-data` (FormData with `file` and `mode`, do NOT set Content-Type header manually); and a pure helper `exportFileUrl(filename)` returning `${API_URL}/dkv/exports/${encodeURIComponent(filename)}`.
Create `StatusBadge.tsx`: props `{ status, errorMessage? }`. Render `<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium">` with the exact inline OKLCH color/background per 07-UI-SPEC "StatusBadge Component" for each of the three statuses; add `title={errorMessage}` for Fehler / Versand fehlgeschlagen when provided. Labels come from `t('status.processed'|'status.error'|'status.sendFailed')`.
Create `InvoiceHistoryTable.tsx`: fetches via `fetchHistory` in useEffect, renders the 6-column table exactly per 07-UI-SPEC (column header style `text-xs font-semibold uppercase tracking-wider text-muted-foreground`, widths and alignments as specified, row hover `hover:bg-muted/50`, container `rounded border border-border overflow-hidden`). Show 5 skeleton rows while loading (`h-4 rounded bg-muted animate-pulse`). Render StatusBadge in the STATUS column and, in the EXPORTDATEI column, a download `<a href={exportFileUrl(filename)} download>` with inline SVG download icon (`text-primary text-sm hover:underline`) when present else an em-dash `<span class="text-muted-foreground">–</span>`. Empty state uses `t('emptyHistory')` heading + `t('emptyHistoryBody')`. Add pagination controls only when total > 25 (per UI-SPEC), wiring `fetchHistory(page, 25)`.
Create `ExportFileList.tsx`: lists up to 10 export files (derive filenames from history entries' `exportFilename`, newest first, dedup) each as an inline SVG download icon + `<a>` (`text-sm text-primary hover:underline`) using `exportFileUrl`; empty state `t('emptyExports')`.
Create `page.tsx` (`'use client'`, default export `DkvFleetPage`): `mx-auto max-w-5xl space-y-6 p-6`. Header row: `<h1 class="text-2xl font-semibold tracking-tight">{t('pageTitle')}</h1>` with the "Jetzt prüfen" button right-aligned and the "Zuletzt geprüft" text (`text-sm text-muted-foreground`). "Jetzt prüfen" button uses the exact idle/loading classes from UI-SPEC; on click set loading, call `checkNow()`, on success clear loading and trigger an InvoiceHistoryTable refetch (lift a refresh key or expose a ref/callback), on error show the destructive error banner above the table (`border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive` with a × close button) using `t('errors.pollFailed')`. Render `<h2>Verarbeitungshistorie</h2>` + `<InvoiceHistoryTable />`, then `<h2>Letzte Exportdateien</h2>` + `<ExportFileList />`.
All copy via `useTranslations('dkvFleet')` keys defined in 07-UI-SPEC (Plan 04 ships these keys). Dark + light mode must both work (use CSS tokens only). Do not introduce any external icon package — inline SVG only.
</action>
<verify>
<automated>pnpm --filter @tessera/web exec tsc --noEmit -p tsconfig.json && grep -q "credentials: 'include'" apps/web/src/lib/dkv-api.ts && grep -q "check-now" apps/web/src/lib/dkv-api.ts && grep -q "useTranslations('dkvFleet')" "apps/web/src/app/(portal)/modules/dkv-fleet/page.tsx"</automated>
</verify>
<acceptance_criteria>
- `dkv-api.ts` exports `checkNow`, `fetchHistory`, `fetchConfig`, `saveConfig`, `testConnection`, vehicle CRUD functions, `importVehiclesCsv`, and `exportFileUrl`; all use `credentials: 'include'`
- `dkv-api.ts` never declares a returned `password` field on `DkvConfig` (only `hasPassword`)
- Main page renders an `<h1>` with `t('pageTitle')` and a "Jetzt prüfen" button that calls `checkNow` then refetches history
- InvoiceHistoryTable renders the 6 columns from UI-SPEC and uses StatusBadge for status
- Export download links use `exportFileUrl(...)` (server-validated filename) — no client-built path traversal
- Web type-check / build passes (`tsc --noEmit`)
</acceptance_criteria>
<done>The module main page lists processing history + export files and the user can trigger an inbox check from the browser.</done>
</task>
<task type="auto">
<name>Task 2: Module settings page + InboxConfigForm (Surface B inbox — DKV-01)</name>
<files>apps/web/src/app/(portal)/modules/dkv-fleet/settings/page.tsx, apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/InboxConfigForm.tsx</files>
<read_first>
- apps/web/src/components/settings/calendar-source-form.tsx — form field pattern: `'use client'`, controlled inputs, label `mb-1 block text-sm text-foreground`, input `h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm`, select pattern, password input, actions row `flex gap-3`, submit `data-testid` + disabled styling, show/hide not present here (add it per UI-SPEC)
- apps/web/src/app/(portal)/settings/dashboard/page.tsx — settings page heading `mb-6 text-lg font-semibold text-foreground`
- apps/web/src/lib/dkv-api.ts (Task 1) — fetchConfig / saveConfig / testConnection signatures
- 07-UI-SPEC.md "Surface B: Module Settings Page" → "Tab 1: Posteingang → InboxConfigForm" — exact field list/order, password show/hide toggle, Active toggle switch, form actions row, inline test feedback colors
- 07-UI-SPEC.md "Copywriting Contract" + form i18n keys (`form.*`)
- 07-CONTEXT.md D-01/D-02/D-03 (inbox fields), D-07 (export recipient per module), D-19 (vehicle format string)
</read_first>
<action>
Create `settings/page.tsx` (`'use client'`, default export `DkvFleetSettingsPage`, `useTranslations('dkvFleet')`): heading `<h1 class="text-2xl font-semibold tracking-tight mb-6">{t('settingsTitle')}</h1>`, then render `<InboxConfigForm />`. (Per the checker's route split, vehicle management lives at its own `/modules/dkv-fleet/vehicles` route built in Task 3; add a simple inline link/tab to it here, e.g. a secondary-styled `<Link href="/modules/dkv-fleet/vehicles">{t('tabs.vehicles')}</Link>` styled per the UI-SPEC tab pattern.)
Create `InboxConfigForm.tsx` (`'use client'`): on mount `fetchConfig()` to populate controlled state; render the fields in the exact order from UI-SPEC Tab 1 — Protokoll (select IMAP/Exchange), Host (text), Port (number), Verschlüsselung (select Keine/STARTTLS/SSL-TLS → values none/starttls/ssl-tls), Ordner (text default "INBOX"), Absenderfilter (email, optional, placeholder rechnung@dkv.com), Abrufintervall (number min=5, suffix "Minuten"), Benutzername (text optional), Passwort (password optional with show/hide toggle button — `type="button"`, inline eye/eye-off SVG, toggles input type between password/text), Export-Empfänger (email required), Fahrzeug-Formatstring (text default `{Marke}/{Modell}/{Kennzeichen}` with help text listing placeholders), Aktiv (toggle switch `role="switch" aria-checked`). Use the calendar-source-form label/input classes. Form actions row `flex gap-3 pt-4 border-t border-border mt-6`: "Verbindung testen" (secondary) → calls `testConnection(currentFormPayload)` and shows inline green/destructive feedback per UI-SPEC; "Einstellungen speichern" (primary) → calls `saveConfig`, disabling the button while saving and showing inline error on failure. Never render the existing password back from the server (config returns `hasPassword`, not the password) — leave the password field blank and only send it when the user types a new one. All copy via `t('form.*')`. Both color themes must work.
</action>
<verify>
<automated>pnpm --filter @tessera/web exec tsc --noEmit -p tsconfig.json && grep -q "saveConfig" "apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/InboxConfigForm.tsx" && grep -q "testConnection" "apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/InboxConfigForm.tsx" && grep -q "role=\"switch\"" "apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/InboxConfigForm.tsx"</automated>
</verify>
<acceptance_criteria>
- Settings page renders `t('settingsTitle')` heading and the InboxConfigForm
- InboxConfigForm renders all UI-SPEC Tab-1 fields in order, with the show/hide password toggle and the Active `role="switch"` toggle
- Abrufintervall input enforces `min=5`
- "Verbindung testen" calls `testConnection`; "Einstellungen speichern" calls `saveConfig`
- Password field is never pre-filled from server data (only `hasPassword` is known)
- Web type-check / build passes
</acceptance_criteria>
<done>The user can configure and test the inbox connection and save module settings from the browser.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Vehicles page + VehicleTable + CsvImportButton (Surface B vehicles — DKV-03)</name>
<files>apps/web/src/app/(portal)/modules/dkv-fleet/vehicles/page.tsx, apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx, apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/CsvImportButton.tsx, apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx</files>
<read_first>
- apps/web/src/app/(portal)/settings/dashboard/calendar/calendar-settings.test.tsx — Vitest+Testing-Library pattern: `vi.mock('next-intl', ...)`, `vi.mock('@/lib/calendar-api', ...)`, dynamic `await import(...)`, `render`, `screen`, `waitFor`, `fireEvent`, `afterEach` cleanup. Mirror this exactly for VehicleTable.test.tsx (mock `@/lib/dkv-api`)
- apps/web/src/lib/dkv-api.ts (Task 1) — fetchVehicles / createVehicle / updateVehicle / deleteVehicle / importVehiclesCsv
- apps/web/src/components/settings/calendar-source-form.tsx — input/label/button classes to reuse
- 07-UI-SPEC.md "Tab 2: Fahrzeuge → VehicleTable + CsvImportButton" — toolbar, columns (KENNZEICHEN/MARKE/MODELL/FAHRER/AKTIONEN), view vs edit row modes, icon button aria-labels + SVG sizes, add-vehicle row, CSV import dialog (merge/replace), destructive replace confirm, success toast
- 07-UI-SPEC.md "Copywriting Contract" — delete dialog + CSV replace dialog copy; "col.*" header keys
- apps/web/vitest.config.ts — confirm jsdom env + setup (tests run via `pnpm --filter @tessera/web test`)
</read_first>
<behavior>
VehicleTable.test.tsx (write FIRST — RED):
- Renders a list of two mocked vehicles (Kennzeichen, Marke, Modell, Fahrer) returned by mocked `fetchVehicles`
- Shows the empty-state heading (`t('emptyVehicles')` → mocked string) when `fetchVehicles` resolves `[]`
- Clicking the delete (trash) icon button opens a confirm dialog, and confirming calls `deleteVehicle` with the vehicle id
- Clicking the edit (pencil) icon button switches the row to inputs; saving calls `updateVehicle` with the id and changed fields
- Icon-only action buttons expose the UI-SPEC `aria-label`s ("Fahrzeug bearbeiten", "Fahrzeug löschen", "Änderungen speichern", "Bearbeitung abbrechen")
</behavior>
<action>
Write `VehicleTable.test.tsx` first and confirm it fails (RED) before implementing — mirror the calendar-settings.test.tsx mocking approach exactly (mock `next-intl` to echo keys/short strings, mock `@/lib/dkv-api`).
Create `VehicleTable.tsx` (`'use client'`): on mount `fetchVehicles()`; toolbar `flex justify-between items-center mb-3` with a left count badge (`t('vehicleCount', { count })`) and right `[CsvImportButton]` + "Fahrzeug hinzufügen" (primary) buttons. Table columns per UI-SPEC with the shared header style. Each row has view mode (plain cells + pencil + trash icon buttons) and edit mode (text inputs, Kennzeichen `uppercase`, + checkmark/× icon buttons). All icon-only buttons carry the exact UI-SPEC `aria-label`s and SVG sizes (16×16, inline SVG). "Fahrzeug hinzufügen" appends a new empty edit-mode row. Delete uses an inline confirm dialog with the UI-SPEC delete copy (`deleteVehicle`/`deleteVehicleBody`), confirm button destructive-styled; on confirm call `deleteVehicle(id)` and refetch. Save row calls `createVehicle` (new) or `updateVehicle(id, ...)` (existing); validation: Kennzeichen required, inline `text-destructive text-xs` on error. Wire CsvImportButton's `onImported` to refetch + show a success toast (`importSuccess`).
Create `CsvImportButton.tsx` (`'use client'`): hidden `<input type="file" accept=".csv">`; the visible "CSV importieren" button triggers it. After a file is selected, show the inline import-mode dialog (Zusammenführen default / Ersetzen) per UI-SPEC; "Ersetzen" → destructive confirm dialog before importing. On confirm call `importVehiclesCsv(file, mode)` then call the `onImported(count)` callback. Validate the selected file is `.csv` client-side; show parse/import errors as `text-destructive text-sm` in the dialog.
Create `vehicles/page.tsx` (`'use client'`, default export `DkvFleetVehiclesPage`, `useTranslations('dkvFleet')`): heading `<h1 class="text-2xl font-semibold tracking-tight mb-6">{t('tabs.vehicles')}</h1>` (or settingsTitle + subheading), a back link to `/modules/dkv-fleet/settings`, then `<VehicleTable />`. Import VehicleTable/CsvImportButton from `../settings/components/` (UI-SPEC component file map). Both color themes must work; inline SVG icons only.
</action>
<verify>
<automated>pnpm --filter @tessera/web test -- --run VehicleTable && pnpm --filter @tessera/web exec tsc --noEmit -p tsconfig.json</automated>
</verify>
<acceptance_criteria>
- `VehicleTable.test.tsx` exists, mocks `@/lib/dkv-api` + `next-intl`, and passes (was RED before VehicleTable existed)
- VehicleTable lists vehicles, supports inline edit (calls `updateVehicle`), create (`createVehicle`), and delete with confirm dialog (`deleteVehicle`)
- All icon-only action buttons carry the UI-SPEC `aria-label`s
- CsvImportButton offers merge/replace modes and shows a destructive confirm before replace, calling `importVehiclesCsv(file, mode)`
- `vehicles/page.tsx` renders VehicleTable under a heading with a back link to settings
- `pnpm --filter @tessera/web test` (VehicleTable) and web type-check pass
</acceptance_criteria>
<done>The user can manage the vehicle→driver master list (CRUD + CSV merge/replace) from the browser, covered by a passing Vitest test.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser form → /dkv/* API | Admin-entered inbox config, credentials, and vehicle/CSV data cross to the backend |
| API response → React render | Backend-supplied history/vehicle/export data is rendered in the DOM |
| user file → CSV import | User-selected file uploaded for bulk vehicle import |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-07-12 | Information Disclosure | InboxConfigForm / dkv-api.ts | mitigate | Password field never pre-filled from server (config returns `hasPassword` only, never the secret — mirrors calendar T-05-09); password sent only when user types a new one; transport is cookie auth via `credentials: 'include'` (V3) |
| T-07-13 | Tampering | ExportFileList / InvoiceHistoryTable | mitigate | Download URLs built with `exportFileUrl()` + `encodeURIComponent`; the server enforces the `DKV_*.xlsx` filename format and path-traversal guard (Plan 04) — client never constructs raw paths |
| T-07-14 | Injection (XSS) | InvoiceHistoryTable / VehicleTable | mitigate | All backend strings rendered through React text nodes (auto-escaped); no `dangerouslySetInnerHTML`; StatusBadge tooltip uses the `title` attribute, not raw HTML |
| T-07-15 | Tampering | CsvImportButton | mitigate | Client restricts selection to `.csv` (`accept=".csv"`) and the replace path requires an explicit destructive confirmation; server-side validation/size limits remain authoritative (Plan 04) |
| T-07-SC | — | npm/pnpm installs | n/a | No new frontend dependencies are added in this plan (inline SVG icons, existing Vitest/Testing-Library only) |
</threat_model>
<verification>
- `pnpm --filter @tessera/web exec tsc --noEmit -p tsconfig.json` exits 0
- `pnpm --filter @tessera/web test -- --run VehicleTable` passes
- All copy resolves through `useTranslations('dkvFleet')` keys defined in 07-UI-SPEC (shipped by Plan 04)
- Components honor the 07-UI-SPEC color/typography/spacing tokens in both light and dark mode
</verification>
<success_criteria>
- DKV-01 inbox-config UI + manual "Jetzt prüfen" trigger operable in the browser
- DKV-03 vehicle master CRUD + CSV merge/replace import operable, covered by a Vitest test
- DKV-04 export download links + processing-history display operable
- All three surfaces follow the 07-UI-SPEC visual contract
</success_criteria>
<output>
Create `.planning/phases/07-dkv-fleet-module/07-05-SUMMARY.md` when done. Record the InvoiceHistoryTable refetch mechanism chosen, how the password-blank-on-load behavior is implemented, and any UI-SPEC deviations.
</output>
@@ -0,0 +1,196 @@
---
phase: 07-dkv-fleet-module
plan: 06
type: execute
wave: 2
depends_on: ["07-01", "07-03"]
files_modified:
- apps/web/src/lib/settings-api.ts
- apps/web/src/components/settings/smtp-settings-form.tsx
- apps/web/src/app/(portal)/settings/general/smtp/page.tsx
- apps/web/src/app/(portal)/settings/general/smtp/smtp-settings.test.tsx
- apps/web/src/components/settings/settings-sidebar.tsx
autonomous: true
requirements: [DKV-05]
user_setup: []
must_haves:
truths:
- "User can open /settings/general/smtp and see the SMTP configuration form"
- "User can enter SMTP host, port, encryption, username, password, and from-address and save it"
- "User can test the SMTP connection and see inline success/error feedback"
- "The settings sub-sidebar shows an 'Allgemein' category with an 'SMTP' link above the existing Dashboard category"
- "The saved password is never rendered back into the form (only a hasPassword indicator is known)"
artifacts:
- path: "apps/web/src/lib/settings-api.ts"
provides: "Typed fetch client for GET/PUT /settings/smtp + POST /settings/smtp/test"
contains: "credentials: 'include'"
- path: "apps/web/src/components/settings/smtp-settings-form.tsx"
provides: "SMTP configuration form (Surface C)"
min_lines: 40
- path: "apps/web/src/app/(portal)/settings/general/smtp/page.tsx"
provides: "SMTP settings route within the settings layout"
min_lines: 10
- path: "apps/web/src/app/(portal)/settings/general/smtp/smtp-settings.test.tsx"
provides: "Vitest coverage for the SMTP settings form (Wave 0 / VALIDATION DKV-05)"
contains: "vi.mock"
- path: "apps/web/src/components/settings/settings-sidebar.tsx"
provides: "Settings sub-sidebar extended with the Allgemein > SMTP category"
contains: "categoryGeneral"
key_links:
- from: "apps/web/src/components/settings/smtp-settings-form.tsx"
to: "apps/web/src/lib/settings-api.ts (fetchSmtp/saveSmtp/testSmtp)"
via: "form load + save + connection test"
pattern: "saveSmtp|testSmtp"
- from: "apps/web/src/app/(portal)/settings/general/smtp/page.tsx"
to: "apps/web/src/components/settings/smtp-settings-form.tsx"
via: "page renders the form"
pattern: "SmtpSettingsForm"
- from: "apps/web/src/components/settings/settings-sidebar.tsx"
to: "/settings/general/smtp"
via: "Allgemein category Link"
pattern: "/settings/general/smtp"
---
<objective>
Deliver the shared SMTP configuration UI (Surface C from 07-UI-SPEC.md): a settings API client for `/settings/smtp`, the SMTP configuration form, the `/settings/general/smtp` route inside the settings layout, and the settings sub-sidebar extension that adds an "Allgemein" category linking to the SMTP page. Consumes the SettingsController endpoints built in Plan 03.
Purpose: This is the user-facing half of DKV-05 — SMTP settings configurable in general settings, shared across modules. Each task is a vertical slice: after Task 1 a user can save and test SMTP config; after Task 2 they can navigate to it from the settings sidebar.
Output: settings-api client, smtp-settings-form, smtp page (+ test), extended settings-sidebar.
</objective>
## Phase Goal
**Als** Administrator **möchte ich** DKV-Tankkarten-Rechnungen automatisch aus einem E-Mail-Postfach verarbeiten lassen, **damit** Flotten-Tankdaten ohne manuelle Eingabe als Excel-Datei exportiert und per SMTP zugestellt werden.
This plan lets the administrator configure the shared SMTP server that the DKV export delivery (and other modules) use.
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/phases/07-dkv-fleet-module/07-CONTEXT.md
@.planning/phases/07-dkv-fleet-module/07-UI-SPEC.md
@.planning/phases/07-dkv-fleet-module/07-PATTERNS.md
@apps/web/src/lib/calendar-api.ts
@apps/web/src/components/settings/calendar-source-form.tsx
@apps/web/src/components/settings/settings-sidebar.tsx
@apps/web/src/app/(portal)/settings/dashboard/page.tsx
@apps/web/src/app/(portal)/settings/dashboard/calendar/calendar-settings.test.tsx
</context>
## Artifacts this phase produces (Plan 06 portion)
New symbols (exclude from drift verification):
- File `apps/web/src/lib/settings-api.ts`: type `SmtpConfig` (host, port, encryption, username, fromAddress, hasPassword — never a password); functions `fetchSmtp`, `saveSmtp`, `testSmtp`
- Component `SmtpSettingsForm` (`apps/web/src/components/settings/smtp-settings-form.tsx`)
- Component `SmtpSettingsPage` (default export, `apps/web/src/app/(portal)/settings/general/smtp/page.tsx`)
Modified (existing) symbols:
- `apps/web/src/components/settings/settings-sidebar.tsx` — adds the "Allgemein" category (SMTP link) above the existing Dashboard category
> **Path notes:** The API-client path uses the repo convention `apps/web/src/lib/settings-api.ts` (matching `apps/web/src/lib/calendar-api.ts`); the checker brief's `lib/api/settings.ts` does not exist in the repo. The form lives at `apps/web/src/components/settings/smtp-settings-form.tsx` (per the checker brief, consistent with the existing `components/settings/` location of `calendar-source-form.tsx` and `settings-sidebar.tsx`). The `@` import alias maps to `apps/web/src`.
<tasks>
<task type="auto" tdd="true">
<name>Task 1: settings-api client + SmtpSettingsForm + SMTP page (DKV-05)</name>
<files>apps/web/src/lib/settings-api.ts, apps/web/src/components/settings/smtp-settings-form.tsx, apps/web/src/app/(portal)/settings/general/smtp/page.tsx, apps/web/src/app/(portal)/settings/general/smtp/smtp-settings.test.tsx</files>
<read_first>
- apps/web/src/lib/calendar-api.ts — API-client convention: `API_URL` const, `credentials: 'include'`, typed exports, throw on non-ok (mirror structure)
- apps/web/src/components/settings/calendar-source-form.tsx — form pattern: controlled inputs, label `mb-1 block text-sm text-foreground`, input `h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm`, select, password input, actions row `flex gap-3`, submit `data-testid` + disabled styling
- apps/web/src/app/(portal)/settings/dashboard/page.tsx — settings page heading `mb-6 text-lg font-semibold text-foreground`, fetch-in-useEffect pattern
- apps/web/src/app/(portal)/settings/dashboard/calendar/calendar-settings.test.tsx — Vitest pattern: `vi.mock('next-intl', ...)`, `vi.mock('@/lib/...')`, dynamic `await import(...)`, render/screen/waitFor/fireEvent, afterEach cleanup — mirror exactly (mock `@/lib/settings-api`)
- 07-UI-SPEC.md "Surface C: General Settings SMTP" — exact field list/order (Host, Port default 587, Verschlüsselung default STARTTLS, Benutzername, Passwort w/ show-hide, Absenderadresse), `max-w-md` on all inputs, page heading `text-lg font-semibold`, form actions row `flex gap-3 pt-2`, test feedback colors + auto-clear 6s
- 07-UI-SPEC.md "i18n Translation Keys" → `settings.smtp.*` + `settings.categorySmtp` (shipped by Plan 04)
- 07-CONTEXT.md D-05 (SMTP in general settings: host, port, username, password, encryption, sender address)
</read_first>
<behavior>
smtp-settings.test.tsx (write FIRST — RED):
- On mount the form loads existing config via mocked `fetchSmtp` and populates host/port/encryption/username/fromAddress fields (password stays blank even when `hasPassword` is true)
- Submitting the form calls `saveSmtp` with the entered values
- Clicking "Verbindung testen" calls `testSmtp` and renders the success message when it resolves `{ success: true }` and the error message when `{ success: false }`
- The password input has a working show/hide toggle button (type switches password↔text)
</behavior>
<action>
Write `smtp-settings.test.tsx` FIRST and confirm RED before implementing — mirror calendar-settings.test.tsx mocking (mock `next-intl` to echo short strings, mock `@/lib/settings-api`).
Create `apps/web/src/lib/settings-api.ts` following calendar-api.ts: `const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';`, `credentials: 'include'`, throw on non-ok. Export type `SmtpConfig` (host string, port number, encryption 'none'|'starttls'|'ssl-tls', username?, fromAddress string, hasPassword boolean — NEVER a password field) and a `SaveSmtpPayload` (same minus hasPassword, plus optional `password`). Export `fetchSmtp()` GET /settings/smtp (may return null); `saveSmtp(payload)` PUT /settings/smtp; `testSmtp(payload)` POST /settings/smtp/test → `{ success: boolean }`.
Create `smtp-settings-form.tsx` (`'use client'`, exported component `SmtpSettingsForm`, `useTranslations('settings')`): on mount `fetchSmtp()` to populate controlled state; render fields in UI-SPEC Surface C order — Host (text, placeholder smtp.example.com), Port (number, default 587), Verschlüsselung (select Keine/STARTTLS/SSL-TLS → none/starttls/ssl-tls, default starttls), Benutzername (text optional), Passwort (password optional with show/hide toggle button — `type="button"`, inline eye/eye-off SVG 16×16, toggles input type), Absenderadresse (email required, placeholder tessera@example.com, with help text). All inputs `max-w-md`, using the calendar-source-form label/input classes. Never pre-fill the password from server data (config returns `hasPassword` only); only send `password` when the user types one. Actions row `flex gap-3 pt-2`: "Einstellungen speichern" (primary, disabled while saving) → `saveSmtp`; "Verbindung testen" (secondary) → `testSmtp(currentPayload)` showing inline feedback below the row (loading muted text, success green `oklch(0.40 0.15 148)`, error `text-destructive`), auto-clearing after 6 seconds. All copy via `t('smtp.*')`. Both light + dark mode must work; inline SVG only.
Create `apps/web/src/app/(portal)/settings/general/smtp/page.tsx` (`'use client'`, default export `SmtpSettingsPage`, `useTranslations('settings')`): heading `<h1 class="mb-6 text-lg font-semibold text-foreground">{t('smtp.title')}</h1>` (matches settings/dashboard/page.tsx) then `<SmtpSettingsForm />`. The route sits under the existing settings layout (SettingsSidebar + back link already wrap it).
</action>
<verify>
<automated>pnpm --filter @tessera/web test -- --run smtp-settings && pnpm --filter @tessera/web exec tsc --noEmit -p tsconfig.json && grep -q "credentials: 'include'" apps/web/src/lib/settings-api.ts && grep -q "smtp/test" apps/web/src/lib/settings-api.ts</automated>
</verify>
<acceptance_criteria>
- `smtp-settings.test.tsx` exists, mocks `@/lib/settings-api` + `next-intl`, and passes (was RED before the form existed)
- `settings-api.ts` exports `fetchSmtp`, `saveSmtp`, `testSmtp` (all `credentials: 'include'`) and a `SmtpConfig` type with `hasPassword` but no `password`
- The form renders all Surface C fields in order with the show/hide password toggle and never pre-fills the password
- "Einstellungen speichern" calls `saveSmtp`; "Verbindung testen" calls `testSmtp` and shows inline success/error feedback
- `page.tsx` renders `t('smtp.title')` heading and `<SmtpSettingsForm />`
- `pnpm --filter @tessera/web test` (smtp-settings) and web type-check pass
</acceptance_criteria>
<done>The administrator can view, save, and connection-test the shared SMTP configuration from /settings/general/smtp, covered by a passing Vitest test.</done>
</task>
<task type="auto">
<name>Task 2: Extend SettingsSidebar with the Allgemein > SMTP category</name>
<files>apps/web/src/components/settings/settings-sidebar.tsx</files>
<read_first>
- apps/web/src/components/settings/settings-sidebar.tsx — current sidebar: `items` array, `isActive` helper, category header `<h2 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">`, nav `flex flex-col gap-1 px-3`, active link `bg-sidebar-accent text-sidebar-accent-foreground font-medium` else `text-sidebar-foreground hover:bg-muted`, `aria-current`
- 07-UI-SPEC.md "Surface C → SettingsSidebar extension" — add "Allgemein" section ABOVE the existing Dashboard section, with an "SMTP" link to `/settings/general/smtp`; copy active/inactive link styling verbatim
- 07-UI-SPEC.md "i18n Translation Keys" → `settings.categoryGeneral` = "Allgemein", `settings.categorySmtp` = "SMTP" (shipped by Plan 04)
</read_first>
<action>
Extend `settings-sidebar.tsx` to render a new "Allgemein" category ABOVE the existing "Dashboard" category. Add a category header `<h2>` using `t('categoryGeneral')` with the exact `text-xs font-semibold uppercase tracking-wider text-muted-foreground` class, and a nav list containing a single `<Link href="/settings/general/smtp">{t('categorySmtp')}</Link>` using the SAME active/inactive link classes and `aria-current` logic already present for the Dashboard items (factor the existing link rendering into a reusable map or duplicate the markup — keep styling identical). Ensure `isActive('/settings/general/smtp')` works via the existing `pathname.startsWith` branch. Do not remove or restyle the existing Dashboard/Widgets/Calendar items. Both light + dark themes must remain correct (sidebar tokens only).
</action>
<verify>
<automated>pnpm --filter @tessera/web exec tsc --noEmit -p tsconfig.json && grep -q "categoryGeneral" apps/web/src/components/settings/settings-sidebar.tsx && grep -q "/settings/general/smtp" apps/web/src/components/settings/settings-sidebar.tsx && grep -q "categorySmtp" apps/web/src/components/settings/settings-sidebar.tsx</automated>
</verify>
<acceptance_criteria>
- The sidebar renders an "Allgemein" category header (`t('categoryGeneral')`) above the existing Dashboard category
- An "SMTP" link (`t('categorySmtp')`) points to `/settings/general/smtp` and uses the existing active/inactive styling + `aria-current`
- Existing Dashboard/Widgets/Calendar links are unchanged
- Web type-check passes
</acceptance_criteria>
<done>Users can navigate to the SMTP settings page from the settings sub-sidebar's new Allgemein category.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser form → /settings/smtp | Admin-entered SMTP host/credentials cross to the backend |
| API response → React render | Backend-supplied SMTP config (without password) is rendered in the DOM |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-07-17 | Information Disclosure | smtp-settings-form / settings-api.ts | mitigate | `SmtpConfig` exposes only `hasPassword`, never the secret (mirrors calendar T-05-09); password field never pre-filled; password sent only when newly typed; cookie auth via `credentials: 'include'` (V3) |
| T-07-18 | Tampering | settings-api.ts | mitigate | Backend `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` on every /settings/smtp endpoint (Plan 03) and `SmtpConfigDto` validation are authoritative; the client cannot bypass them |
| T-07-19 | Injection (XSS) | smtp-settings-form / settings-sidebar | mitigate | All backend strings rendered via React text nodes (auto-escaped); no `dangerouslySetInnerHTML` |
| T-07-SC | — | npm/pnpm installs | n/a | No new frontend dependencies are added (inline SVG icons, existing Vitest/Testing-Library only) |
</threat_model>
<verification>
- `pnpm --filter @tessera/web exec tsc --noEmit -p tsconfig.json` exits 0
- `pnpm --filter @tessera/web test -- --run smtp-settings` passes
- SMTP form never renders a password back from the server
- Sidebar Allgemein > SMTP link navigates to /settings/general/smtp with correct active state
- Components honor 07-UI-SPEC color/typography/spacing tokens in light + dark mode
</verification>
<success_criteria>
- DKV-05 SMTP settings UI complete: configurable + connection-testable in general settings, password never disclosed
- Settings sub-sidebar exposes the Allgemein > SMTP category per 07-UI-SPEC Surface C
</success_criteria>
<output>
Create `.planning/phases/07-dkv-fleet-module/07-06-SUMMARY.md` when done. Record how the test-feedback auto-clear is implemented and any UI-SPEC deviations.
</output>
@@ -0,0 +1,829 @@
# Phase 7: DKV Fleet Module - Pattern Map
**Mapped:** 2026-06-26
**Files analyzed:** 21 new/modified files
**Analogs found:** 19 / 21
---
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `apps/api/src/dkv/dkv.module.ts` | module | — | `apps/api/src/calendar/calendar.module.ts` | exact |
| `apps/api/src/dkv/dkv.controller.ts` | controller | request-response | `apps/api/src/ldap/ldap.controller.ts` | exact |
| `apps/api/src/dkv/dkv.service.ts` | service | orchestration | `apps/api/src/calendar/calendar.service.ts` | role-match |
| `apps/api/src/dkv/dkv-scheduler.service.ts` | service | event-driven | `apps/api/src/ldap/ldap-sync.scheduler.ts` | exact |
| `apps/api/src/dkv/dkv-parser.service.ts` | service | transform | `apps/api/src/calendar/calendar.service.ts` | partial |
| `apps/api/src/dkv/dkv-export.service.ts` | service | file-I/O | — | no analog |
| `apps/api/src/dkv/dkv-mail.service.ts` | service | request-response | `apps/api/src/mail/mail.service.ts` | role-match |
| `apps/api/src/dkv/providers/inbox-provider.interface.ts` | interface | — | `apps/api/src/calendar/calendar.service.ts` (CalendarProvider) | exact |
| `apps/api/src/dkv/providers/imap.provider.ts` | provider | file-I/O | `apps/api/src/calendar/providers/caldav.provider.ts` | role-match |
| `apps/api/src/dkv/providers/exchange-inbox.provider.ts` | provider | request-response | `apps/api/src/calendar/providers/exchange.provider.ts` | exact |
| `apps/api/src/dkv/dto/dkv-config.dto.ts` | dto | — | `apps/api/src/calendar/dto/create-calendar-source.dto.ts` | exact |
| `apps/api/src/dkv/dto/dkv-vehicle.dto.ts` | dto | — | `apps/api/src/calendar/dto/create-calendar-source.dto.ts` | exact |
| `apps/api/src/dkv/dto/dkv-history.dto.ts` | dto | — | `apps/api/src/calendar/dto/create-calendar-source.dto.ts` | role-match |
| `apps/api/src/settings/settings.module.ts` | module | — | `apps/api/src/calendar/calendar.module.ts` | role-match |
| `apps/api/src/settings/settings.controller.ts` | controller | request-response | `apps/api/src/ldap/ldap.controller.ts` | exact |
| `apps/api/src/settings/settings.service.ts` | service | CRUD | `apps/api/src/calendar/calendar.service.ts` | role-match |
| `apps/api/src/settings/dto/smtp-config.dto.ts` | dto | — | `apps/api/src/calendar/dto/create-calendar-source.dto.ts` | role-match |
| `apps/api/src/app.module.ts` (modify) | module | — | self | — |
| `apps/web/src/app/(portal)/modules/dkv-fleet/page.tsx` | component | request-response | `apps/web/src/app/(portal)/modules/domaincheck/page.tsx` | exact |
| `apps/web/src/app/(portal)/modules/dkv-fleet/settings/page.tsx` | component | CRUD | `apps/web/src/app/(portal)/settings/dashboard/calendar/page.tsx` | role-match |
| `apps/web/src/app/(portal)/settings/general/smtp/page.tsx` | component | CRUD | `apps/web/src/app/(portal)/settings/dashboard/calendar/page.tsx` | role-match |
---
## Pattern Assignments
### `apps/api/src/dkv/dkv.module.ts` (module)
**Analog:** `apps/api/src/calendar/calendar.module.ts`
**Module structure pattern** (lines 1–33):
```typescript
import { Module } from '@nestjs/common';
import { DkvController } from './dkv.controller';
import { DkvService } from './dkv.service';
import { DkvSchedulerService } from './dkv-scheduler.service';
import { DkvParserService } from './dkv-parser.service';
import { DkvExportService } from './dkv-export.service';
import { DkvMailService } from './dkv-mail.service';
import { ImapProvider } from './providers/imap.provider';
import { ExchangeInboxProvider } from './providers/exchange-inbox.provider';
import { CalendarCryptoService } from '../calendar/crypto.service';
@Module({
controllers: [DkvController],
providers: [
DkvService,
DkvSchedulerService,
DkvParserService,
DkvExportService,
DkvMailService,
ImapProvider,
ExchangeInboxProvider,
CalendarCryptoService, // imported from CalendarModule — inject directly, not re-declared
],
exports: [DkvService],
})
export class DkvModule {}
```
**Note:** `CalendarCryptoService` must also be exported from `CalendarModule` (add `exports: [CalendarCryptoService]` there) so DkvModule can import it.
---
### `apps/api/src/dkv/dkv.controller.ts` (controller, request-response)
**Analog:** `apps/api/src/ldap/ldap.controller.ts`
**Imports pattern** (lines 1–19):
```typescript
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
NotFoundException,
Param,
Patch,
Post,
Req,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import { DkvService } from './dkv.service';
import { DkvConfigDto } from './dto/dkv-config.dto';
import { CreateVehicleDto, UpdateVehicleDto } from './dto/dkv-vehicle.dto';
```
**Auth/Guard pattern** — ADMIN-only, same as `ldap.controller.ts` lines 38–40:
```typescript
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
```
Apply to every handler. Global `JwtAuthGuard` already enforces JWT; `@Roles` adds role check.
**Tenant extraction pattern** — copy from `ldap.controller.ts` lines 40–47:
```typescript
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
}
```
**Core routes pattern:**
```typescript
@Controller('dkv')
export class DkvController {
constructor(private readonly dkvService: DkvService) {}
// GET /dkv/config — get module config (no decrypted passwords)
// PUT /dkv/config — save module config
// POST /dkv/check-now — manual inbox poll trigger
// GET /dkv/history — processing history (paginated)
// GET /dkv/exports/:filename — file download
// GET /dkv/vehicles — list vehicle master
// POST /dkv/vehicles — create vehicle
// PUT /dkv/vehicles/:id — update vehicle
// DELETE /dkv/vehicles/:id — delete vehicle
// POST /dkv/vehicles/import — CSV bulk import
}
```
**Error handling pattern** — follow `ldap.controller.ts`:
```typescript
try {
const result = await this.dkvService.someMethod(tenantId, dto);
return result;
} catch (error) {
if (error instanceof NotFoundException) throw error;
if (error instanceof BadRequestException) throw error;
throw error; // let global exception filter handle it
}
```
---
### `apps/api/src/dkv/dkv.service.ts` (service, orchestration)
**Analog:** `apps/api/src/calendar/calendar.service.ts`
**Imports pattern** (lines 1–14 of calendar.service.ts):
```typescript
import {
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CalendarCryptoService } from '../calendar/crypto.service';
// + DkvParserService, DkvExportService, DkvMailService, providers
```
**Safe DB select pattern** — never return encrypted credentials (calendar.service.ts lines 50–67):
```typescript
const CONFIG_SAFE_SELECT = {
id: true,
tenantId: true,
protocol: true,
host: true,
port: true,
encryption: true,
folder: true,
senderFilter: true,
pollIntervalMin: true,
isActive: true,
exportRecipient: true,
vehicleFormatString: true,
// encryptedInboxCreds: NEVER included
createdAt: true,
updatedAt: true,
} as const;
```
**Credential encryption pattern** (from RESEARCH.md Code Examples):
```typescript
// Encrypt at save time
const encryptedInboxCreds = this.crypto.encrypt(
JSON.stringify({ username: dto.username, password: dto.password })
);
// Decrypt at use time (never log result — T-05-13)
const { username, password } = JSON.parse(
this.crypto.decrypt(config.encryptedInboxCreds!)
);
```
**Error handling — generic messages** (exchange.provider.ts lines 42–48):
```typescript
} catch (error) {
this.logger.error(
`DKV inbox poll failed for tenant ${tenantId}: ${(error as Error).message}`,
);
// T-05-13: no credential details in error
return [];
}
```
---
### `apps/api/src/dkv/dkv-scheduler.service.ts` (service, event-driven)
**Analog:** `apps/api/src/ldap/ldap-sync.scheduler.ts`
**Full structure pattern** (ldap-sync.scheduler.ts lines 1–89):
```typescript
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
// DkvSchedulerService DIFFERS: uses SchedulerRegistry + dynamic CronJob
// instead of static @Cron — because interval is configurable from DB
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { CronJob } from 'cron';
const JOB_NAME = 'dkv-inbox-poll';
@Injectable()
export class DkvSchedulerService implements OnModuleInit {
private readonly logger = new Logger(DkvSchedulerService.name);
constructor(
private readonly schedulerRegistry: SchedulerRegistry,
private readonly dkvService: DkvService,
) {}
onModuleInit() {
this.dkvService.loadConfig().then(config => {
if (config?.isActive) this.setInterval(config.pollIntervalMin);
}).catch(err => this.logger.error('Failed to init DKV scheduler', err));
}
setInterval(intervalMin: number): void {
try {
this.schedulerRegistry.getCronJob(JOB_NAME).stop();
this.schedulerRegistry.deleteCronJob(JOB_NAME);
} catch { /* not yet registered */ }
const job = new CronJob(`*/${intervalMin} * * * *`, () => {
this.dkvService.processInbox().catch(err =>
this.logger.error('DKV inbox poll failed', err)
);
});
this.schedulerRegistry.addCronJob(JOB_NAME, job);
job.start();
}
}
```
**Key difference from LdapSyncScheduler:** Static `@Cron()` decorator cannot change at runtime. Use `SchedulerRegistry.addCronJob()` pattern instead.
---
### `apps/api/src/dkv/dkv-parser.service.ts` (service, transform)
**No direct analog** — unique PDF parsing logic. Use NestJS `@Injectable()` service wrapper.
**Service shell pattern** (follows all other services):
```typescript
import { Injectable, Logger } from '@nestjs/common';
@Injectable()
export class DkvParserService {
private readonly logger = new Logger(DkvParserService.name);
async parsePdf(buffer: Buffer): Promise<DkvVehicleBlock[]> {
// Wave 0: validate pdf-parse v2 API against user-files/invoice.pdf first
// then implement production regex
}
}
```
**Critical: pdf-parse v2 API** — old `pdfParse(buffer)` function call does NOT exist in v2:
```typescript
import { PDFParse } from 'pdf-parse';
async function extractPdfText(buffer: Buffer): Promise<string> {
const parser = new PDFParse({ data: buffer });
const result = await parser.getText();
await parser.destroy();
return result.text;
}
```
**DKV vehicle block regex** (ASSUMED — validate against `user-files/invoice.pdf` in Wave 0):
```typescript
const vehicleBlockPattern =
/VEHICLE:\s+(\S+)\s+CARD NO\.:\s+(\S+)([\s\S]*?)(?=VEHICLE:|$)/g;
```
**German number parsing** (mandatory for Kilometerstand/Menge):
```typescript
parseFloat(raw.replace(/\./g, '').replace(',', '.'))
```
---
### `apps/api/src/dkv/dkv-export.service.ts` (service, file-I/O)
**No analog** — first file-I/O service in codebase. Use NestJS `@Injectable()` pattern.
**SheetJS buffer pattern** (RESEARCH.md Pattern 5):
```typescript
import * as XLSX from 'xlsx';
function buildExcelBuffer(rows: ExportRow[]): Buffer {
const headers = ['Lieferdatum', 'Fahrzeug', 'Fahrer', 'Ort', 'Kilometerstand'];
const data = rows.map(r => [r.lieferdatum, r.fahrzeug, r.fahrer, r.ort, r.kilometerstand]);
const ws = XLSX.utils.aoa_to_sheet([headers, ...data]);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'DKV Export');
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as Buffer;
}
```
**Fahrzeug format string resolver** (RESEARCH.md Code Examples):
```typescript
function resolveFahrzeug(vehicle: DkvVehicleMaster, formatString: string): string {
return formatString
.replace('{Marke}', vehicle.marke)
.replace('{Modell}', vehicle.modell)
.replace('{Kennzeichen}', vehicle.kennzeichen)
.replace('{Fahrer}', vehicle.fahrer);
}
```
**10-file prune guard** — add processing lock to prevent race:
```typescript
private processing = false;
async processInbox(): Promise<void> {
if (this.processing) return; // Pitfall 7: race condition guard
this.processing = true;
try { /* ... */ } finally { this.processing = false; }
}
```
---
### `apps/api/src/dkv/dkv-mail.service.ts` (service, request-response)
**Analog:** `apps/api/src/mail/mail.service.ts`
**Key difference:** Do NOT use `MailerService` from `@nestjs-modules/mailer`. Use `nodemailer.createTransport()` directly — transport created at send time from DB config (Pitfall 3).
**Imports pattern** (mail.service.ts lines 1–4, adapted):
```typescript
import { Injectable, Logger } from '@nestjs/common';
import * as nodemailer from 'nodemailer';
import { PrismaService } from '../prisma/prisma.service';
import { CalendarCryptoService } from '../calendar/crypto.service';
```
**Error handling pattern** (mail.service.ts lines 69–78):
```typescript
try {
await transport.sendMail({ ... });
this.logger.log(`DKV export sent to ${recipient}`);
} catch (error) {
// Log but surface to caller for retry logic (unlike MailService which swallows)
this.logger.error(
`Failed to send DKV export to ${recipient}`,
error instanceof Error ? error.stack : String(error),
);
throw error; // DkvService handles retries with exponential backoff
}
```
**Dynamic SMTP transport pattern** (RESEARCH.md Pattern 6):
```typescript
const transport = nodemailer.createTransport({
host: smtpConfig.host,
port: smtpConfig.port,
secure: smtpConfig.encryption === 'ssl-tls',
requireTLS: smtpConfig.encryption === 'starttls',
auth: smtpConfig.username
? { user: smtpConfig.username, pass: decryptedPassword }
: undefined,
});
```
---
### `apps/api/src/dkv/providers/inbox-provider.interface.ts` (interface)
**Analog:** `apps/api/src/calendar/calendar.service.ts` — `CalendarProvider` interface (lines 33–44)
```typescript
// Mirror of CalendarProvider pattern — same structure, different method names
export interface InboxEmail {
uid: number | string;
messageId: string;
subject: string;
from: string;
date: Date;
attachments: InboxAttachment[];
}
export interface InboxAttachment {
filename: string;
contentType: string;
buffer: Buffer;
}
export interface InboxProvider {
fetchPdfAttachments(config: InboxConfig): Promise<InboxEmail[]>;
testConnection(config: InboxConfig): Promise<boolean>;
}
```
---
### `apps/api/src/dkv/providers/exchange-inbox.provider.ts` (provider, request-response)
**Analog:** `apps/api/src/calendar/providers/exchange.provider.ts` (full file, 228 lines)
**Class shell + Logger pattern** (exchange.provider.ts lines 1–48):
```typescript
import { Injectable, Logger } from '@nestjs/common';
import { InboxEmail, InboxProvider } from './inbox-provider.interface';
@Injectable()
export class ExchangeInboxProvider implements InboxProvider {
private readonly logger = new Logger(ExchangeInboxProvider.name);
async fetchPdfAttachments(config: InboxConfig): Promise<InboxEmail[]> {
try {
return await this.fetchViaEws(config);
} catch (error) {
this.logger.error(
`EWS inbox fetch failed: ${(error as Error).message}`,
// T-05-13: never include credentials in error
);
return [];
}
}
}
```
**EWS dynamic import pattern** (exchange.provider.ts lines 154–155):
```typescript
const ews: any = await import('ews-javascript-api');
const service = new ews.ExchangeService(ews.ExchangeVersion.Exchange2013);
service.Url = new ews.Uri(config.host);
service.Credentials = new ews.WebCredentials(config.username, config.password);
```
**Critical difference from ExchangeProvider:** Use `ews.WellKnownFolderName.Inbox` with `service.FindItems()` and `ews.EmailMessage` — NOT `FindAppointments` / `CalendarView` (Pitfall 6).
---
### `apps/api/src/dkv/providers/imap.provider.ts` (provider, file-I/O)
**Analog:** `apps/api/src/calendar/providers/caldav.provider.ts` (role-match — async provider shell)
**Class shell:**
```typescript
import { Injectable, Logger } from '@nestjs/common';
import { ImapFlow } from 'imapflow';
import { InboxEmail, InboxProvider } from './inbox-provider.interface';
@Injectable()
export class ImapProvider implements InboxProvider {
private readonly logger = new Logger(ImapProvider.name);
// ...
}
```
**Critical imapflow pattern** (RESEARCH.md Pattern 2 + Pitfall 1):
```typescript
// NEVER call download() inside fetch() async iterator — causes IMAP deadlock
// Always: fetchAll() first → loop → download()
const messages = await client.fetchAll(
uids.join(','),
{ envelope: true, bodyStructure: true },
{ uid: true },
);
for (const msg of messages) {
const { content } = await client.download(String(msg.uid), partId, { uid: true });
}
```
**Logger suppression** (security — never log IMAP credentials):
```typescript
const client = new ImapFlow({
// ...
logger: false, // suppress verbose imapflow logs (contain credentials)
});
```
---
### `apps/api/src/dkv/dto/dkv-config.dto.ts` (dto)
**Analog:** `apps/api/src/calendar/dto/create-calendar-source.dto.ts`
**Validation decorator pattern** (lines 1–47):
```typescript
import {
IsBoolean,
IsEmail,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
export class DkvConfigDto {
@IsIn(['imap', 'exchange'])
protocol!: string;
@IsOptional()
@IsString()
host?: string;
@IsOptional()
@IsInt()
@Min(1)
@Max(65535)
port?: number;
@IsIn(['none', 'starttls', 'ssl-tls'])
encryption!: string;
@IsOptional()
@IsString()
folder?: string;
@IsOptional()
@IsEmail()
senderFilter?: string; // IsEmail() validator — Pitfall: credential injection
@IsOptional()
@IsEmail()
exportRecipient?: string;
@IsOptional()
@IsInt()
@Min(1)
pollIntervalMin?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
```
---
### `apps/api/src/settings/settings.controller.ts` (controller, request-response)
**Analog:** `apps/api/src/ldap/ldap.controller.ts`
**Pattern:** Same admin-only guard, same tenant extraction. Routes: `GET /settings/smtp` and `PUT /settings/smtp`.
```typescript
@Controller('settings')
export class SettingsController {
constructor(private readonly settingsService: SettingsService) {}
@Get('smtp')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getSmtpConfig(@Req() req: any) {
const tenantId = req.tenantId;
if (!tenantId) throw new BadRequestException('No tenant context');
const config = await this.settingsService.getSmtpConfig(tenantId);
// Never return decryptedPassword — return hasPassword boolean (T-05-09 pattern)
return config ? { ...config, encryptedPassword: undefined, hasPassword: !!config.encryptedPassword } : null;
}
@Put('smtp')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async saveSmtpConfig(@Req() req: any, @Body() dto: SmtpConfigDto) {
const tenantId = req.tenantId;
if (!tenantId) throw new BadRequestException('No tenant context');
return this.settingsService.saveSmtpConfig(tenantId, dto);
}
}
```
---
### `apps/web/src/app/(portal)/modules/dkv-fleet/page.tsx` (component, request-response)
**Analog:** `apps/web/src/app/(portal)/modules/domaincheck/page.tsx`
**Full component pattern** (domaincheck/page.tsx lines 1–64):
```typescript
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
export default function DkvFleetPage() {
const t = useTranslations('dkvFleet');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
return (
<div className="mx-auto max-w-5xl space-y-6 p-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
<p className="text-sm text-muted-foreground mt-1">{t('description')}</p>
</div>
<div className="rounded-lg border border-border bg-card p-6 shadow-sm space-y-4">
{/* InvoiceHistoryTable + manual trigger button */}
</div>
</div>
);
}
```
**Layout:** `mx-auto max-w-5xl space-y-6 p-6` — wider than domaincheck (27 vehicles × 5 columns needs more space).
---
### `apps/web/src/app/(portal)/modules/dkv-fleet/settings/page.tsx` (component, CRUD)
**Analog:** `apps/web/src/app/(portal)/settings/dashboard/calendar/page.tsx`
**Pattern** (calendar/page.tsx lines 1–21):
```typescript
'use client';
import { useTranslations } from 'next-intl';
import { InboxConfigForm } from './components/InboxConfigForm';
import { VehicleTable } from './components/VehicleTable';
import { CsvImportButton } from './components/CsvImportButton';
export default function DkvFleetSettingsPage() {
const t = useTranslations('dkvFleet');
return (
<div>
<h1 className="mb-6 text-lg font-semibold text-foreground">
{t('settingsTitle')}
</h1>
<InboxConfigForm />
<VehicleTable />
</div>
);
}
```
---
### `apps/web/src/app/(portal)/settings/general/smtp/page.tsx` (component, CRUD)
**Analog:** `apps/web/src/app/(portal)/settings/dashboard/calendar/page.tsx`
This page lives in the settings section — uses `SettingsLayout` (already wraps the route).
```typescript
'use client';
import { useTranslations } from 'next-intl';
export default function SmtpSettingsPage() {
const t = useTranslations('settings');
return (
<div>
<h1 className="mb-6 text-lg font-semibold text-foreground">
{t('categorySmtp')}
</h1>
{/* SmtpConfigForm component */}
</div>
);
}
```
**Settings sidebar extension** — add to `apps/web/src/components/settings/settings-sidebar.tsx`:
```typescript
// Extend items array — add new "Allgemein" category above "Dashboard"
const generalItems = [
{ label: t('categorySmtp'), href: '/settings/general/smtp' },
];
```
---
## Shared Patterns
### Authentication / Authorization
**Source:** `apps/api/src/ldap/ldap.controller.ts` lines 38–40
**Apply to:** All DKV and Settings controller handlers
```typescript
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
```
Global `JwtAuthGuard` is already applied in `main.ts`. `@Roles` decorator adds role check on top.
---
### Credential Encryption (AES-256-GCM)
**Source:** `apps/api/src/calendar/crypto.service.ts` (full file, 75 lines)
**Apply to:** `dkv.service.ts` (inbox creds), `settings.service.ts` (SMTP password)
```typescript
// Inject CalendarCryptoService — it handles the key lifecycle
constructor(private readonly crypto: CalendarCryptoService) {}
// Encrypt at save time
const encrypted = this.crypto.encrypt(JSON.stringify({ username, password }));
// Decrypt at use time — NEVER log result (T-05-13)
const creds = JSON.parse(this.crypto.decrypt(encrypted));
```
---
### Tenant Context Extraction
**Source:** `apps/api/src/ldap/ldap.controller.ts` lines 40–47
**Apply to:** All DKV and Settings controller handlers
```typescript
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
}
```
---
### Generic Error Logging (no credential exposure)
**Source:** `apps/api/src/calendar/providers/exchange.provider.ts` lines 42–48
**Apply to:** `dkv-mail.service.ts`, `imap.provider.ts`, `exchange-inbox.provider.ts`, `dkv-scheduler.service.ts`
```typescript
this.logger.error(
`Operation failed for tenant ${tenantId}: ${(error as Error).message}`,
// T-05-13: message only — never include stack with potential credential details
);
```
---
### NestJS Logger
**Source:** `apps/api/src/calendar/providers/exchange.provider.ts` line 15
**Apply to:** All new `@Injectable()` services and providers
```typescript
private readonly logger = new Logger(ClassName.name);
```
---
### Safe API Response (no encrypted fields)
**Source:** `apps/api/src/calendar/calendar.service.ts` lines 50–67
**Apply to:** `dkv.service.ts` (config), `settings.service.ts` (SMTP config)
Define a `const X_SAFE_SELECT` Prisma select object that explicitly excludes `encryptedInboxCreds` / `encryptedPassword`. Never return these fields to the frontend.
---
### Frontend `'use client'` + `useTranslations` shell
**Source:** `apps/web/src/app/(portal)/modules/domaincheck/page.tsx` lines 1–7
**Apply to:** All new Next.js page and component files
```typescript
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
```
---
### Frontend Card Layout
**Source:** `apps/web/src/app/(portal)/modules/domaincheck/page.tsx` lines 40–64
**Apply to:** `dkv-fleet/page.tsx`, settings form components
```typescript
<div className="rounded-lg border border-border bg-card p-6 shadow-sm space-y-4">
{/* content */}
</div>
```
---
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `apps/api/src/dkv/dkv-export.service.ts` | service | file-I/O | No file-writing service exists in codebase (first file-I/O service) |
| `apps/api/src/dkv/dkv-parser.service.ts` | service | transform | No binary-parsing / regex transform service exists (novel pattern) |
For these files, use RESEARCH.md Patterns 3–5 (pdf-parse v2, DKV regex, SheetJS) as primary reference. Wrap in standard `@Injectable()` NestJS service shell from the shared pattern above.
---
## AppModule Modification
**File:** `apps/api/src/app.module.ts`
**Required change:** Add `ScheduleModule.forRoot()` (NOT imported yet — confirmed by codebase grep) and `DkvModule`:
```typescript
import { ScheduleModule } from '@nestjs/schedule';
import { DkvModule } from './dkv/dkv.module';
import { SettingsModule } from './settings/settings.module';
@Module({
imports: [
// ... existing imports ...
ScheduleModule.forRoot(), // prerequisite for DkvSchedulerService (Pitfall 4)
DkvModule,
SettingsModule,
],
})
```
---
## Metadata
**Analog search scope:** `apps/api/src/`, `apps/web/src/app/(portal)/`
**Files scanned:** 21 existing source files
**Pattern extraction date:** 2026-06-26
@@ -907,27 +907,15 @@ function resolveFahrzeug(
---
## Open Questions
## Open Questions (RESOLVED)
1. **pdf-parse v2 LoadParameters Buffer key**
- What we know: Documentation shows `{ url: '...' }` and `{ data: buffer }` loading modes
- What's unclear: Exact property name for Buffer loading (`data` vs `buffer` vs `content`)
- Recommendation: Wave 0 must include a 10-line test script: `new PDFParse({ data: fs.readFileSync('user-files/invoice.pdf') })` — if it fails, try `{ url: 'file://...' }` as fallback
1. **pdf-parse v2 LoadParameters Buffer key** — RESOLVED: `{ data: buffer }` is the correct key per TypeDoc LoadParameters interface. Plan 01 Task 4 (Wave-0-Validation) runs an empirical test against `user-files/invoice.pdf` before the parser service is written — this resolves the assumption at runtime.
2. **DKV PDF exact regex for transaction rows**
- What we know: VEHICLE: marker anchors vehicle blocks; each row has date, station, km, product, quantity, unit
- What's unclear: Exact column spacing, whether amounts (Netto/Brutto) are on the same row or summary rows
- Recommendation: Wave 0 must print the raw text from invoice.pdf and examine it before writing regex
2. **DKV PDF exact regex for transaction rows** — RESOLVED: Plan 01 Task 4 (Wave-0-Validation) prints raw PDF text from `user-files/invoice.pdf` and validates the regex before building `dkv-parser.service.ts`. Regex in Pattern 4 is the starting point; adjustments recorded in 07-01-SUMMARY.md.
3. **ScheduleModule in AppModule**
- What we know: `@nestjs/schedule@^6.1.3` is installed, NOT in AppModule imports
- What's unclear: Whether any other planned module (future phase) also needs it — no conflict either way
- Recommendation: Add `ScheduleModule.forRoot()` to AppModule in Wave 0, then DkvModule uses `SchedulerRegistry`
3. **ScheduleModule in AppModule** — RESOLVED: Plan 01 Task 2 adds `ScheduleModule.forRoot()` to AppModule. No conflict with other modules — `ScheduleModule.forRoot()` is idempotent.
4. **Settings sidebar restructuring**
- What we know: Settings currently has only "Dashboard" category (Widgets + Calendar)
- What's unclear: Whether to add "Allgemein > SMTP" as a new top-level sidebar category or embed it in an existing one
- Recommendation: Add a "Allgemein" category section to `SettingsSidebar` with SMTP as the first item — clean separation from dashboard settings
4. **Settings sidebar restructuring** — RESOLVED: Add "Allgemein" top-level category to `SettingsSidebar` with SMTP as first item. Plan 05/06 (frontend) implements this.
---