The picker in the bell now governs both lists, so note_subscriptions is renamed to colleague_subscriptions -- a name that only mentions notes would mislead the next reader. Reading and writing a draft now reach differently far. hire_drafts_owner (for all) is split into four policies: select lets in your own drafts and those of the people you added, while insert/update/delete stay with the owner. A draft is unfinished work with no lock and no history; two people writing into the same row would overwrite each other silently. That split forces a change in the actions: a policy does not reject a write, it lets it hit no rows. saveHireDraft and deleteHireDraft now read the row count instead of reporting success over a row that never changed. The card shows a foreign draft with its author and without Fortsetzen or Loeschen -- offering a button that reliably ends in a database error is a promise without cover. check-schema-types.mjs learns `alter table ... rename to`; without it the drift check reports one rename as two errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
443 lines
20 KiB
Markdown
443 lines
20 KiB
Markdown
# 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, 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 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.
|
||
|
||
`saved_reports` gehört ganz der anlegenden Person. Bei `hire_drafts` gehen
|
||
Lesen und Schreiben seit September 2026 verschieden weit: **lesen** darf, wem
|
||
der Entwurf gehört, und wer die anlegende Person in seiner Glocke hinzugewählt
|
||
hat (`colleague_subscriptions`); **anlegen, ändern und löschen** darf nur die
|
||
anlegende Person selbst. Ein Entwurf ist unfertige Arbeit ohne Sperre und ohne
|
||
Historie — zwei Personen, die abwechselnd hineinschreiben, überschreiben
|
||
einander lautlos.
|
||
|
||
Die Anwendung liest deshalb bei `update` und `delete` die Zahl der
|
||
betroffenen Zeilen: eine Policy weist ein Schreiben nicht mit einem Fehler ab,
|
||
sie lässt es ins Leere laufen (`actions/hireDrafts.ts`).
|
||
|
||
### `colleague_subscriptions`
|
||
|
||
Wen jemand hinzugewählt hat, je eine Zeile aus `user_id` und
|
||
`author_user_id`. Die Vorgabe ist die eigene Person: **ohne** Zeile sieht man
|
||
nur die eigenen Notizen und Entwürfe, mit Zeile zusätzlich die der genannten
|
||
Person. Die Auswahl sitzt in der Glocke in der Kopfzeile und steuert beides.
|
||
|
||
Ein Abo auf sich selbst lässt `chk_kollegen_abos_nicht_selbst` nicht zu — die
|
||
eigenen Zeilen sind ohnehin immer dabei. Die Tabelle hiess bis September 2026
|
||
`note_subscriptions`, als die Auswahl nur für die Notizen galt.
|
||
|
||
---
|
||
|
||
## 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_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.
|
||
- `saved_reports` und `colleague_subscriptions` verlangen zusätzlich, dass
|
||
die Zeile der anfragenden Person gehört.
|
||
- `hire_drafts` hat vier — `select`, `insert`, `update`, `delete` getrennt,
|
||
weil das Lesen seit September 2026 weiter reicht als das Schreiben (siehe
|
||
oben). Die alte Sammelregel `for all` musste dafür weichen: Policies
|
||
addieren sich, eine stehengebliebene hätte die vier neuen ausgehebelt.
|
||
- `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.*
|