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

416 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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