Record what a change was, not only which field it touched

The audit log said "Adresse, wirksam ab 30.07.2026". That names the field
and hides the answer: what did it say before? For a personnel record that is
the question the log exists to answer.

Both values are in hand at the moment of the change — v_old holds the row as
it was, the payload holds what is being written. change_employee_data
already compared them to decide whether to mention the field at all, then
dropped them. It now keeps them in audit_log.changes as
[{feld, vorher, nachher}], and derives the old one-line text from the same
array so existing views are unaffected.

Clicking a row opens the detail. Fields with no previous value read "leer"
rather than showing an empty cell, because "was not set" is itself a
statement.

Two honest limits, both stated in the panel rather than left to look like a
bug:

  - Existing entries cannot be enriched. The values were never captured;
    there is nothing to recover.
  - Hire, exit and import record no individual fields, so they show none.

The rewritten function also drops auth.uid() for app_current_user_id(),
which works on either system — one of the last few call sites before #23.

Caught while writing this: my scripted edit of types.ts silently did nothing
and my own check reported success, because the pattern matched
pending_org_changes. Redone with the editor. That is the second time a
regex-driven edit has lied about its result in this project.

Not verified end to end: the migration needs privileges I no longer hold
after the database password was rotated. Until it is applied the audit page
will not load, since it selects a column that does not exist yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 14:54:03 +02:00
parent 3926f1bb80
commit 1271cef879
4 changed files with 356 additions and 2 deletions

View File

@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { Suspense } from "react"; import { Suspense } from "react";
import { AuditDetail } from "@/components/audit/AuditDetail";
import { AuditFilters } from "@/components/audit/AuditFilters"; import { AuditFilters } from "@/components/audit/AuditFilters";
import { CARD_CLASS } from "@/components/ui/Card"; import { CARD_CLASS } from "@/components/ui/Card";
import { Pagination } from "@/components/ui/Pagination"; import { Pagination } from "@/components/ui/Pagination";
@@ -54,7 +55,7 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
const [entries, total] = await Promise.all([ const [entries, total] = await Promise.all([
base() base()
.select(["id", "occurred_at", "actor_name", "action", "target_label", "target_employee_id", "details"]) .select(["id", "occurred_at", "actor_name", "action", "target_label", "target_employee_id", "details", "changes"])
// Nach id als zweitem Kriterium: bei gleichem Zeitstempel wäre die // Nach id als zweitem Kriterium: bei gleichem Zeitstempel wäre die
// Reihenfolge sonst unbestimmt und ein Eintrag könnte auf zwei Seiten // Reihenfolge sonst unbestimmt und ein Eintrag könnte auf zwei Seiten
// erscheinen oder auf keiner. // erscheinen oder auf keiner.
@@ -115,7 +116,9 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
entry.target_label entry.target_label
)} )}
</td> </td>
<td className="px-4 py-2.5 text-ink-muted">{entry.details ?? ""}</td> <td className="px-2 py-1.5">
<AuditDetail eintrag={entry} />
</td>
</tr> </tr>
); );
})} })}

View File

@@ -0,0 +1,140 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { SlideOver } from "@/components/ui/SlideOver";
import { actionBadgeStyle } from "@/lib/colors";
import type { AuditChange } from "@/lib/supabase/types";
// Eine Protokollzeile zum Aufklappen.
//
// Die Liste zeigt, *dass* etwas geändert wurde; hier steht, *was*. Beides in
// der Tabelle unterzubringen ginge nicht — bei sieben geänderten Feldern
// wäre die Zeile höher als der Bildschirm.
export type AuditEintrag = {
id: string;
occurred_at: string;
actor_name: string;
action: string;
target_label: string;
target_employee_id: string | null;
details: string | null;
changes: AuditChange[] | null;
};
const zeitFormat = new Intl.DateTimeFormat("de-AT", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZone: "Europe/Vienna",
});
/** Leerer Wert heisst „war nicht gesetzt“ — und das ist eine Aussage. */
function Wert({ text, art }: { text: string | null; art: "vorher" | "nachher" }) {
if (text === null || text === "") {
return <span className="text-ink-muted italic">leer</span>;
}
return <span className={art === "vorher" ? "text-ink-muted line-through decoration-ink-muted/40" : "text-ink"}>{text}</span>;
}
export function AuditDetail({ eintrag }: { eintrag: AuditEintrag }) {
const [offen, setOffen] = useState(false);
const anzahl = eintrag.changes?.length ?? 0;
return (
<>
<button
type="button"
onClick={() => setOffen(true)}
aria-haspopup="dialog"
className="w-full rounded px-2 py-1 text-left text-ink-muted hover:bg-brand-50 hover:text-ink
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
<span>{eintrag.details ?? ""}</span>
{anzahl > 0 && (
<span className="ml-2 whitespace-nowrap rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-semibold text-brand-700">
{anzahl} {anzahl === 1 ? "Feld" : "Felder"}
</span>
)}
</button>
<SlideOver
open={offen}
onClose={() => setOffen(false)}
title={eintrag.target_label}
subtitle={`${eintrag.action} · ${zeitFormat.format(new Date(eintrag.occurred_at))}`}
>
<dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 text-sm">
<dt className="font-semibold text-ink-muted">Aktion</dt>
<dd>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(eintrag.action)}`}>
{eintrag.action}
</span>
</dd>
<dt className="font-semibold text-ink-muted">Benutzer:in</dt>
<dd className="text-ink">{eintrag.actor_name}</dd>
<dt className="font-semibold text-ink-muted">Zeitpunkt</dt>
<dd className="tabular-nums text-ink">{zeitFormat.format(new Date(eintrag.occurred_at))}</dd>
{eintrag.target_employee_id && (
<>
<dt className="font-semibold text-ink-muted">Objekt</dt>
<dd>
<Link
href={`/employees/${eintrag.target_employee_id}`}
className="rounded font-semibold text-brand-700 hover:underline
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
{eintrag.target_label}
</Link>
</dd>
</>
)}
</dl>
{eintrag.details && (
<p className="mt-5 rounded-md bg-surface px-3 py-2 text-sm text-ink-body">{eintrag.details}</p>
)}
<h3 className="mt-6 text-sm font-bold text-ink">Geänderte Felder</h3>
{anzahl > 0 ? (
<div className="mt-2 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
<th className="py-2 pr-4">Feld</th>
<th className="py-2 pr-4">Vorher</th>
<th className="py-2">Nachher</th>
</tr>
</thead>
<tbody>
{eintrag.changes!.map((c, i) => (
<tr key={i} className="border-b border-border-subtle align-top last:border-0">
<td className="py-2 pr-4 font-semibold text-ink-body">{c.feld}</td>
<td className="py-2 pr-4">
<Wert text={c.vorher} art="vorher" />
</td>
<td className="py-2">
<Wert text={c.nachher} art="nachher" />
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
// Kein Aufzählungszeichen für „nichts da“: der Grund ist wichtig,
// damit niemand einen Fehler vermutet.
<p className="mt-2 max-w-prose text-sm text-ink-muted">
Für diesen Eintrag liegen keine Feldwerte vor. Vorgänge wie Eintritt, Austritt oder Import erfassen keine
Einzelfelder und Einträge von vor der Erweiterung des Protokolls haben nur die Feldnamen behalten, nicht
die Werte. Nachliefern lässt sich das nicht.
</p>
)}
</SlideOver>
</>
);
}

View File

@@ -12,6 +12,16 @@ export type GenderType = "m" | "w";
export type WorkerType = "Angestellte:r" | "Arbeiter:in"; export type WorkerType = "Angestellte:r" | "Arbeiter:in";
export type CollectiveAgreement = "Handel" | "Süßwaren"; export type CollectiveAgreement = "Handel" | "Süßwaren";
export type Weekday = "Mo" | "Di" | "Mi" | "Do" | "Fr" | "Sa" | "So"; export type Weekday = "Mo" | "Di" | "Mi" | "Do" | "Fr" | "Sa" | "So";
/**
* Eine einzelne Feldänderung im Protokoll.
*
* `vorher` und `nachher` sind bewusst Text: die Datenbank stellt jeden Typ
* über ::text dar, damit ein Datum, eine Zahl und eine Liste von Arbeitstagen
* in derselben Spalte nebeneinander stehen können. Für die Anzeige reicht
* das; gerechnet wird damit nicht.
*/
export type AuditChange = { feld: string; vorher: string | null; nachher: string | null };
export type RelationshipType = "Ehepartner:in" | "Lebenspartner:in" | "Kind" | "Sonstige"; export type RelationshipType = "Ehepartner:in" | "Lebenspartner:in" | "Kind" | "Sonstige";
export type NoteCategory = "Allgemein" | "Vertraulich" | "Personalgespräch" | "Wiedervorlage" | "Lob / Anerkennung"; export type NoteCategory = "Allgemein" | "Vertraulich" | "Personalgespräch" | "Wiedervorlage" | "Lob / Anerkennung";
// Single HR-only role (see docs/decisions/0001-hr-only-access.md). Kept as a // Single HR-only role (see docs/decisions/0001-hr-only-access.md). Kept as a
@@ -267,6 +277,13 @@ export type Database = {
target_label: string; target_label: string;
target_employee_id: string | null; target_employee_id: string | null;
details: string | null; details: string | null;
/**
* Feldweise Änderungen. Null bei Einträgen aus der Zeit vor
* 20260803140000_audit_changes_detail.sql — dort wurden nur die
* Feldnamen behalten, nicht die Werte, und das lässt sich nicht
* nachliefern.
*/
changes: AuditChange[] | null;
}; };
Insert: { Insert: {
id?: string; id?: string;
@@ -277,6 +294,7 @@ export type Database = {
target_label: string; target_label: string;
target_employee_id?: string | null; target_employee_id?: string | null;
details?: string | null; details?: string | null;
changes?: AuditChange[] | null;
}; };
Update: Partial<Database["public"]["Tables"]["audit_log"]["Insert"]>; Update: Partial<Database["public"]["Tables"]["audit_log"]["Insert"]>;
}; };

View File

@@ -0,0 +1,193 @@
-- Das Protokoll soll sagen, *was* sich geändert hat, nicht nur *welches Feld*.
--
-- Bisher stand im Audit-Log „Adresse, wirksam ab 30.07.2026". Damit lässt
-- sich nicht nachvollziehen, was vorher dort stand — und genau das ist die
-- Frage, die man einem Personalprotokoll stellt.
--
-- Die Werte sind im Moment der Änderung beide vorhanden: v_old trägt den
-- alten Datensatz, die Nutzlast den neuen. Sie wurden nur nicht behalten.
--
-- **Rückwirkend geht das nicht.** Für die bestehenden Einträge wurde
-- Vorher/Nachher nie erfasst; sie bleiben, wie sie sind.
alter table audit_log add column if not exists changes jsonb;
comment on column audit_log.changes is
'Feldweise Änderungen als [{feld, vorher, nachher}]. Null bei Einträgen von vor dieser Migration.';
-- ═══ Hilfsfunktion ═══════════════════════════════════════════════
-- Hängt eine Änderung an, wenn sich der Wert wirklich unterscheidet.
--
-- Verglichen wird über coalesce auf Leerstring: null und '' sind in diesem
-- Schema beide „nicht gesetzt", und ein Wechsel zwischen beiden ist keine
-- Änderung, die jemanden interessiert.
create or replace function app_aenderung(p_liste jsonb, p_feld text, p_vorher text, p_nachher text)
returns jsonb
language sql
immutable
set search_path = public, pg_temp
as $$
select case
when coalesce(p_vorher, '') = coalesce(p_nachher, '') then p_liste
else p_liste || jsonb_build_object('feld', p_feld, 'vorher', p_vorher, 'nachher', p_nachher)
end;
$$;
-- Die Feldnamen aus einer Änderungsliste — für die Kurzfassung in `details`,
-- damit bestehende Ansichten unverändert weiterlaufen.
create or replace function app_aenderungsfelder(p_liste jsonb)
returns text
language sql
immutable
set search_path = public, pg_temp
as $$
select string_agg(x->>'feld', ', ') from jsonb_array_elements(coalesce(p_liste, '[]'::jsonb)) x;
$$;
-- ═══ Stammdaten- und Vertragsänderung ════════════════════════════
create or replace function change_employee_data(payload jsonb)
returns void
language plpgsql
set search_path = public, pg_temp
as $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_old employees%rowtype;
v_name text;
v_person_changes jsonb := '[]'::jsonb;
v_contract_changes jsonb := '[]'::jsonb;
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
v_role jsonb := payload->'role';
v_immediate boolean;
v_new_work_days text[];
v_new_title_prefix text[];
v_new_title_suffix text[];
begin
perform require_hr_admin();
select * into v_old from employees where id = v_employee_id;
v_name := v_old.first_name || ' ' || v_old.last_name;
v_immediate := v_effective_date <= current_date;
-- Der `?`-Test bleibt: ein fehlender Schlüssel heisst „nicht übermittelt",
-- nicht „geleert". Ohne ihn würde jedes nicht gesendete Feld als Änderung
-- auf null gemeldet.
if v_person ? 'first_name' then v_person_changes := app_aenderung(v_person_changes, 'Vorname', v_old.first_name, v_person->>'first_name'); end if;
if v_person ? 'last_name' then v_person_changes := app_aenderung(v_person_changes, 'Nachname', v_old.last_name, v_person->>'last_name'); end if;
if v_person ? 'gender' then v_person_changes := app_aenderung(v_person_changes, 'Geschlecht', v_old.gender::text, v_person->>'gender'); end if;
-- Datumswerte über ::date::text vergleichen, damit „2026-8-3" und
-- „2026-08-03" nicht als Änderung gelten.
if v_person ? 'birth_date' then v_person_changes := app_aenderung(v_person_changes, 'Geburtsdatum', v_old.birth_date::text, (nullif(v_person->>'birth_date','')::date)::text); end if;
if v_person ? 'sv_nummer' then v_person_changes := app_aenderung(v_person_changes, 'SV-Nummer', v_old.sv_nummer, v_person->>'sv_nummer'); end if;
if v_person ? 'nationality' then v_person_changes := app_aenderung(v_person_changes, 'Staatsbürgerschaft', v_old.nationality, v_person->>'nationality'); end if;
if v_person ? 'address' then v_person_changes := app_aenderung(v_person_changes, 'Adresse', v_old.address, v_person->>'address'); end if;
if v_person ? 'postal_code' then v_person_changes := app_aenderung(v_person_changes, 'Postleitzahl', v_old.postal_code, v_person->>'postal_code'); end if;
if v_person ? 'city' then v_person_changes := app_aenderung(v_person_changes, 'Ort', v_old.city, v_person->>'city'); end if;
if v_person ? 'address_country' then v_person_changes := app_aenderung(v_person_changes, 'Land', v_old.address_country, v_person->>'address_country'); end if;
if v_person ? 'email' then v_person_changes := app_aenderung(v_person_changes, 'E-Mail', v_old.email, v_person->>'email'); end if;
if v_person ? 'phone' then v_person_changes := app_aenderung(v_person_changes, 'Telefon', v_old.phone, v_person->>'phone'); end if;
if v_person ? 'title_prefix' then
v_new_title_prefix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_prefix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (vorangestellt)',
array_to_string(v_old.title_prefix, ', '), array_to_string(v_new_title_prefix, ', '));
end if;
if v_person ? 'title_suffix' then
v_new_title_suffix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_suffix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (nachgestellt)',
array_to_string(v_old.title_suffix, ', '), array_to_string(v_new_title_suffix, ', '));
end if;
if v_contract ? 'employment_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_contract->>'employment_type'); end if;
-- Über ::numeric::text, damit „38.50" und „38.5" gleich zählen.
if v_contract ? 'weekly_hours' then v_contract_changes := app_aenderung(v_contract_changes, 'Wochenstunden', v_old.weekly_hours::text, (nullif(v_contract->>'weekly_hours','')::numeric)::text); end if;
if v_contract ? 'contract_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Vertragsart', v_old.contract_type::text, v_contract->>'contract_type'); end if;
if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if;
if v_role ? 'worker_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Angestellte:r/Arbeiter:in', v_old.worker_type::text, v_role->>'worker_type'); end if;
if v_role ? 'collective_agreement' then v_contract_changes := app_aenderung(v_contract_changes, 'Kollektivvertrag', v_old.collective_agreement::text, v_role->>'collective_agreement'); end if;
if v_role ? 'work_days' then
v_new_work_days := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}');
v_contract_changes := app_aenderung(v_contract_changes, 'Arbeitstage',
array_to_string(v_old.work_days, ', '), array_to_string(v_new_work_days, ', '));
end if;
if v_role ? 'is_betriebsrat' then v_contract_changes := app_aenderung(v_contract_changes, 'Betriebsrat', v_old.is_betriebsrat::text, v_role->>'is_betriebsrat'); end if;
if v_role ? 'has_dienstwagen' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen', v_old.has_dienstwagen::text, v_role->>'has_dienstwagen'); end if;
if v_role ? 'is_laterale_fuehrung' then v_contract_changes := app_aenderung(v_contract_changes, 'Laterale Führung', v_old.is_laterale_fuehrung::text, v_role->>'is_laterale_fuehrung'); end if;
if v_role ? 'is_c_level' then v_contract_changes := app_aenderung(v_contract_changes, 'C-Level', v_old.is_c_level::text, v_role->>'is_c_level'); end if;
if v_immediate then
update employees set
first_name = coalesce(v_person->>'first_name', first_name),
last_name = coalesce(v_person->>'last_name', last_name),
gender = coalesce((v_person->>'gender')::gender_type, gender),
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
postal_code = coalesce(v_person->>'postal_code', postal_code),
city = coalesce(v_person->>'city', city),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
title_prefix = case when v_person ? 'title_prefix' then v_new_title_prefix else title_prefix end,
title_suffix = case when v_person ? 'title_suffix' then v_new_title_suffix else title_suffix end,
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end,
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_role ? 'work_days' then v_new_work_days else work_days end,
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level)
where id = v_employee_id;
elsif jsonb_array_length(v_person_changes) > 0 or jsonb_array_length(v_contract_changes) > 0 then
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'contract_change', v_effective_date, payload);
end if;
if jsonb_array_length(v_person_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Stammdatenänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date);
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(), 'Stammdatenänderung', v_name, v_employee_id,
app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
end if;
if jsonb_array_length(v_contract_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Vertragsänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date);
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(), 'Vertragsänderung', v_name, v_employee_id,
app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
end if;
end;
$function$;
-- ═══ Gegenprobe ══════════════════════════════════════════════════
-- Die Hilfsfunktion muss Gleiches übergehen und Ungleiches behalten —
-- inklusive des Falls null gegen Leerstring, der sonst als Änderung im
-- Protokoll landet und niemandem etwas sagt.
do $$
declare v jsonb;
begin
v := app_aenderung('[]'::jsonb, 'Ort', 'Wien', 'Wien');
if jsonb_array_length(v) <> 0 then raise exception 'app_aenderung() meldet eine Änderung, wo keine ist.'; end if;
v := app_aenderung('[]'::jsonb, 'Ort', null, '');
if jsonb_array_length(v) <> 0 then raise exception 'app_aenderung() wertet null gegen Leerstring als Änderung.'; end if;
v := app_aenderung('[]'::jsonb, 'Ort', 'Wien', 'Graz');
if jsonb_array_length(v) <> 1 or v->0->>'vorher' <> 'Wien' or v->0->>'nachher' <> 'Graz' then
raise exception 'app_aenderung() hält Vorher/Nachher nicht fest.';
end if;
if app_aenderungsfelder(v) <> 'Ort' then raise exception 'app_aenderungsfelder() liefert die Feldnamen nicht.'; end if;
end;
$$;