diff --git a/backend/src/routes/upload.routes.ts b/backend/src/routes/upload.routes.ts index 3262b7f4..0e4a7a3f 100644 --- a/backend/src/routes/upload.routes.ts +++ b/backend/src/routes/upload.routes.ts @@ -15,6 +15,7 @@ import { import { validateOptionalIsoDate } from '../utils/sanitize.js'; import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.js'; import { maybeCancelOnCancellationConfirmation } from '../services/contractStatusScheduler.service.js'; +import { deleteFileAbsolute, deleteUploadByRelativePath } from '../utils/fileCleanup.js'; // Pentest 56.1 (HIGH, 2026-06-01): Upload-Endpoints prüften nur die // Permission, nicht ob die Ziel-Resource zum Caller passt. Helper-Funktion @@ -30,11 +31,6 @@ async function resolveInvoiceContractId(invoiceId: number): Promise { try { if (req.user?.isCustomerPortal) { - cleanupFile(req.file?.path); + deleteFileAbsolute(req.file?.path); res.status(403).json({ success: false, error: 'Kein Zugriff' }); return; } @@ -960,16 +956,16 @@ router.post( select: { contractId: true, receiptPath: true }, }); if (!cn) { - cleanupFile(req.file.path); + deleteFileAbsolute(req.file.path); res.status(404).json({ success: false, error: 'Gutschrift nicht gefunden' }); return; } if (!(await canAccessContract(req, res, cn.contractId))) { - cleanupFile(req.file.path); + deleteFileAbsolute(req.file.path); return; } const relativePath = `/uploads/credit-note-receipts/${req.file.filename}`; - if (cn.receiptPath) cleanupFile(path.join(process.cwd(), cn.receiptPath)); + deleteUploadByRelativePath(cn.receiptPath); await prisma.creditNote.update({ where: { id }, data: { receiptPath: relativePath } }); await logChange({ req, action: 'UPDATE', resourceType: 'CreditNote', resourceId: id.toString(), @@ -978,7 +974,7 @@ router.post( res.json({ success: true, data: { receiptPath: relativePath } }); } catch (error) { console.error('Credit-note receipt upload error:', error); - cleanupFile(req.file?.path); + deleteFileAbsolute(req.file?.path); res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' }); } } @@ -1004,7 +1000,7 @@ router.delete( return; } if (!(await canAccessContract(req, res, cn.contractId))) return; - if (cn.receiptPath) cleanupFile(path.join(process.cwd(), cn.receiptPath)); + deleteUploadByRelativePath(cn.receiptPath); await prisma.creditNote.update({ where: { id }, data: { receiptPath: null } }); await logChange({ req, action: 'UPDATE', resourceType: 'CreditNote', resourceId: id.toString(), diff --git a/backend/src/services/creditNote.service.ts b/backend/src/services/creditNote.service.ts index 3157fe60..fa2ee111 100644 --- a/backend/src/services/creditNote.service.ts +++ b/backend/src/services/creditNote.service.ts @@ -2,26 +2,12 @@ // CRUD für Vertrags-Gutschriften (Subventionen: Geld/Sachwert) inkl. // USt-Berechnung (pro Gutschrift wählbar: vatRelevant + Basis Netto/Brutto). -import fs from 'fs'; -import path from 'path'; import prisma from '../lib/prisma.js'; import { ApiError } from '../utils/apiError.js'; import { assignNextNumber } from './creditNoteNumberRange.service.js'; +import { deleteUploadByRelativePath } from '../utils/fileCleanup.js'; import { CreditNoteType, CreditNoteCustomerType, CreditNoteAmountBasis } from '@prisma/client'; -// Löscht eine hochgeladene/erzeugte Datei von der Platte (best effort). -function deleteFileIfExists(filePath: string | null) { - if (!filePath) return; - const absolute = path.join(process.cwd(), filePath); - if (fs.existsSync(absolute)) { - try { - fs.unlinkSync(absolute); - } catch (error) { - console.error('Fehler beim Löschen der Gutschrift-Datei:', absolute, error); - } - } -} - const round2 = (n: number) => Math.round((n + Number.EPSILON) * 100) / 100; export interface AmountResult { @@ -245,10 +231,11 @@ export async function updateCreditNote(id: number, input: CreateCreditNoteInput) } // Nummer bleibt unverändert (einmal vergeben = fix). Ein evtl. schon // erzeugtes PDF ist nach inhaltlicher Änderung veraltet → Pfad leeren - // (der User erzeugt es bei Bedarf neu) und die alte Datei entfernen, - // damit sie nicht verwaist liegen bleibt. - if (existing.pdfPath) deleteFileIfExists(existing.pdfPath); - return prisma.creditNote.update({ where: { id }, data: { ...normalized, pdfPath: null } }); + // (der User erzeugt es bei Bedarf neu). Reihenfolge (R140): erst DB-Update, + // DANN die alte Datei löschen – schlägt das Update fehl, bleibt die Datei. + const updated = await prisma.creditNote.update({ where: { id }, data: { ...normalized, pdfPath: null } }); + deleteUploadByRelativePath(existing.pdfPath); + return updated; } export async function deleteCreditNote(id: number) { @@ -256,11 +243,13 @@ export async function deleteCreditNote(id: number) { if (!existing) { throw new ApiError(404, 'Gutschrift nicht gefunden'); } - // Verwaiste Dateien vermeiden: generiertes PDF + Überweisungsbeleg von der - // Platte entfernen (Pentest R138-Hinweis). - deleteFileIfExists(existing.pdfPath); - deleteFileIfExists(existing.receiptPath); - return prisma.creditNote.delete({ where: { id } }); + // Reihenfolge (R140): erst den DB-Datensatz löschen, DANN die Dateien – + // schlägt das DB-Delete fehl, bleiben PDF + Beleg erhalten (kein + // ins-Leere-zeigender Eintrag). + const deleted = await prisma.creditNote.delete({ where: { id } }); + deleteUploadByRelativePath(existing.pdfPath); + deleteUploadByRelativePath(existing.receiptPath); + return deleted; } // Setzt/aktualisiert den Pfad des hochgeladenen Überweisungsbelegs. diff --git a/backend/src/services/customer.service.ts b/backend/src/services/customer.service.ts index 73bf7503..64fdc3d7 100644 --- a/backend/src/services/customer.service.ts +++ b/backend/src/services/customer.service.ts @@ -2,21 +2,7 @@ import { CustomerType, ContractStatus } from '@prisma/client'; import prisma from '../lib/prisma.js'; import { generateCustomerNumber, paginate, buildPaginationResponse } from '../utils/helpers.js'; import { ApiError } from '../utils/apiError.js'; -import fs from 'fs'; -import path from 'path'; - -// Helper zum Löschen von Dateien -function deleteFileIfExists(filePath: string | null) { - if (!filePath) return; - const absolutePath = path.join(process.cwd(), filePath); - if (fs.existsSync(absolutePath)) { - try { - fs.unlinkSync(absolutePath); - } catch (error) { - console.error('Fehler beim Löschen der Datei:', absolutePath, error); - } - } -} +import { deleteUploadByRelativePath } from '../utils/fileCleanup.js'; export interface CustomerFilters { search?: string; @@ -205,17 +191,17 @@ export async function deleteCustomer(id: number) { // Kundendokumente löschen if (customer) { - deleteFileIfExists(customer.businessRegistrationPath); - deleteFileIfExists(customer.commercialRegisterPath); - deleteFileIfExists(customer.privacyPolicyPath); + deleteUploadByRelativePath(customer.businessRegistrationPath); + deleteUploadByRelativePath(customer.commercialRegisterPath); + deleteUploadByRelativePath(customer.privacyPolicyPath); } // Bankkarten- und Ausweisdokumente löschen for (const card of bankCards) { - deleteFileIfExists(card.documentPath); + deleteUploadByRelativePath(card.documentPath); } for (const doc of identityDocs) { - deleteFileIfExists(doc.documentPath); + deleteUploadByRelativePath(doc.documentPath); } // Jetzt DB-Eintrag löschen (Cascade löscht die verknüpften Einträge) @@ -353,7 +339,7 @@ export async function deleteBankCard(id: number) { // Erst Datei-Pfad holen, dann Datei löschen, dann DB-Eintrag löschen const bankCard = await prisma.bankCard.findUnique({ where: { id } }); if (bankCard?.documentPath) { - deleteFileIfExists(bankCard.documentPath); + deleteUploadByRelativePath(bankCard.documentPath); } return prisma.bankCard.delete({ where: { id } }); } @@ -417,7 +403,7 @@ export async function deleteDocument(id: number) { // Erst Datei-Pfad holen, dann Datei löschen, dann DB-Eintrag löschen const document = await prisma.identityDocument.findUnique({ where: { id } }); if (document?.documentPath) { - deleteFileIfExists(document.documentPath); + deleteUploadByRelativePath(document.documentPath); } return prisma.identityDocument.delete({ where: { id } }); } diff --git a/backend/src/utils/fileCleanup.ts b/backend/src/utils/fileCleanup.ts new file mode 100644 index 00000000..81b0c420 --- /dev/null +++ b/backend/src/utils/fileCleanup.ts @@ -0,0 +1,29 @@ +// ==================== DATEI-AUFRÄUMEN (Uploads) ==================== +// Gemeinsame Helfer zum Best-Effort-Löschen von Upload-Dateien. Ersetzt die +// vorher mehrfach kopierten `deleteFileIfExists`/`cleanupFile`-Funktionen +// (Pentest R140, Hygiene: eine Quelle statt Duplikate). + +import fs from 'fs'; +import path from 'path'; + +/** + * Löscht eine Datei anhand ihres ABSOLUTEN Pfads. Wirft nie (loggt nur) – + * z.B. für Multer-Temp-Dateien (`req.file.path`). + */ +export function deleteFileAbsolute(absolutePath: string | null | undefined): void { + if (!absolutePath) return; + try { + if (fs.existsSync(absolutePath)) fs.unlinkSync(absolutePath); + } catch (error) { + console.error('Fehler beim Löschen der Datei:', absolutePath, error); + } +} + +/** + * Löscht eine Datei anhand ihres in der DB gespeicherten RELATIVEN Upload- + * Pfads (z.B. `/uploads/credit-notes/…`), aufgelöst gegen `process.cwd()`. + */ +export function deleteUploadByRelativePath(relativePath: string | null | undefined): void { + if (!relativePath) return; + deleteFileAbsolute(path.join(process.cwd(), relativePath)); +} diff --git a/docs/todo.md b/docs/todo.md index 4a4f2c1c..85a0c2d3 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -143,6 +143,12 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung - **Datei-Cleanup (R138-Hinweis):** Beim Löschen einer Gutschrift werden PDF + Beleg von der Platte entfernt; beim Bearbeiten (pdfPath wird geleert) wird das alte PDF gelöscht → keine verwaisten Dateien mehr. + - **Hygiene R140:** Datei-Lösch-Helfer in `utils/fileCleanup.ts` + konsolidiert (`deleteFileAbsolute` + `deleteUploadByRelativePath`), + ersetzt die vorher 3× kopierten `deleteFileIfExists`/`cleanupFile` + (creditNote-, upload-, customer-Service). Reihenfolge umgestellt: + erst DB-Delete/-Update, DANN Datei löschen (schlägt DB fehl, bleibt + die Datei). Verifiziert. - [~] **🧾 Gutschriftsverwaltung (Subventionen am Vertrag) – Phase 1: Backend** (2026-08-06) - Use-Case: zu einem Vertrag kann eine Subvention gewährt werden – **Geld**