Vertrag-Löschen: Kette bleibt intakt, Liste refresht
Zwei zusammenspielende Bugs: 1) Backend: deleteContract-Reihenfolge scheiterte am UNIQUE- Constraint auf Contract.previousContractId. Beim Middle-Delete (A → B → C, B löschen) hielt B im UPDATE-Moment noch selbst previousContractId=A – der Versuch, C ebenfalls auf A umzubiegen, warf MariaDB Duplicate-Entry, das UPDATE brach ab. Fix: Transaction, umgedrehte Reihenfolge – erst B löschen (DB-Cascade ON DELETE SET NULL räumt C.previousContractId ab, gibt A-Slot frei), dann C sauber auf A umbiegen. 2) Frontend: deleteMutation invalidierte weder Contract-Listen noch Kunden-Vertragsbaum und hatte keinen onError-Handler. Nach dem Delete wurde nach /contracts navigiert, dort zeigte der stale Cache noch den gelöschten Vertrag – Eindruck: „ganze Historie weg". Bei Bug 1 fehlgeschlagen brach das schweigend ab. Fix: queryClient.invalidateQueries für ['contracts'], ['contract-tree', customerId], ['customer', customerId] nach Erfolg. onError-Toast bei Fehler. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -707,22 +707,47 @@ export async function updateContract(
|
|||||||
|
|
||||||
export async function deleteContract(id: number) {
|
export async function deleteContract(id: number) {
|
||||||
// Vertragskette erhalten beim Löschen:
|
// 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 deleted = await tx.contract.delete({ where: { id } });
|
||||||
const contractToDelete = await prisma.contract.findUnique({
|
|
||||||
where: { id },
|
// Kette schließen, falls sowohl Vorgänger (A) als auch Folgevertrag
|
||||||
select: { previousContractId: true },
|
// (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) {
|
export async function createFollowUpContract(previousContractId: number) {
|
||||||
|
|||||||
@@ -97,6 +97,24 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ 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**
|
- [x] **📝 DSGVO-Audit: Portaldaten-Opt-out als eigenes CRITICAL-Event**
|
||||||
- Auf Wunsch des Pentesters (R117-Nachtrag): das Umschalten des
|
- Auf Wunsch des Pentesters (R117-Nachtrag): das Umschalten des
|
||||||
`portalCredentialsNotRequired`-Flags emittiert jetzt zusätzlich zum
|
`portalCredentialsNotRequired`-Flags emittiert jetzt zusätzlich zum
|
||||||
|
|||||||
@@ -1567,8 +1567,22 @@ export default function ContractDetail() {
|
|||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: () => contractApi.delete(contractId),
|
mutationFn: () => contractApi.delete(contractId),
|
||||||
onSuccess: () => {
|
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');
|
navigate('/contracts');
|
||||||
},
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Unbekannter Fehler';
|
||||||
|
toast.error(`Löschen fehlgeschlagen: ${msg}`);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const followUpMutation = useMutation({
|
const followUpMutation = useMutation({
|
||||||
|
|||||||
Reference in New Issue
Block a user