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