import { BadRequestException, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { CryptoService } from '../crypto/crypto.service'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { PrismaService } from '../prisma/prisma.service'; import { forSystem, forTenant } from '../prisma/prisma-tenant.extension'; import { DkvExportService } from './dkv-export.service'; import { DkvMailService } from './dkv-mail.service'; import { DkvParserService } from './dkv-parser.service'; import { DkvConfigDto } from './dto/dkv-config.dto'; import { CreateVehicleDto, UpdateVehicleDto } from './dto/dkv-vehicle.dto'; import { ExchangeInboxProvider } from '../inbox/exchange-inbox.provider'; import { ImapProvider } from '../inbox/imap.provider'; import type { DkvVehicleBlock, InboxConfig, InboxEmail } from './dkv.types'; /** * Prisma select for DkvModuleConfig — never includes encryptedInboxCreds. * T-07-12: Encrypted credential blob is excluded from all API responses. */ 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, domain: true, // encryptedInboxCreds: NEVER included — T-07-12 createdAt: true, updatedAt: true, } as const; /** * DkvService — orchestrates the full DKV processing pipeline. * * Pipeline: * poll inbox → download PDF attachments → parse vehicle/transaction data * → map license plates to drivers → build xlsx → write to user-files/ * → send via SMTP → record history * * Security: * - T-05-13: Decrypted credentials never logged * - T-07-12: CONFIG_SAFE_SELECT excludes encryptedInboxCreds in all responses * - T-07-09: Export filename validated against safe pattern before reading (traversal guard) * - Single-flight guard: prevents concurrent inbox processing (Pitfall 7) * * Multi-tenant note (seit 260914-eym, Etappe 3c): Der Planer laedt seinen * Startpfad ueber `loadActiveConfigsForScheduler()` — SYSTEMGEBUNDEN * (`forSystem()`, liest ALLE aktiven Konfigurationen ueber alle Mandanten, * nur lesend) — und registriert je aktivem Mandanten einen eigenen * Cron-Auftrag (einmal-abfragen-viele-bedienen, WINDOWS #21 geschlossen). * Each processInbox(tenantId) call is per-tenant and fully forTenant()-bound * (260909-mir). The Controller scopes all operations to req.tenantId. * * Bewusst NICHT angefasst (260914-eym): der Single-Flight-Riegel * `processing` ist EIN prozessweites Boolean, nicht je Mandant — siehe * Kommentar am Feld und WINDOWS-Eintrag (Ledger). */ @Injectable() export class DkvService { private readonly logger = new Logger(DkvService.name); /** * Single-flight guard: if processing is already in progress, any concurrent * call to processInbox() returns early without starting a second pipeline * run (Pitfall 7 — prevents the prune race condition and duplicate records). * * PROZESSWEIT, nicht je Mandant (260914-eym, bewusst unangetastet): seit * je aktivem Mandanten ein eigener Cron-Auftrag laeuft, koennen sich zwei * Ticks verschiedener Mandanten ueberschneiden — der zweite bricht dann * still ab und wartet bis zum naechsten Intervall (Verzoegerung, kein * Datenverlust; mit EINEM Mandanten unveraendert). Loesungsweg: Riegel je * Mandant (Set) — als Ledger-Eintrag in .planning/WINDOWS.md * gefuehrt, nicht in diesem Durchlauf gebaut (Auftrag: Tick unangetastet). */ private processing = false; /** Resolved path to user-files/ directory (monorepo root). */ private readonly userFilesDir: string; constructor( private readonly prisma: PrismaService, private readonly crypto: CryptoService, private readonly parser: DkvParserService, private readonly exporter: DkvExportService, private readonly mailer: DkvMailService, private readonly imapProvider: ImapProvider, private readonly exchangeProvider: ExchangeInboxProvider, ) { // __dirname at runtime = apps/api/dist/dkv/ — go up 4 levels to monorepo root this.userFilesDir = path.resolve(__dirname, '..', '..', '..', '..', 'user-files'); } // ─── Config ────────────────────────────────────────────────────────────────── /** * Load DKV module config for a tenant (safe — no encrypted creds). * * Mandantengebunden (WINDOWS #20 Etappe 2, 260909-mir): der Mandant kommt * hier als PFLICHT-Parameter herein, ist also vor dem Zugriff bereits * bekannt. Diese Methode war frueher `loadConfig(tenantId?)` mit einem * optionalen Parameter, hinter dem der eine Zweig gebunden werden MUSSTE * und der andere gebunden werden DURFTE NICHT — genau die Form, die * dieser Umbau aufloest. Der uebergreifende Zweig ist jetzt eine eigene, * benannte Methode: `loadActiveConfigsForScheduler()` unten (seit * 260914-eym systemgebunden, eine Zeile je aktivem Mandanten). */ async loadConfig(tenantId: string) { const tenantPrisma = forTenant(this.prisma, tenantId); return tenantPrisma.dkvModuleConfig.findUnique({ where: { tenantId }, select: CONFIG_SAFE_SELECT, }); } /** * Alle AKTIVEN DKV-Konfigurationen ueber ALLE Mandanten — verwendet * AUSSCHLIESSLICH von DkvSchedulerService.onModuleInit(), das je Zeile * einen eigenen Cron-Auftrag `dkv-inbox-poll:` registriert. * * SYSTEMGEBUNDEN (Etappe 3c, 260914-eym, WINDOWS #21 GESCHLOSSEN): liest * ueber `forSystem()` (Sitzungsvariable `app.system_context = 'true'`, * Regel `system_read_policy ... FOR SELECT` auf "DkvModuleConfig", * Migration 20260914120000). Warum VIELE statt EINER: * * - Die Vorgaengerform `findFirst()` ohne Bedingung zog bei mehreren * Mandanten EINEN beliebigen und bediente die uebrigen NIE — HEUTE * schon falsch (260909-mir, Befund D). `findMany({ where: { isActive: * true } })` liefert jeden aktiven Mandanten genau einmal, sortiert nach * tenantId (deterministische Reihenfolge der Auftraege). * - Das VERSTUMMEN nach dem Scharfschalten (Etappe 4) ist strukturell * ausgeschlossen: ohne Systemkontext saehe dieser Pfad unter einer Rolle * ohne BYPASSRLS NULL Zeilen; `system_read_policy` oeffnet genau diese * Tabelle fuer genau diesen Kontext, nur lesend (Werkzeugbeleg * `dkvmoduleconfig-systemkontext-sieht-beide-mandanten`). * - Mit EINEM Mandanten ist das Ergebnis beobachtbar identisch zur * Vorgaengerform: eine Zeile, derselbe Auftrag, dieselbe Cron-Expression * (dkv-scheduler.service.spec.ts, Test 1). * * `CONFIG_SAFE_SELECT`: die verschluesselten Zugangsdaten bleiben draussen * (T-07-12) — der Planer braucht nur tenantId und pollIntervalMin. */ async loadActiveConfigsForScheduler() { const systemPrisma = forSystem(this.prisma); return systemPrisma.dkvModuleConfig.findMany({ where: { isActive: true }, select: CONFIG_SAFE_SELECT, orderBy: { tenantId: 'asc' }, }); } /** * Load config for API response: safe fields + decrypted username + hasPassword flag. * T-07-12: password is NEVER returned — only hasPassword boolean. * * Mandantengebunden (260909-mir): EIN gebundener Klient fuer BEIDE * Lesezugriffe dieser Methode (nicht `this.loadConfig(tenantId)` plus ein * zweiter Aufruf — das waere ein Klient je Modellzugriff statt je * Methode). */ async getConfigForApi(tenantId: string) { const tenantPrisma = forTenant(this.prisma, tenantId); const safe = await tenantPrisma.dkvModuleConfig.findUnique({ where: { tenantId }, select: CONFIG_SAFE_SELECT, }); if (!safe) return null; let username: string | null = null; let hasPassword = false; try { const raw = await tenantPrisma.dkvModuleConfig.findUnique({ where: { tenantId } }); if (raw?.encryptedInboxCreds) { const creds = JSON.parse(this.crypto.decrypt(raw.encryptedInboxCreds)) as { username?: string; password?: string }; username = creds.username ?? null; hasPassword = Boolean(creds.password); } } catch { /* ignore decrypt errors — return empty username */ } return { ...safe, username, hasPassword }; } /** * Upsert DKV module config for a tenant. * * Credential handling: * - If dto.password is non-empty: re-encrypt {username, password} together. * username is taken from dto.username if provided, else preserved from DB. * - If dto.username is non-empty but dto.password is empty: preserve existing * password; re-encrypt with new username. * - If both are empty/undefined: preserve existing encryptedInboxCreds entirely. * * T-07-12: Returns safe select (no encryptedInboxCreds). * T-05-13: Never logs decrypted credentials. * * Mandantengebunden (260909-mir): EIN gebundener Klient fuer den * erhaltenden Lesezugriff UND den Schreibzugriff dieser Methode — beide * sehen dadurch denselben Mandanten, ein leerer Lesezugriff kann nicht * mit einem erfolgreichen Schreibzugriff unter einem anderen Kontext * kombiniert werden (T-MIR-07, die zerstoerende Stelle aus Befund K). * Kein `where`-Filter entfaellt — die Mandantenbedingung bleibt neben der * Bindung als zweite Schicht bestehen. */ async saveConfig(tenantId: string, dto: DkvConfigDto) { const tenantPrisma = forTenant(this.prisma, tenantId); let encryptedInboxCreds: string | undefined; const credChanged = (dto.password && dto.password.length > 0) || (dto.username !== undefined && dto.username !== null); if (credChanged) { let username: string = dto.username ?? ''; let password: string = dto.password ?? ''; // Preserve the field that was left empty from the existing stored value if (!dto.password || !dto.username) { try { const existing = await tenantPrisma.dkvModuleConfig.findUnique({ where: { tenantId } }); if (existing?.encryptedInboxCreds) { // T-05-13: decrypt only to preserve — never log the result const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as { username?: string; password?: string; }; if (!dto.username) username = stored.username ?? ''; if (!dto.password) password = stored.password ?? ''; } } catch { // Ignore decrypt errors — will overwrite with whatever was provided } } encryptedInboxCreds = this.crypto.encrypt(JSON.stringify({ username, password })); } const data: Record = { protocol: dto.protocol, encryption: dto.encryption, ...(dto.host !== undefined && { host: dto.host }), ...(dto.port !== undefined && { port: dto.port }), ...(dto.folder !== undefined && { folder: dto.folder }), ...(dto.senderFilter !== undefined && { senderFilter: dto.senderFilter }), ...(dto.pollIntervalMin !== undefined && { pollIntervalMin: dto.pollIntervalMin }), ...(dto.isActive !== undefined && { isActive: dto.isActive }), ...(dto.exportRecipient !== undefined && { exportRecipient: dto.exportRecipient }), ...(dto.vehicleFormatString !== undefined && { vehicleFormatString: dto.vehicleFormatString }), ...(dto.domain !== undefined && { domain: dto.domain }), ...(encryptedInboxCreds !== undefined && { encryptedInboxCreds }), }; return tenantPrisma.dkvModuleConfig.upsert({ where: { tenantId }, create: { tenantId, ...data }, update: data, select: CONFIG_SAFE_SELECT, }); } /** * Test the inbox connection using credentials from the form DTO. * When dto.password is empty, falls back to the stored encrypted password. * * T-05-13: Decrypted password used only within this method scope — never logged. * Mandantengebunden (260909-mir): EIN gebundener Klient fuer den * Rueckgriff auf die gespeicherten Zugangsdaten. */ async testConnection(tenantId: string, dto: DkvConfigDto): Promise<{ success: boolean; message?: string }> { const tenantPrisma = forTenant(this.prisma, tenantId); let password: string | undefined = dto.password; // If no password in DTO, fall back to the stored one if (!password) { try { const existing = await tenantPrisma.dkvModuleConfig.findUnique({ where: { tenantId } }); if (existing?.encryptedInboxCreds) { const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as { password?: string; }; password = stored.password; } } catch { // T-05-13: generic — no credential details this.logger.error(`DKV testConnection: failed to load stored credentials for tenant ${tenantId}`); } } const inboxConfig: InboxConfig = { protocol: dto.protocol, host: dto.host ?? '', port: dto.port ?? 993, username: dto.username, password, encryption: dto.encryption, folder: dto.folder ?? 'INBOX', senderFilter: dto.senderFilter, domain: dto.domain, }; const provider = dto.protocol === 'exchange' ? this.exchangeProvider : this.imapProvider; return provider.testConnection(inboxConfig); } // ─── Pipeline ───────────────────────────────────────────────────────────────── /** * Trigger an inbox check for a tenant. * Returns a summary object: status, message, checkedAt. * * Used by POST /dkv/check-now and indirectly by the scheduler. */ async checkNow(tenantId: string): Promise<{ status: string; message: string; checkedAt: string; }> { const checkedAt = new Date().toISOString(); try { await this.processInbox(tenantId); return { status: 'ok', message: 'Posteingang geprüft', checkedAt }; } catch (err) { return { status: 'error', message: (err as Error).message, checkedAt, }; } } /** * Run the full DKV inbox processing pipeline for a tenant. * * Single-flight guard: returns early if already processing (Pitfall 7). * Pipeline: load config → decrypt creds → fetch PDFs → parse → map drivers * → build xlsx → write to user-files/ → send SMTP → record history. */ async processInbox(tenantId: string): Promise { if (this.processing) { this.logger.warn(`DKV inbox already processing for tenant ${tenantId} — skipping`); return; } this.processing = true; try { await this._runPipeline(tenantId); } finally { this.processing = false; } } private async _runPipeline(tenantId: string): Promise { // Load raw config (need encryptedInboxCreds for decryption). // Mandantengebunden (260909-mir). const tenantPrisma = forTenant(this.prisma, tenantId); const config = await tenantPrisma.dkvModuleConfig.findUnique({ where: { tenantId } }); if (!config) { this.logger.warn(`DKV processInbox: no config for tenant ${tenantId}`); return; } if (!config.isActive) { this.logger.log(`DKV inbox polling is inactive for tenant ${tenantId}`); return; } // Decrypt inbox credentials — T-05-13: never log result let inboxConfig: InboxConfig; try { const creds = config.encryptedInboxCreds ? (JSON.parse(this.crypto.decrypt(config.encryptedInboxCreds)) as { username?: string; password?: string; }) : {}; inboxConfig = { protocol: config.protocol, host: config.host ?? '', port: config.port ?? 993, username: creds.username, password: creds.password, encryption: config.encryption, folder: config.folder, senderFilter: config.senderFilter ?? undefined, }; } catch { this.logger.error(`DKV inbox config decryption failed for tenant ${tenantId}`); return; } // Select provider by protocol const provider = config.protocol === 'exchange' ? this.exchangeProvider : this.imapProvider; // Fetch PDF attachments from inbox let emails: InboxEmail[]; try { emails = await provider.fetchPdfAttachments(inboxConfig); } catch (err) { this.logger.error( `DKV inbox fetch failed for tenant ${tenantId}: ${(err as Error).message}`, ); return; } if (emails.length === 0) { this.logger.log(`DKV inbox check: no matching emails for tenant ${tenantId}`); return; } const vehicleFormatString = config.vehicleFormatString ?? '{Marke}/{Modell}/{Kennzeichen}'; for (const email of emails) { for (const attachment of email.attachments) { await this._processAttachment( tenantId, attachment.buffer, email, config.exportRecipient ?? undefined, vehicleFormatString, ); } } } /** * Process a single PDF attachment through the full pipeline. * D-10: Up to 3 parse retries; on final failure record Fehler history row. * D-16: Up to 3 SMTP send retries with exponential backoff; on final failure * record Versand fehlgeschlagen (file stays locally available). */ private async _processAttachment( tenantId: string, pdfBuffer: Buffer, email: InboxEmail, recipient: string | undefined, vehicleFormatString: string, ): Promise { // Mandantengebunden (260909-mir): EIN gebundener Klient fuer beide // dkvInvoiceHistory.create()-Aufrufe dieser Methode (Erfolgsfall UND // Zerlegungsfehler-Fall). const tenantPrisma = forTenant(this.prisma, tenantId); // D-10: Up to 3 parse retries let parseResult: Awaited> | null = null; let parseError: string | null = null; for (let attempt = 1; attempt <= 3; attempt++) { try { parseResult = await this.parser.parsePdf(pdfBuffer); parseError = null; break; } catch (err) { parseError = (err as Error).message; this.logger.warn( `DKV PDF parse attempt ${attempt}/3 for tenant ${tenantId}: ${parseError}`, ); } } if (!parseResult) { // Record parse failure in history (D-10) await tenantPrisma.dkvInvoiceHistory.create({ data: { tenantId, rechnungsnummer: this._buildRechnungsnummer(null, email.subject, email.uid), anzahlFahrzeuge: 0, anzahlTransaktionen: 0, status: 'Fehler', errorMessage: parseError ?? 'PDF parse failed', }, }); return; } const { vehicles, rechnungsnummer: rgNr, rechnungsdatum: rgDat } = parseResult; // Build filename: RG-DKV-{nr}-{date}.xlsx const rechnungsnummer = this._buildRechnungsnummer(rgNr, email.subject, email.uid); const datePart = rgDat ? _formatDateYYMMDD(rgDat) : this._extractInvoiceMonth(vehicles).replace('-', ''); const exportBaseName = `RG-DKV-${rechnungsnummer}-${datePart}`; const anzahlTransaktionen = vehicles.reduce((s, v) => s + v.transactions.length, 0); // Build export rows — look up drivers from vehicle master const exportRows = await this._buildExportRows(tenantId, vehicles, vehicleFormatString); // Generate xlsx buffer and write to user-files/ (DkvExportService) const xlsxBuffer = this.exporter.buildExcelBuffer(exportRows); const exportFilename = this.exporter.writeAndPrune(xlsxBuffer, exportBaseName); // D-16: SMTP send with 3-retry exponential backoff let smtpStatus: 'Verarbeitet' | 'Versand fehlgeschlagen' = 'Verarbeitet'; let smtpError: string | undefined; if (recipient) { for (let attempt = 1; attempt <= 3; attempt++) { try { await this.mailer.sendExportEmail(tenantId, recipient, xlsxBuffer, exportFilename); smtpStatus = 'Verarbeitet'; smtpError = undefined; break; } catch (err) { smtpError = (err as Error).message; this.logger.warn(`DKV SMTP send attempt ${attempt}/3 failed: ${smtpError}`); if (attempt < 3) { await _delay(2 ** attempt * 1000); // 2s, 4s } else { smtpStatus = 'Versand fehlgeschlagen'; // D-16: file stays locally available for manual download } } } } else { // No recipient configured — mark as processed without sending this.logger.warn(`DKV: no exportRecipient configured for tenant ${tenantId} — skipping SMTP send`); } // Record history row (D-20) await tenantPrisma.dkvInvoiceHistory.create({ data: { tenantId, rechnungsnummer, anzahlFahrzeuge: vehicles.length, anzahlTransaktionen, status: smtpStatus, ...(smtpError && { errorMessage: smtpError }), exportFilename, }, }); this.logger.log( `DKV processed: ${rechnungsnummer} — ${vehicles.length} vehicles, ${anzahlTransaktionen} transactions, status: ${smtpStatus}`, ); } // ─── Vehicle CRUD ──────────────────────────────────────────────────────────── async listVehicles(tenantId: string) { const tenantPrisma = forTenant(this.prisma, tenantId); return tenantPrisma.dkvVehicleMaster.findMany({ where: { tenantId }, orderBy: { kennzeichen: 'asc' }, }); } async createVehicle(tenantId: string, dto: CreateVehicleDto) { const tenantPrisma = forTenant(this.prisma, tenantId); return tenantPrisma.dkvVehicleMaster.create({ data: { tenantId, ...dto }, }); } /** * Mandantengebunden (260909-mir, Befund G): EIN gebundener Klient fuer * BEIDE Anweisungen — die Besitzpruefung UND den Schreibzugriff. Die * Mandantenbedingung in der Besitzpruefung (`findFirst({ id, tenantId })`) * bleibt zusaetzlich erhalten und wird nicht durch die Bindung ersetzt: * Aufgabe 1 hat gemessen, dass ein gebundenes UPDATE ueber die Kennung * allein auf eine fremde Zeile still 0 Zeilen trifft statt laut zu * scheitern — eine gebundene Vorpruefung mit einem ungebundenen * Schreibzugriff dahinter waere genau die Luecke, nicht die Loesung. */ async updateVehicle(tenantId: string, id: string, dto: UpdateVehicleDto) { const tenantPrisma = forTenant(this.prisma, tenantId); const existing = await tenantPrisma.dkvVehicleMaster.findFirst({ where: { id, tenantId }, }); if (!existing) throw new NotFoundException('Vehicle not found'); return tenantPrisma.dkvVehicleMaster.update({ where: { id }, data: dto }); } /** Mandantengebunden (260909-mir, Befund G) — siehe updateVehicle() oben. */ async deleteVehicle(tenantId: string, id: string): Promise<{ deleted: boolean }> { const tenantPrisma = forTenant(this.prisma, tenantId); const existing = await tenantPrisma.dkvVehicleMaster.findFirst({ where: { id, tenantId }, }); if (!existing) throw new NotFoundException('Vehicle not found'); await tenantPrisma.dkvVehicleMaster.delete({ where: { id } }); return { deleted: true }; } /** * Bulk-import vehicles from semicolon-delimited CSV text. * * CSV header (case-insensitive): Kennzeichen;Marke;Modell;Fahrer * * mode='merge': upsert each vehicle (add new, update existing by Kennzeichen). * mode='replace': delete all existing vehicles for this tenant, then insert. * * Research pattern: CSV Vehicle Import Pattern (RESEARCH.md Code Examples). * * Mandantengebunden (260909-mir): EIN gebundener Klient fuer JEDE * Anweisung in beiden Modi. Keine Kollisionsbehandlung noetig im * Zusammenfuehren-Modus — `@@unique([tenantId, kennzeichen])` traegt den * Mandanten als Teil des Schluessels (Befund H, in Aufgabe 1 gemessen: * `dkvvehiclemaster-schluessel-traegt-mandant-keine-fremdkollision`). */ async importVehiclesCsv( tenantId: string, csvText: string, mode: 'merge' | 'replace', ): Promise<{ imported: number; mode: string }> { const tenantPrisma = forTenant(this.prisma, tenantId); const vehicles = _parseVehicleCsv(csvText); if (vehicles.length === 0) { throw new BadRequestException( 'CSV enthält keine gültigen Fahrzeuge. Erwarteter Header: Kennzeichen;Marke;Modell;Fahrer', ); } if (mode === 'replace') { await tenantPrisma.dkvVehicleMaster.deleteMany({ where: { tenantId } }); await tenantPrisma.dkvVehicleMaster.createMany({ data: vehicles.map((v) => ({ tenantId, ...v })), }); } else { // Merge: upsert by (tenantId, kennzeichen) compound unique key for (const v of vehicles) { await tenantPrisma.dkvVehicleMaster.upsert({ where: { tenantId_kennzeichen: { tenantId, kennzeichen: v.kennzeichen } }, create: { tenantId, ...v }, update: { marke: v.marke, modell: v.modell, fahrer: v.fahrer }, }); } } this.logger.log(`DKV CSV import: ${vehicles.length} vehicles, mode=${mode}, tenant=${tenantId}`); return { imported: vehicles.length, mode }; } // ─── History ───────────────────────────────────────────────────────────────── /** * Get paginated processing history for a tenant. * Ordered by datumZeit descending (most recent first). * T-07-06: pagination prevents unbounded result-set DoS. * * Mandantengebunden (260909-mir): EIN gebundener Klient fuer beide * Abfragen, die ueber `Promise.all` parallel laufen — das ist die * Nebenlaeufigkeitsform, auf die sich dieser Bereich stuetzt (Befund C, * in Aufgabe 1 gemessen: `dkv-zwei-parallele-gebundene-einzelabfragen-je-eigener-kontext`). */ async getHistory( tenantId: string, page = 1, limit = 20, ): Promise<{ items: unknown[]; total: number; page: number; limit: number; }> { const tenantPrisma = forTenant(this.prisma, tenantId); const skip = (page - 1) * limit; const [items, total] = await Promise.all([ tenantPrisma.dkvInvoiceHistory.findMany({ where: { tenantId }, orderBy: { datumZeit: 'desc' }, skip, take: limit, }), tenantPrisma.dkvInvoiceHistory.count({ where: { tenantId } }), ]); return { items, total, page, limit }; } // ─── Export file download ──────────────────────────────────────────────────── /** * Read a DKV export file from user-files/ and return its Buffer. * * T-07-09 traversal guard: validates filename against the server-generated * pattern `DKV_*.xlsx` before reading. Rejects any filename containing path * separators, `..`, or characters outside the expected character set. * * OWNERSHIP GATE (260909-mir, Befund E/T-MIR-02 — closes the ldap-class * gap of this area): `user-files/` is a directory SHARED by all tenants * (Befund F/T-MIR-08), so the traversal-safe filename pattern alone never * proved this tenant owns the file — any tenant admin could download * another tenant's export given (or guessed at) the filename. A bound * read against `DkvInvoiceHistory.exportFilename` now decides ownership. * This DELIBERATELY changes behavior: a file that sits on disk but names * no history row for this tenant is no longer downloadable — that is the * intent, not a bug. Absence (no DB row) and foreign ownership (a DB row * under a different tenant) collapse to the SAME NotFoundException so the * response reveals nothing about whether a foreign tenant's file exists. * * @throws BadRequestException when filename fails the pattern check * @throws NotFoundException when no history row of THIS tenant names this * file, or when the file is missing from disk despite an owning row */ async getExportFile(tenantId: string, filename: string): Promise { // Stage 1 (unchanged, T-07-09): traversal guard, whitelist-validate the // filename before doing anything else with it. if ( filename.includes('/') || filename.includes('\\') || filename.includes('..') || !/^(RG-DKV-|DKV_)[\w-]+\.xlsx$/.test(filename) ) { throw new BadRequestException('Invalid export filename'); } // Stage 2 (NEW, 260909-mir): the ownership gate. A bound read — the // only tenant-scoped statement of who this file belongs to. const tenantPrisma = forTenant(this.prisma, tenantId); const owningHistoryRow = await tenantPrisma.dkvInvoiceHistory.findFirst({ where: { tenantId, exportFilename: filename }, }); if (!owningHistoryRow) { throw new NotFoundException(`Export file not found: ${filename}`); } const filePath = path.join(this.userFilesDir, filename); if (!fs.existsSync(filePath)) { throw new NotFoundException(`Export file not found: ${filename}`); } return fs.readFileSync(filePath); } // ─── Private helpers ────────────────────────────────────────────────────────── /** * Build ExportRow[] for the xlsx generator by looking up each vehicle's driver. * * Vehicles with no matching DkvVehicleMaster entry still appear in the export * with an empty Fahrer field (D-13). The Fahrzeug column is resolved via the * format string with empty Marke/Modell/Fahrer placeholders for unknown plates. * * Mandantengebunden (260909-mir): der gebuendelte Lesezugriff auf die * Fahrzeugstammdaten laeuft ueber `forTenant()` — ungebunden wuerde * Befund K, Stelle 7 zuschlagen: eine vollstaendige Ausfuhrdatei OHNE * einen einzigen Fahrer, ohne Fehler, ohne Warnung. */ private async _buildExportRows( tenantId: string, vehicles: DkvVehicleBlock[], vehicleFormatString: string, ): Promise<{ lieferdatum: string; fahrzeug: string; fahrer: string; ort: string; kilometerstand: number | null }[]> { // Batch load vehicle master to avoid N+1 queries const tenantPrisma = forTenant(this.prisma, tenantId); const masters = await tenantPrisma.dkvVehicleMaster.findMany({ where: { tenantId } }); // Normalize keys: DKV PDF may omit hyphens or use spaces ("GP JL 740E" vs "GP-JL 740E") const masterMap = new Map(masters.map((m) => [_normalizeKennzeichen(m.kennzeichen), m])); const rows: { lieferdatum: string; fahrzeug: string; fahrer: string; ort: string; kilometerstand: number | null }[] = []; for (const vehicle of vehicles) { const master = masterMap.get(_normalizeKennzeichen(vehicle.kennzeichen)); // For unknown plates: use empty string values for unknown fields const vehicleForFormat = { kennzeichen: vehicle.kennzeichen, marke: master?.marke ?? '', modell: master?.modell ?? '', fahrer: master?.fahrer ?? '', }; const fahrzeug = this.exporter.resolveFahrzeug(vehicleForFormat, vehicleFormatString); const fahrer = master?.fahrer ?? ''; for (const tx of vehicle.transactions) { // Only keep km values that are plausible odometer readings (whole numbers ≥ 0). // Decimal values indicate a misaligned column (e.g. kWh or EUR from EV charging rows). const rawKm = tx.kilometerstand; const km: number | null = Number.isNaN(rawKm) || !Number.isFinite(rawKm) || !Number.isInteger(rawKm) ? null : rawKm; rows.push({ lieferdatum: tx.lieferdatum, fahrzeug, fahrer, ort: tx.ort, kilometerstand: km, }); } } return rows; } /** * Build a safe rechnungsnummer string for use in filenames. * Priority: PDF-extracted number → email subject → uid fallback. * All slashes and non-safe chars are replaced so the value is path-safe. */ private _buildRechnungsnummer( fromPdf: string | null, subject: string, uid: number | string, ): string { if (fromPdf) return fromPdf.replace(/\//g, '-'); // DKV email subjects carry the invoice number with slashes: "26/650869002/002" const m = subject?.match(/(\d{2}[/-]\d{9}[/-]\d{3})/); if (m?.[1]) return m[1].replace(/\//g, '-'); return `email-${String(uid).replace(/[^a-zA-Z0-9-]/g, '_')}`; } /** * Derive the invoice month (YYYY-MM) from the first transaction date. * DKV dates are "DD.MM.YYYY" — this converts to "YYYY-MM" for the filename. * Falls back to the current month when no transaction date is available. */ private _extractInvoiceMonth(vehicles: DkvVehicleBlock[]): string { const firstDate = vehicles[0]?.transactions[0]?.lieferdatum; if (firstDate) { const parts = firstDate.split('.'); if (parts.length === 3) return `${parts[2]}-${parts[1]}`; } return new Date().toISOString().slice(0, 7); } } // ─── Module-level helpers ──────────────────────────────────────────────────── /** * Parse semicolon-delimited CSV into vehicle master records. * Expected header: Kennzeichen;Marke;Modell;Fahrer (case-insensitive). * Skips rows with empty Kennzeichen. * * Research: CSV Vehicle Import Pattern (RESEARCH.md Code Examples). */ function _parseVehicleCsv( csvText: string, ): { kennzeichen: string; marke: string; modell: string; fahrer: string }[] { const lines = csvText .trim() .split('\n') .map((l) => l.trim()) .filter(Boolean); if (lines.length < 2) return []; const headers = lines[0].split(';').map((h) => h.trim().toLowerCase()); return lines .slice(1) .map((line) => { const cols = line.split(';').map((c) => c.trim()); return { kennzeichen: cols[headers.indexOf('kennzeichen')] ?? '', marke: cols[headers.indexOf('marke')] ?? '', modell: cols[headers.indexOf('modell')] ?? '', fahrer: cols[headers.indexOf('fahrer')] ?? '', }; }) .filter((v) => v.kennzeichen.length > 0); } /** Simple promise-based delay for retry backoff. */ function _delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** Format "DD.MM.YYYY" → "YYMMDD" for compact filename date segment. */ function _formatDateYYMMDD(date: string): string { const [d, m, y] = date.split('.'); if (!d || !m || !y) return date.replace(/\./g, ''); return `${y.slice(2)}${m}${d}`; } /** * Normalize a Kennzeichen for fuzzy vehicle lookup. * DKV PDF extraction may omit hyphens or use spaces as separators. * Example: "GP JL 740E" == "GP-JL 740E" == "GPJL740E" after normalization. */ function _normalizeKennzeichen(k: string): string { return k.toUpperCase().replace(/[\s\-.]/g, ''); }