Maximilian Stubhan 4d8e3f7154 Restore the shapes the application was written against
The app runs against a real database for the first time since the port, and
two things were broken. Both were invisible to typecheck, lint, 192 tests
and the build.

Sign-in looped. Auth.js created the session and app_upsert_user() adopted
the existing profiles id correctly — the row was in app_users, right id and
all — but the proxy builds its own Auth.js instance from lib/auth/config.ts
alone, and the session callback that copies token.uid onto session.user.id
lived in auth.ts. So the proxy saw a session without an id, treated every
signed-in user as signed out, and sent them back to /login. Click, flash,
login page: from the outside it looked like the button did nothing.

The callback moves to the config both instances share. auth.ts now spreads
the base callbacks instead of replacing them, which is the mistake that
would reintroduce this.

The proxy test did not catch it because its fixture hands the handler a
session that already has user.id — it tested the routing, not the shape
Auth.js actually produces.

Then the dashboard crashed on a.date.localeCompare. PostgREST returned JSON:
a `date` arrived as "2026-08-03", a `numeric` as a number, and that is what
lib/supabase/types.ts declares and what every sort, every date comparison
and every status derivation assumes. The pg driver does the opposite — Date
object and string respectively. The declarations stayed true to what the
code believes; only the runtime value changed, which is why nothing flagged
it.

The driver is configured back to the declared shapes in lib/db/pool.ts,
rather than rewriting 49 call sites. That also removes a timezone hazard:
`date` is a calendar day, and as a Date object it acquires midnight in the
server's zone — a birth date would shift by a day in Austria, always. The
same class of bug as in the seed.

int8 stays a string on purpose: it only comes from count() and is read
through Number() everywhere; parsed as a number it would quietly lose
precision past 2^53.

A missing sign-in error now reaches the server log. Auth.js was failing
silently — a 302 back to /login and nothing to read. That was its own
defect, and it is the reason the first diagnosis took as long as it did.

Verified against the live database: all six pages render, 797 active of
852 records, 744.4 FTE, and a detail page shows birth date 15.08.1968
against SV number 7960 150868 — the digits agree, so no day has shifted.
Both new tests were checked by mutation: remove the fix and they fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 10:11:34 +02:00

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.ts statt middleware.ts). Vor Änderungen an Framework-nahen Dateien die lokalen Docs unter node_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-Funktion is_hr_user(). Kein Sub-Rollensystem — siehe docs/data-model.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 als SECURITY 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 von pg ausserhalb von lib/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 siehe docs/security-review.md.
  • Historie ist append-only (employee_history, audit_log) — RLS erlaubt kein update/delete. Korrekturen sind kompensierende Einträge.

Cron-Konfiguration

/api/cron/apply-pending-changes wendet wirksam gewordene, zukunftsdatierte Änderungen an (pending_org_changesapply_due_pending_changes()).

  • Auf Vercel: vercel.json definiert den täglichen Schedule; Vercel Cron sendet Authorization: Bearer <CRON_SECRET> automatisch, wenn CRON_SECRET in den Projekt-Env-Vars gesetzt ist.
  • Außerhalb von Vercel (Docker): kein Vercel Cron verfügbar — siehe DEPLOYMENT.md für den Cron-Sidecar-Container, der denselben Endpoint mit demselben Schema aufruft.
  • Fehlt CRON_SECRET oder stimmt der Header nicht, antwortet die Route mit 401 (nicht 500 — bewusst, siehe tests/unit/security.test.ts).

Supabase-Hinweise

  • Schema-Quelle der Wahrheit: supabase/migrations/. Menschlich lesbare Zusammenfassung: docs/data-model.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 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 (siehe tests/integration/authorization.test.ts fü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.ts setzt 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 .gitignore ausgeschlossen; 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.
Description
No description provided
Readme 938 KiB
Languages
TypeScript 67.3%
PLpgSQL 31.2%
JavaScript 0.8%
CSS 0.5%
Dockerfile 0.2%