Files
alpenwerk-hr/docs/data-model.md
Maximilian Stubhan 64eb155dda Write down what is actually in the database
The one document describing the schema, docs/data-model.md, predates two
rebuilds. It names divisions/departments/teams and a positions table that
no longer exist, describes Supabase auth with an anon key and a service
role that were removed, and puts the policy count at 58 when it is 21.
Anyone reading it to understand the data would have been misled on every
count.

docs/datenkatalog.md replaces it, and was not typed up from memory: the
columns, defaults, keys and check constraints were read out of
information_schema and pg_catalog on the running database. Fifteen
tables, 142 columns, ten enum types, 21 policies. Where a rule appears in
prose, the constraint it comes from is named next to it.

Some of it only became visible by asking the database rather than the
migrations. generate_company_email and the is_hr_admin pair are still
defined but nothing calls them any more. Position numbers look like a
six followed by seven digits because the generator builds them that way,
not because anything enforces it — the column requires only uniqueness.
monthly_salary_gross is dead weight kept in case old rows hold data.

Three claims I drafted were wrong and the database said so: the position
number format, the event trigger's name (ensure_rls, the function behind
it is rls_auto_enable), and which tables deviate from the plain
is_hr_user() policy.

The old document keeps a pointer at the top instead of being deleted —
it is linked from the security review, and a stale document that says so
is more useful than a dead link.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 07:46:49 +02:00

115 lines
7.5 KiB
Markdown

# 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
strukturellen Änderungen mitgepflegt.
## Grundprinzipien
- **Person ist nicht Position.** `employees` (Personen) und `positions`
(Planstellen/Ausschreibungen) sind getrennte Tabellen. Eine Position wird
bei Einstellung mit einer Person verknüpft (`filled_by_employee_id`),
existiert aber unabhängig davon (offene Ausschreibung).
- **History ist append-only.** `employee_history` (Ereignisse pro Person)
und `audit_log` (systemweit, wer hat was wann geändert) haben keine
Update-/Delete-Policy — RLS erlaubt nur `select`/`insert`. Korrekturen
erfolgen durch einen neuen, kompensierenden Eintrag, nie durch Ändern der
Historie (siehe `undo_reorg`, das eine "Reorganisation rückgängig"-Zeile
anhängt statt die ursprünglichen Zeilen zu löschen).
- **Audit-Log ist Pflicht bei Änderungen — und lebt in der Datenbank, nicht
im App-Code.** Jede mutierende SQL-Funktion (`hire_employee`,
`change_employee_data`, `add_employee_dependent`, `add_employee_note`, …)
schreibt ihren `audit_log`-Eintrag in derselben Transaktion wie die
eigentliche Änderung. Das ist bewusst atomar: ein fehlgeschlagener
Audit-Insert lässt die ganze Transaktion fehlschlagen, statt still eine
Änderung ohne Log zu hinterlassen. Es gibt keinen App-seitigen
`writeAuditLog()`-Helper und es sollte auch keinen geben — das würde eine
zweite, nicht-atomare Logging-Quelle neben der bestehenden schaffen.
- **Service-Role-Zugriff ist server-only.** `lib/supabase/admin.ts` ist die
einzige Stelle, die den Service-Role-Key verwendet; `import "server-only"`
macht einen versehentlichen Client-Import zu einem Build-Fehler. Alles
andere läuft über den anon key + RLS.
- **RLS ist bereits aktiv**, nicht nur für die Produktion vorgemerkt: jede
Tabelle hat `enable row level security` plus mindestens eine Policy
(siehe unten). Zusätzlich existieren explizite `grant`-Statements für
`anon`/`authenticated`/`service_role`
(`20260714120500_default_grants.sql`) — ohne die schlägt jede Query auch
mit korrekter RLS-Policy mit "permission denied" fehl, weil Postgres
Objekt-Rechte unabhängig von RLS prüft.
## Zugriffsmodell
Ein einziges Rollenmodell, kein Mehrfach-Rollen-System:
- `profiles.role` ist per Check-Constraint auf den einzigen Wert `'hr'`
fixiert (Migration `20260714120000_hr_only_access.sql`).
- `profiles.is_active` (default `false`) muss zusätzlich wahr sein.
- Die SQL-Funktion `is_hr_user()` (SECURITY DEFINER, vermeidet RLS-Rekursion
auf `profiles`) prüft beides und gated praktisch jede Policy im Schema.
- `proxy.ts` (Next.js Proxy, ehem. Middleware) spiegelt dieselbe Prüfung auf
App-Ebene: nicht eingeloggt → `/login`; eingeloggt aber nicht aktive
HR-Person → `/login` mit Fehlermeldung. Das ist bewusst nur "defense in
depth" — die eigentliche Schranke ist RLS, nicht die UI-Prüfung.
- Es gibt **keine** granulareren Rollen (kein `hr_admin`/`read_only`/
`it_admin`-Split o. Ä.) und aktuell keinen zweiten Anwendungsfall dafür.
Sollte das nötig werden, ist der richtige Ansatz eine neue Migration, die
`is_hr_user()` um echte Rollenspalten erweitert — nicht ein
App-seitiges Rollenmodell, das der DB-Policy-Ebene nicht entspricht.
## Kernentitäten
| Tabelle | Zweck |
|---|---|
| `divisions` / `departments` / `teams` | Org-Hierarchie ("Bereich" 20xx / "Abteilung" 21xx / "Team" 22xx), je mit eindeutiger `org_number`. |
| `locations` | Standorte, an ein Land gebunden (steuert die Standort-Picklist im UI). |
| `profiles` | Ein Datensatz pro Supabase-Auth-User; Rolle + Aktivierungsstatus (siehe oben). |
| `employees` | Zentrale Personentabelle: Stammdaten, Vertrag (Vollzeit/Teilzeit, befristet/unbefristet), Org-Zuordnung, Status (`Aktiv`/`Karenz`/`Geplant`/`Ausgetreten`), Rolle & Anstellung (Angestellte:r/Arbeiter:in, Kollektivvertrag, Arbeitstage, Betriebsrat/Dienstwagen/laterale Führung/C-Level-Flags), akademische Titel (Prefix/Suffix-Arrays). |
| `employee_history` | Append-only Ereignis-Timeline pro Person (fester Enum: Eintritt, Beförderung, Versetzung, Karenz, Vertragsänderung, Stammdatenänderung, Austritt, Wiedereintritt, Reorganisation, Gehaltsanpassung, Rückkehr). |
| `employee_dependents` | Angehörige (Ehepartner:in/Lebenspartner:in/Kind/Sonstige) je Mitarbeiter:in; Edit = Löschen + Neuanlage, kein In-place-Update. |
| `employee_notes` | HR-Notizen je Mitarbeiter:in, add-only, mit optionalem "Wiedervorlage am"-Datum. Bewusst nicht autor-gescoped — jede aktive HR-Person sieht jede offene Notiz ("Meine Notizen" ist ein geteiltes Postfach, kein persönliches). |
| `positions` | Planstellen mit eindeutiger `position_number` (^6\d{7}$), Status `open`/`filled`, `valid_from`-Gültigkeitsfenster. |
| `hire_drafts` | Fortsetzbarer Zwischenstand des Neueinstellungs-Wizards (JSONB-Payload), eigentümer-gescoped. |
| `saved_reports` | Gespeicherte Report-Konfigurationen, eigentümer-gescoped. |
| `pending_org_changes` | Effective-dated (zukünftig wirksame) Änderungen — Versetzung/Beförderung/Karenz-Start/-Rückkehr/Vertragsänderung/Reorg — die erst am `effective_date` angewendet werden. Wird von `apply_due_pending_changes()` verarbeitet, aufgerufen vom Cron-Route-Handler. Entspricht dem, was in generischen HR-Schemata oft "planned_changes" heißt. |
| `audit_log` | Systemweiter, unveränderlicher Audit-Trail (wer/wann/was/an wem). Wird ausschließlich von SQL-Funktionen beschrieben, nie direkt aus der App. |
| `reorg_scenarios` / `reorg_moves` | Persistierte Reorg-Pläne inkl. `undo_snapshot` (Pre-Change-Zustand für die Rückgängig-Funktion). |
## Cron / effective-dated changes
`apply_due_pending_changes()` (SQL, `SECURITY DEFINER`) ist die einzige
Funktion, deren Ausführungsrecht explizit auf `service_role` beschränkt ist
(`revoke ... from public, anon, authenticated; grant ... to service_role`).
Sie wird von `app/api/cron/apply-pending-changes/route.ts` aufgerufen —
täglich per Vercel Cron (`vercel.json`), außerhalb von Vercel per
Ersatz-Scheduler (siehe `DEPLOYMENT.md`, Docker-Cron-Sidecar). Die Route
selbst authentifiziert per `CRON_SECRET`-Bearer-Token, nicht per
Supabase-Session — es gibt keine anfragende Person, nur den Scheduler.
Idempotenz: `pending_org_changes.status` läuft `pending` → `applied` (oder
`cancelled` bei Reorg-Undo); die Auswahl-Query filtert immer auf
`status = 'pending'`, ein zweiter Lauf wirkt daher auf bereits angewendete
Einträge nicht erneut.
## Bekannte Lücken vor Produktivbetrieb
Siehe README.md → "Known TODOs" für den aktuellen Stand.