import { Injectable, Logger } from '@nestjs/common'; import * as fs from 'node:fs'; import * as path from 'node: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 = 'RG-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, baseName: 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 = `${baseName}.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}`, ); } } }