Files
tessera-ctl/apps/api/src/dkv/dkv-export.service.ts
T
schalli 636fe0df8f refactor(quick-260921-bi2): maschinelle Lint-Fixe und toten Code abbauen
- Aufgabe 2: vier sichere Biome-Regeln (useImportType pfadgebunden auf
  apps/web+packages, noUselessEscapeInRegex, useConst,
  useExponentiationOperator) sowie fuenf ungesicherte Regeln
  (useNodejsImportProtocol, useLiteralKeys, useOptionalChain, useTemplate,
  useParseIntRadix) angewendet und den gesamten Diff von Hand gelesen
  (ldap.service.ts zeichenweise gegen Gross-/Kleinschreibung der
  AD-Merkmale, auth.service.ts/jwt.strategy.ts gegen Durchwinken bei
  fehlender Sitzung geprueft)
- noUselessSwitchCase bleibt bewusst stehen (tender-normalizer.service.ts:60,
  die Fallmarke dokumentiert Absicht)
- Toter Code (D-03): fuenf folgenlose Auffangvariablen entfernt, eine
  nicht benutzte Funktion (forSystemQuery, Pruefskript) entfernt, ein
  positionsgebundener Dekoratorparameter umbenannt (current-user.decorator.ts),
  fuenf Symptomfunde entfernt und als Folgeaufgaben zu melden (siehe unten)
- Sechs weitere, im Plan nicht namentlich gelistete aber
  gleich-kategorische Dead-Code-Fundstellen in Testdateien zusaetzlich
  bereinigt (groups.service.spec.ts, cert-manager.test.tsx,
  ldap.service.spec.ts, prisma-tenant.extension.spec.ts x3) — noetig, um
  die vom Plan selbst verlangten Nullstaende bei noUnusedVariables/
  noUnusedImports/noUnusedFunctionParameters zu erreichen

Dekoratordaten aus apps/api unveraendert (593 Zeilen, sha256 6e1583f1...).
Endstand 620 Befunde (541 echt, 79 Test) statt der im Plan geschaetzten
621/542 — eine Differenz von 1, weil das Streichen des Namens aus
`catch (e: any)` in calendar.service.ts (Symptom-Fix) den dort ebenfalls
gemeldeten noExplicitAny-Befund miteliminiert; das ist eine erwuenschte
Nebenwirkung, keine Regression. Fehlerstufe 0, beide Testlaeufe
punktgleich gruen (69/1124, 66/459), pnpm type-check 4/4, pnpm lint
--force 5/5.

Folgeaufgaben aus D-03 (nicht in diesem Vorgang behoben):
- force-password-change.interceptor.ts: Freigabeliste prueft nur den Pfad,
  nicht die HTTP-Methode
- change-password/page.tsx: nach erzwungenem Wechsel bleibt die Person auf
  der Seite stehen (keine Weiterleitung, keine Aktualisierung der
  Benutzerablage)
- VehicleTable.tsx: Loeschschaltflaeche hat keinen Besetztzustand, laesst
  sich doppelt ausloesen
- SplitTab.tsx: downloadAllAsZip erhielt eine ungenutzte
  Uebersetzungsfunktion, Hinweis auf fest verdrahtete Texte im Zip-Pfad

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 08:58:32 +02:00

188 lines
6.9 KiB
TypeScript

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_<Rechnungsnummer>.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}`,
);
}
}
}