Files
alpenwerk-hr/docs/datenkatalog.md
Maximilian Stubhan 6297288c13 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>
2026-08-13 20:56:48 +02:00

19 KiB
Raw Blame History

Datenkatalog

Jede Tabelle, jede Spalte, jede Regel — ausgelesen aus der laufenden Datenbank am 13.08.2026.

Die Wahrheit steht in supabase/migrations/ (48 Dateien). Dieses Dokument ist eine lesbare Fassung davon und wurde nicht abgetippt, sondern aus dem Systemkatalog erzeugt: Spaltentypen, Vorgabewerte, Schlüssel und Prüfbedingungen stammen aus information_schema und pg_catalog. Wo unten eine Regel in Worten steht, steht daneben, aus welcher CHECK-Bedingung sie kommt.

Nicht verwechseln mit docs/data-model.md. Das Dokument beschreibt den Stand vor der Umstellung auf das SAP-OM-Modell und auf Auth.js — es nennt Tabellen (divisions, departments, teams, positions), die es nicht mehr gibt. Bei Widerspruch gilt dieser Katalog.

Tabellen 15
Spalten 142
Aufzählungstypen 10
Sichten (Views) 0
Eigene SQL-Funktionen 36 (plus 31 aus der Erweiterung pg_trgm)
RLS-Policies 21, auf jeder Tabelle mindestens eine

Wie das Modell gedacht ist

Drei Entscheidungen erklären fast jede Eigenheit weiter unten.

Person und Planstelle sind zwei Dinge. Eine Person (employees) hat keine Spalte „Abteilung". Sie sitzt auf einer Planstelle (om_positions), und die Planstelle hängt in einer Organisationseinheit (org_units). Wo jemand arbeitet, ist damit nicht ein Feld, sondern ein Weg über zwei Tabellen. Der Preis ist ein Join; dafür lässt sich eine Planstelle ausschreiben, bevor jemand darauf sitzt, und eine Person wechseln, ohne dass die Stelle verschwindet.

Zuordnungen haben ein Von und ein Bis. position_assignments, om_positions und org_units tragen alle valid_from / valid_to als halboffenes Intervall: valid_from gehört dazu, valid_to nicht mehr. Eine Zuordnung, die heute endet, hat valid_to = heute und gilt heute schon nicht mehr. Deshalb kann jede Auswertung einen Stichtag haben, auch einen in der Vergangenheit, und deshalb ist eine Versetzung zum Ersten des nächsten Monats kein Termin im Kalender, sondern eine Zeile, die erst dann greift.

Geschichte wird geschrieben, nicht überschrieben. employee_history und audit_log haben keine Update- und keine Delete-Policy — RLS lässt nur 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 is_chief entspricht A012 („ist Leiter von"). Wer das Modell aus SAP kennt, findet sich wieder; wer nicht, verliert nichts.

org_units ──parent_id──┐          (rekursiv: Gesellschaft › Bereich › Abteilung › Team)
    │                  │
    └──────────────────┘
    ▲
    │ org_unit_id
om_positions ──job_id──► jobs
    ▲
    │ position_id
position_assignments ──employee_id──► employees ──location_id──► locations
                                          ▲
                                          ├── employee_history      (Ereignisse)
                                          ├── employee_dependents   (Angehörige)
                                          ├── employee_notes        (HR-Notizen)
                                          ├── pending_org_changes   (wirkt später)
                                          └── audit_log             (wer/wann/was)

app_users ──1:1──► profiles        (Anmeldung ist nicht Berechtigung)

Personen

employees — 46 Spalten

Die Person selbst: Stammdaten, Vertrag, Status. Nicht die Organisation — die kommt über die Planstelle.

Spalte Typ Null Vorgabe Bedeutung
id uuid – gen_random_uuid() Schlüssel
personnel_number int4 – – Eindeutig. Wird eingegeben, nicht vergeben — sie muss mit Loga/Interflex übereinstimmen
first_name, last_name text – –
gender gender_type – – m / w
birth_date date – –
sv_nummer text ja – Österreichische SV-Nummer; gegen Prüfziffer und Geburtsdatum geprüft (Trigger fn_validate_employee_svnr)
nationality text – 'Österreich'
address, postal_code, city, address_country text ja – Wohnanschrift
email text ja – Private E-Mail. Freiwillig, aber eindeutig, wenn angegeben
phone text ja – Private Telefonnummer. Freiwillig
job_title text – – Anzeigetitel. Der verbindliche Titel steht am Job der Planstelle
location_id uuid – – → locations
employment_type employment_type – Vollzeit
weekly_hours numeric – 38.5 An employment_type gekoppelt, siehe Regeln
contract_type contract_type – unbefristet
contract_end_date date ja – Pflicht bei befristet
worker_type worker_type – Angestellte:r
collective_agreement collective_agreement – Handel
paygrade paygrade_type – B A–F
source source_type – Extern Intern besetzt oder extern geholt
work_days text[] – {Mo,Di,Mi,Do,Fr} In Klickreihenfolge gespeichert, nicht sortiert
status employment_status – Aktiv Gilt für heute; für einen Stichtag wird er zurückgerechnet
entry_date date – –
exit_date, exit_reason date/text ja –
karenz_start_date, karenz_return_date date ja – Laufende Langzeitabwesenheit
absence_type text ja – Art der Abwesenheit; wird bei der Rückkehr geleert. 13 erlaubte Werte
is_betriebsrat, is_laterale_fuehrung, is_c_level bool – false
has_dienstwagen bool – false
dienstwagen_art text ja – Verbrenner / Elektro; nur zusammen mit has_dienstwagen
emergency_contact_name, _phone, _relation text ja – Daten einer dritten Person, nur für den Notfall erhoben
title_prefix, title_suffix text[] – {} Akademische Grade, gegen feste Listen geprüft
avatar_color text ja – Darstellung
monthly_salary_gross numeric ja – Stillgelegt. Gehalt ist ausserhalb des Funktionsumfangs; keine Funktion liest oder schreibt die Spalte mehr. Steht nur noch da, falls Altdaten drin sind
created_at, updated_at timestamptz – now() updated_at per Trigger

Regeln, die die Datenbank durchsetzt:

  • Vollzeit heisst 38,5 Stunden; Teilzeit heisst mehr als 0 und weniger als 38,5. Ein Vollzeitvertrag mit 30 Stunden lässt sich nicht speichern (chk_weekly_hours).
  • Befristet ohne Enddatum gibt es nicht (chk_befristet_end).
  • Austritt nicht vor Eintritt, Rückkehr nicht vor Eintritt (chk_exit_after_entry, chk_karenz_return_after_entry).
  • Dienstwagen und Antriebsart gehören zusammen — beides oder keins (chk_dienstwagen_art). Die Bedingung nennt den Fall ohne Wagen ausdrücklich, weil art in (…) bei null weder wahr noch falsch ergibt und die Regel sonst genau das durchgelassen hätte, was sie verhindern soll.
  • Notfallkontakt: Name und Telefon gemeinsam oder gar nicht, und keiner der beiden leer (chk_emergency_contact).
  • Arbeitstage nur aus Mo–So und mindestens einer (chk_work_days_valid).
  • Titel nur aus den bekannten Listen — 10 vorangestellte, 12 nachgestellte (chk_title_prefix_valid, chk_title_suffix_valid).
  • Abwesenheitsart nur aus den 13 bekannten (chk_absence_type).

employee_history — die Zeitleiste

Eine Zeile je Ereignis: employee_id, event_date, event_type (Aufzählung, 11 Werte), description. Ein Trigger verhindert Ereignisse vor dem Eintrittsdatum (fn_check_history_not_before_entry). Nur einfügen und lesen — kein Ändern, kein Löschen.

employee_dependents — Angehörige

first_name, last_name, relationship (Ehepartner:in / Lebenspartner:in / Kind / Sonstige), birth_date, optional sv_nummer. Ändern heisst löschen und neu anlegen; ein In-place-Update gibt es nicht. Beim Löschen der Person verschwinden sie mit (on delete cascade).

employee_notes — HR-Notizen

category (Allgemein / Vertraulich / Personalgespräch / Wiedervorlage / Lob / Anerkennung), note_text, optional due_date für die Wiedervorlage, dazu done / done_at / done_by.

Bewusst nicht auf die verfassende Person eingeschränkt: jede aktive HR-Person sieht jede offene Notiz. „Meine Notizen" ist ein gemeinsames Postfach, kein privates.


Organisation

org_units — Einheiten (SAP-Objekttyp O)

org_number (eindeutig), name, parent_id (rekursiv), unit_type (Gesellschaft / Bereich / Abteilung / Team), valid_from / valid_to.

unit_type ist ein Etikett für die Anzeige, keine Struktur — die Struktur ist parent_id. Eine Abteilung unter einer Abteilung wäre erlaubt. Was die Datenbank verhindert, ist nur, dass eine Einheit ihr eigenes Elternteil wird (chk_org_unit_not_own_parent); tiefere Zyklen fängt sie nicht ab.

jobs — Tätigkeiten (Objekttyp C)

code und title, beide eindeutig. Ein schlanker Katalog: die Planstelle verweist darauf, statt den Titel abzuschreiben.

om_positions — Planstellen (Objekttyp S)

position_number (eindeutig), org_unit_id, job_id, is_chief, valid_from / valid_to.

Die Nummern haben die Form 6 + sieben Ziffern, weil next_position_number() sie so erzeugt — erzwungen wird das Format aber nicht: an der Spalte hängt nur Eindeutigkeit. Wer von aussen eine Nummer einträgt, kann eine andere Form wählen, und die Zählfunktion übergeht sie dann (sie sucht ihr Maximum nur unter ^6[0-9]{7}$).

is_chief markiert die Leitungsstelle einer Einheit — daraus entsteht die Führungslinie, nicht aus einem Feld „Vorgesetzte:r" an der Person. Eine Planstelle lässt sich nur besetzen, solange sie gültig ist.

position_assignments — Besetzungen (Verknüpfung A008)

position_id, employee_id, valid_from / valid_to. Diese Tabelle beantwortet „wer sass wann wo" — die einzige Stelle, an der das steht.

locations — Standorte

name (eindeutig) und country, beschränkt auf Österreich, Deutschland, Tschechien und Slowenien.


Ablauf und Nachweis

pending_org_changes — was später wirkt

change_type (transfer / promotion / karenz_start / karenz_return / contract_change / reorg / dependent_add / dependent_remove), effective_date, payload (JSONB), status (pending / applied / cancelled).

Der Weg ist pending → applied. Die Auswahl filtert immer auf pending, ein zweiter Lauf wirkt also nicht doppelt. Verarbeitet wird täglich von apply_due_pending_changes().

audit_log — wer, wann, was, an wem

occurred_at, actor_user_id + actor_name, action, target_label, target_employee_id, details, und changes als JSONB in der Form [{feld, vorher, nachher}] — daher die aufklappbare Detailansicht in der Oberfläche. Bei Einträgen von vor der entsprechenden Migration ist changes null.

Geschrieben wird ausschliesslich aus den SQL-Funktionen heraus, in derselben Transaktion wie die Änderung selbst. Das ist der Punkt: ein fehlgeschlagener Log-Eintrag lässt die ganze Änderung scheitern, statt still eine Änderung ohne Nachweis zu hinterlassen. Einen Helfer im Anwendungscode gibt es nicht und sollte es nicht geben — das wäre eine zweite, nicht atomare Quelle.

Dass actor_name als Text mitgeschrieben wird und nicht nur die Kennung: der Nachweis soll lesbar bleiben, auch wenn das Benutzerkonto später verschwindet.


Zugang

app_users

Ersetzt auth.users aus der Supabase-Zeit. external_id ist die oid aus Entra ID — nicht die E-Mail-Adresse, die kann sich ändern. Angelegt wird die Zeile bei der ersten Anmeldung durch app_upsert_user().

profiles

Eine Zeile je Konto, gleicher Schlüssel wie app_users. role ist per Prüfbedingung auf den einen Wert 'hr' festgenagelt, is_active steht anfangs auf false.

Anmelden können heisst nichts. Wer sich mit dem Firmenkonto anmeldet, bekommt eine app_users-Zeile und kommt trotzdem an keine einzige Personalzeile, solange profiles.is_active nicht gesetzt ist. Die Freigabe ist ein bewusster zweiter Schritt.

hire_drafts, saved_reports

Zwischenstand des Einstellungsassistenten (payload JSONB, step) und gespeicherte Berichtskonfigurationen. Beide sind auf die anlegende Person eingeschränkt.


Aufzählungstypen

Typ Werte
employment_status Aktiv, Karenz, Geplant, Ausgetreten
employment_type Vollzeit, Teilzeit
contract_type unbefristet, befristet
worker_type Angestellte:r, Arbeiter:in
collective_agreement Handel, Süßwaren
paygrade_type A, B, C, D, E, F
source_type Intern, Extern
gender_type m, w
org_unit_type Gesellschaft, Bereich, Abteilung, Team
history_event_type Eintritt, Beförderung, Versetzung, Karenz, Vertragsänderung, Stammdatenänderung, Austritt, Wiedereintritt, Reorganisation, Gehaltsanpassung, Rückkehr

Karenz heisst in der Oberfläche „Langzeitabwesenheit" — der gespeicherte Wert wurde beim Umbenennen bewusst nicht angefasst, die Beschriftung folgt dem neuen Begriff.

Nicht als Aufzählungstyp, sondern als Prüfbedingung auf einer Textspalte gelöst: Abwesenheitsart, Antriebsart, Verhältnis von Angehörigen, Notizkategorie, Änderungsart, Land. Der praktische Unterschied: eine Prüfbedingung lässt sich in einer Migration ändern, ein Aufzählungstyp nur erweitern.


Die SQL-Schnittstelle

Änderungen laufen nicht über insert/update aus der Anwendung, sondern über Funktionen. Jede schreibt ihren Nachweis und ihre Historie in derselben Transaktion mit.

Personal: hire_employee, rehire_employee, terminate_employee, transfer_employee, promote_employee, change_employee_data, start_karenz, adjust_karenz_return, record_karenz_return

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

Auswertung: om_reporting_lines(p_as_of) — löst zum Stichtag auf, wer an wen berichtet, samt Vertretung bei Abwesenheit (acting_manager_id neben formal_manager_id)

Zugang und Nachweis: is_hr_user, app_current_user_id, app_upsert_user, current_actor_name, app_aenderung, app_aenderungsfelder

Automatik: apply_due_pending_changes (täglich), rls_auto_enable (hängt am Ereignis-Trigger ensure_rls: neue Tabellen bekommen sofort RLS), die vier fn_*-Trigger, is_valid_svnr

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 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.

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.


Zugriffsschutz

Auf jeder der 15 Tabellen ist Row Level Security aktiv, zusammen 21 Policies. Fast alle prüfen dasselbe: is_hr_user() — also profiles.role = 'hr' und is_active. Nachgereicht wird das nicht: der Ereignis-Trigger ensure_rls schaltet RLS bei jeder neu angelegten Tabelle sofort ein.

Die Prüfung hängt an einer Sitzungsvariablen (app.user_id), die withUser() als erste Anweisung jeder Transaktion setzt — transaktionslokal, damit sie nicht an der Verbindung kleben bleibt und die nächste Anfrage aus dem Pool mit fremder Kennung läuft.

Wie wirksam das ist, zeigt sich beim Erzeugen dieses Katalogs: die Verbindung lief ohne Sitzungskontext, und jede Tabelle lieferte null Zeilen — bei vollständig vorhandenen Strukturdaten. Nicht „alles", nicht ein Fehler, sondern nichts. Die Anwendung verbindet sich ausserdem als alpenwerk_app — eine Rolle ohne BYPASSRLS und ohne Superuser-Recht; es gibt also keinen Weg daran vorbei, auch nicht versehentlich.

Wo das Muster abweicht:

  • audit_log und employee_history haben je zwei Policies — lesen und einfügen, getrennt, und kein Ändern oder Löschen. Das ist die Unveränderlichkeit, technisch durchgesetzt.
  • profiles hat vier, weil dort auch die Freischaltung anderer Konten passiert — und eine davon lässt jede Person die eigene Zeile lesen, auch ohne Freischaltung. Sonst könnte niemand erfahren, warum er nicht hineinkommt.
  • app_users ebenso: die eigene Zeile oder HR.
  • hire_drafts und saved_reports verlangen zusätzlich, dass die Zeile der anfragenden Person gehört.
  • locations trennt Lesen und Schreiben in zwei Policies, prüft aber beide Male dasselbe.

Was hier nicht steht

Gehalt. monthly_salary_gross ist stillgelegt und wird von keiner Funktion mehr angefasst. Gehaltsdaten leben in Loga.

Zeitwirtschaft. Kommen und Gehen, Urlaubskonten, Krankenstände als Einzelfälle — das ist Interflex. Hier steht nur die dauerhafte Langzeitabwesenheit, weil sie die Führungslinie verschiebt.

Bewerbungen. Eine offene Planstelle ist hier eine Planstelle ohne Besetzung, mehr nicht.


Erzeugt aus dem Systemkatalog der laufenden Datenbank. Nach strukturellen Änderungen gehört dieses Dokument nachgezogen — am ehrlichsten, indem es neu aus der Datenbank erzeugt wird, statt es von Hand zu pflegen.