The database moved to a container of our own; the platform is gone. This takes out what was left of it — and, where the leftovers were load bearing, moves rather than deletes. Moved, not deleted: supabase/migrations/ -> db/migrations/ the schema's source of truth supabase/build-org.ts -> scripts/build-org.ts lib/supabase/types.ts -> lib/types.ts 52 import sites repointed The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`, and simply renaming the schema would have left the runner facing an empty table: it would have called all 67 migrations pending and replayed them against a database that is long since current. So the runner now creates `migrationen.schema_migrations` and, once, copies the old rows across — guarded so a second run does nothing and a fresh database skips it entirely. Only then does migration 20260907100000 drop the old schema. Deleted: the CLI config, the seed, the historical schema/function dumps (nothing read them), scripts/umzug-von-supabase.sh (the move is done), and both Supabase packages plus the CLI. Nothing in the application imported them — the build now succeeds with no environment variables at all, which is the proof. Integration tests: six of them signed in through Supabase Auth and asserted against the anon key and the service role. That model is gone, so the tests were not portable — they are deleted. session-context and employee-status-filter already ran on pg and are untouched; om-reporting is ported to a direct connection because it guards a real risk (the reporting line rule exists twice, once in SQL and once in TypeScript). CI: the integration job started a Supabase stack. It now runs a postgres service, applies deploy/db-init and every migration to an empty database — that was the valuable part, and it still holds — then checks that a second run is a no-op, which is what proves the bookkeeping works. Docs: security-review.md audited a service-role key, a cookie adapter and auth.users, none of which exist. Restating findings about removed components would suggest today's system had been reviewed; it has not. It now records what was removed and says a fresh review is due. data-model.md was already marked obsolete and described the pre-OM schema; azure-migration.md was a plan for a route not taken. Both deleted. Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the packages. Integration tests skip cleanly with no database. Migration SQL and the runner are reviewed but NOT executed: no Docker here, and the old instance no longer resolves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
19 KiB
Datenkatalog
Jede Tabelle, jede Spalte, jede Regel — ausgelesen aus der laufenden Datenbank am 13.08.2026.
Die Wahrheit steht in db/migrations/ (68 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.
| Tabellen | 15 |
| Spalten | 142 |
| Aufzählungstypen | 10 |
| Sichten (Views) | 0 |
| Eigene SQL-Funktionen | 38 (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.
Zwei Ausnahmen gibt es, und sie sind eng: delete_history_entry nimmt eine
irrtümlich erfasste Stammdaten- oder Vertragsänderung samt ihrer Wirkung
zurück, update_history_entry berichtigt Wert und Datum einer solchen. Die
Policies bleiben dabei unangetastet — beide laufen als SECURITY DEFINER an
ihnen vorbei und prüfen 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, weilart in (…)beinullweder 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 abgelösten Plattform. 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.
update_history_entry berichtigt stattdessen Wert und Datum — das „vorher"
bleibt unangetastet, und der heutige Stand wird je Feld aus dem jüngsten
Eintrag abgeleitet, der es trägt. Beide sind der einzige Weg an den fehlenden
update- und delete-Policies vorbei, deshalb SECURITY DEFINER und mit
require_hr_admin() davor. app_feld_karte liefert beiden die Zuordnung
Beschriftung → Spalte und Typ.
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
Sechs 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,
delete_history_entry und update_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 letzten beiden müssen an employee_history schreiben,
wo es absichtlich weder eine update- noch eine delete-Policy gibt; beide
prüfen 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_logundemployee_historyhaben je zwei Policies — lesen und einfügen, getrennt, und kein Ändern oder Löschen. Das ist die Unveränderlichkeit, technisch durchgesetzt.profileshat 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_usersebenso: die eigene Zeile oder HR.hire_draftsundsaved_reportsverlangen zusätzlich, dass die Zeile der anfragenden Person gehört.locationstrennt 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.