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>
417 lines
19 KiB
Markdown
417 lines
19 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. 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_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.*
|