diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts index 57a2f55b..c4689dbb 100644 --- a/backend/src/services/contract.service.ts +++ b/backend/src/services/contract.service.ts @@ -707,22 +707,47 @@ export async function updateContract( export async function deleteContract(id: number) { // Vertragskette erhalten beim Löschen: - // Wenn A → B → C und B gelöscht wird, soll C direkt auf A zeigen (A → C) + // Wenn A → B → C und B gelöscht wird, soll C direkt auf A zeigen (A → C). + // + // Hintergrund: `Contract.previousContractId` hat ein UNIQUE-Constraint + // (jeder Vertrag darf nur EINEN Vorgänger sein, umgekehrt gilt auch: + // ein Vorgänger kann nur EINEN Nachfolger haben). Naive Reihenfolge + // „C erst auf A umbiegen, dann B löschen" scheitert an genau diesem + // Constraint, weil B in dem Moment noch selbst auf A zeigt → + // Duplicate-Entry-Fehler (Prisma P2002) und das UPDATE bricht ab. + // Vorher landete der User dadurch bei einem 400-Fehler, das Cache- + // Invalidate war ebenfalls nicht sauber (siehe Frontend-Fix), so dass + // es wie „Vertrag verschwindet aber ist noch da" aussah. + // + // Fix: erst B löschen (DB-Cascade `ON DELETE SET NULL` räumt + // C.previousContractId auf null ab und gibt damit den A-Slot wieder + // frei), dann C sauber auf A umbiegen. Alles in einer Transaktion, + // damit kein Zwischenzustand sichtbar wird und ein Fehler die + // gesamte Operation zurückrollt. + return prisma.$transaction(async (tx) => { + const contractToDelete = await tx.contract.findUnique({ + where: { id }, + select: { previousContractId: true }, + }); + const followUp = await tx.contract.findFirst({ + where: { previousContractId: id }, + select: { id: true }, + }); - // 1. Zu löschenden Vertrag holen um dessen Vorgänger zu kennen - const contractToDelete = await prisma.contract.findUnique({ - where: { id }, - select: { previousContractId: true }, + const deleted = await tx.contract.delete({ where: { id } }); + + // Kette schließen, falls sowohl Vorgänger (A) als auch Folgevertrag + // (C) existieren. Ohne einen der beiden ist nichts zu tun – der + // verbleibende Endpunkt wird zum neuen Kettenanfang bzw. -ende. + if (followUp && contractToDelete?.previousContractId) { + await tx.contract.update({ + where: { id: followUp.id }, + data: { previousContractId: contractToDelete.previousContractId }, + }); + } + + return deleted; }); - - // 2. Folgevertrag(e) mit dem Vorgänger des gelöschten Vertrags verbinden - // So bleibt die Kette erhalten: A → B → C wird zu A → C - await prisma.contract.updateMany({ - where: { previousContractId: id }, - data: { previousContractId: contractToDelete?.previousContractId ?? null }, - }); - - return prisma.contract.delete({ where: { id } }); } export async function createFollowUpContract(previousContractId: number) { diff --git a/docs/todo.md b/docs/todo.md index 2415774d..466ba858 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,24 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🐞 Vertrag-Löschen: Kette unterbrach, Liste aktualisierte nicht** + - Zwei zusammenspielende Bugs: (1) im Service scheiterte das Umbiegen + des Folgevertrags an der `@unique`-Regel auf `Contract.previousContractId` + – wenn A → B → C stand und B gelöscht wurde, hielt B im UPDATE-Moment + noch selbst `previousContractId=A`, C sollte auch auf A → Duplicate- + Entry, das Update brach ab. (2) Der Frontend-`deleteMutation` in + `ContractDetail` invalidierte die Contract-Listen-Query nicht, + navigierte nur nach `/contracts` und zeigte den alten Cache. + Zusammen sah es aus, als wäre die ganze Historie weg – tatsächlich + stand der Vorgänger noch im Backend. + - Fix Service: in einer Transaktion erst B löschen (DB-Cascade räumt + C.previousContractId auf NULL und gibt den A-Slot frei), dann C + sauber auf A umbiegen. Kein Zwischenzustand mehr sichtbar. + - Fix Frontend: `queryClient.invalidateQueries(['contracts'])` + plus `['contract-tree', customerId]` und `['customer', customerId]` + nach dem Delete. `onError`-Toast ergänzt, damit fehlgeschlagene + Löschungen nicht mehr still verschwinden. + - [x] **📝 DSGVO-Audit: Portaldaten-Opt-out als eigenes CRITICAL-Event** - Auf Wunsch des Pentesters (R117-Nachtrag): das Umschalten des `portalCredentialsNotRequired`-Flags emittiert jetzt zusätzlich zum diff --git a/frontend/src/pages/contracts/ContractDetail.tsx b/frontend/src/pages/contracts/ContractDetail.tsx index d295da0a..c7efcd3b 100644 --- a/frontend/src/pages/contracts/ContractDetail.tsx +++ b/frontend/src/pages/contracts/ContractDetail.tsx @@ -1567,8 +1567,22 @@ export default function ContractDetail() { const deleteMutation = useMutation({ mutationFn: () => contractApi.delete(contractId), onSuccess: () => { + // Nach dem Löschen die zentralen Vertragslisten + den Kunden- + // Vertragsbaum invalidieren – sonst zeigt die Liste danach den + // gerade gelöschten Vertrag noch an, während der ans /contracts + // navigierte User den Eindruck bekommt, der Vorgänger sei mitgelöscht + // worden (siehe Bug „Vertrag löscht ganze Historie"). + queryClient.invalidateQueries({ queryKey: ['contracts'] }); + if (contractCustomerId) { + queryClient.invalidateQueries({ queryKey: ['contract-tree', contractCustomerId] }); + queryClient.invalidateQueries({ queryKey: ['customer', contractCustomerId] }); + } navigate('/contracts'); }, + onError: (err) => { + const msg = err instanceof Error ? err.message : 'Unbekannter Fehler'; + toast.error(`Löschen fehlgeschlagen: ${msg}`); + }, }); const followUpMutation = useMutation({