Let an entry be taken back, along with what it did
HR can now delete a history entry, but only where deleting one is an honest thing to do — and deleting it also undoes it. The rule they asked for is the interesting part: the last valid change wins. Deleting an entry walks its fields one at a time. If a later entry touched the same field, the current value stays — that later change is the one in force. Otherwise the field goes back to what the deleted entry recorded as its "before". So the middle of three entries can be removed without an old value overwriting a newer one. Four kinds of entry refuse to be deleted, each saying why in the place the button would have been. Eintritt anchors the timeline. Transfers, promotions, absences and exits moved positions and status — they have proper operations for that, and guessing backwards is how you corrupt an org chart. Anything not yet effective hangs off a planned change, and that link is not trustworthy: there is no key between a history row and its pending row, only a person and a date, and the data already has an Eintritt and a Vertragsänderung sharing one. Matching on the date would eventually cancel a change nobody meant. And entries from before the history carried values have nothing to fall back to. Confirmation is not "are you sure" — that question gets a reflex yes by the third time. The dialog says what will be different afterwards: which field goes back to which value, and which one stays because something later claimed it. employee_history keeps its append-only policies; delete_history_entry is SECURITY DEFINER and checks the permission itself in its first line. The audit log keeps the deletion with the values that were removed, and the audit log genuinely cannot be edited. The rule lives twice — in SQL and in lib/history.ts. The database is the authority; the copy exists so the UI can hide a button that would fail and print the reason instead. Rehearsed against real data in a rolled-back transaction first: the later change held, the untouched field reverted, all four refusals fired. Also corrected in the data catalogue: I had written that require_hr_admin was called by nothing. It guards all sixteen mutating functions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
78
lib/history.ts
Normal file
78
lib/history.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { AuditChange, HistoryEventType } from "./supabase/types";
|
||||
|
||||
// Welche Historieneinträge sich zurücknehmen lassen — und warum die übrigen
|
||||
// nicht.
|
||||
//
|
||||
// Dieselbe Regel steht in der Datenbank (delete_history_entry). Das ist eine
|
||||
// Doppelung, und zwar mit Absicht: die Datenbank ist die verbindliche Stelle,
|
||||
// weil sie die einzige ist, an der niemand vorbeikommt. Hier steht sie
|
||||
// nochmal, damit die Oberfläche einen Knopf nur dort zeigt, wo er auch
|
||||
// funktioniert, und daneben schreiben kann, woran es sonst liegt. Ein Knopf,
|
||||
// der erst nach dem Klick sagt „geht nicht", ist eine Falle.
|
||||
//
|
||||
// Läuft eine Seite der anderen davon, gewinnt die Datenbank: sie weist ab,
|
||||
// und die Oberfläche zeigt ihre Begründung.
|
||||
|
||||
export type LoeschUrteil = { erlaubt: true } | { erlaubt: false; grund: string };
|
||||
|
||||
type Eintrag = {
|
||||
event_type: HistoryEventType;
|
||||
event_date: string;
|
||||
changes: AuditChange[] | null;
|
||||
};
|
||||
|
||||
export function darfGeloeschtWerden(eintrag: Eintrag, heute: string): LoeschUrteil {
|
||||
if (eintrag.event_type === "Eintritt") {
|
||||
return { erlaubt: false, grund: "Der Eintritt ist der Anfang der Zeitleiste und bleibt." };
|
||||
}
|
||||
|
||||
if (eintrag.event_type !== "Stammdatenänderung" && eintrag.event_type !== "Vertragsänderung") {
|
||||
return {
|
||||
erlaubt: false,
|
||||
grund:
|
||||
"Dieser Vorgang hat Planstellen oder den Status bewegt. Zurücknehmen lässt er sich nur über den passenden " +
|
||||
"Vorgang, nicht durch Löschen der Zeile.",
|
||||
};
|
||||
}
|
||||
|
||||
if (eintrag.event_date > heute) {
|
||||
return {
|
||||
erlaubt: false,
|
||||
grund: "Diese Änderung ist noch nicht wirksam und hängt an einem geplanten Vorgang. Sie muss dort abgebrochen werden.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!eintrag.changes || eintrag.changes.length === 0) {
|
||||
return {
|
||||
erlaubt: false,
|
||||
grund: "Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.",
|
||||
};
|
||||
}
|
||||
|
||||
return { erlaubt: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Was das Löschen bewirken würde: je Feld entweder Zurücksetzen oder nicht,
|
||||
* weil ein späterer Eintrag dasselbe Feld angefasst hat.
|
||||
*
|
||||
* Dient allein der Ankündigung im Bestätigungsdialog — entschieden wird es
|
||||
* in der Datenbank, an denselben Daten, im selben Augenblick.
|
||||
*/
|
||||
export type Vorschau = { feld: string; von: string | null; auf: string | null; bleibt: boolean };
|
||||
|
||||
export function loeschVorschau(
|
||||
eintrag: Eintrag & { id: string; created_at: string },
|
||||
alle: (Eintrag & { id: string; created_at: string })[]
|
||||
): Vorschau[] {
|
||||
return (eintrag.changes ?? []).map((c) => {
|
||||
const spaeter = alle.some(
|
||||
(h) =>
|
||||
h.id !== eintrag.id &&
|
||||
(h.changes ?? []).some((a) => a.feld === c.feld) &&
|
||||
(h.event_date > eintrag.event_date ||
|
||||
(h.event_date === eintrag.event_date && h.created_at > eintrag.created_at))
|
||||
);
|
||||
return { feld: c.feld, von: c.nachher, auf: c.vorher, bleibt: spaeter };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user