f574884b32
CALENDAR_ENCRYPTION_KEY was named after the calendar module because that module needed encryption first, in Phase 5. Every feature since has shared the same key -- SMTP, the DKV and tender mailboxes, and as of today the LDAP bind password -- so the name has been describing one of five users rather than the thing itself, and each new feature inherited the confusion. TESSERA_ENCRYPTION_KEY is the name now. The old one is still read, because renaming outright would stop every existing installation at the next start: their .env carries the old name, and compose was just made to fail hard on a missing key. When only the old name is present the API logs a deprecation warning naming both, and when both are set the new one wins -- otherwise a half-migrated .env would encrypt with one key and decrypt with the other. CalendarCryptoService becomes CryptoService in its own global CryptoModule. Four modules used to import CalendarModule purely to reach the provider, which read as a dependency on calendars where there was none; that import is gone. Compose keeps the hard failure: without either name the stack refuses to start. Verified in both files for all three cases -- neither name set (abort), only the old name (starts), only the new name (starts). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
741 lines
27 KiB
TypeScript
741 lines
27 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { CryptoService } from '../crypto/crypto.service';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
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 (v1): The scheduler loads config via findFirst().
|
|
* Each processInbox(tenantId) call is per-tenant. The Controller scopes all
|
|
* operations to req.tenantId. Full per-tenant scheduling (one cron per active
|
|
* tenant) is deferred to a future plan — v1 covers single-tenant deployments.
|
|
*/
|
|
@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).
|
|
*/
|
|
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).
|
|
* When tenantId is omitted, returns the first row (used by scheduler on init).
|
|
*/
|
|
async loadConfig(tenantId?: string) {
|
|
if (!tenantId) {
|
|
return this.prisma.dkvModuleConfig.findFirst({ select: CONFIG_SAFE_SELECT });
|
|
}
|
|
return this.prisma.dkvModuleConfig.findUnique({
|
|
where: { tenantId },
|
|
select: CONFIG_SAFE_SELECT,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Load config for API response: safe fields + decrypted username + hasPassword flag.
|
|
* T-07-12: password is NEVER returned — only hasPassword boolean.
|
|
*/
|
|
async getConfigForApi(tenantId: string) {
|
|
const safe = await this.loadConfig(tenantId);
|
|
if (!safe) return null;
|
|
|
|
let username: string | null = null;
|
|
let hasPassword = false;
|
|
try {
|
|
const raw = await this.prisma.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.
|
|
*/
|
|
async saveConfig(tenantId: string, dto: DkvConfigDto) {
|
|
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 this.prisma.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 this.prisma.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.
|
|
*/
|
|
async testConnection(tenantId: string, dto: DkvConfigDto): Promise<{ success: boolean; message?: string }> {
|
|
let password: string | undefined = dto.password;
|
|
|
|
// If no password in DTO, fall back to the stored one
|
|
if (!password) {
|
|
try {
|
|
const existing = await this.prisma.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)
|
|
const config = await this.prisma.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> {
|
|
// 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 this.prisma.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(Math.pow(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 this.prisma.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) {
|
|
return this.prisma.dkvVehicleMaster.findMany({
|
|
where: { tenantId },
|
|
orderBy: { kennzeichen: 'asc' },
|
|
});
|
|
}
|
|
|
|
async createVehicle(tenantId: string, dto: CreateVehicleDto) {
|
|
return this.prisma.dkvVehicleMaster.create({
|
|
data: { tenantId, ...dto },
|
|
});
|
|
}
|
|
|
|
async updateVehicle(tenantId: string, id: string, dto: UpdateVehicleDto) {
|
|
const existing = await this.prisma.dkvVehicleMaster.findFirst({
|
|
where: { id, tenantId },
|
|
});
|
|
if (!existing) throw new NotFoundException('Vehicle not found');
|
|
return this.prisma.dkvVehicleMaster.update({ where: { id }, data: dto });
|
|
}
|
|
|
|
async deleteVehicle(tenantId: string, id: string): Promise<{ deleted: boolean }> {
|
|
const existing = await this.prisma.dkvVehicleMaster.findFirst({
|
|
where: { id, tenantId },
|
|
});
|
|
if (!existing) throw new NotFoundException('Vehicle not found');
|
|
await this.prisma.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).
|
|
*/
|
|
async importVehiclesCsv(
|
|
tenantId: string,
|
|
csvText: string,
|
|
mode: 'merge' | 'replace',
|
|
): Promise<{ imported: number; mode: string }> {
|
|
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 this.prisma.dkvVehicleMaster.deleteMany({ where: { tenantId } });
|
|
await this.prisma.dkvVehicleMaster.createMany({
|
|
data: vehicles.map((v) => ({ tenantId, ...v })),
|
|
});
|
|
} else {
|
|
// Merge: upsert by (tenantId, kennzeichen) compound unique key
|
|
for (const v of vehicles) {
|
|
await this.prisma.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.
|
|
*/
|
|
async getHistory(
|
|
tenantId: string,
|
|
page = 1,
|
|
limit = 20,
|
|
): Promise<{
|
|
items: unknown[];
|
|
total: number;
|
|
page: number;
|
|
limit: number;
|
|
}> {
|
|
const skip = (page - 1) * limit;
|
|
const [items, total] = await Promise.all([
|
|
this.prisma.dkvInvoiceHistory.findMany({
|
|
where: { tenantId },
|
|
orderBy: { datumZeit: 'desc' },
|
|
skip,
|
|
take: limit,
|
|
}),
|
|
this.prisma.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.
|
|
*
|
|
* @throws BadRequestException when filename fails validation
|
|
* @throws NotFoundException when the file does not exist
|
|
*/
|
|
async getExportFile(tenantId: string, filename: string): Promise<Buffer> {
|
|
// Traversal guard: whitelist-validate the filename before reading
|
|
if (
|
|
filename.includes('/') ||
|
|
filename.includes('\\') ||
|
|
filename.includes('..') ||
|
|
!/^(RG-DKV-|DKV_)[\w\-]+\.xlsx$/.test(filename)
|
|
) {
|
|
throw new BadRequestException('Invalid export 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.
|
|
*/
|
|
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 masters = await this.prisma.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, '');
|
|
}
|