From 6b76ca96338c37e922bd306e5cdf06e44c0405ba Mon Sep 17 00:00:00 2001 From: Schalli Date: Sat, 27 Jun 2026 00:10:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(07-03):=20DkvExportService=20=E2=80=94=20x?= =?UTF-8?q?lsx=20generation=20+=20user-files/=20prune=20(DKV-04)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildExcelBuffer: 5-column xlsx per D-13 (Lieferdatum as string, never Date), SheetJS aoa_to_sheet - resolveFahrzeug: replaces {Marke}/{Modell}/{Kennzeichen}/{Fahrer} tokens in format string (D-19) - writeAndPrune: server-side filename DKV_YYYY-MM_.xlsx (T-07-09 path-traversal prevention), writes to user-files/ (resolved from monorepo root, not request input), prunes to last 10 DKV_*.xlsx files sorted by mtime ascending (D-15, Pitfall 7 atomicity) --- apps/api/src/dkv/dkv-export.service.ts | 187 +++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 apps/api/src/dkv/dkv-export.service.ts diff --git a/apps/api/src/dkv/dkv-export.service.ts b/apps/api/src/dkv/dkv-export.service.ts new file mode 100644 index 0000000..73a26ac --- /dev/null +++ b/apps/api/src/dkv/dkv-export.service.ts @@ -0,0 +1,187 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as XLSX from 'xlsx'; +import { ExportRow } from './dkv.types'; + +/** + * Maximum number of export files to keep in user-files/ (D-15). + * Prune logic deletes oldest files when count exceeds this limit. + */ +const MAX_EXPORT_FILES = 10; + +/** + * Glob pattern for DKV export files — used for prune selection. + */ +const DKV_FILE_PREFIX = 'DKV_'; +const DKV_FILE_SUFFIX = '.xlsx'; + +/** + * Vehicle master data shape — used for resolveFahrzeug. + * Mirrors DkvVehicleMaster Prisma model fields relevant to display formatting. + */ +export interface DkvVehicleMaster { + kennzeichen: string; + marke: string; + modell: string; + fahrer: string; +} + +/** + * DkvExportService — Excel file generation and user-files/ management. + * + * Responsibilities: + * - Build xlsx Buffer from ExportRow[] with the exact 5-column contract (D-13) + * - Resolve the Fahrzeug format string (D-19) + * - Write export files to user-files/ with server-generated filenames (D-12) + * - Prune user-files/ to keep the last MAX_EXPORT_FILES DKV_*.xlsx files (D-15) + * + * Security: T-07-09 — export filename is generated server-side (never from request input) + * to prevent path-traversal attacks. + * + * Race condition: writeAndPrune is a single atomic method. The caller (DkvService/Plan 04) + * is responsible for serialising invoice processing via the processing-lock pattern (Pitfall 7). + */ +@Injectable() +export class DkvExportService { + private readonly logger = new Logger(DkvExportService.name); + + /** + * Resolve the user-files/ directory path relative to the monorepo root. + * Computed once and cached. Path is NEVER derived from user input (T-07-09). + */ + private readonly userFilesDir: string; + + constructor() { + // Resolve relative to the running process — apps/api/ is the cwd at runtime. + // Go up two levels to reach the monorepo root: apps/api/ -> apps/ -> root/. + // Then append user-files/. + this.userFilesDir = path.resolve(__dirname, '..', '..', '..', '..', 'user-files'); + } + + /** + * Resolve the Fahrzeug column value from a vehicle record and a format string. + * + * Default format: "{Marke}/{Modell}/{Kennzeichen}" (D-19). + * Supported tokens: {Marke}, {Modell}, {Kennzeichen}, {Fahrer}. + * + * @param vehicle - Vehicle master data record + * @param formatString - Template string with placeholder tokens + * @returns Resolved string, e.g. "Mercedes/GLC 300 de 4MATIC/GP-JL 728E" + */ + resolveFahrzeug(vehicle: DkvVehicleMaster, formatString: string): string { + return formatString + .replace('{Marke}', vehicle.marke) + .replace('{Modell}', vehicle.modell) + .replace('{Kennzeichen}', vehicle.kennzeichen) + .replace('{Fahrer}', vehicle.fahrer); + } + + /** + * Build an xlsx Buffer from the given export rows. + * + * Column order is fixed per D-13: + * 1. Lieferdatum (string "DD.MM.YYYY" — written as-is, NOT as a JS Date) + * 2. Fahrzeug (string from format template) + * 3. Fahrer (string "Vorname Nachname") + * 4. Ort (string, service station city) + * 5. Kilometerstand (number — no unit) + * + * Research anti-pattern avoidance: Lieferdatum is written as the already-formatted + * German date string from ExportRow.lieferdatum — never wrapped in `new Date()`. + * + * @param rows - Export rows built by the orchestrator (Plan 04) + * @returns Buffer containing the xlsx file content + */ + buildExcelBuffer(rows: ExportRow[]): Buffer { + const headers = ['Lieferdatum', 'Fahrzeug', 'Fahrer', 'Ort', 'Kilometerstand']; + + const data = rows.map((r) => [ + r.lieferdatum, // string: "DD.MM.YYYY" — no Date conversion + r.fahrzeug, // string: resolved format + r.fahrer, // string: "Vorname Nachname" + r.ort, // string: service station city + r.kilometerstand, // number: numeric odometer reading + ]); + + 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; + } + + /** + * Write the export buffer to user-files/ and prune old exports. + * + * Filename format (D-12): DKV_YYYY-MM_.xlsx + * Example: DKV_2026-04_26-651566449-001.xlsx + * + * The filename is computed entirely server-side from invoiceMonth and rechnungsnummer + * parameters that come from the parsed PDF — never from user-supplied request input (T-07-09). + * + * After writing, prune DKV_*.xlsx files by mtime (oldest first) to keep at most + * MAX_EXPORT_FILES (10) files (D-15). + * + * @param buffer - xlsx buffer from buildExcelBuffer() + * @param rechnungsnummer - Invoice number from PDF, e.g. "26-651566449-001" + * @param invoiceMonth - Month string "YYYY-MM", e.g. "2026-04" + * @returns The filename that was written (relative to user-files/) + */ + writeAndPrune(buffer: Buffer, rechnungsnummer: string, invoiceMonth: string): string { + // Ensure the directory exists + if (!fs.existsSync(this.userFilesDir)) { + fs.mkdirSync(this.userFilesDir, { recursive: true }); + this.logger.log(`Created user-files directory at ${this.userFilesDir}`); + } + + // Build filename server-side — NEVER from request input (T-07-09 path-traversal mitigation) + const filename = `DKV_${invoiceMonth}_${rechnungsnummer}.xlsx`; + const filePath = path.join(this.userFilesDir, filename); + + // Write the file + fs.writeFileSync(filePath, buffer); + this.logger.log(`DKV export written: ${filename}`); + + // Prune: keep only the last MAX_EXPORT_FILES files (D-15) + this.pruneExports(); + + return filename; + } + + /** + * Prune DKV_*.xlsx files in user-files/ to keep the last MAX_EXPORT_FILES. + * Files are sorted by mtime ascending — oldest files are deleted first. + * + * This is called synchronously inside writeAndPrune to ensure atomicity (Pitfall 7). + * The caller's processing lock prevents interleaved write+prune operations. + */ + private pruneExports(): void { + try { + const entries = fs + .readdirSync(this.userFilesDir) + .filter( + (f) => f.startsWith(DKV_FILE_PREFIX) && f.endsWith(DKV_FILE_SUFFIX), + ) + .map((f) => { + const fullPath = path.join(this.userFilesDir, f); + const stat = fs.statSync(fullPath); + return { name: f, path: fullPath, mtime: stat.mtime.getTime() }; + }) + // Sort ascending by mtime (oldest first) + .sort((a, b) => a.mtime - b.mtime); + + if (entries.length > MAX_EXPORT_FILES) { + const toDelete = entries.slice(0, entries.length - MAX_EXPORT_FILES); + for (const file of toDelete) { + fs.unlinkSync(file.path); + this.logger.log(`Pruned old DKV export: ${file.name}`); + } + } + } catch (error) { + this.logger.error( + `Failed to prune DKV exports: ${(error as Error).message}`, + ); + } + } +}