b188946e31
- 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
885 lines
35 KiB
TypeScript
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, '');
|
|
}
|