diff --git a/README.md b/README.md index b2b30ca..d60346b 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut, Ersatzkontrolle. - **Ein Rollenmodell:** `profiles.role = 'hr'` + `profiles.is_active = true`, geprüft über die SQL-Funktion `is_hr_user()`. Kein Sub-Rollensystem — - siehe [`docs/data-model.md`](docs/data-model.md#zugriffsmodell). + siehe [`docs/datenkatalog.md`](docs/datenkatalog.md#zugriffsschutz). - **Es gibt keinen privilegierten Zugang mehr.** Der Dienstschlüssel, der RLS aushebelte, ist ersatzlos entfallen; auch der nächtliche Lauf benutzt dieselbe Rolle ohne `BYPASSRLS`. Was ohne angemeldete Person laufen muss, @@ -122,7 +122,8 @@ selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut, ## Supabase-Hinweise - Schema-Quelle der Wahrheit: `supabase/migrations/`. Menschlich lesbare - Zusammenfassung: [`docs/data-model.md`](docs/data-model.md). + Fassung, aus der laufenden Datenbank erzeugt: + [`docs/datenkatalog.md`](docs/datenkatalog.md). - Migrationen einspielen: `supabase db push` (gegen das verlinkte Projekt) bzw. `supabase start` + automatische Anwendung für lokale Entwicklung. - `supabase/seed.ts` und `.env.test.local` sind nur für lokale diff --git a/docs/data-model.md b/docs/data-model.md index 82e4c0d..8850934 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -1,5 +1,23 @@ # Datenmodell +> **Veraltet — siehe `docs/datenkatalog.md`.** +> +> Dieses Dokument beschreibt den Stand vor zwei Umbauten und stimmt in +> wesentlichen Teilen nicht mehr: +> +> - Die Org-Tabellen `divisions` / `departments` / `teams` und die Tabelle +> `positions` gibt es nicht mehr. An ihrer Stelle steht das SAP-OM-Modell +> (`org_units`, `jobs`, `om_positions`, `position_assignments`) mit +> zeitabhängigen Zuordnungen. +> - Supabase Auth, der anon key und die Service-Role sind weg. Angemeldet +> wird über Auth.js gegen Entra ID, die Konten stehen in `app_users`, und +> der Zugriff läuft über eine Rolle ohne `BYPASSRLS`. +> - Die Zahl der RLS-Policies ist 21, nicht 58. +> +> Der Datenkatalog wurde aus der laufenden Datenbank erzeugt und gilt. Was +> hier noch stimmt — die Grundprinzipien und der Abschnitt zu den +> effective-dated changes — steht dort ebenfalls. + Beschreibt das tatsächliche Supabase-Schema (siehe `supabase/migrations/`), nicht ein generisches HR-Schema. Quelle der Wahrheit sind immer die Migrationen; dieses Dokument ist eine lesbare Zusammenfassung und wird bei diff --git a/docs/datenkatalog.md b/docs/datenkatalog.md new file mode 100644 index 0000000..e7fa4f2 --- /dev/null +++ b/docs/datenkatalog.md @@ -0,0 +1,397 @@ +# 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 | 35 (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. + +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` + +**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` + +Vier 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. + +**Ü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. + +--- + +## 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.*