Last step moved the four part-time arrangements out of the absence list and recorded the reason in the history text. That answered "what happened" but not "who is in one right now", and the profile showed nothing at all. So it becomes a real field: teilzeit_art, with an optional end date. The objection I raised then still holds — a state goes stale, because nobody goes back to note when a Bildungsteilzeit ended. teilzeit_bis is the answer to it: with an end date a report decides for itself what is still running instead of trusting that someone maintained the row. Left empty it means "open end", which is an honest thing to say. It runs through the ordinary change machinery rather than beside it. It sits in app_feld_karte, so it shows up in the history as a field with before and after, and can be corrected there like any other. The description suffix from last step is gone — writing the same thing twice is how two versions start disagreeing. Reporting: filter by variant, by "in one at all", and by when it ends; group headcount by variant, where the absence of one reads "Keine" rather than a dash, because in a report that is an answer and not a gap. Plus columns in the export and the import. One gap found while rehearsing, and only because the probe happened to pick a return date in the future: a scheduled return carries its payload through pending_org_changes, and that payload did not include the variant. Someone would have come back on reduced hours in April with the reason gone. The daily run now carries it too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Manner HR Master
Interne HR-Stammdatenverwaltung: Mitarbeiter:innen, Organisationsstruktur (Bereich/Abteilung/Team), Planstellen, Neueinstellungen, Versetzungen/ Beförderungen/Karenz, Reorganisationen und der zugehörige Audit-Trail.
Zweck
Die App ersetzt Excel-basierte HR-Stammdatenpflege durch ein Werkzeug mit verbindlichen Regeln (z. B. wirksame Daten statt sofortiger Änderungen, eindeutige Positions-/Org-Nummern, verpflichtende Historie) und einem lückenlosen Audit-Trail für jede Änderung.
Alle Mitarbeiterdaten in diesem System sind vertraulich — Stammdaten, Verträge, Sozialversicherungsnummern, Angehörige und Audit-Daten. Zugriff ist auf explizit aktivierte HR-Benutzer:innen beschränkt (siehe Sicherheitsprinzipien).
Tech-Stack
- Next.js 16 (App Router) — Achtung: Next.js 16 hat Breaking Changes
gegenüber älteren Versionen (u. a.
proxy.tsstattmiddleware.ts). Vor Änderungen an Framework-nahen Dateien die lokalen Docs unternode_modules/next/dist/docs/konsultieren; sie sind maßgeblich, ältere Anleitungen im Netz beschreiben teils überholte APIs. - React 19, TypeScript
- Supabase (Postgres, Auth, RLS) — Datenhaltung liegt vollständig in Supabase, nicht im Next.js-Prozess.
- Tailwind CSS v4
- Vitest — Unit- (Node), Komponenten- (jsdom) und Integrationstests (gegen ein lokales Supabase)
Setup
npm install
cp .env.example .env.local # Werte eintragen, siehe unten
npm run dev
Für lokale Supabase-Entwicklung (statt gegen ein Cloud-Projekt):
supabase start # startet lokalen Postgres/Auth/Studio-Stack
supabase/config.toml und .env.test.local sind bereits auf die
Standard-Ports der lokalen Supabase-CLI abgestimmt.
Umgebungsvariablen
Siehe .env.example für die vollständige, kommentierte
Liste. Kurzfassung:
| Variable | Sichtbarkeit | Zweck |
|---|---|---|
DATABASE_URL |
Nur Server | PostgreSQL-Verbindung. Die Rolle darf kein BYPASSRLS haben |
DATABASE_SSL |
Nur Server | false für lokal/CI ohne TLS |
AUTH_SECRET |
Nur Server | Signiert und verschlüsselt das Sitzungscookie |
AUTH_MICROSOFT_ENTRA_ID_ID |
Nur Server | Anwendungs-ID der Entra-Registrierung |
AUTH_MICROSOFT_ENTRA_ID_SECRET |
Nur Server | Client-Geheimnis dazu |
AUTH_MICROSOFT_ENTRA_ID_ISSUER |
Nur Server | Aussteller mit Mandanten-ID — nicht common |
CRON_SECRET |
Nur Server | Schützt /api/cron/apply-pending-changes |
Es gibt keine NEXT_PUBLIC_*-Variablen mehr. Nichts wird in das
Browser-Bundle eingebacken, weil der Browser mit nichts ausser der Anwendung
selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut,
überall dasselbe — vorher brauchte jede Umgebung ihr eigenes.
Scripts
| Befehl | Zweck |
|---|---|
npm run dev |
Lokaler Dev-Server |
npm run build |
Produktions-Build |
npm run start |
Produktions-Server (nach build) |
npm run lint |
ESLint (eslint-config-next, Flat Config) |
npm run typecheck |
tsc --noEmit |
npm run test |
Vitest, Unit-Tests (tests/unit/**) |
npm run test:integration |
Vitest gegen eine echte (lokale) Supabase-Instanz — braucht supabase start und .env.test.local |
npm run test:e2e |
Playwright |
npm run check |
lint + typecheck + test + build in Folge |
Sicherheitsprinzipien
- RLS ist die eigentliche Schranke, nicht die UI. Jede Tabelle hat Row
Level Security aktiv;
proxy.ts(App-Ebene) ist Defense-in-Depth, keine Ersatzkontrolle. - Ein Rollenmodell:
profiles.role = 'hr'+profiles.is_active = true, geprüft über die SQL-Funktionis_hr_user(). Kein Sub-Rollensystem — siehedocs/datenkatalog.md. - 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, steht alsSECURITY DEFINER-Funktion in der Datenbank und prüft dort selbst, was es tut. - Jede Abfrage läuft in einer Transaktion mit gesetztem Sitzungskontext.
Die Kysely-Instanz wird nicht exportiert — der einzige Weg an die Datenbank
ist
withUser()(lib/db/index.ts), und eine ESLint-Regel verbietet den Import vonpgausserhalb vonlib/db/. - Audit-Log ist transaktional in der Datenbank, nicht im App-Code: jede
mutierende SQL-Funktion schreibt ihren
audit_log-Eintrag in derselben Transaktion wie die Änderung selbst. Details und Prüfung siehedocs/security-review.md. - Historie ist append-only (
employee_history,audit_log) — RLS erlaubt keinupdate/delete. Korrekturen sind kompensierende Einträge.
Cron-Konfiguration
/api/cron/apply-pending-changes wendet wirksam gewordene, zukunftsdatierte
Änderungen an (pending_org_changes → apply_due_pending_changes()).
- Auf Vercel:
vercel.jsondefiniert den täglichen Schedule; Vercel Cron sendetAuthorization: Bearer <CRON_SECRET>automatisch, wennCRON_SECRETin den Projekt-Env-Vars gesetzt ist. - Außerhalb von Vercel (Docker): kein Vercel Cron verfügbar — siehe
DEPLOYMENT.mdfür den Cron-Sidecar-Container, der denselben Endpoint mit demselben Schema aufruft. - Fehlt
CRON_SECREToder stimmt der Header nicht, antwortet die Route mit401(nicht500— bewusst, siehetests/unit/security.test.ts).
Supabase-Hinweise
- Schema-Quelle der Wahrheit:
supabase/migrations/. Menschlich lesbare Fassung, aus der laufenden Datenbank erzeugt:docs/datenkatalog.md. - Migrationen einspielen:
supabase db push(gegen das verlinkte Projekt) bzw.supabase start+ automatische Anwendung für lokale Entwicklung. supabase/seed.tsund.env.test.localsind nur für lokale Entwicklung/Tests gedacht, nie für ein Produktivprojekt verwenden.
Testing
npm run test— schnell, keine externen Abhängigkeiten, läuft in CI.npm run test:integration— braucht eine laufende lokale Supabase-Instanz (supabase start) und.env.test.local; prüft RLS-Verhalten end-to-end (siehetests/integration/authorization.test.tsfür das HR-Only-Zugriffs- modell).npm run test:e2e— Playwright gegen einen laufenden Dev-/Preview-Server.
Deployment
Siehe DEPLOYMENT.md für Docker-basiertes Deployment
(Dockerfile, docker-compose.yml, Reverse-Proxy/TLS, Cron-Ersatz, Updates).
Für Vercel: vercel.json ist bereits vorhanden; Env-Vars im
Vercel-Projekt setzen (siehe oben).
Known TODOs vor Produktivbetrieb
- Content-Security-Policy fehlt noch (
next.config.tssetzt bewusst keine CSP — Skript-/Style-/Connect-Quellen sind noch nicht vollständig inventarisiert; ungeprüft geraten zu setzen riskiert, Hydration oder den Supabase-Client stillschweigend zu brechen). - Lokale Scratch-Artefakte (
.scratch_*,.scratch_shots/) enthalten Screenshots/Hilfsskripte aus einer früheren manuellen Verifikation und liegen noch im Arbeitsverzeichnis. Sie sind jetzt über.gitignoreausgeschlossen; vor einem Produktiv-Handover sollten sie durchgesehen und bei Bedarf gelöscht werden. - Kein granulareres Rollenmodell — aktuell HR-only (alles-oder-nichts).
Falls z. B. eine reine Lese-Rolle künftig gebraucht wird, gehört die
Erweiterung in eine neue Migration (
is_hr_user()/RLS-Policies), nicht in App-seitigen Code.