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
@@ -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.
---