Files
tessera-ctl/apps/api/src/dkv/dkv.service.ts
T
schalli b188946e31 refactor(quick-260921-m34): Aufgabe 1 - Mandantenbindung entzaubert, 105 unnoetige any-Zusicherungen entfernt
- prisma-tenant.extension.ts: (prisma as any) und die Handannotation an
  $allOperations in forTenant()/forSystem() entfernt; Kopfkommentar
  unveraendert. .then((results: any[]) => ...) auf unknown[] umgestellt.
- 105 Aufrufstellen `const X = forTenant(...) as any` / `forSystem(...) as
  any` von der Zusicherung befreit, Zuweisungsform woertlich erhalten
  (rls-access-inventory.spec.ts bleibt scharf, 30/30 gruen einzeln
  geprueft).
- withTenantTransaction(): Prisma.TransactionClient fuer tx probiert,
  gemessen verworfen - bricht das Testdoppel in
  prisma-tenant.extension.spec.ts (TS2322 auf einem absichtlich
  unvollstaendigen Fake-Objekt). tx bleibt any, mit Begruendung am Typ.
- Gefolge des jetzt getypten Klienten entfernt: any[]-Annotationen und
  .map((x: any) => ...) in groups.service.ts, module-grants.service.ts,
  dkv.service.ts, ldap-config.service.ts, tenders.controller.ts:270.
- Befund (D-03): tender-matching.service.ts:159 trug eine Handannotation
  (match: { tender: unknown }), die den Wert nur deshalb auf unknown
  verengte, um TS7006 unter dem alten any-Klienten zu vermeiden - mit dem
  getypten Klienten war das falsch. Annotation geloescht, kein Ersatz
  durch Zusicherung.
- Zwei any bleiben gezielt in groups.service.ts (u/a in
  ensureDefaultGroup(), gefolge von tx: any) - Begruendung am Code.

noExplicitAny apps/api/src: 288 -> 149 (Schranke 155). type-check 4/4,
lint 5/5 (0 error). apps/api 72/1143 gruen, apps/web 73/531 gruen,
rls-access-inventory.spec.ts 30/30 gruen.

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

885 lines
35 KiB
TypeScript

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<tenantId>) — 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:<tenantId>` 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<string, unknown> = {
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<void> {
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<void> {
// 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<void> {
// 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<ReturnType<typeof this.parser.parsePdf>> | 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<Buffer> {
// 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<void> {
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, '');
}