diff --git a/actions/employees.ts b/actions/employees.ts index b2f795e..8dca97d 100644 --- a/actions/employees.ts +++ b/actions/employees.ts @@ -176,6 +176,21 @@ export async function deleteEmployeeDependent(payload: { return callRpc("delete_employee_dependent", payload, [`/employees/${payload.employee_id}`]); } +/** + * Nimmt eine irrtümlich erfasste Stammdaten- oder Vertragsänderung zurück. + * + * Was zurückgesetzt wird und was stehen bleibt, entscheidet die Datenbank — + * sie prüft dabei erneut, ob der Eintrag überhaupt gelöscht werden darf. Die + * Oberfläche zeigt den Knopf nur dort, wo es geht (siehe lib/history.ts); + * kommt trotzdem eine Ablehnung zurück, wird deren Begründung angezeigt. + */ +export async function deleteHistoryEntry(payload: { + history_id: string; + employee_id: string; +}): Promise { + return callRpc("delete_history_entry", { history_id: payload.history_id }, [`/employees/${payload.employee_id}`, "/audit"]); +} + export async function addEmployeeNote(payload: { employee_id: string; category: NoteCategory; diff --git a/components/employees/EmployeeDetail.tsx b/components/employees/EmployeeDetail.tsx index 956d94b..b574c04 100644 --- a/components/employees/EmployeeDetail.tsx +++ b/components/employees/EmployeeDetail.tsx @@ -155,7 +155,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) { breadcrumb={breadcrumb} /> )} - {tab === "Historie" && } + {tab === "Historie" && } {tab === "HR-Notizen" && } diff --git a/components/employees/HistorieLoeschen.tsx b/components/employees/HistorieLoeschen.tsx new file mode 100644 index 0000000..dbb65cc --- /dev/null +++ b/components/employees/HistorieLoeschen.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { Trash2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { deleteHistoryEntry } from "@/actions/employees"; +import { Button } from "@/components/ui/Button"; +import { Modal } from "@/components/ui/Modal"; +import { useToast } from "@/components/ui/Toast"; +import { fmtDate } from "@/lib/format"; +import type { Vorschau } from "@/lib/history"; + +// Löschen mit Ansage. +// +// Bestätigen heisst hier nicht „Wirklich?" — das beantwortet jede Person nach +// dem dritten Mal blind mit Ja. Der Dialog sagt stattdessen, **was danach +// anders ist**: welches Feld auf welchen Wert zurückgeht, und welches nicht, +// weil eine spätere Änderung es erneut angefasst hat. Wer das liest, merkt +// selbst, ob er den richtigen Eintrag erwischt hat. + +export function HistorieLoeschen({ + historyId, + employeeId, + bezeichnung, + datum, + vorschau, +}: { + historyId: string; + employeeId: string; + bezeichnung: string; + datum: string; + vorschau: Vorschau[]; +}) { + const [offen, setOffen] = useState(false); + const [laeuft, setLaeuft] = useState(false); + const { showToast } = useToast(); + const router = useRouter(); + + const zurueck = vorschau.filter((v) => !v.bleibt); + const bleibt = vorschau.filter((v) => v.bleibt); + + async function loeschen() { + setLaeuft(true); + const ergebnis = await deleteHistoryEntry({ history_id: historyId, employee_id: employeeId }); + setLaeuft(false); + if (ergebnis.success) { + showToast("Eintrag gelöscht."); + setOffen(false); + router.refresh(); + } else { + showToast(ergebnis.error ?? "Löschen fehlgeschlagen.", "error"); + } + } + + return ( + <> + + + setOffen(false)} + title="Eintrag löschen" + footer={ + <> + + + + } + > +

+ {bezeichnung} vom {fmtDate(datum)} wird aus der Historie entfernt. +

+ + {zurueck.length > 0 && ( +
+

Wird zurückgesetzt

+
    + {zurueck.map((v) => ( +
  • + {v.feld}{" "} + {v.von || "leer"}{" "} + {v.auf || "leer"} +
  • + ))} +
+
+ )} + + {bleibt.length > 0 && ( +
+

Bleibt unverändert

+
    + {bleibt.map((v) => ( +
  • + {v.feld} — eine spätere Änderung hat dieses Feld erneut + gesetzt, und die gilt weiter. +
  • + ))} +
+
+ )} + +

+ Der Vorgang wird im Protokoll festgehalten — mit Zeitpunkt, Person und den Werten der gelöschten Zeile. Die + Zeile selbst lässt sich nicht wiederherstellen. +

+
+ + ); +} diff --git a/components/employees/tabs/HistorieTab.tsx b/components/employees/tabs/HistorieTab.tsx index cb3b241..695bea6 100644 --- a/components/employees/tabs/HistorieTab.tsx +++ b/components/employees/tabs/HistorieTab.tsx @@ -1,11 +1,14 @@ +import { HistorieLoeschen } from "@/components/employees/HistorieLoeschen"; import { AenderungsTabelle } from "@/components/ui/AenderungsTabelle"; import { actionBadgeStyle } from "@/lib/colors"; import { fmtDate, todayIso } from "@/lib/format"; +import { darfGeloeschtWerden, loeschVorschau } from "@/lib/history"; import type { Database } from "@/lib/supabase/types"; type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"]; -// Die Geschichte einer Person — und zwar aufklappbar bis auf die Werte. +// Die Geschichte einer Person — aufklappbar bis auf die Werte, und dort, wo +// ein Eintrag irrtümlich entstanden ist, auch zurücknehmbar. // // Vorher stand hier nur „Geänderte Felder: Adresse, Ort". Damit liess sich // zwar sehen, *dass* jemand die Anschrift angefasst hat, aber nicht, was @@ -16,8 +19,13 @@ type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"]; // Aufgeklappt wird mit
, nicht mit einem Zustand im Browser: die // Werte stehen dann schon in der Seite, sind durchsuchbar (Strg+F) und im // Ausdruck sichtbar, und es braucht kein Skript dafür. +// +// Der Löschknopf erscheint nur an Einträgen, die sich zurücknehmen lassen. +// An allen anderen steht stattdessen der Grund — leise, aber lesbar. Ein +// Knopf, der erst nach dem Klick sagt „geht nicht", wäre eine Falle; ein +// fehlender Knopf ohne Erklärung wäre ein Rätsel. -export function HistorieTab({ history }: { history: HistoryRow[] }) { +export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; employeeId: string }) { const today = todayIso(); if (history.length === 0) { @@ -29,6 +37,7 @@ export function HistorieTab({ history }: { history: HistoryRow[] }) { {history.map((h) => { const isFuture = h.event_date > today; const changes = h.changes ?? []; + const urteil = darfGeloeschtWerden(h, today); return (
  • @@ -39,6 +48,17 @@ export function HistorieTab({ history }: { history: HistoryRow[] }) { ⏱ zukünftig – wirksam ab {fmtDate(h.event_date)} )} + {urteil.erlaubt && ( + + + + )}

    {h.description}

    @@ -56,6 +76,7 @@ export function HistorieTab({ history }: { history: HistoryRow[] }) {
    + {!urteil.erlaubt &&

    {urteil.grund}

    }
  • )} diff --git a/docs/datenkatalog.md b/docs/datenkatalog.md index e7fa4f2..18ce808 100644 --- a/docs/datenkatalog.md +++ b/docs/datenkatalog.md @@ -21,7 +21,7 @@ kommt. | Spalten | 142 | | Aufzählungstypen | 10 | | Sichten (Views) | 0 | -| Eigene SQL-Funktionen | 35 (plus 31 aus der Erweiterung `pg_trgm`) | +| Eigene SQL-Funktionen | 36 (plus 31 aus der Erweiterung `pg_trgm`) | | RLS-Policies | 21, auf jeder Tabelle mindestens eine | --- @@ -51,6 +51,12 @@ kein Termin im Kalender, sondern eine Zeile, die erst dann greift. `select` und `insert` zu. Eine Korrektur ist ein neuer Eintrag, nie eine geänderte Zeile. +Eine Ausnahme gibt es, und sie ist eng: `delete_history_entry` nimmt eine +irrtümlich erfasste Stammdaten- oder Vertragsänderung samt ihrer Wirkung +zurück. Die Policies bleiben dabei unangetastet — die Funktion läuft als +`SECURITY DEFINER` an ihnen vorbei und prüft die Berechtigung selbst. Das +Protokoll behält den Vorgang, dort verschwindet nichts. + Die Namen der OM-Tabellen sind nicht zufällig gewählt: `org_units` ist der SAP-Objekttyp O, `jobs` ist C, `om_positions` ist S, `employees` ist P, und `position_assignments` ist die Verknüpfung A008 („Inhaber ist"). Das Flag @@ -311,6 +317,12 @@ Transaktion mit. **Planstellen:** `create_position`, `update_position`, `delete_position`, `next_position_number` +**Historie:** `delete_history_entry` — nimmt eine irrtümliche Stammdaten- +oder Vertragsänderung zurück: setzt je Feld auf den Wert davor, sofern kein +späterer Eintrag dasselbe Feld angefasst hat, und entfernt die Zeile. Der +einzige Weg an der fehlenden `delete`-Policy vorbei, deshalb `SECURITY +DEFINER` und mit `require_hr_admin()` davor. + **Umfeld:** `add_employee_dependent`, `delete_employee_dependent`, `add_employee_note`, `complete_employee_note` @@ -326,19 +338,25 @@ wen berichtet, samt Vertretung bei Abwesenheit (`acting_manager_id` neben (hängt am Ereignis-Trigger `ensure_rls`: neue Tabellen bekommen sofort RLS), die vier `fn_*`-Trigger, `is_valid_svnr` -Vier Funktionen laufen als `SECURITY DEFINER`, also mit den Rechten ihrer +Fünf Funktionen laufen als `SECURITY DEFINER`, also mit den Rechten ihrer Eigentümerin statt der aufrufenden Person: `is_hr_user`, -`app_current_user_id`, `app_upsert_user`, `apply_due_pending_changes`. Die -ersten drei müssen es sein, weil sie sonst gegen dieselben Policies liefen, -die sie gerade auswerten sollen — eine Rekursion. Die vierte läuft ohne -angemeldete Person, es gibt ja nur den Zeitplan. +`app_current_user_id`, `app_upsert_user`, `apply_due_pending_changes` und +`delete_history_entry`. Die ersten drei müssen es sein, weil sie sonst gegen +dieselben Policies liefen, die sie gerade auswerten sollen — eine Rekursion. +Die vierte läuft ohne angemeldete Person, es gibt ja nur den Zeitplan. Die +fünfte muss löschen können, wo es absichtlich keine `delete`-Policy gibt — +und prüft die Berechtigung deshalb selbst, in ihrer ersten Zeile. -**Übrig geblieben:** `generate_company_email` und `is_hr_admin` / -`require_hr_admin` stehen noch in der Datenbank, werden aber von nichts mehr -gerufen. Die E-Mail-Erzeugung stammt aus der Zeit, als eine Firmenadresse -automatisch vergeben wurde; heute ist `employees.email` die private Adresse -und freiwillig. Die Admin-Prüfungen stammen aus einem Rollenmodell, das es -nicht mehr gibt. +**Der Türsteher:** `require_hr_admin()` steht am Anfang von **16** +Funktionen — jeder ändernden. Es wirft, wenn `is_hr_user()` falsch ist, und +liefert damit eine lesbare Meldung statt einer nackten RLS-Verletzung. Der +Name täuscht: ein Admin-Rollenmodell gibt es nicht, `is_hr_admin()` ruft +schlicht `is_hr_user()` auf. Die Schranke selbst bleiben die Policies. + +**Übrig geblieben:** `generate_company_email` steht noch in der Datenbank, +wird aber von nichts mehr gerufen — sie stammt aus der Zeit, als eine +Firmenadresse automatisch vergeben wurde; heute ist `employees.email` die +private Adresse und freiwillig. --- diff --git a/lib/history.ts b/lib/history.ts new file mode 100644 index 0000000..bd769a3 --- /dev/null +++ b/lib/history.ts @@ -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 }; + }); +} diff --git a/lib/supabase/types.ts b/lib/supabase/types.ts index e7e42a8..6bffee9 100644 --- a/lib/supabase/types.ts +++ b/lib/supabase/types.ts @@ -502,6 +502,9 @@ export type Database = { delete_employee_dependent: { Args: { payload: Record }; Returns: void }; add_employee_note: { Args: { payload: Record }; Returns: string }; complete_employee_note: { Args: { payload: Record }; Returns: void }; + // Nimmt eine irrtümliche Stammdaten-/Vertragsänderung zurück. Der einzige + // Weg an der fehlenden delete-Policy auf employee_history vorbei. + delete_history_entry: { Args: { payload: Record }; Returns: void }; // Planstelle anlegen bzw. schliessen — im OM-Modell Operationen auf // om_positions, nicht mehr auf einer eigenen Ausschreibungstabelle. create_position: { Args: { payload: Record }; Returns: string }; diff --git a/supabase/migrations/20260813140000_delete_history_entry.sql b/supabase/migrations/20260813140000_delete_history_entry.sql new file mode 100644 index 0000000..019f14c --- /dev/null +++ b/supabase/migrations/20260813140000_delete_history_entry.sql @@ -0,0 +1,209 @@ +-- Einen Historieneintrag zurücknehmen — samt seiner Wirkung. +-- +-- Die Historie ist bewusst fortschreibend: employee_history hat nur Policies +-- für select und insert, es gibt kein update und kein delete. Das bleibt so. +-- Was hier entsteht, ist ein einzelner kontrollierter Weg daran vorbei, und +-- er ist eng: nur eine irrtümlich erfasste Stammdaten- oder Vertragsänderung, +-- nur mit Feldwerten, nur wenn sie bereits wirksam ist. +-- +-- ═══ Warum überhaupt löschen ═══ +-- +-- Eine Adresse, die versehentlich geändert wurde, steht sonst für immer als +-- Änderung in der Akte — und die Person wohnt an der falschen Anschrift, bis +-- jemand sie von Hand zurücksetzt. Dieses Zurücksetzen erzeugt dann eine +-- *zweite* Änderung, und in der Historie stehen zwei Einträge, von denen +-- keiner je stattgefunden hat. Genau das soll der Vorgang hier ersparen. +-- +-- ═══ „Die letztgültige Änderung ist die schlagende" ═══ +-- +-- Beim Zurücknehmen wird je Feld einzeln entschieden: +-- +-- * Hat ein **späterer** Eintrag dasselbe Feld angefasst, bleibt der +-- heutige Wert stehen — die spätere Änderung ist die gültige. +-- * Sonst wird der Wert auf das „vorher" des gelöschten Eintrags gesetzt. +-- +-- Deshalb lässt sich auch der mittlere von drei Einträgen entfernen, ohne +-- dass ein alter Wert einen neueren überschreibt. +-- +-- ═══ Was nicht geht, und warum ═══ +-- +-- * **Eintritt** — der Anker der Zeitleiste. Ohne ihn hat die Person keinen +-- Anfang, und ein Trigger verbietet ohnehin Ereignisse davor. +-- * **Zukünftiges** — dazu gehört eine Zeile in pending_org_changes, und +-- die lässt sich einem Historieneintrag nicht zuverlässig zuordnen: es +-- gibt keinen Schlüssel zwischen beiden, nur Person und Datum. In den +-- Daten hängt bereits ein „Eintritt" und eine Vertragsänderung am selben +-- Tag. Eine Zuordnung über das Datum träfe irgendwann die falsche Zeile, +-- und dann verschwände eine geplante Änderung, die niemand gemeint hat. +-- * **Versetzung, Beförderung, Karenz, Rückkehr, Austritt, Wiedereintritt, +-- Reorganisation** — die haben Planstellen und Zuordnungen bewegt. +-- Dafür gibt es die fachlichen Vorgänge, die das sauber fortschreiben, +-- statt rückwärts zu raten. +-- * **Einträge ohne Feldwerte** — alles vor der Erweiterung der Historie. +-- Es gibt nichts, worauf zurückgesetzt werden könnte. +-- +-- ═══ Der Nachweis bleibt ═══ +-- +-- Gelöscht wird die Historienzeile, nicht die Spur: das Audit-Log bekommt +-- einen Eintrag mit den Werten der gelöschten Zeile. Das Protokoll ist selbst +-- fortschreibend, dort kann nichts verschwinden. + +create or replace function delete_history_entry(payload jsonb) +returns void +language plpgsql +security definer +set search_path to 'public', 'pg_temp' +as $function$ +declare + v_id uuid := (payload->>'history_id')::uuid; + v_eintrag employee_history%rowtype; + v_name text; + v_aenderung jsonb; + v_feld text; + v_wert text; + v_spalte text; + v_typ text; + v_spaeter boolean; + v_zurueckgesetzt jsonb := '[]'::jsonb; + -- Feldbeschriftung → Spalte und Typ. Geschlossene Liste: was + -- change_employee_data schreiben kann, steht hier, sonst nichts. Der + -- Spaltenname geht in dynamisches SQL, deshalb darf er nur von hier kommen. + v_karte constant jsonb := jsonb_build_object( + 'Vorname', jsonb_build_array('first_name', 'text'), + 'Nachname', jsonb_build_array('last_name', 'text'), + 'Geschlecht', jsonb_build_array('gender', 'gender_type'), + 'Geburtsdatum', jsonb_build_array('birth_date', 'date'), + 'SV-Nummer', jsonb_build_array('sv_nummer', 'text'), + 'Staatsbürgerschaft', jsonb_build_array('nationality', 'text'), + 'Adresse', jsonb_build_array('address', 'text'), + 'Postleitzahl', jsonb_build_array('postal_code', 'text'), + 'Ort', jsonb_build_array('city', 'text'), + 'Land', jsonb_build_array('address_country', 'text'), + 'E-Mail', jsonb_build_array('email', 'text'), + 'Telefon', jsonb_build_array('phone', 'text'), + 'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text'), + 'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text'), + 'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text'), + 'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste'), + 'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste'), + 'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type'), + 'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric'), + 'Vertragsart', jsonb_build_array('contract_type', 'contract_type'), + 'Befristet bis', jsonb_build_array('contract_end_date', 'date'), + 'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type'), + 'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement'), + 'Arbeitstage', jsonb_build_array('work_days', 'liste'), + 'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean'), + 'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean'), + 'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean'), + 'C-Level', jsonb_build_array('is_c_level', 'boolean'), + 'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text') + ); +begin + perform require_hr_admin(); + + select * into v_eintrag from employee_history where id = v_id; + if not found then + raise exception 'Historieneintrag nicht gefunden.'; + end if; + + if v_eintrag.event_type = 'Eintritt' then + raise exception 'Der Eintritt lässt sich nicht löschen — er ist der Anfang der Zeitleiste.'; + end if; + + if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung') then + raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier zurücknehmen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type; + end if; + + if v_eintrag.event_date > current_date then + raise exception 'Diese Änderung ist noch nicht wirksam und hängt an einem geplanten Vorgang. Sie muss dort abgebrochen werden.'; + end if; + + if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then + raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.'; + end if; + + select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id; + + -- Je Feld: nur zurücksetzen, wenn kein späterer Eintrag dasselbe Feld + -- angefasst hat. Sonst gilt der spätere Wert weiter. + for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop + v_feld := v_aenderung->>'feld'; + + if not v_karte ? v_feld then + continue; -- unbekannte Beschriftung: nichts anfassen + end if; + + select exists ( + select 1 + from employee_history h, + lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a + where h.employee_id = v_eintrag.employee_id + and h.id <> v_eintrag.id + and a->>'feld' = v_feld + and (h.event_date, h.created_at) > (v_eintrag.event_date, v_eintrag.created_at) + ) into v_spaeter; + + if v_spaeter then + continue; + end if; + + v_spalte := v_karte->v_feld->>0; + v_typ := v_karte->v_feld->>1; + v_wert := v_aenderung->>'vorher'; + + if v_typ = 'liste' then + execute format('update employees set %I = coalesce(string_to_array(%L, '', ''), ''{}'') where id = %L', + v_spalte, nullif(v_wert, ''), v_eintrag.employee_id); + else + execute format('update employees set %I = %L::%s where id = %L', + v_spalte, nullif(v_wert, ''), v_typ, v_eintrag.employee_id); + end if; + + v_zurueckgesetzt := v_zurueckgesetzt || jsonb_build_object( + 'feld', v_feld, + 'vorher', v_aenderung->>'nachher', + 'nachher', v_wert + ); + end loop; + + delete from employee_history where id = v_id; + + insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes) + values (app_current_user_id(), current_actor_name(), 'Historieneintrag gelöscht', v_name, v_eintrag.employee_id, + v_eintrag.event_type || ' vom ' || v_eintrag.event_date || + case when jsonb_array_length(v_zurueckgesetzt) = 0 + then ' gelöscht; keine Werte zurückgesetzt (spätere Änderungen gelten)' + else ' gelöscht und zurückgesetzt: ' || app_aenderungsfelder(v_zurueckgesetzt) end, + v_zurueckgesetzt); +end; +$function$; + +comment on function delete_history_entry(jsonb) is + 'Nimmt eine irrtümliche Stammdaten- oder Vertragsänderung zurück: setzt je Feld auf den Wert davor, sofern kein späterer Eintrag dasselbe Feld geändert hat, und entfernt die Historienzeile. Der Vorgang selbst wird im Audit-Log festgehalten. SECURITY DEFINER, weil employee_history absichtlich keine delete-Policy hat.'; + +-- Selbstprüfung: lieber laut scheitern als still nichts tun. +do $$ +declare + v_def text; +begin + select pg_get_functiondef('public.delete_history_entry(jsonb)'::regprocedure) into v_def; + + if not (select prosecdef from pg_proc where oid = 'public.delete_history_entry(jsonb)'::regprocedure) then + raise exception 'delete_history_entry muss SECURITY DEFINER sein, sonst greift die fehlende delete-Policy'; + end if; + + if v_def not like '%require_hr_admin%' then + raise exception 'delete_history_entry prüft die Berechtigung nicht'; + end if; + + -- Die delete-Policy darf es weiterhin nicht geben: der Weg hier ist der + -- einzige, und er ist geprüft. + if exists ( + select 1 from pg_policy p join pg_class c on c.oid = p.polrelid + where c.relname = 'employee_history' and p.polcmd = 'd' + ) then + raise exception 'employee_history hat eine delete-Policy bekommen — das war nicht beabsichtigt'; + end if; +end +$$; diff --git a/tests/components/HistorieTab.test.tsx b/tests/components/HistorieTab.test.tsx index ba6ca38..83b3f97 100644 --- a/tests/components/HistorieTab.test.tsx +++ b/tests/components/HistorieTab.test.tsx @@ -1,8 +1,29 @@ -import { render, screen, within } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { render as rtlRender, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; import { HistorieTab } from "@/components/employees/tabs/HistorieTab"; +import { ToastProvider } from "@/components/ui/Toast"; import type { Database } from "@/lib/supabase/types"; +// Der Löschknopf hängt an einer Server-Action, und die zieht über lib/db das +// Paket `server-only` nach — im Test ein Fehler beim Import. Ersetzt wird +// deshalb das Aktionsmodul, nicht die Komponente: geprüft wird hier, was die +// Historie *anzeigt*, nicht was die Datenbank tut. Das steht in +// tests/unit/history.test.ts und in der Migration selbst. +vi.mock("@/actions/employees", () => ({ + deleteHistoryEntry: vi.fn(async () => ({ success: true })), +})); + +// Ausserhalb von Next gibt es keinen Router; die Komponente ruft nach dem +// Löschen refresh() auf. +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }), +})); + +function render(ui: React.ReactElement) { + return rtlRender({ui}); +} + type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"]; // Der Anlass: eine Adressänderung war in der Historie der Person nur als @@ -33,13 +54,13 @@ const ADRESSWECHSEL = eintrag({ describe("HistorieTab", () => { it("zeigt die alte Anschrift, nicht nur die neue", () => { - render(); + render(); expect(screen.getByText("Feldweg 66")).toBeInTheDocument(); expect(screen.getByText("Bahnhofstrasse 5")).toBeInTheDocument(); }); it("stellt Vorher und Nachher je Feld gegenüber", () => { - render(); + render(); const zeile = screen.getByText("Adresse").closest("tr")!; const zellen = within(zeile).getAllByRole("cell"); expect(zellen[1]).toHaveTextContent("Feldweg 66"); @@ -47,17 +68,17 @@ describe("HistorieTab", () => { }); it("nennt die Zahl der Felder am Aufklapper", () => { - render(); + render(); expect(screen.getByText(/2 Felder im Detail/)).toBeInTheDocument(); }); it("benennt ein einzelnes Feld im Singular", () => { - render(); + render(); expect(screen.getByText(/1 Feld im Detail/)).toBeInTheDocument(); }); it("macht aus einem leeren Vorher-Wert eine Aussage statt einer Lücke", () => { - render(); + render(); const zeile = screen.getByText("Telefon").closest("tr")!; expect(within(zeile).getAllByRole("cell")[1]).toHaveTextContent("leer"); }); @@ -65,14 +86,90 @@ describe("HistorieTab", () => { it("hängt keinen Aufklapper an Ereignisse ohne Feldwerte", () => { // Eintritt, Austritt und die Zeilen von vor dieser Erweiterung haben // keine Werte. Ein leerer Aufklapper würde einen Fehler vermuten lassen. - render(); + render(); expect(screen.getByText("Eintritt als Elektrotechniker:in")).toBeInTheDocument(); expect(screen.queryByText(/im Detail/)).not.toBeInTheDocument(); expect(document.querySelector("details")).toBeNull(); }); + it("bietet das Löschen an einer irrtümlichen Änderung an", () => { + render(); + expect(screen.getByRole("button", { name: /Stammdatenänderung vom .* löschen/ })).toBeInTheDocument(); + }); + + it("bietet es am Eintritt nicht an und sagt, warum", () => { + render( + + ); + expect(screen.queryByRole("button", { name: /löschen/ })).not.toBeInTheDocument(); + expect(screen.getByText(/Anfang der Zeitleiste/)).toBeInTheDocument(); + }); + + it("bietet es an einer Versetzung nicht an", () => { + render( + + ); + expect(screen.queryByRole("button", { name: /löschen/ })).not.toBeInTheDocument(); + expect(screen.getByText(/Planstellen oder den Status/)).toBeInTheDocument(); + }); + + it("bietet es an einer noch nicht wirksamen Änderung nicht an", () => { + render(); + expect(screen.queryByRole("button", { name: /löschen/ })).not.toBeInTheDocument(); + }); + + it("sagt vor dem Löschen, was zurückgesetzt wird und was bleibt", async () => { + const user = userEvent.setup(); + const alt = eintrag({ + id: "alt", + event_date: "2026-08-01", + created_at: "2026-08-01T09:00:00.000Z", + changes: [ + { feld: "Adresse", vorher: "Feldweg 66", nachher: "Zwischenweg 1" }, + { feld: "Telefon", vorher: "0664 000", nachher: "0664 111" }, + ], + }); + const neu = eintrag({ + id: "neu", + event_date: "2026-08-10", + created_at: "2026-08-10T09:00:00.000Z", + changes: [{ feld: "Adresse", vorher: "Zwischenweg 1", nachher: "Endstrasse 9" }], + }); + render(); + + await user.click(screen.getByRole("button", { name: /Stammdatenänderung vom 01\.08\.2026 löschen/ })); + + const dialog = screen.getByRole("dialog"); + // Das Telefon geht zurück, die Adresse nicht — sie wurde später erneut + // geändert, und diese Änderung ist die schlagende. + const zurueck = within(dialog).getByRole("heading", { name: "Wird zurückgesetzt" }).parentElement!; + expect(within(zurueck).getByText("Telefon")).toBeInTheDocument(); + expect(within(zurueck).queryByText("Adresse")).not.toBeInTheDocument(); + + const bleibt = within(dialog).getByRole("heading", { name: "Bleibt unverändert" }).parentElement!; + expect(within(bleibt).getByText("Adresse")).toBeInTheDocument(); + }); + + it("löscht erst nach ausdrücklicher Bestätigung", async () => { + const user = userEvent.setup(); + const { deleteHistoryEntry } = await import("@/actions/employees"); + render(); + + await user.click(screen.getByRole("button", { name: /löschen$/ })); + expect(deleteHistoryEntry).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Löschen und zurücksetzen" })); + expect(deleteHistoryEntry).toHaveBeenCalledWith({ history_id: "h1", employee_id: "e1" }); + }); + it("bleibt bei leerer Historie bei einem Satz", () => { - render(); + render(); expect(screen.getByText("Keine Historieneinträge vorhanden.")).toBeInTheDocument(); }); }); diff --git a/tests/unit/history.test.ts b/tests/unit/history.test.ts new file mode 100644 index 0000000..2f47f72 --- /dev/null +++ b/tests/unit/history.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { darfGeloeschtWerden, loeschVorschau } from "@/lib/history"; +import type { AuditChange, HistoryEventType } from "@/lib/supabase/types"; + +const HEUTE = "2026-08-13"; + +function eintrag(teil: Partial<{ id: string; event_type: HistoryEventType; event_date: string; created_at: string; changes: AuditChange[] | null }> = {}) { + return { + id: "h1", + event_type: "Stammdatenänderung" as HistoryEventType, + event_date: "2026-08-10", + created_at: "2026-08-10T09:00:00.000Z", + changes: [{ feld: "Adresse", vorher: "Feldweg 66", nachher: "Bahnhofstrasse 5" }] as AuditChange[] | null, + ...teil, + }; +} + +describe("darfGeloeschtWerden", () => { + it("lässt eine wirksame Stammdatenänderung mit Werten zu", () => { + expect(darfGeloeschtWerden(eintrag(), HEUTE)).toEqual({ erlaubt: true }); + }); + + it("lässt eine Vertragsänderung ebenso zu", () => { + expect(darfGeloeschtWerden(eintrag({ event_type: "Vertragsänderung" }), HEUTE).erlaubt).toBe(true); + }); + + it("schützt den Eintritt", () => { + const u = darfGeloeschtWerden(eintrag({ event_type: "Eintritt", changes: null }), HEUTE); + expect(u.erlaubt).toBe(false); + expect(u.erlaubt === false && u.grund).toMatch(/Anfang der Zeitleiste/); + }); + + it.each(["Versetzung", "Beförderung", "Karenz", "Rückkehr", "Austritt", "Wiedereintritt", "Reorganisation"])( + "verweist bei %s auf den fachlichen Vorgang", + (typ) => { + const u = darfGeloeschtWerden(eintrag({ event_type: typ }), HEUTE); + expect(u.erlaubt).toBe(false); + expect(u.erlaubt === false && u.grund).toMatch(/passenden Vorgang|Planstellen oder den Status/); + } + ); + + it("lässt Zukünftiges in Ruhe — daran hängt ein geplanter Vorgang", () => { + const u = darfGeloeschtWerden(eintrag({ event_date: "2026-09-01" }), HEUTE); + expect(u.erlaubt).toBe(false); + expect(u.erlaubt === false && u.grund).toMatch(/noch nicht wirksam/); + }); + + it("zählt den heutigen Tag als wirksam", () => { + expect(darfGeloeschtWerden(eintrag({ event_date: HEUTE }), HEUTE).erlaubt).toBe(true); + }); + + it("weist Einträge ohne Feldwerte ab", () => { + for (const ohne of [null, []]) { + const u = darfGeloeschtWerden(eintrag({ changes: ohne }), HEUTE); + expect(u.erlaubt).toBe(false); + expect(u.erlaubt === false && u.grund).toMatch(/keine Feldwerte/); + } + }); +}); + +describe("loeschVorschau", () => { + const alt = eintrag({ + id: "alt", + event_date: "2026-08-01", + created_at: "2026-08-01T09:00:00.000Z", + changes: [ + { feld: "Adresse", vorher: "Feldweg 66", nachher: "Zwischenweg 1" }, + { feld: "Telefon", vorher: "0664 000", nachher: "0664 111" }, + ], + }); + const neu = eintrag({ + id: "neu", + event_date: "2026-08-10", + created_at: "2026-08-10T09:00:00.000Z", + changes: [{ feld: "Adresse", vorher: "Zwischenweg 1", nachher: "Endstrasse 9" }], + }); + + it("lässt ein Feld stehen, das später erneut geändert wurde", () => { + // Das ist die Regel, um die es geht: die letztgültige Änderung schlägt. + const v = loeschVorschau(alt, [alt, neu]); + expect(v.find((x) => x.feld === "Adresse")!.bleibt).toBe(true); + }); + + it("setzt ein Feld zurück, das seither niemand angefasst hat", () => { + const v = loeschVorschau(alt, [alt, neu]); + const tel = v.find((x) => x.feld === "Telefon")!; + expect(tel.bleibt).toBe(false); + expect(tel.auf).toBe("0664 000"); + }); + + it("setzt beim neuesten Eintrag alles zurück", () => { + const v = loeschVorschau(neu, [alt, neu]); + expect(v.every((x) => !x.bleibt)).toBe(true); + expect(v[0].auf).toBe("Zwischenweg 1"); + }); + + it("unterscheidet zwei Einträge am selben Tag über die Erfassungszeit", () => { + const frueh = eintrag({ id: "a", event_date: HEUTE, created_at: `${HEUTE}T08:00:00.000Z` }); + const spaet = eintrag({ id: "b", event_date: HEUTE, created_at: `${HEUTE}T16:00:00.000Z` }); + expect(loeschVorschau(frueh, [frueh, spaet])[0].bleibt).toBe(true); + expect(loeschVorschau(spaet, [frueh, spaet])[0].bleibt).toBe(false); + }); + + it("nennt die Richtung so, wie sie im Dialog steht", () => { + // von = was jetzt drinsteht, auf = worauf zurückgesetzt wird. + const [v] = loeschVorschau(neu, [neu]); + expect(v).toMatchObject({ feld: "Adresse", von: "Endstrasse 9", auf: "Zwischenweg 1" }); + }); +});