Compare commits
13 Commits
730521ee79
...
feat/sap-o
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d8af7cdf0 | |||
| a87c688c0a | |||
| e44f71a60d | |||
| 1271cef879 | |||
| 3926f1bb80 | |||
| 16b37244c8 | |||
| 73656461d1 | |||
| 99e50fbbf9 | |||
| 4d8e3f7154 | |||
| b9efe81bec | |||
| 2ba9b37aa7 | |||
| b3a0af2b8f | |||
| a66263a96e |
46
.env.example
46
.env.example
@@ -1,12 +1,42 @@
|
|||||||
# Public: safe to expose to the browser (inlined into the client bundle at
|
# Direkter PostgreSQL-Zugang. Die Anwendung spricht künftig unmittelbar mit
|
||||||
# build time). Anon-key access is still fully gated by RLS server-side.
|
# der Datenbank statt über eine API-Schicht — damit läuft sie auf jedem
|
||||||
NEXT_PUBLIC_SUPABASE_URL=
|
# PostgreSQL ab 15 (Azure Flexible Server, RDS, Cloud SQL, eigenes Blech).
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
#
|
||||||
|
# Die Rolle in diesem String darf KEIN BYPASSRLS haben: fehlt der
|
||||||
|
# Sitzungskontext, sollen die Policies nichts zurückgeben statt alles.
|
||||||
|
#
|
||||||
|
# Hinter einem Verbindungspooler (Supabase Supavisor, PgBouncer) den
|
||||||
|
# TRANSAKTIONS-Modus nehmen, nicht den Sitzungs-Modus — bei Supabase Port
|
||||||
|
# 6543 statt 5432. Jede Abfrage dieser Anwendung läuft ohnehin in einer
|
||||||
|
# Transaktion, und der Sitzungskontext wird transaktionslokal gesetzt; beides
|
||||||
|
# passt genau dazu. Der Sitzungs-Modus belegt dagegen je Client eine feste
|
||||||
|
# Verbindung und ist bei Supabase auf 15 begrenzt — danach antwortet die
|
||||||
|
# Anwendung nur noch mit „max clients reached".
|
||||||
|
DATABASE_URL=
|
||||||
|
# Auf "false" setzen, wenn die Datenbank ohne TLS läuft (lokal, CI).
|
||||||
|
DATABASE_SSL=
|
||||||
|
# Verbindungen im Pool; Vorgabe 10.
|
||||||
|
DATABASE_POOL_MAX=
|
||||||
|
|
||||||
# Server-only: bypasses Row Level Security entirely. Never prefix with
|
# ── Anmeldung (Auth.js + Microsoft Entra ID) ─────────────────────────
|
||||||
# NEXT_PUBLIC_, never import outside lib/supabase/admin.ts (guarded by
|
# Schlüssel, mit dem das Sitzungscookie signiert und verschlüsselt wird.
|
||||||
# `import "server-only"`), never log or return in an API response.
|
# Erzeugen mit `npx auth secret` oder `openssl rand -base64 32`. Ein Wechsel
|
||||||
SUPABASE_SERVICE_ROLE_KEY=
|
# meldet alle ab — was im Ernstfall genau das gewünschte Mittel ist.
|
||||||
|
AUTH_SECRET=
|
||||||
|
|
||||||
|
# Aus der Anwendungsregistrierung im Entra-Portal: Anwendungs-ID (Client),
|
||||||
|
# ein Geheimnis daraus, und der Aussteller mit der Verzeichnis-ID (Mandant).
|
||||||
|
#
|
||||||
|
# Der Aussteller darf NICHT auf /common/ stehen bleiben — sonst könnte sich
|
||||||
|
# jedes Microsoft-Konto anmelden, auch ein privates.
|
||||||
|
AUTH_MICROSOFT_ENTRA_ID_ID=
|
||||||
|
AUTH_MICROSOFT_ENTRA_ID_SECRET=
|
||||||
|
AUTH_MICROSOFT_ENTRA_ID_ISSUER=https://login.microsoftonline.com/<verzeichnis-id>/v2.0
|
||||||
|
|
||||||
|
# Nur nötig, wenn die Anwendung hinter einem Reverse Proxy unter einer
|
||||||
|
# anderen Adresse erreichbar ist, als sie selbst sieht. Ohne diesen Wert baut
|
||||||
|
# Auth.js die Rückruf-Adresse aus den Request-Headern.
|
||||||
|
AUTH_URL=
|
||||||
|
|
||||||
# Shared secret Vercel Cron sends as `Authorization: Bearer <value>` when it
|
# Shared secret Vercel Cron sends as `Authorization: Bearer <value>` when it
|
||||||
# calls /api/cron/apply-pending-changes (set the same value in the Vercel
|
# calls /api/cron/apply-pending-changes (set the same value in the Vercel
|
||||||
|
|||||||
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
@@ -68,16 +68,23 @@ jobs:
|
|||||||
- name: Supabase starten
|
- name: Supabase starten
|
||||||
run: supabase start
|
run: supabase start
|
||||||
|
|
||||||
# `-o env` emits API_URL / ANON_KEY / SERVICE_ROLE_KEY; the app expects
|
# `-o env` emits API_URL / ANON_KEY / SERVICE_ROLE_KEY / DB_URL; the app
|
||||||
# them under its own names.
|
# expects them under its own names. DATABASE_URL ist der direkte
|
||||||
|
# Postgres-Zugang — den braucht die neue Zugriffsschicht (lib/db) und
|
||||||
|
# vor allem der Nachweis zum Sitzungskontext.
|
||||||
- name: Testumgebung schreiben
|
- name: Testumgebung schreiben
|
||||||
run: |
|
run: |
|
||||||
supabase status -o env \
|
supabase status -o env \
|
||||||
--override-name api.url=NEXT_PUBLIC_SUPABASE_URL \
|
--override-name api.url=NEXT_PUBLIC_SUPABASE_URL \
|
||||||
--override-name auth.anon_key=NEXT_PUBLIC_SUPABASE_ANON_KEY \
|
--override-name auth.anon_key=NEXT_PUBLIC_SUPABASE_ANON_KEY \
|
||||||
--override-name auth.service_role_key=SUPABASE_SERVICE_ROLE_KEY \
|
--override-name auth.service_role_key=SUPABASE_SERVICE_ROLE_KEY \
|
||||||
| grep -E '^(NEXT_PUBLIC_SUPABASE_URL|NEXT_PUBLIC_SUPABASE_ANON_KEY|SUPABASE_SERVICE_ROLE_KEY)=' \
|
--override-name db.url=DATABASE_URL \
|
||||||
|
| grep -E '^(NEXT_PUBLIC_SUPABASE_URL|NEXT_PUBLIC_SUPABASE_ANON_KEY|SUPABASE_SERVICE_ROLE_KEY|DATABASE_URL)=' \
|
||||||
| tr -d '"' > .env.test.local
|
| tr -d '"' > .env.test.local
|
||||||
|
# Lokal läuft Postgres ohne TLS; ohne das versucht `pg` es trotzdem.
|
||||||
|
echo "DATABASE_SSL=false" >> .env.test.local
|
||||||
|
grep -q '^DATABASE_URL=' .env.test.local \
|
||||||
|
|| { echo "DATABASE_URL wurde nicht geschrieben — der Sitzungskontext-Nachweis liefe ins Leere."; exit 1; }
|
||||||
|
|
||||||
- name: Seed
|
- name: Seed
|
||||||
run: node --env-file=.env.test.local supabase/seed.ts
|
run: node --env-file=.env.test.local supabase/seed.ts
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -54,3 +54,6 @@ next-env.d.ts
|
|||||||
# seeded DB — can carry HR data or use SUPABASE_SERVICE_ROLE_KEY; never commit)
|
# seeded DB — can carry HR data or use SUPABASE_SERVICE_ROLE_KEY; never commit)
|
||||||
.scratch_*
|
.scratch_*
|
||||||
/.scratch_shots/
|
/.scratch_shots/
|
||||||
|
|
||||||
|
# Datenbank-Sicherungen (enthalten Personendaten) – nie committen.
|
||||||
|
.backups/
|
||||||
|
|||||||
@@ -37,21 +37,22 @@ Werte eintragen:
|
|||||||
|
|
||||||
| Variable | Woher |
|
| Variable | Woher |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase-Projekt → Settings → API |
|
| `DATABASE_URL` | Verbindungsstring der PostgreSQL-Instanz. Die Rolle darf **kein** `BYPASSRLS` haben; hinter einem Pooler den **Transaktions-Modus** (bei Supabase Port 6543) |
|
||||||
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase-Projekt → Settings → API |
|
| `DATABASE_SSL` | nur setzen (`false`), wenn die Datenbank ohne TLS läuft |
|
||||||
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase-Projekt → Settings → API (geheim!) |
|
| `AUTH_SECRET` | selbst generieren: `openssl rand -base64 32` |
|
||||||
|
| `AUTH_MICROSOFT_ENTRA_ID_ID` | Entra-Portal → App-Registrierung → Übersicht |
|
||||||
|
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | Entra-Portal → Zertifikate & Geheimnisse (nur einmal sichtbar!) |
|
||||||
|
| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | `https://login.microsoftonline.com/<verzeichnis-id>/v2.0` |
|
||||||
| `CRON_SECRET` | selbst generieren: `openssl rand -hex 32` |
|
| `CRON_SECRET` | selbst generieren: `openssl rand -hex 32` |
|
||||||
|
|
||||||
|
Details zur Entra-Registrierung: [`docs/entra-sso.md`](docs/entra-sso.md).
|
||||||
|
|
||||||
Wichtig zum Verständnis:
|
Wichtig zum Verständnis:
|
||||||
|
|
||||||
- `NEXT_PUBLIC_*`-Variablen werden **beim Build** in das Browser-Bundle
|
- **Nichts davon wird in das Image eingebacken.** Es gibt keine
|
||||||
eingebacken (Next.js-Verhalten, nicht Docker-spezifisch). Ändern sich
|
`NEXT_PUBLIC_*`-Variablen mehr; alle Werte liest die Anwendung zur Laufzeit
|
||||||
diese Werte, muss das Image **neu gebaut** werden – ein reiner Container-
|
über `env_file`. Eine Änderung braucht deshalb nur einen Neustart, keinen
|
||||||
Neustart reicht nicht.
|
neuen Build — und dasselbe Image läuft in Test und Produktion.
|
||||||
- `SUPABASE_SERVICE_ROLE_KEY` und `CRON_SECRET` sind Server-only-Secrets.
|
|
||||||
Sie werden bewusst **nicht** als Build-Arg übergeben (das würde sie im
|
|
||||||
Image-Layer-History sichtbar machen), sondern erst zur Laufzeit über
|
|
||||||
`env_file` injiziert.
|
|
||||||
- `.env` steht schon in `.gitignore` – nicht committen.
|
- `.env` steht schon in `.gitignore` – nicht committen.
|
||||||
|
|
||||||
## 2. Bauen und lokal testen
|
## 2. Bauen und lokal testen
|
||||||
@@ -145,12 +146,22 @@ Single-Instance-Compose-Konfiguration ist das nicht nötig.
|
|||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
- **Login-Redirect-Loop / `proxy.ts` verhält sich falsch:** meist falsche
|
- **Anmeldung endet auf `/login?error=…`:** die Umleitungs-URI in der
|
||||||
`NEXT_PUBLIC_SUPABASE_URL`/`ANON_KEY` – Image neu bauen (siehe oben, diese
|
Entra-Registrierung muss exakt
|
||||||
Werte sind eingebacken).
|
`https://<host>/api/auth/callback/microsoft-entra-id` lauten. Steht die
|
||||||
|
Anwendung hinter einem Reverse Proxy unter einer anderen Adresse, als sie
|
||||||
|
selbst sieht, zusätzlich `AUTH_URL` setzen.
|
||||||
|
- **Angemeldet, aber sofort zurück auf `/login?error=no_hr_access`:** die
|
||||||
|
Anmeldung hat funktioniert, es fehlt die Freischaltung. Es braucht eine
|
||||||
|
`profiles`-Zeile mit `role = 'hr'` und `is_active = true` auf derselben
|
||||||
|
Kennung, die in `app_users` steht.
|
||||||
- **Cron läuft nicht:** `docker compose logs cron` – prüft, ob
|
- **Cron läuft nicht:** `docker compose logs cron` – prüft, ob
|
||||||
`/etc/crontabs/root` korrekt geschrieben wurde und ob `CRON_SECRET` in
|
`/etc/crontabs/root` korrekt geschrieben wurde und ob `CRON_SECRET` in
|
||||||
`.env` gesetzt ist (leer/fehlend führt serverseitig zu `401`).
|
`.env` gesetzt ist (leer/fehlend führt serverseitig zu `401`).
|
||||||
- **Healthcheck rot:** `docker compose logs app` – meist fehlende/falsche
|
- **`max clients reached in session mode`:** der Verbindungsstring zeigt auf
|
||||||
Supabase-Env-Variablen zur Laufzeit (`SUPABASE_SERVICE_ROLE_KEY`,
|
den Sitzungs-Modus des Poolers. Auf den Transaktions-Modus wechseln (bei
|
||||||
Server-Komponenten).
|
Supabase Port 6543).
|
||||||
|
- **Healthcheck rot:** `docker compose logs app` – meist `DATABASE_URL`
|
||||||
|
fehlend oder nicht erreichbar. Der Pool baut die Verbindung erst beim
|
||||||
|
ersten Zugriff auf, der Fehler steht deshalb im Log der Anfrage, nicht im
|
||||||
|
Start-Log.
|
||||||
|
|||||||
14
Dockerfile
14
Dockerfile
@@ -12,13 +12,13 @@ WORKDIR /app
|
|||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Public env vars are inlined into the client bundle at build time, so they
|
# Keine Build-Argumente mehr: es gibt keine NEXT_PUBLIC_*-Werte mehr, die in
|
||||||
# must be available here, not just at runtime. Values are passed in via
|
# das Browser-Bundle eingebacken würden. Datenbank und Anmeldung sprechen
|
||||||
# --build-arg (see DEPLOYMENT.md).
|
# ausschliesslich den Server an, und dessen Zugangsdaten kommen zur Laufzeit.
|
||||||
ARG NEXT_PUBLIC_SUPABASE_URL
|
#
|
||||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
# Dadurch ist dieses Abbild umgebungsneutral: einmal gebaut, in Test und
|
||||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
# Produktion dasselbe. Vorher hätte jede Umgebung ihr eigenes gebraucht — und
|
||||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
# eine Baustrecke, die die Zugangsdaten schon zum Bauen kennt.
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|||||||
29
README.md
29
README.md
@@ -54,14 +54,18 @@ Liste. Kurzfassung:
|
|||||||
|
|
||||||
| Variable | Sichtbarkeit | Zweck |
|
| Variable | Sichtbarkeit | Zweck |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `NEXT_PUBLIC_SUPABASE_URL` | Browser + Server | Supabase-Projekt-URL |
|
| `DATABASE_URL` | Nur Server | PostgreSQL-Verbindung. Die Rolle darf **kein** `BYPASSRLS` haben |
|
||||||
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Browser + Server | Anon-Key, RLS-gebunden |
|
| `DATABASE_SSL` | Nur Server | `false` für lokal/CI ohne TLS |
|
||||||
| `SUPABASE_SERVICE_ROLE_KEY` | **Nur Server** | Umgeht RLS vollständig — niemals im Browser-Bundle, niemals loggen |
|
| `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` |
|
| `CRON_SECRET` | Nur Server | Schützt `/api/cron/apply-pending-changes` |
|
||||||
|
|
||||||
`NEXT_PUBLIC_*`-Werte werden beim Build in das Client-Bundle eingebacken —
|
**Es gibt keine `NEXT_PUBLIC_*`-Variablen mehr.** Nichts wird in das
|
||||||
eine Änderung erfordert einen Rebuild, nicht nur einen Neustart (relevant
|
Browser-Bundle eingebacken, weil der Browser mit nichts ausser der Anwendung
|
||||||
für Docker-Deployments, siehe unten).
|
selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut,
|
||||||
|
überall dasselbe — vorher brauchte jede Umgebung ihr eigenes.
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
@@ -85,10 +89,15 @@ für Docker-Deployments, siehe unten).
|
|||||||
- **Ein Rollenmodell:** `profiles.role = 'hr'` + `profiles.is_active = true`,
|
- **Ein Rollenmodell:** `profiles.role = 'hr'` + `profiles.is_active = true`,
|
||||||
geprüft über die SQL-Funktion `is_hr_user()`. Kein Sub-Rollensystem —
|
geprüft über die SQL-Funktion `is_hr_user()`. Kein Sub-Rollensystem —
|
||||||
siehe [`docs/data-model.md`](docs/data-model.md#zugriffsmodell).
|
siehe [`docs/data-model.md`](docs/data-model.md#zugriffsmodell).
|
||||||
- **Service-Role-Key ist server-only.** Einzige Verwendung:
|
- **Es gibt keinen privilegierten Zugang mehr.** Der Dienstschlüssel, der RLS
|
||||||
`lib/supabase/admin.ts`, geschützt durch `import "server-only"` (macht
|
aushebelte, ist ersatzlos entfallen; auch der nächtliche Lauf benutzt
|
||||||
einen versehentlichen Client-Import zu einem Build-Fehler statt einem
|
dieselbe Rolle ohne `BYPASSRLS`. Was ohne angemeldete Person laufen muss,
|
||||||
Laufzeitproblem).
|
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
|
- **Audit-Log ist transaktional in der Datenbank**, nicht im App-Code: jede
|
||||||
mutierende SQL-Funktion schreibt ihren `audit_log`-Eintrag in derselben
|
mutierende SQL-Funktion schreibt ihren `audit_log`-Eintrag in derselben
|
||||||
Transaktion wie die Änderung selbst. Details und Prüfung siehe
|
Transaktion wie die Änderung selbst. Details und Prüfung siehe
|
||||||
|
|||||||
@@ -1,43 +1,22 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { headers } from "next/headers";
|
import { signIn, signOut } from "@/auth";
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { createClient } from "@/lib/supabase/server";
|
|
||||||
|
|
||||||
// Anmeldung ausschliesslich über Entra ID (in Supabase heisst der Anbieter
|
// Anmeldung ausschliesslich über Entra ID. Es gibt bewusst keinen
|
||||||
// „Azure"). Es gibt bewusst keinen Passwort-Pfad mehr: ein zweiter Anmeldeweg
|
// Passwort-Pfad: ein zweiter Anmeldeweg neben dem Firmenkonto hebelt jede
|
||||||
// neben dem Firmenkonto hebelt jede Vorgabe des Mandanten aus — Mehrfaktor,
|
// Vorgabe des Mandanten aus — Mehrfaktor, bedingten Zugriff, Sperrung beim
|
||||||
// bedingten Zugriff, Sperrung beim Austritt.
|
// Austritt.
|
||||||
//
|
//
|
||||||
// Für die Datenbank ändert sich dadurch nichts. auth.uid() liefert weiterhin
|
// Die Herkunft muss hier nicht mehr aus dem Request geholt werden: Auth.js
|
||||||
// eine UUID, profiles.id trägt weiterhin role und is_active, und damit bleiben
|
// baut die Rückruf-Adresse selbst und akzeptiert nur Ziele auf demselben
|
||||||
// is_hr_user() und alle darauf gebauten RLS-Policies unverändert gültig.
|
// Host. Ein untergeschobener Host läuft also weiterhin ins Leere.
|
||||||
|
|
||||||
export async function signInWithEntra() {
|
export async function signInWithEntra() {
|
||||||
const supabase = await createClient();
|
// Kehrt nicht zurück: signIn löst eine Weiterleitung aus, und die wirft in
|
||||||
|
// Next.js.
|
||||||
// Die Herkunft kommt aus dem Request statt aus einer Umgebungsvariablen,
|
await signIn("microsoft-entra-id", { redirectTo: "/" });
|
||||||
// damit lokal, Vorschau und Produktion denselben Code benutzen. Supabase
|
|
||||||
// nimmt das Ziel nur an, wenn es in der Redirect-Allowlist des Projekts
|
|
||||||
// steht — ein untergeschobener Host läuft also ins Leere.
|
|
||||||
const origin = (await headers()).get("origin") ?? "http://localhost:3000";
|
|
||||||
|
|
||||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
|
||||||
provider: "azure",
|
|
||||||
options: {
|
|
||||||
// openid/profile/email sind das Minimum für Anmeldung und Anzeigename.
|
|
||||||
// Weitere Berechtigungen holt sich die Anwendung bewusst nicht.
|
|
||||||
scopes: "openid profile email",
|
|
||||||
redirectTo: `${origin}/auth/callback`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error || !data.url) redirect("/login?error=sso_failed");
|
|
||||||
redirect(data.url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout() {
|
export async function logout() {
|
||||||
const supabase = await createClient();
|
await signOut({ redirectTo: "/login" });
|
||||||
await supabase.auth.signOut();
|
|
||||||
redirect("/login");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
import type { CollectiveAgreement, Database, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
|
import { withUser } from "@/lib/db";
|
||||||
|
import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc";
|
||||||
type ActionResult = { success: boolean; error?: string };
|
import type { CollectiveAgreement, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
|
||||||
type MutationFn = keyof Database["public"]["Functions"];
|
|
||||||
|
|
||||||
async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> {
|
async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> {
|
||||||
const supabase = await createClient();
|
const result = await runMutation(await currentUserId(), fn, payload);
|
||||||
const { error } = await supabase.rpc(fn, { payload });
|
if (!result.success) return result;
|
||||||
if (error) return { success: false, error: error.message };
|
|
||||||
for (const path of revalidate) revalidatePath(path);
|
for (const path of revalidate) revalidatePath(path);
|
||||||
return { success: true };
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hireEmployee(payload: {
|
export async function hireEmployee(payload: {
|
||||||
@@ -23,6 +21,15 @@ export async function hireEmployee(payload: {
|
|||||||
gender: "m" | "w";
|
gender: "m" | "w";
|
||||||
birth_date: string;
|
birth_date: string;
|
||||||
sv_nummer?: string;
|
sv_nummer?: string;
|
||||||
|
/**
|
||||||
|
* Pflicht, weil employees.email NOT NULL ist.
|
||||||
|
*
|
||||||
|
* Der Assistent hat die Adresse immer erhoben und in der Zusammenfassung
|
||||||
|
* angezeigt — sie fehlte nur in dieser Signatur und wurde deshalb
|
||||||
|
* stillschweigend verworfen. Jede Einstellung scheiterte danach an der
|
||||||
|
* Spaltenbedingung.
|
||||||
|
*/
|
||||||
|
email: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
position_id?: string;
|
position_id?: string;
|
||||||
team_id?: string;
|
team_id?: string;
|
||||||
@@ -43,13 +50,19 @@ export async function hireEmployee(payload: {
|
|||||||
is_laterale_fuehrung?: boolean;
|
is_laterale_fuehrung?: boolean;
|
||||||
is_c_level?: boolean;
|
is_c_level?: boolean;
|
||||||
}): Promise<ActionResult & { employeeId?: string }> {
|
}): Promise<ActionResult & { employeeId?: string }> {
|
||||||
const supabase = await createClient();
|
// Einzige Mutation, deren Rückgabewert gebraucht wird: die neue
|
||||||
const { data, error } = await supabase.rpc("hire_employee", { payload });
|
// Personen-Kennung, damit die Oberfläche direkt auf die Akte springen kann.
|
||||||
if (error) return { success: false, error: error.message };
|
try {
|
||||||
|
const employeeId = await withUser(await currentUserId(), (tx) =>
|
||||||
|
callFunction(tx, "hire_employee", payload as Record<string, unknown>)
|
||||||
|
);
|
||||||
revalidatePath("/employees");
|
revalidatePath("/employees");
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
revalidatePath("/positions");
|
revalidatePath("/positions");
|
||||||
return { success: true, employeeId: data as string };
|
return { success: true, employeeId: employeeId as string };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function terminateEmployee(payload: {
|
export async function terminateEmployee(payload: {
|
||||||
|
|||||||
@@ -1,45 +1,52 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
|
import { withUser } from "@/lib/db";
|
||||||
type ActionResult = { success: boolean; error?: string };
|
import type { ActionResult } from "@/lib/db/rpc";
|
||||||
|
|
||||||
export async function saveHireDraft(payload: {
|
export async function saveHireDraft(payload: {
|
||||||
id?: string;
|
id?: string;
|
||||||
step: number;
|
step: number;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
}): Promise<ActionResult & { id?: string }> {
|
}): Promise<ActionResult & { id?: string }> {
|
||||||
const supabase = await createClient();
|
const userId = await currentUserId();
|
||||||
const {
|
if (!userId) return { success: false, error: "Nicht angemeldet." };
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
if (!user) return { success: false, error: "Nicht angemeldet." };
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
const id = await withUser(userId, async (tx) => {
|
||||||
if (payload.id) {
|
if (payload.id) {
|
||||||
const { error } = await supabase
|
// Ob die Zeile der aufrufenden Person gehört, entscheidet die
|
||||||
.from("hire_drafts")
|
// Policy hire_drafts_owner — nicht eine Prüfung hier.
|
||||||
.update({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() })
|
await tx
|
||||||
.eq("id", payload.id);
|
.updateTable("hire_drafts")
|
||||||
if (error) return { success: false, error: error.message };
|
.set({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() })
|
||||||
revalidatePath("/");
|
.where("id", "=", payload.id)
|
||||||
return { success: true, id: payload.id };
|
.execute();
|
||||||
|
return payload.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const row = await tx
|
||||||
.from("hire_drafts")
|
.insertInto("hire_drafts")
|
||||||
.insert({ created_by: user.id, step: payload.step, payload: payload.data })
|
.values({ created_by: userId, step: payload.step, payload: payload.data })
|
||||||
.select("id")
|
.returning("id")
|
||||||
.single();
|
.executeTakeFirstOrThrow();
|
||||||
if (error) return { success: false, error: error.message };
|
return row.id;
|
||||||
|
});
|
||||||
|
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
return { success: true, id: data.id };
|
return { success: true, id };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteHireDraft(id: string): Promise<ActionResult> {
|
export async function deleteHireDraft(id: string): Promise<ActionResult> {
|
||||||
const supabase = await createClient();
|
try {
|
||||||
const { error } = await supabase.from("hire_drafts").delete().eq("id", id);
|
await withUser(await currentUserId(), (tx) => tx.deleteFrom("hire_drafts").where("id", "=", id).execute());
|
||||||
if (error) return { success: false, error: error.message };
|
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
|
import { runMutation, type ActionResult } from "@/lib/db/rpc";
|
||||||
type ActionResult = { success: boolean; error?: string };
|
|
||||||
|
|
||||||
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
|
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
|
||||||
|
|
||||||
async function callRpc(
|
async function callRpc(
|
||||||
fn: "create_position" | "delete_position",
|
fn: "create_position" | "update_position" | "delete_position",
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
revalidate: string[]
|
revalidate: string[]
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
const supabase = await createClient();
|
const result = await runMutation(await currentUserId(), fn, payload);
|
||||||
const { error } = await supabase.rpc(fn, { payload });
|
if (!result.success) return result;
|
||||||
if (error) return { success: false, error: error.message };
|
|
||||||
for (const path of revalidate) revalidatePath(path);
|
for (const path of revalidate) revalidatePath(path);
|
||||||
return { success: true };
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPosition(payload: {
|
export async function createPosition(payload: {
|
||||||
@@ -28,6 +26,25 @@ export async function createPosition(payload: {
|
|||||||
return callRpc("create_position", payload, POSITION_PATHS);
|
return callRpc("create_position", payload, POSITION_PATHS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ändert eine Planstelle.
|
||||||
|
*
|
||||||
|
* Weggelassene Felder bleiben, wie sie sind. `valid_to: null` beendet die
|
||||||
|
* Befristung ausdrücklich — deshalb ist es hier `string | null` und nicht
|
||||||
|
* optional: „nicht mitgeschickt" und „auf leer setzen" müssen unterscheidbar
|
||||||
|
* bleiben.
|
||||||
|
*/
|
||||||
|
export async function updatePosition(payload: {
|
||||||
|
position_id: string;
|
||||||
|
org_unit_id?: string;
|
||||||
|
job_title?: string;
|
||||||
|
is_chief?: boolean;
|
||||||
|
valid_from?: string;
|
||||||
|
valid_to?: string | null;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
return callRpc("update_position", payload, POSITION_PATHS);
|
||||||
|
}
|
||||||
|
|
||||||
export async function deletePosition(positionId: string): Promise<ActionResult> {
|
export async function deletePosition(positionId: string): Promise<ActionResult> {
|
||||||
return callRpc("delete_position", { position_id: positionId }, POSITION_PATHS);
|
return callRpc("delete_position", { position_id: positionId }, POSITION_PATHS);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,31 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
|
import { withUser } from "@/lib/db";
|
||||||
type ActionResult = { success: boolean; error?: string };
|
import type { ActionResult } from "@/lib/db/rpc";
|
||||||
|
|
||||||
export async function saveReport(payload: { name: string; config: Record<string, unknown> }): Promise<ActionResult> {
|
export async function saveReport(payload: { name: string; config: Record<string, unknown> }): Promise<ActionResult> {
|
||||||
const supabase = await createClient();
|
const userId = await currentUserId();
|
||||||
const {
|
if (!userId) return { success: false, error: "Nicht angemeldet." };
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
if (!user) return { success: false, error: "Nicht angemeldet." };
|
|
||||||
|
|
||||||
const { error } = await supabase.from("saved_reports").insert({ created_by: user.id, name: payload.name, config: payload.config });
|
try {
|
||||||
if (error) return { success: false, error: error.message };
|
await withUser(userId, (tx) =>
|
||||||
|
tx.insertInto("saved_reports").values({ created_by: userId, name: payload.name, config: payload.config }).execute()
|
||||||
|
);
|
||||||
revalidatePath("/reports");
|
revalidatePath("/reports");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteReport(id: string): Promise<ActionResult> {
|
export async function deleteReport(id: string): Promise<ActionResult> {
|
||||||
const supabase = await createClient();
|
try {
|
||||||
const { error } = await supabase.from("saved_reports").delete().eq("id", id);
|
await withUser(await currentUserId(), (tx) => tx.deleteFrom("saved_reports").where("id", "=", id).execute());
|
||||||
if (error) return { success: false, error: error.message };
|
|
||||||
revalidatePath("/reports");
|
revalidatePath("/reports");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import { AuditDetail } from "@/components/audit/AuditDetail";
|
||||||
import { AuditFilters } from "@/components/audit/AuditFilters";
|
import { AuditFilters } from "@/components/audit/AuditFilters";
|
||||||
import { CARD_CLASS } from "@/components/ui/Card";
|
import { CARD_CLASS } from "@/components/ui/Card";
|
||||||
import { Pagination } from "@/components/ui/Pagination";
|
import { Pagination } from "@/components/ui/Pagination";
|
||||||
import { actionBadgeStyle } from "@/lib/colors";
|
import { actionBadgeStyle } from "@/lib/colors";
|
||||||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { withUser } from "@/lib/db";
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
@@ -34,33 +35,50 @@ const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", {
|
|||||||
|
|
||||||
export default async function AuditPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
export default async function AuditPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
||||||
const from = (page - 1) * PAGE_SIZE;
|
|
||||||
const to = from + PAGE_SIZE - 1;
|
|
||||||
|
|
||||||
let query = supabase
|
const { entries, count } = await withUser(await currentUserId(), async (tx) => {
|
||||||
.from("audit_log")
|
const base = () => {
|
||||||
.select("id, occurred_at, actor_name, action, target_label, target_employee_id, details", { count: "exact" })
|
let q = tx.selectFrom("audit_log");
|
||||||
.order("occurred_at", { ascending: false })
|
if (params.action) q = q.where("action", "=", params.action);
|
||||||
.range(from, to);
|
|
||||||
|
|
||||||
if (params.action) query = query.eq("action", params.action);
|
|
||||||
if (params.q) {
|
if (params.q) {
|
||||||
const q = sanitizeIlikeTerm(params.q.trim());
|
// Als Parameter gebunden statt in die Abfrage geschrieben: die
|
||||||
query = query.or(`target_label.ilike.%${q}%,details.ilike.%${q}%,actor_name.ilike.%${q}%`);
|
// Zeichen, die in der alten Filtersyntax ausbrechen konnten, haben
|
||||||
|
// hier keine Bedeutung mehr.
|
||||||
|
const like = `%${params.q.trim()}%`;
|
||||||
|
q = q.where((eb) =>
|
||||||
|
eb.or([eb("target_label", "ilike", like), eb("details", "ilike", like), eb("actor_name", "ilike", like)])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
return q;
|
||||||
|
};
|
||||||
|
|
||||||
const { data: entries, count } = await query;
|
const [entries, total] = await Promise.all([
|
||||||
const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE));
|
base()
|
||||||
|
.select(["id", "occurred_at", "actor_name", "action", "target_label", "target_employee_id", "details", "changes"])
|
||||||
|
// Nach id als zweitem Kriterium: bei gleichem Zeitstempel wäre die
|
||||||
|
// Reihenfolge sonst unbestimmt und ein Eintrag könnte auf zwei Seiten
|
||||||
|
// erscheinen oder auf keiner.
|
||||||
|
.orderBy("occurred_at", "desc")
|
||||||
|
.orderBy("id", "desc")
|
||||||
|
.limit(PAGE_SIZE)
|
||||||
|
.offset((page - 1) * PAGE_SIZE)
|
||||||
|
.execute(),
|
||||||
|
base()
|
||||||
|
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
||||||
|
.executeTakeFirst(),
|
||||||
|
]);
|
||||||
|
return { entries, count: Number(total?.anzahl ?? 0) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<AuditFilters />
|
<AuditFilters />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<p className="text-sm text-ink-muted">{count ?? 0} Einträge</p>
|
<p className="text-sm text-ink-muted">{count} Einträge</p>
|
||||||
|
|
||||||
<div className={`overflow-x-auto ${CARD_CLASS}`}>
|
<div className={`overflow-x-auto ${CARD_CLASS}`}>
|
||||||
<table className="w-full min-w-[800px] text-sm">
|
<table className="w-full min-w-[800px] text-sm">
|
||||||
@@ -74,7 +92,7 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(entries ?? []).map((entry) => {
|
{entries.map((entry) => {
|
||||||
return (
|
return (
|
||||||
<tr key={entry.id} className="border-b border-border-subtle transition-colors last:border-0 hover:bg-brand-50">
|
<tr key={entry.id} className="border-b border-border-subtle transition-colors last:border-0 hover:bg-brand-50">
|
||||||
<td className="whitespace-nowrap px-4 py-2.5 tabular-nums text-ink-body">
|
<td className="whitespace-nowrap px-4 py-2.5 tabular-nums text-ink-body">
|
||||||
@@ -98,11 +116,13 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
|
|||||||
entry.target_label
|
entry.target_label
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-ink-muted">{entry.details ?? "–"}</td>
|
<td className="px-2 py-1.5">
|
||||||
|
<AuditDetail eintrag={entry} />
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{(entries ?? []).length === 0 && (
|
{entries.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-4 py-8 text-center text-sm text-ink-muted">
|
<td colSpan={5} className="px-4 py-8 text-center text-sm text-ink-muted">
|
||||||
Keine Einträge gefunden.
|
Keine Einträge gefunden.
|
||||||
|
|||||||
@@ -1,52 +1,44 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { EmployeeDetail } from "@/components/employees/EmployeeDetail";
|
import { EmployeeDetail } from "@/components/employees/EmployeeDetail";
|
||||||
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
|
import { withUser } from "@/lib/db";
|
||||||
import { todayIso } from "@/lib/format";
|
import { todayIso } from "@/lib/format";
|
||||||
import { breadcrumbLabel, loadOrgMaps } from "@/lib/org";
|
import { breadcrumbLabel, loadOrgMaps } from "@/lib/org";
|
||||||
import { loadPlacements, type ReportingLine } from "@/lib/placement";
|
import { loadPlacements, loadReportingLines } from "@/lib/placement";
|
||||||
import { loadOpenPositions } from "@/lib/positions";
|
import { loadOpenPositions } from "@/lib/positions";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
|
||||||
|
|
||||||
type PageProps = { params: Promise<{ id: string }> };
|
type PageProps = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
export default async function EmployeeDetailPage({ params }: PageProps) {
|
export default async function EmployeeDetailPage({ params }: PageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const supabase = await createClient();
|
|
||||||
const today = todayIso();
|
const today = todayIso();
|
||||||
|
|
||||||
// Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie kommen
|
const data = await withUser(await currentUserId(), async (tx) => {
|
||||||
// aus om_reporting_lines(). Beide Abfragen filtern *in* der Funktion, es
|
// Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie
|
||||||
// wandern also neun Zeilen über die Leitung und nicht achthundert.
|
// kommen aus om_reporting_lines(). Beide Abfragen schränken *in* der
|
||||||
const [
|
// Funktion ein, es wandern also neun Zeilen über die Leitung und nicht
|
||||||
{ data: employee },
|
// achthundert.
|
||||||
{ data: ownLine },
|
const [employee, ownLines, reports, history, dependents, notes, orgMaps, placements, openPositions] =
|
||||||
{ data: reportLines },
|
await Promise.all([
|
||||||
{ data: history },
|
tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst(),
|
||||||
{ data: dependents },
|
loadReportingLines(tx, today, { employeeId: id }),
|
||||||
{ data: notes },
|
loadReportingLines(tx, today, { actingManagerId: id }),
|
||||||
orgMaps,
|
tx
|
||||||
placements,
|
.selectFrom("employee_history")
|
||||||
openPositions,
|
.selectAll()
|
||||||
] = await Promise.all([
|
.where("employee_id", "=", id)
|
||||||
supabase.from("employees").select("*").eq("id", id).single(),
|
.orderBy("event_date", "desc")
|
||||||
supabase.rpc("om_reporting_lines", { p_as_of: today }).eq("employee_id", id).maybeSingle(),
|
.orderBy("created_at", "desc")
|
||||||
supabase.rpc("om_reporting_lines", { p_as_of: today }).eq("acting_manager_id", id),
|
.execute(),
|
||||||
supabase
|
tx.selectFrom("employee_dependents").selectAll().where("employee_id", "=", id).orderBy("created_at").execute(),
|
||||||
.from("employee_history")
|
tx.selectFrom("employee_notes").selectAll().where("employee_id", "=", id).orderBy("created_at", "desc").execute(),
|
||||||
.select("*")
|
loadOrgMaps(tx),
|
||||||
.eq("employee_id", id)
|
loadPlacements(tx, { asOf: today, employeeIds: [id] }),
|
||||||
.order("event_date", { ascending: false })
|
loadOpenPositions(tx),
|
||||||
.order("created_at", { ascending: false }),
|
|
||||||
supabase.from("employee_dependents").select("*").eq("employee_id", id).order("created_at"),
|
|
||||||
supabase.from("employee_notes").select("*").eq("employee_id", id).order("created_at", { ascending: false }),
|
|
||||||
loadOrgMaps(supabase),
|
|
||||||
loadPlacements(supabase, { asOf: today, employeeIds: [id] }),
|
|
||||||
loadOpenPositions(supabase),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!employee) notFound();
|
if (!employee) return null;
|
||||||
|
const line = ownLines[0] ?? null;
|
||||||
const line = ownLine as ReportingLine | null;
|
|
||||||
const reports = (reportLines ?? []) as ReportingLine[];
|
|
||||||
|
|
||||||
// Namen für die beteiligten Personen in einem Zug: die Vertretung, die
|
// Namen für die beteiligten Personen in einem Zug: die Vertretung, die
|
||||||
// formal zuständige Leitung und die direkten Berichte.
|
// formal zuständige Leitung und die direkten Berichte.
|
||||||
@@ -57,12 +49,30 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const { data: relatedRows } = relatedIds.length
|
const relatedRows = relatedIds.length
|
||||||
? await supabase.from("employees").select("id, first_name, last_name, job_title, status").in("id", relatedIds)
|
? await tx
|
||||||
: { data: [] };
|
.selectFrom("employees")
|
||||||
const byId = new Map((relatedRows ?? []).map((e) => [e.id, e]));
|
.select(["id", "first_name", "last_name", "job_title", "status"])
|
||||||
|
.where("id", "in", relatedIds)
|
||||||
|
.execute()
|
||||||
|
: [];
|
||||||
|
|
||||||
const placement = placements.get(id) ?? null;
|
return {
|
||||||
|
employee,
|
||||||
|
line,
|
||||||
|
reports,
|
||||||
|
history,
|
||||||
|
dependents,
|
||||||
|
notes,
|
||||||
|
orgMaps,
|
||||||
|
placement: placements.get(id) ?? null,
|
||||||
|
openPositions,
|
||||||
|
byId: new Map(relatedRows.map((e) => [e.id, e])),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) notFound();
|
||||||
|
const { employee, line, reports, history, dependents, notes, orgMaps, placement, openPositions, byId } = data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmployeeDetail
|
<EmployeeDetail
|
||||||
|
|||||||
@@ -5,30 +5,16 @@ import { Avatar } from "@/components/ui/Avatar";
|
|||||||
import { CARD_CLASS } from "@/components/ui/Card";
|
import { CARD_CLASS } from "@/components/ui/Card";
|
||||||
import { Pagination } from "@/components/ui/Pagination";
|
import { Pagination } from "@/components/ui/Pagination";
|
||||||
import { StatusChip } from "@/components/ui/StatusChip";
|
import { StatusChip } from "@/components/ui/StatusChip";
|
||||||
import { applyDerivedStatusFilter } from "@/lib/employee-status-filter";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
|
import { withUser } from "@/lib/db";
|
||||||
|
import { derivedStatusFilter } from "@/lib/employee-status-filter";
|
||||||
import { fmtDate, todayIso } from "@/lib/format";
|
import { fmtDate, todayIso } from "@/lib/format";
|
||||||
import { loadPlacements } from "@/lib/placement";
|
|
||||||
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf } from "@/lib/org";
|
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf } from "@/lib/org";
|
||||||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
import { loadPlacements } from "@/lib/placement";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
|
||||||
import type { EmploymentStatus } from "@/lib/supabase/types";
|
import type { EmploymentStatus } from "@/lib/supabase/types";
|
||||||
|
|
||||||
const PAGE_SIZE = 15;
|
const PAGE_SIZE = 15;
|
||||||
|
|
||||||
const COLUMNS =
|
|
||||||
"id, first_name, last_name, personnel_number, job_title, location_id, entry_date, employment_type, weekly_hours, status, absence_type";
|
|
||||||
|
|
||||||
// Was applyFilters vom Query-Builder braucht — mehr nicht.
|
|
||||||
type Narrowable = {
|
|
||||||
eq: (column: string, value: string | number) => Narrowable;
|
|
||||||
or: (filters: string) => Narrowable;
|
|
||||||
gt: (column: string, value: string) => Narrowable;
|
|
||||||
lte: (column: string, value: string) => Narrowable;
|
|
||||||
gte: (column: string, value: string) => Narrowable;
|
|
||||||
is: (column: string, value: null) => Narrowable;
|
|
||||||
not: (column: string, operator: string, value: null) => Narrowable;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SearchParams = { q?: string; division?: string; status?: string; location?: string; page?: string };
|
type SearchParams = { q?: string; division?: string; status?: string; location?: string; page?: string };
|
||||||
|
|
||||||
type EmployeesPageProps = {
|
type EmployeesPageProps = {
|
||||||
@@ -47,25 +33,9 @@ function pageHref(params: SearchParams, page: number): string {
|
|||||||
|
|
||||||
export default async function EmployeesPage({ searchParams }: EmployeesPageProps) {
|
export default async function EmployeesPage({ searchParams }: EmployeesPageProps) {
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
||||||
const from = (page - 1) * PAGE_SIZE;
|
|
||||||
const to = from + PAGE_SIZE - 1;
|
|
||||||
const today = todayIso();
|
const today = todayIso();
|
||||||
|
|
||||||
// Die Referenzdaten kommen zuerst, weil der Bereichsfilter den Teilbaum
|
|
||||||
// braucht: „Produktion" meint die Abteilungen und Teams darunter, nicht die
|
|
||||||
// Einheit selbst — dort sitzt nur die Bereichsleitung.
|
|
||||||
const orgMaps = await loadOrgMaps(supabase);
|
|
||||||
|
|
||||||
// Nach Organisationseinheit gefiltert wird über die laufende Besetzung.
|
|
||||||
// `!inner` macht aus der Einbettung einen echten Join, sodass die Bedingung
|
|
||||||
// die Person aus dem Ergebnis nimmt statt bloss ihre eingebettete Liste zu
|
|
||||||
// leeren. Die Einbettung ändert die Form der Zeile, deshalb steht sie im
|
|
||||||
// Select und nicht in einem nachträglichen Filter.
|
|
||||||
const unitFilter = params.division && orgMaps.units.has(params.division) ? params.division : null;
|
|
||||||
|
|
||||||
// Comma-separated, so a dashboard tile can link here with the same
|
// Comma-separated, so a dashboard tile can link here with the same
|
||||||
// status set it counted rather than a narrower one.
|
// status set it counted rather than a narrower one.
|
||||||
const statuses = (params.status ?? "")
|
const statuses = (params.status ?? "")
|
||||||
@@ -73,47 +43,100 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
|||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus));
|
.filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus));
|
||||||
|
|
||||||
// Strukturell typisiert und generisch über den Builder, damit die beiden
|
const { orgMaps, employees, count, placements } = await withUser(await currentUserId(), async (tx) => {
|
||||||
// Select-Formen unten ihre Zeilenform behalten. Ein bedingt
|
// Die Referenzdaten zuerst: der Bereichsfilter braucht den Teilbaum.
|
||||||
// zusammengesetzter Select-String wird zu einer Union zweier Literale, die
|
// „Produktion" meint die Abteilungen und Teams darunter — in der Einheit
|
||||||
// der Typparser von postgrest-js nicht mehr auflösen kann — daher zwei
|
// selbst sitzt nur die Bereichsleitung.
|
||||||
// getrennte Abfragen mit einer gemeinsamen Filterkette.
|
const orgMaps = await loadOrgMaps(tx);
|
||||||
function applyFilters<Q extends Narrowable>(query: Q): Q {
|
const unitFilter = params.division && orgMaps.units.has(params.division) ? params.division : null;
|
||||||
let q = query;
|
|
||||||
if (params.q) {
|
// Eine Filterkette, zwei Abfragen: eine für die Seite, eine für die
|
||||||
const term = params.q.trim();
|
// Gesamtzahl. Am direkten Zugang teilen sie sich denselben Aufbau —
|
||||||
if (/^\d+$/.test(term)) q = q.eq("personnel_number", Number(term)) as Q;
|
// vorher brauchte es zwei getrennte Select-Formen, weil der Typparser der
|
||||||
else {
|
// API-Schicht einen bedingt zusammengesetzten Select-String nicht
|
||||||
const safe = sanitizeIlikeTerm(term);
|
// auflösen konnte.
|
||||||
q = q.or(`first_name.ilike.%${safe}%,last_name.ilike.%${safe}%,job_title.ilike.%${safe}%`) as Q;
|
const base = () => {
|
||||||
}
|
let q = tx.selectFrom("employees");
|
||||||
}
|
|
||||||
// Derived from the dates, not read off employees.status — see
|
if (unitFilter) {
|
||||||
// lib/employee-status-filter.ts for why the two can disagree.
|
// Nach Organisationseinheit gefiltert wird über die *laufende*
|
||||||
q = applyDerivedStatusFilter(q, statuses, today);
|
// Besetzung. Als EXISTS, damit eine Person nicht mehrfach erscheint,
|
||||||
if (params.location) q = q.eq("location_id", params.location) as Q;
|
// wenn sie über die Zeit mehrere Zuordnungen hatte.
|
||||||
return q;
|
const units = subtreeOf(orgMaps, unitFilter);
|
||||||
|
q = q.where((eb) =>
|
||||||
|
eb.exists(
|
||||||
|
eb
|
||||||
|
.selectFrom("position_assignments as a")
|
||||||
|
.innerJoin("om_positions as p", "p.id", "a.position_id")
|
||||||
|
.select("a.id")
|
||||||
|
.whereRef("a.employee_id", "=", "employees.id")
|
||||||
|
.where("a.valid_to", "is", null)
|
||||||
|
.where("p.org_unit_id", "in", units)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: employeesData, count } = unitFilter
|
if (params.q) {
|
||||||
? await applyFilters(
|
const term = params.q.trim();
|
||||||
supabase
|
if (/^d+$/.test(term)) {
|
||||||
.from("employees")
|
q = q.where("personnel_number", "=", Number(term));
|
||||||
.select(`${COLUMNS}, position_assignments!inner(valid_to, om_positions!inner(org_unit_id))`, { count: "exact" })
|
} else {
|
||||||
.order("last_name", { ascending: true })
|
// Als Parameter gebunden statt in die Abfrage geschrieben: die
|
||||||
.range(from, to)
|
// Zeichen, die in der alten Filtersyntax ausbrechen konnten, sind
|
||||||
.is("position_assignments.valid_to", null)
|
// hier bedeutungslos.
|
||||||
.in("position_assignments.om_positions.org_unit_id", subtreeOf(orgMaps, unitFilter))
|
const like = `%${term}%`;
|
||||||
)
|
q = q.where((eb) =>
|
||||||
: await applyFilters(
|
eb.or([eb("first_name", "ilike", like), eb("last_name", "ilike", like), eb("job_title", "ilike", like)])
|
||||||
supabase.from("employees").select(COLUMNS, { count: "exact" }).order("last_name", { ascending: true }).range(from, to)
|
|
||||||
);
|
);
|
||||||
const employees = employeesData ?? [];
|
}
|
||||||
const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE));
|
}
|
||||||
|
|
||||||
|
// Derived from the dates, not read off employees.status — see
|
||||||
|
// lib/employee-status-filter.ts for why the two can disagree.
|
||||||
|
if (statuses.length > 0) {
|
||||||
|
q = q.where((eb) => derivedStatusFilter(eb, statuses, today) ?? eb.val(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.location) q = q.where("location_id", "=", params.location);
|
||||||
|
return q;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [rows, total] = await Promise.all([
|
||||||
|
base()
|
||||||
|
.select([
|
||||||
|
"id",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"personnel_number",
|
||||||
|
"job_title",
|
||||||
|
"location_id",
|
||||||
|
"entry_date",
|
||||||
|
"employment_type",
|
||||||
|
"weekly_hours",
|
||||||
|
"status",
|
||||||
|
"absence_type",
|
||||||
|
])
|
||||||
|
// Nach id als zweitem Kriterium: bei gleichem Nachnamen wäre die
|
||||||
|
// Reihenfolge sonst unbestimmt, und dieselbe Person könnte auf zwei
|
||||||
|
// Seiten erscheinen oder auf keiner.
|
||||||
|
.orderBy("last_name")
|
||||||
|
.orderBy("id")
|
||||||
|
.limit(PAGE_SIZE)
|
||||||
|
.offset((page - 1) * PAGE_SIZE)
|
||||||
|
.execute(),
|
||||||
|
base()
|
||||||
|
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
||||||
|
.executeTakeFirst(),
|
||||||
|
]);
|
||||||
|
|
||||||
// Die Einordnung kommt über die Planstelle — nur für die 15 Zeilen dieser
|
// Die Einordnung kommt über die Planstelle — nur für die 15 Zeilen dieser
|
||||||
// Seite, nicht für den ganzen Bestand.
|
// Seite, nicht für den ganzen Bestand.
|
||||||
const placements = await loadPlacements(supabase, { asOf: today, employeeIds: employees.map((e) => e.id) });
|
const placements = await loadPlacements(tx, { asOf: today, employeeIds: rows.map((e) => e.id) });
|
||||||
|
|
||||||
|
return { orgMaps, employees: rows, count: Number(total?.anzahl ?? 0), placements };
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
|||||||
16
app/(app)/import/page.tsx
Normal file
16
app/(app)/import/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { ImportWorkbench } from "@/components/import/ImportWorkbench";
|
||||||
|
|
||||||
|
export const metadata = { title: "Import" };
|
||||||
|
|
||||||
|
export default function ImportPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="max-w-prose text-sm text-ink-muted">
|
||||||
|
Übernahme aus einer Datei — Organisation, Planstellen, Personen, Historie und Angehörige. Angelegt wird nur;
|
||||||
|
bestehende Datensätze werden nie überschrieben. Geprüft wird vor dem Schreiben, und geschrieben wird alles
|
||||||
|
zusammen oder gar nichts.
|
||||||
|
</p>
|
||||||
|
<ImportWorkbench />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,36 +2,55 @@ import { redirect } from "next/navigation";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { HireWizardProvider } from "@/components/hire/HireWizardContext";
|
import { HireWizardProvider } from "@/components/hire/HireWizardContext";
|
||||||
import { AppShell } from "@/components/shell/AppShell";
|
import { AppShell } from "@/components/shell/AppShell";
|
||||||
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
|
import { withUser } from "@/lib/db";
|
||||||
import { loadOpenNotes } from "@/lib/notes";
|
import { loadOpenNotes } from "@/lib/notes";
|
||||||
import { loadOpenPositions } from "@/lib/positions";
|
import { loadOpenPositions } from "@/lib/positions";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
|
||||||
|
|
||||||
export default async function AppLayout({ children }: { children: ReactNode }) {
|
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||||
const supabase = await createClient();
|
const userId = await currentUserId();
|
||||||
const {
|
if (!userId) redirect("/login");
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
if (!user) redirect("/login");
|
|
||||||
|
|
||||||
// Defense in depth: proxy.ts already redirects any non-active-HR session
|
// Alles in *einer* Transaktion, weil nur dort der Sitzungskontext gilt —
|
||||||
// away before this layout ever renders. Re-checking here means a gap in
|
// und damit nebenbei auf einem einheitlichen Lesestand.
|
||||||
// the proxy matcher (or a future route added outside it) still fails
|
const data = await withUser(userId, async (tx) => {
|
||||||
// closed instead of silently granting access — see docs/security.md.
|
// Hier — und nicht im Proxy — fällt die Entscheidung über den Zugang.
|
||||||
const { data: profile } = await supabase.from("profiles").select("full_name, email, role, is_active").eq("id", user.id).maybeSingle();
|
// Der Proxy prüft nur, ob überhaupt jemand angemeldet ist; er hat keine
|
||||||
if (profile?.role !== "hr" || profile?.is_active !== true) redirect("/login");
|
// Datenbankverbindung. Diese Abfrage läuft bei jedem Aufbau frisch, eine
|
||||||
|
// entzogene Freischaltung wirkt also sofort statt erst mit dem nächsten
|
||||||
|
// Sitzungstoken. Die eigentliche Grenze bleibt darunter RLS.
|
||||||
|
const profile = await tx
|
||||||
|
.selectFrom("profiles")
|
||||||
|
.select(["full_name", "email", "role", "is_active"])
|
||||||
|
.where("id", "=", userId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (profile?.role !== "hr" || profile?.is_active !== true) return null;
|
||||||
|
|
||||||
const userLabel = profile.full_name || profile.email || user.email || "";
|
const [openPositions, locations, drafts, openNotes] = await Promise.all([
|
||||||
|
loadOpenPositions(tx),
|
||||||
const [openPositions, locationsRes, draftsRes, openNotes] = await Promise.all([
|
tx.selectFrom("locations").select(["id", "name", "country"]).orderBy("name").execute(),
|
||||||
loadOpenPositions(supabase),
|
tx
|
||||||
supabase.from("locations").select("id, name, country").order("name"),
|
.selectFrom("hire_drafts")
|
||||||
supabase.from("hire_drafts").select("id, step, payload, updated_at").eq("created_by", user.id).order("updated_at", { ascending: false }),
|
.select(["id", "step", "payload", "updated_at"])
|
||||||
loadOpenNotes(supabase),
|
.where("created_by", "=", userId)
|
||||||
|
.orderBy("updated_at", "desc")
|
||||||
|
.execute(),
|
||||||
|
loadOpenNotes(tx),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
return { profile, openPositions, locations, drafts, openNotes };
|
||||||
|
});
|
||||||
|
|
||||||
|
// `data` ist null, wenn die Person angemeldet, aber nicht freigeschaltet
|
||||||
|
// ist. Ohne den Grund in der Adresse stünde sie vor einer wortlosen
|
||||||
|
// Anmeldeseite und versuchte es endlos erneut.
|
||||||
|
if (!data) redirect("/login?error=no_hr_access");
|
||||||
|
|
||||||
|
const userLabel = data.profile.full_name || data.profile.email || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HireWizardProvider openPositions={openPositions} locations={locationsRes.data ?? []} drafts={draftsRes.data ?? []}>
|
<HireWizardProvider openPositions={data.openPositions} locations={data.locations} drafts={data.drafts}>
|
||||||
<AppShell userLabel={userLabel} openNotes={openNotes}>
|
<AppShell userLabel={userLabel} openNotes={data.openNotes}>
|
||||||
{children}
|
{children}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</HireWizardProvider>
|
</HireWizardProvider>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import type { OrgUnitNode } from "@/components/orgchart/types";
|
|||||||
import { todayIso } from "@/lib/format";
|
import { todayIso } from "@/lib/format";
|
||||||
import { loadOrgAsOf } from "@/lib/orgchart-data";
|
import { loadOrgAsOf } from "@/lib/orgchart-data";
|
||||||
import { parseIsoDateParam } from "@/lib/reports";
|
import { parseIsoDateParam } from "@/lib/reports";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
|
import { withUser } from "@/lib/db";
|
||||||
|
|
||||||
type SearchParams = { asOf?: string; focus?: string };
|
type SearchParams = { asOf?: string; focus?: string };
|
||||||
|
|
||||||
@@ -18,18 +19,19 @@ export default async function OrgChartPage({ searchParams }: { searchParams: Pro
|
|||||||
// so a junk value can't reach the client as an arbitrary string.
|
// so a junk value can't reach the client as an arbitrary string.
|
||||||
const focusId = params.focus && UUID.test(params.focus) ? params.focus : null;
|
const focusId = params.focus && UUID.test(params.focus) ? params.focus : null;
|
||||||
|
|
||||||
const supabase = await createClient();
|
const { org, units } = await withUser(await currentUserId(), async (tx) => {
|
||||||
|
const [org, units] = await Promise.all([
|
||||||
const [org, { data: units }] = await Promise.all([
|
loadOrgAsOf(tx, asOf),
|
||||||
loadOrgAsOf(supabase, asOf),
|
tx.selectFrom("org_units").select(["id", "org_number", "name", "parent_id", "unit_type"]).orderBy("org_number").execute(),
|
||||||
supabase.from("org_units").select("id, org_number, name, parent_id, unit_type").order("org_number"),
|
|
||||||
]);
|
]);
|
||||||
|
return { org, units };
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<OrgChartClient
|
<OrgChartClient
|
||||||
employees={org.employees}
|
employees={org.employees}
|
||||||
units={(units ?? []) as OrgUnitNode[]}
|
units={units as OrgUnitNode[]}
|
||||||
vacancies={org.vacancies}
|
vacancies={org.vacancies}
|
||||||
asOf={asOf}
|
asOf={asOf}
|
||||||
today={today}
|
today={today}
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import { divisionOf, loadOrgMaps } from "@/lib/org";
|
|||||||
import { loadPlacements } from "@/lib/placement";
|
import { loadPlacements } from "@/lib/placement";
|
||||||
import { loadOpenPositions } from "@/lib/positions";
|
import { loadOpenPositions } from "@/lib/positions";
|
||||||
import { deriveStatusAsOf } from "@/lib/reports";
|
import { deriveStatusAsOf } from "@/lib/reports";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
import { fetchAllRows } from "@/lib/supabase/query";
|
import { withUser } from "@/lib/db";
|
||||||
|
import type { HistoryEventType } from "@/lib/supabase/types";
|
||||||
|
|
||||||
// Each KPI carries a colour already; the accent bar repeats it in a second
|
// Each KPI carries a colour already; the accent bar repeats it in a second
|
||||||
// channel so the tiles are scannable as a row rather than six identical
|
// channel so the tiles are scannable as a row rather than six identical
|
||||||
@@ -39,18 +40,6 @@ const DOT_STYLES: Record<string, string> = {
|
|||||||
const KIND_LABEL = { hire: "Eintritt", exit: "Austritt", return: "Rückkehr aus Abwesenheit" } as const;
|
const KIND_LABEL = { hire: "Eintritt", exit: "Austritt", return: "Rückkehr aus Abwesenheit" } as const;
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const supabase = await createClient();
|
|
||||||
const {
|
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
const { data: drafts } = user
|
|
||||||
? await supabase
|
|
||||||
.from("hire_drafts")
|
|
||||||
.select("id, step, payload, updated_at")
|
|
||||||
.eq("created_by", user.id)
|
|
||||||
.order("updated_at", { ascending: false })
|
|
||||||
: { data: [] };
|
|
||||||
|
|
||||||
// Built as strings, not by round-tripping a local Date through
|
// Built as strings, not by round-tripping a local Date through
|
||||||
// toISOString(): in any positive-offset zone new Date(year, 0, 1) is still
|
// toISOString(): in any positive-offset zone new Date(year, 0, 1) is still
|
||||||
// the previous year in UTC, which shifted the whole YTD window a day early
|
// the previous year in UTC, which shifted the whole YTD window a day early
|
||||||
@@ -61,6 +50,8 @@ export default async function DashboardPage() {
|
|||||||
const yearEnd = `${year}-12-31`;
|
const yearEnd = `${year}-12-31`;
|
||||||
const in60Iso = addDaysIso(today, 60);
|
const in60Iso = addDaysIso(today, 60);
|
||||||
|
|
||||||
|
const userId = await currentUserId();
|
||||||
|
|
||||||
// Headcount, FTE, Karenz and the division bars all come from one full read
|
// Headcount, FTE, Karenz and the division bars all come from one full read
|
||||||
// and the *derived* status, not from the `employees.status` column.
|
// and the *derived* status, not from the `employees.status` column.
|
||||||
//
|
//
|
||||||
@@ -69,71 +60,117 @@ export default async function DashboardPage() {
|
|||||||
// planned hire whose start date has passed, or a Karenz that ended without
|
// planned hire whose start date has passed, or a Karenz that ended without
|
||||||
// anyone recording the return, made the dashboard and the Berichte page
|
// anyone recording the return, made the dashboard and the Berichte page
|
||||||
// disagree about the same headcount. Same derivation, same numbers.
|
// disagree about the same headcount. Same derivation, same numbers.
|
||||||
// It also replaces four separate count queries with one.
|
const {
|
||||||
const [
|
drafts,
|
||||||
staffRows,
|
staffRows,
|
||||||
hiresYtdRes,
|
hiresYtd,
|
||||||
exitsYtdRes,
|
exitsYtd,
|
||||||
openPositions,
|
openPositions,
|
||||||
orgMaps,
|
orgMaps,
|
||||||
placements,
|
placements,
|
||||||
upcomingHiresRes,
|
upcomingHires,
|
||||||
upcomingExitsRes,
|
upcomingExits,
|
||||||
upcomingReturnsRes,
|
upcomingReturns,
|
||||||
historyRes,
|
history,
|
||||||
|
} = await withUser(userId, async (tx) => {
|
||||||
|
const countIn = (types: readonly HistoryEventType[]) =>
|
||||||
|
tx
|
||||||
|
.selectFrom("employee_history")
|
||||||
|
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
||||||
|
.where("event_type", "in", [...types])
|
||||||
|
.where("event_date", ">=", yearStart)
|
||||||
|
.where("event_date", "<=", yearEnd)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
const [
|
||||||
|
drafts,
|
||||||
|
staffRows,
|
||||||
|
hiresYtd,
|
||||||
|
exitsYtd,
|
||||||
|
openPositions,
|
||||||
|
orgMaps,
|
||||||
|
placements,
|
||||||
|
upcomingHires,
|
||||||
|
upcomingExits,
|
||||||
|
upcomingReturns,
|
||||||
|
history,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
fetchAllRows(() =>
|
userId
|
||||||
supabase
|
? tx
|
||||||
.from("employees")
|
.selectFrom("hire_drafts")
|
||||||
.select("id, weekly_hours, entry_date, exit_date, karenz_start_date, karenz_return_date")
|
.select(["id", "step", "payload", "updated_at"])
|
||||||
.order("id")
|
.where("created_by", "=", userId)
|
||||||
),
|
.orderBy("updated_at", "desc")
|
||||||
|
.execute()
|
||||||
|
: Promise.resolve([]),
|
||||||
|
|
||||||
|
tx
|
||||||
|
.selectFrom("employees")
|
||||||
|
.select(["id", "weekly_hours", "entry_date", "exit_date", "karenz_start_date", "karenz_return_date"])
|
||||||
|
.orderBy("id")
|
||||||
|
.execute(),
|
||||||
|
|
||||||
// Entries/exits count history events, which is what the linked report
|
// Entries/exits count history events, which is what the linked report
|
||||||
// counts too. `entry_date` would also sweep up rehires, whose event is
|
// counts too. `entry_date` would also sweep up rehires, whose event is
|
||||||
// logged as 'Wiedereintritt' — the tile and its destination then showed
|
// logged as 'Wiedereintritt' — the tile and its destination then showed
|
||||||
// different numbers for the same year.
|
// different numbers for the same year.
|
||||||
supabase
|
countIn(["Eintritt", "Wiedereintritt"]),
|
||||||
.from("employee_history")
|
countIn(["Austritt"]),
|
||||||
.select("id", { count: "exact", head: true })
|
|
||||||
.in("event_type", ["Eintritt", "Wiedereintritt"])
|
loadOpenPositions(tx),
|
||||||
.gte("event_date", yearStart)
|
loadOrgMaps(tx),
|
||||||
.lte("event_date", yearEnd),
|
loadPlacements(tx, { asOf: today }),
|
||||||
supabase
|
|
||||||
.from("employee_history")
|
tx
|
||||||
.select("id", { count: "exact", head: true })
|
.selectFrom("employees")
|
||||||
.eq("event_type", "Austritt")
|
.select(["id", "first_name", "last_name", "entry_date"])
|
||||||
.gte("event_date", yearStart)
|
.where("status", "=", "Geplant")
|
||||||
.lte("event_date", yearEnd),
|
.where("entry_date", ">=", today)
|
||||||
loadOpenPositions(supabase),
|
.where("entry_date", "<=", in60Iso)
|
||||||
loadOrgMaps(supabase),
|
.execute(),
|
||||||
loadPlacements(supabase, { asOf: today }),
|
|
||||||
supabase
|
tx
|
||||||
.from("employees")
|
.selectFrom("employees")
|
||||||
.select("id, first_name, last_name, entry_date")
|
.select(["id", "first_name", "last_name", "exit_date"])
|
||||||
.eq("status", "Geplant")
|
.where("exit_date", "is not", null)
|
||||||
.gte("entry_date", today)
|
.where("exit_date", ">=", today)
|
||||||
.lte("entry_date", in60Iso),
|
.where("exit_date", "<=", in60Iso)
|
||||||
supabase
|
.execute(),
|
||||||
.from("employees")
|
|
||||||
.select("id, first_name, last_name, exit_date")
|
tx
|
||||||
.not("exit_date", "is", null)
|
.selectFrom("employees")
|
||||||
.gte("exit_date", today)
|
.select(["id", "first_name", "last_name", "karenz_return_date"])
|
||||||
.lte("exit_date", in60Iso),
|
.where("status", "=", "Karenz")
|
||||||
supabase
|
.where("karenz_return_date", "is not", null)
|
||||||
.from("employees")
|
.where("karenz_return_date", ">=", today)
|
||||||
.select("id, first_name, last_name, karenz_return_date")
|
.where("karenz_return_date", "<=", in60Iso)
|
||||||
.eq("status", "Karenz")
|
.execute(),
|
||||||
.not("karenz_return_date", "is", null)
|
|
||||||
.gte("karenz_return_date", today)
|
tx
|
||||||
.lte("karenz_return_date", in60Iso),
|
.selectFrom("employee_history as h")
|
||||||
supabase
|
.leftJoin("employees as e", "e.id", "h.employee_id")
|
||||||
.from("employee_history")
|
.select(["h.id", "h.employee_id", "h.event_date", "h.event_type", "h.description", "e.first_name", "e.last_name"])
|
||||||
.select("id, employee_id, event_date, event_type, description")
|
.orderBy("h.event_date", "desc")
|
||||||
.order("event_date", { ascending: false })
|
.orderBy("h.created_at", "desc")
|
||||||
.order("created_at", { ascending: false })
|
.limit(10)
|
||||||
.limit(10),
|
.execute(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
drafts,
|
||||||
|
staffRows,
|
||||||
|
hiresYtd: Number(hiresYtd?.anzahl ?? 0),
|
||||||
|
exitsYtd: Number(exitsYtd?.anzahl ?? 0),
|
||||||
|
openPositions,
|
||||||
|
orgMaps,
|
||||||
|
placements,
|
||||||
|
upcomingHires,
|
||||||
|
upcomingExits,
|
||||||
|
upcomingReturns,
|
||||||
|
history,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// "Aktiv" means status Aktiv — somebody on Karenz is employed but not
|
// "Aktiv" means status Aktiv — somebody on Karenz is employed but not
|
||||||
// active, and is counted by its own tile instead. FTE follows the same
|
// active, and is counted by its own tile instead. FTE follows the same
|
||||||
// set: Karenz contributes no capacity, so including it would overstate
|
// set: Karenz contributes no capacity, so including it would overstate
|
||||||
@@ -166,19 +203,19 @@ export default async function DashboardPage() {
|
|||||||
|
|
||||||
type UpcomingItem = { id: string; label: string; date: string; kind: keyof typeof KIND_LABEL };
|
type UpcomingItem = { id: string; label: string; date: string; kind: keyof typeof KIND_LABEL };
|
||||||
const upcoming: UpcomingItem[] = [
|
const upcoming: UpcomingItem[] = [
|
||||||
...(upcomingHiresRes.data ?? []).map((e) => ({
|
...(upcomingHires).map((e) => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
label: `${e.first_name} ${e.last_name}`,
|
label: `${e.first_name} ${e.last_name}`,
|
||||||
date: e.entry_date,
|
date: e.entry_date,
|
||||||
kind: "hire" as const,
|
kind: "hire" as const,
|
||||||
})),
|
})),
|
||||||
...(upcomingExitsRes.data ?? []).map((e) => ({
|
...(upcomingExits).map((e) => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
label: `${e.first_name} ${e.last_name}`,
|
label: `${e.first_name} ${e.last_name}`,
|
||||||
date: e.exit_date!,
|
date: e.exit_date!,
|
||||||
kind: "exit" as const,
|
kind: "exit" as const,
|
||||||
})),
|
})),
|
||||||
...(upcomingReturnsRes.data ?? []).map((e) => ({
|
...(upcomingReturns).map((e) => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
label: `${e.first_name} ${e.last_name}`,
|
label: `${e.first_name} ${e.last_name}`,
|
||||||
date: e.karenz_return_date!,
|
date: e.karenz_return_date!,
|
||||||
@@ -188,12 +225,6 @@ export default async function DashboardPage() {
|
|||||||
.sort((a, b) => a.date.localeCompare(b.date))
|
.sort((a, b) => a.date.localeCompare(b.date))
|
||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
|
|
||||||
const historyEmployeeIds = Array.from(new Set((historyRes.data ?? []).map((h) => h.employee_id)));
|
|
||||||
const historyEmployeesRes = historyEmployeeIds.length
|
|
||||||
? await supabase.from("employees").select("id, first_name, last_name").in("id", historyEmployeeIds)
|
|
||||||
: { data: [] as { id: string; first_name: string; last_name: string }[] };
|
|
||||||
const employeeNameById = new Map((historyEmployeesRes.data ?? []).map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
|
|
||||||
|
|
||||||
// Each tile links to the view that shows what it counts, with the filters
|
// Each tile links to the view that shows what it counts, with the filters
|
||||||
// pre-applied.
|
// pre-applied.
|
||||||
//
|
//
|
||||||
@@ -214,13 +245,13 @@ export default async function DashboardPage() {
|
|||||||
{ label: "FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" },
|
{ label: "FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" },
|
||||||
{
|
{
|
||||||
label: "Eintritte (Jahr)",
|
label: "Eintritte (Jahr)",
|
||||||
value: hiresYtdRes.count ?? 0,
|
value: hiresYtd,
|
||||||
tone: "success",
|
tone: "success",
|
||||||
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
|
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Austritte (Jahr)",
|
label: "Austritte (Jahr)",
|
||||||
value: exitsYtdRes.count ?? 0,
|
value: exitsYtd,
|
||||||
tone: "danger",
|
tone: "danger",
|
||||||
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
|
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
|
||||||
},
|
},
|
||||||
@@ -300,14 +331,14 @@ export default async function DashboardPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardTitle className="mb-1">Letzte Aktivitäten</CardTitle>
|
<CardTitle className="mb-1">Letzte Aktivitäten</CardTitle>
|
||||||
<ul className="flex flex-col divide-y divide-border-subtle">
|
<ul className="flex flex-col divide-y divide-border-subtle">
|
||||||
{(historyRes.data ?? []).map((h) => (
|
{(history).map((h) => (
|
||||||
<li key={h.id} className="flex gap-2.5 py-2.5">
|
<li key={h.id} className="flex gap-2.5 py-2.5">
|
||||||
{/* Dot aligned to the first line of text, not centred on the
|
{/* Dot aligned to the first line of text, not centred on the
|
||||||
whole row, so it stays put as descriptions wrap. */}
|
whole row, so it stays put as descriptions wrap. */}
|
||||||
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${DOT_STYLES[h.event_type] ?? "bg-ink-muted"}`} aria-hidden />
|
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${DOT_STYLES[h.event_type] ?? "bg-ink-muted"}`} aria-hidden />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<span className="text-sm font-semibold text-ink">{employeeNameById.get(h.employee_id) ?? "Unbekannt"}</span>
|
<span className="text-sm font-semibold text-ink">{h.first_name && h.last_name ? `${h.first_name} ${h.last_name}` : "Unbekannt"}</span>
|
||||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(h.event_type)}`}>
|
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(h.event_type)}`}>
|
||||||
{h.event_type}
|
{h.event_type}
|
||||||
</span>
|
</span>
|
||||||
@@ -316,7 +347,7 @@ export default async function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{(historyRes.data ?? []).length === 0 && <p className="py-2 text-sm text-ink-muted">Keine Aktivitäten vorhanden.</p>}
|
{(history).length === 0 && <p className="py-2 text-sm text-ink-muted">Keine Aktivitäten vorhanden.</p>}
|
||||||
</ul>
|
</ul>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,21 +3,23 @@ import { PositionsPageClient } from "@/components/positions/PositionsPageClient"
|
|||||||
import { daysBetweenIso } from "@/lib/format";
|
import { daysBetweenIso } from "@/lib/format";
|
||||||
import { loadOrgMaps } from "@/lib/org";
|
import { loadOrgMaps } from "@/lib/org";
|
||||||
import { loadOpenPositions } from "@/lib/positions";
|
import { loadOpenPositions } from "@/lib/positions";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
|
import { withUser } from "@/lib/db";
|
||||||
|
|
||||||
export default async function PositionsPage() {
|
export default async function PositionsPage() {
|
||||||
const supabase = await createClient();
|
const { openPositions, orgMaps, chiefRows } = await withUser(await currentUserId(), async (tx) => {
|
||||||
|
const [openPositions, orgMaps, chiefRows] = await Promise.all([
|
||||||
const [openPositions, orgMaps, { data: chiefRows }] = await Promise.all([
|
loadOpenPositions(tx),
|
||||||
loadOpenPositions(supabase),
|
loadOrgMaps(tx),
|
||||||
loadOrgMaps(supabase),
|
|
||||||
// Wo es schon eine gültige Leitungsplanstelle gibt, lässt der
|
// Wo es schon eine gültige Leitungsplanstelle gibt, lässt der
|
||||||
// Unique-Index keine zweite zu — das gehört in den Dialog, nicht in eine
|
// Unique-Index keine zweite zu — das gehört in den Dialog, nicht in eine
|
||||||
// Fehlermeldung nach dem Absenden.
|
// Fehlermeldung nach dem Absenden.
|
||||||
supabase.from("om_positions").select("org_unit_id").eq("is_chief", true).is("valid_to", null),
|
tx.selectFrom("om_positions").select("org_unit_id").where("is_chief", "=", true).where("valid_to", "is", null).execute(),
|
||||||
]);
|
]);
|
||||||
|
return { openPositions, orgMaps, chiefRows };
|
||||||
|
});
|
||||||
|
|
||||||
const withChief = new Set((chiefRows ?? []).map((r) => r.org_unit_id));
|
const withChief = new Set(chiefRows.map((r) => r.org_unit_id));
|
||||||
const units: UnitOption[] = orgMaps.unitList.map((u) => ({
|
const units: UnitOption[] = orgMaps.unitList.map((u) => ({
|
||||||
id: u.id,
|
id: u.id,
|
||||||
name: u.name,
|
name: u.name,
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
totalForRows,
|
totalForRows,
|
||||||
} from "@/lib/reports";
|
} from "@/lib/reports";
|
||||||
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
|
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
|
import { withUser } from "@/lib/db";
|
||||||
|
|
||||||
type SearchParams = {
|
type SearchParams = {
|
||||||
mode?: string;
|
mode?: string;
|
||||||
@@ -35,7 +36,6 @@ type SearchParams = {
|
|||||||
|
|
||||||
export default async function ReportsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
export default async function ReportsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const supabase = await createClient();
|
|
||||||
const mode = parseMode(params.mode);
|
const mode = parseMode(params.mode);
|
||||||
|
|
||||||
// Both modes are parsed up front so the data load can start before
|
// Both modes are parsed up front so the data load can start before
|
||||||
@@ -55,11 +55,17 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
|||||||
// in, so all three go out together. Against a hosted database a round trip
|
// in, so all three go out together. Against a hosted database a round trip
|
||||||
// costs about as much as the query itself, which made this page's three
|
// costs about as much as the query itself, which made this page's three
|
||||||
// sequential waves its dominant cost.
|
// sequential waves its dominant cost.
|
||||||
const [{ lookups, divisions, locations }, { data: userRes }, events, employees] = await Promise.all([
|
const userId = await currentUserId();
|
||||||
loadOrgLookups(supabase),
|
|
||||||
supabase.auth.getUser(),
|
// Alles in einer Transaktion — dort gilt der Sitzungskontext, und der
|
||||||
|
// Lesestand ist über alle Abfragen hinweg derselbe. Vorher waren es drei
|
||||||
|
// Wellen nacheinander, was gegen eine entfernte Datenbank der teuerste
|
||||||
|
// Teil dieser Seite war.
|
||||||
|
const { lookups, divisions, locations, events, employees, savedReports } = await withUser(userId, async (tx) => {
|
||||||
|
const [{ lookups, divisions, locations }, events, employees, savedReports] = await Promise.all([
|
||||||
|
loadOrgLookups(tx),
|
||||||
mode === "events"
|
mode === "events"
|
||||||
? loadEventHistory(supabase, {
|
? loadEventHistory(tx, {
|
||||||
eventType: eventType ?? undefined,
|
eventType: eventType ?? undefined,
|
||||||
division: params.division,
|
division: params.division,
|
||||||
location: params.location,
|
location: params.location,
|
||||||
@@ -68,7 +74,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
|||||||
})
|
})
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
mode === "snapshot"
|
mode === "snapshot"
|
||||||
? loadSnapshotEmployees(supabase, {
|
? loadSnapshotEmployees(tx, {
|
||||||
division: params.division,
|
division: params.division,
|
||||||
location: params.location,
|
location: params.location,
|
||||||
status: params.status,
|
status: params.status,
|
||||||
@@ -76,13 +82,17 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
|||||||
asOf,
|
asOf,
|
||||||
})
|
})
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
|
userId
|
||||||
|
? tx
|
||||||
|
.selectFrom("saved_reports")
|
||||||
|
.select(["id", "name", "config"])
|
||||||
|
.where("created_by", "=", userId)
|
||||||
|
.orderBy("created_at", "desc")
|
||||||
|
.execute()
|
||||||
|
: Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
|
return { lookups, divisions, locations, events, employees, savedReports };
|
||||||
const user = userRes.user;
|
});
|
||||||
// Still a wave of its own: it needs the user id the call above resolves.
|
|
||||||
const { data: savedReports } = user
|
|
||||||
? await supabase.from("saved_reports").select("id, name, config").eq("created_by", user.id).order("created_at", { ascending: false })
|
|
||||||
: { data: [] };
|
|
||||||
|
|
||||||
if (mode === "events") {
|
if (mode === "events") {
|
||||||
const rows = aggregateEvents(events, eventGroup, eventSplit, lookups);
|
const rows = aggregateEvents(events, eventGroup, eventSplit, lookups);
|
||||||
@@ -100,7 +110,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
|||||||
recordCount={events.length}
|
recordCount={events.length}
|
||||||
divisions={divisions}
|
divisions={divisions}
|
||||||
locations={locations}
|
locations={locations}
|
||||||
savedReports={savedReports ?? []}
|
savedReports={savedReports}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
@@ -127,7 +137,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
|||||||
recordCount={employees.length}
|
recordCount={employees.length}
|
||||||
divisions={divisions}
|
divisions={divisions}
|
||||||
locations={locations}
|
locations={locations}
|
||||||
savedReports={savedReports ?? []}
|
savedReports={savedReports}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,7 +25,17 @@ type LoginPageProps = {
|
|||||||
|
|
||||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const code = params.error && Object.hasOwn(ERROR_MESSAGES, params.error) ? (params.error as ErrorCode) : null;
|
// Ein unbekannter Code wird nicht verschluckt, sondern auf die allgemeine
|
||||||
|
// Meldung abgebildet: Auth.js schickt bei einem Fehlschlag seine eigenen
|
||||||
|
// Codes („Configuration", „AccessDenied", „OAuthCallbackError" …), und ohne
|
||||||
|
// diese Abbildung stünde man vor einer Anmeldeseite, die so tut, als wäre
|
||||||
|
// nichts gewesen. Angezeigt wird trotzdem nur eigener Text — der Parameter
|
||||||
|
// selbst kommt nie auf die Seite.
|
||||||
|
const code = params.error
|
||||||
|
? Object.hasOwn(ERROR_MESSAGES, params.error)
|
||||||
|
? (params.error as ErrorCode)
|
||||||
|
: "sso_failed"
|
||||||
|
: null;
|
||||||
const error = code ? ERROR_MESSAGES[code] : null;
|
const error = code ? ERROR_MESSAGES[code] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
9
app/api/auth/[...nextauth]/route.ts
Normal file
9
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { handlers } from "@/auth";
|
||||||
|
|
||||||
|
// Der Rückweg aus Entra ID und die Endpunkte für An- und Abmeldung.
|
||||||
|
//
|
||||||
|
// Tritt an die Stelle von app/auth/callback/route.ts: den Tausch des
|
||||||
|
// Einmal-Codes gegen eine Sitzung, die Prüfung von `state` und `nonce` und
|
||||||
|
// das Setzen des Cookies macht jetzt Auth.js. Die Rückruf-Adresse in der
|
||||||
|
// Entra-Anwendungsregistrierung ändert sich dadurch — siehe docs/entra-sso.md.
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
import { createAdminClient } from "@/lib/supabase/admin";
|
import { asSystem } from "@/lib/db";
|
||||||
|
import { callFunction } from "@/lib/db/rpc";
|
||||||
|
|
||||||
// Applies effective-dated changes (Versetzung/Beförderung/Karenz/Reorg/Daten
|
// Applies effective-dated changes (Versetzung/Beförderung/Karenz/Reorg/Daten
|
||||||
// ändern with a future "Wirksam ab" date) once their date has arrived — see
|
// ändern with a future "Wirksam ab" date) once their date has arrived — see
|
||||||
@@ -13,13 +14,15 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Nicht autorisiert." }, { status: 401 });
|
return NextResponse.json({ error: "Nicht autorisiert." }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabase = createAdminClient();
|
// Kein privilegierter Zugang mehr: derselbe Datenbankbenutzer ohne
|
||||||
const { data, error } = await supabase.rpc("apply_due_pending_changes");
|
// BYPASSRLS wie überall. apply_due_pending_changes ist SECURITY DEFINER
|
||||||
|
// und prüft selbst, was sie tut — der Dienstschlüssel, der RLS aushebelte,
|
||||||
if (error) {
|
// ist damit entfallen.
|
||||||
console.error("apply_due_pending_changes failed:", error);
|
try {
|
||||||
|
const applied = await asSystem((tx) => callFunction(tx, "apply_due_pending_changes"));
|
||||||
|
return NextResponse.json({ applied });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("apply_due_pending_changes failed:", err);
|
||||||
return NextResponse.json({ error: "Interner Fehler." }, { status: 500 });
|
return NextResponse.json({ error: "Interner Fehler." }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ applied: data });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ import { statusLabel } from "@/lib/absence";
|
|||||||
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
||||||
import { todayIso } from "@/lib/format";
|
import { todayIso } from "@/lib/format";
|
||||||
import { subtreeOf } from "@/lib/org";
|
import { subtreeOf } from "@/lib/org";
|
||||||
import { loadPlacements, loadReportingLines } from "@/lib/placement";
|
import { loadPlacements, loadReportingLineMap } from "@/lib/placement";
|
||||||
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports";
|
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports";
|
||||||
import { loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
|
import { loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
|
||||||
import { requireHrUser } from "@/lib/supabase/auth";
|
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||||
import { fetchAllRows } from "@/lib/supabase/query";
|
import { withUser } from "@/lib/db";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
|
||||||
import type { Database, EmploymentType, Weekday } from "@/lib/supabase/types";
|
import type { Database, EmploymentType, Weekday } from "@/lib/supabase/types";
|
||||||
|
|
||||||
// Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt
|
// Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt
|
||||||
@@ -26,9 +25,8 @@ type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"] & {
|
|||||||
// filtering happens against the *derived* status as of that date rather
|
// filtering happens against the *derived* status as of that date rather
|
||||||
// than the live `status` column — see deriveStatusAsOf.
|
// than the live `status` column — see deriveStatusAsOf.
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const supabase = await createClient();
|
const gate = await requireHrUser();
|
||||||
const denied = await requireHrUser(supabase);
|
if ("denied" in gate) return gate.denied;
|
||||||
if (denied) return denied;
|
|
||||||
|
|
||||||
const params = request.nextUrl.searchParams;
|
const params = request.nextUrl.searchParams;
|
||||||
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
||||||
@@ -44,23 +42,38 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const stichtag = asOf ?? todayIso();
|
const stichtag = asOf ?? todayIso();
|
||||||
|
|
||||||
|
const { employees, lookups, orgMaps, allEmployees, dependentsCounts, placements, lines } = await withUser(
|
||||||
|
gate.userId,
|
||||||
|
async (tx) => {
|
||||||
function employeeQuery() {
|
function employeeQuery() {
|
||||||
let query = supabase.from("employees").select("*").order("last_name").order("id");
|
let q = tx.selectFrom("employees").selectAll().orderBy("last_name").orderBy("id");
|
||||||
if (filters.location) query = query.eq("location_id", filters.location);
|
if (filters.location) q = q.where("location_id", "=", filters.location);
|
||||||
if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType);
|
if (filters.employment) q = q.where("employment_type", "=", filters.employment as EmploymentType);
|
||||||
if (!asOf) query = query.in("status", statuses);
|
if (!asOf) q = q.where("status", "in", statuses);
|
||||||
return query;
|
return q;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [employees, { lookups, orgMaps }, allEmployees, dependentsCounts, placements, lines] = await Promise.all([
|
const [employees, lookupResult, allEmployees, dependentsCounts, placements, lines] = await Promise.all([
|
||||||
fetchAllRows(employeeQuery),
|
employeeQuery().execute(),
|
||||||
loadOrgLookups(supabase),
|
loadOrgLookups(tx),
|
||||||
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")),
|
tx.selectFrom("employees").select(["id", "first_name", "last_name"]).orderBy("id").execute(),
|
||||||
loadDependentsCounts(supabase),
|
loadDependentsCounts(tx),
|
||||||
loadPlacements(supabase, { asOf: stichtag }),
|
loadPlacements(tx, { asOf: stichtag }),
|
||||||
loadReportingLines(supabase, stichtag),
|
loadReportingLineMap(tx, stichtag),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
employees,
|
||||||
|
lookups: lookupResult.lookups,
|
||||||
|
orgMaps: lookupResult.orgMaps,
|
||||||
|
allEmployees,
|
||||||
|
dependentsCounts,
|
||||||
|
placements,
|
||||||
|
lines,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const managerName = new Map(allEmployees.map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
|
const managerName = new Map(allEmployees.map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
|
||||||
// Der Einheitenfilter meint den ganzen Teilbaum — sonst enthielte ein
|
// Der Einheitenfilter meint den ganzen Teilbaum — sonst enthielte ein
|
||||||
// Export für "Produktion" nur die Bereichsleitung.
|
// Export für "Produktion" nur die Bereichsleitung.
|
||||||
|
|||||||
@@ -2,25 +2,25 @@ import { NextResponse, type NextRequest } from "next/server";
|
|||||||
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
||||||
import { EVENT_TYPE_LABELS, parseEventDateParam, parseEventType, type OrgLookups, type ReportEvent } from "@/lib/reports";
|
import { EVENT_TYPE_LABELS, parseEventDateParam, parseEventType, type OrgLookups, type ReportEvent } from "@/lib/reports";
|
||||||
import { loadEventHistory, loadOrgLookups } from "@/lib/reports-data";
|
import { loadEventHistory, loadOrgLookups } from "@/lib/reports-data";
|
||||||
import { requireHrUser } from "@/lib/supabase/auth";
|
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { withUser } from "@/lib/db";
|
||||||
|
|
||||||
// Full raw event-log dump — one row per employee_history entry in the
|
// Full raw event-log dump — one row per employee_history entry in the
|
||||||
// selected period (default: current year), every event type unless one is
|
// selected period (default: current year), every event type unless one is
|
||||||
// picked, org columns resolved from each affected employee's current
|
// picked, org columns resolved from each affected employee's current
|
||||||
// placement (see loadEventHistory).
|
// placement (see loadEventHistory).
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const supabase = await createClient();
|
const gate = await requireHrUser();
|
||||||
const denied = await requireHrUser(supabase);
|
if ("denied" in gate) return gate.denied;
|
||||||
if (denied) return denied;
|
|
||||||
|
|
||||||
const params = request.nextUrl.searchParams;
|
const params = request.nextUrl.searchParams;
|
||||||
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
||||||
const eventType = parseEventType(params.get("eventType"));
|
const eventType = parseEventType(params.get("eventType"));
|
||||||
|
|
||||||
|
const { lookups, events } = await withUser(gate.userId, async (tx) => {
|
||||||
const [{ lookups }, events] = await Promise.all([
|
const [{ lookups }, events] = await Promise.all([
|
||||||
loadOrgLookups(supabase),
|
loadOrgLookups(tx),
|
||||||
loadEventHistory(supabase, {
|
loadEventHistory(tx, {
|
||||||
eventType: eventType ?? undefined,
|
eventType: eventType ?? undefined,
|
||||||
division: params.get("division") ?? undefined,
|
division: params.get("division") ?? undefined,
|
||||||
location: params.get("location") ?? undefined,
|
location: params.get("location") ?? undefined,
|
||||||
@@ -28,6 +28,8 @@ export async function GET(request: NextRequest) {
|
|||||||
to: parseEventDateParam(params.get("to")),
|
to: parseEventDateParam(params.get("to")),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
return { lookups, events };
|
||||||
|
});
|
||||||
|
|
||||||
const columns = eventExportColumns(lookups);
|
const columns = eventExportColumns(lookups);
|
||||||
const filename = exportFilename(`ereignisse-${eventType ?? "alle"}`, format);
|
const filename = exportFilename(`ereignisse-${eventType ?? "alle"}`, format);
|
||||||
|
|||||||
@@ -24,23 +24,24 @@ import {
|
|||||||
type ReportRow,
|
type ReportRow,
|
||||||
} from "@/lib/reports";
|
} from "@/lib/reports";
|
||||||
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
|
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
|
||||||
import { requireHrUser } from "@/lib/supabase/auth";
|
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { withUser } from "@/lib/db";
|
||||||
|
|
||||||
// Exports exactly the pivot table currently on screen (same mode/measure or
|
// Exports exactly the pivot table currently on screen (same mode/measure or
|
||||||
// event-type/group/split/filters, read from the query string the client
|
// event-type/group/split/filters, read from the query string the client
|
||||||
// already keeps in the URL) as a flat table — one row per group, one column
|
// already keeps in the URL) as a flat table — one row per group, one column
|
||||||
// per split value if a split is active.
|
// per split value if a split is active.
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const supabase = await createClient();
|
const gate = await requireHrUser();
|
||||||
const denied = await requireHrUser(supabase);
|
if ("denied" in gate) return gate.denied;
|
||||||
if (denied) return denied;
|
|
||||||
|
|
||||||
const params = request.nextUrl.searchParams;
|
const params = request.nextUrl.searchParams;
|
||||||
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
||||||
const mode = parseMode(params.get("mode"));
|
const mode = parseMode(params.get("mode"));
|
||||||
const { lookups } = await loadOrgLookups(supabase);
|
// Eine Transaktion für Nachschlagewerte und Daten: dort gilt der
|
||||||
|
// Sitzungskontext, und beide sehen denselben Lesestand.
|
||||||
|
const { rows, columns, filenameBase } = await withUser(gate.userId, async (tx) => {
|
||||||
|
const { lookups } = await loadOrgLookups(tx);
|
||||||
let rows: ReportRow[];
|
let rows: ReportRow[];
|
||||||
let columns: ExportColumn<ReportRow>[];
|
let columns: ExportColumn<ReportRow>[];
|
||||||
let filenameBase: string;
|
let filenameBase: string;
|
||||||
@@ -49,7 +50,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const group = parseEventGroupDimension(params.get("group"));
|
const group = parseEventGroupDimension(params.get("group"));
|
||||||
const split = parseEventSplitDimension(params.get("split"));
|
const split = parseEventSplitDimension(params.get("split"));
|
||||||
const eventType = parseEventType(params.get("eventType"));
|
const eventType = parseEventType(params.get("eventType"));
|
||||||
const events = await loadEventHistory(supabase, {
|
const events = await loadEventHistory(tx, {
|
||||||
eventType: eventType ?? undefined,
|
eventType: eventType ?? undefined,
|
||||||
division: params.get("division") ?? undefined,
|
division: params.get("division") ?? undefined,
|
||||||
location: params.get("location") ?? undefined,
|
location: params.get("location") ?? undefined,
|
||||||
@@ -64,7 +65,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const group = parseGroupDimension(params.get("group"));
|
const group = parseGroupDimension(params.get("group"));
|
||||||
const split = parseSplitDimension(params.get("split"));
|
const split = parseSplitDimension(params.get("split"));
|
||||||
const asOf = parseIsoDateParam(params.get("asOf"));
|
const asOf = parseIsoDateParam(params.get("asOf"));
|
||||||
const employees = await loadSnapshotEmployees(supabase, {
|
const employees = await loadSnapshotEmployees(tx, {
|
||||||
division: params.get("division") ?? undefined,
|
division: params.get("division") ?? undefined,
|
||||||
location: params.get("location") ?? undefined,
|
location: params.get("location") ?? undefined,
|
||||||
status: params.get("status") ?? undefined,
|
status: params.get("status") ?? undefined,
|
||||||
@@ -76,7 +77,11 @@ export async function GET(request: NextRequest) {
|
|||||||
filenameBase = `bericht-${measure}-${group}`;
|
filenameBase = `bericht-${measure}-${group}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { rows, columns, filenameBase };
|
||||||
|
});
|
||||||
|
|
||||||
const filename = exportFilename(filenameBase, format);
|
const filename = exportFilename(filenameBase, format);
|
||||||
|
|
||||||
const body = format === "xlsx" ? await toXlsx(rows, columns, "Bericht") : toCsv(rows, columns);
|
const body = format === "xlsx" ? await toXlsx(rows, columns, "Bericht") : toCsv(rows, columns);
|
||||||
// TS 5.9's Uint8Array<ArrayBufferLike> vs DOM's BlobPart/ArrayBuffer<> generic
|
// TS 5.9's Uint8Array<ArrayBufferLike> vs DOM's BlobPart/ArrayBuffer<> generic
|
||||||
// mismatch (microsoft/TypeScript#59417) — a real Uint8Array works fine here.
|
// mismatch (microsoft/TypeScript#59417) — a real Uint8Array works fine here.
|
||||||
|
|||||||
131
app/api/import/route.ts
Normal file
131
app/api/import/route.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||||
|
import { withUser } from "@/lib/db";
|
||||||
|
import { bestandLaden, laden, type Ladebericht } from "@/lib/import/load";
|
||||||
|
import { dateiLesen, type ImportSheet } from "@/lib/import/parse";
|
||||||
|
import { pruefe, type Befund } from "@/lib/import/validate";
|
||||||
|
|
||||||
|
// Massenimport — Prüflauf und Übernahme über denselben Weg.
|
||||||
|
//
|
||||||
|
// Es gibt bewusst **keinen** Zwischenspeicher zwischen beiden Schritten. Die
|
||||||
|
// Oberfläche schickt die Datei zweimal: einmal mit `pruefen=1`, um den
|
||||||
|
// Bericht zu zeigen, und nach der Bestätigung noch einmal zum Übernehmen.
|
||||||
|
// Das kostet eine Übertragung und erspart serverseitigen Zustand, der
|
||||||
|
// ablaufen, vollaufen oder zwischen zwei Personen verwechselt werden kann.
|
||||||
|
//
|
||||||
|
// Beide Läufe sehen denselben Bestand, weil Prüfung und Schreiben in
|
||||||
|
// derselben Transaktion stattfinden. Zwischen „geprüft" und „geschrieben"
|
||||||
|
// passt sonst eine fremde Änderung — etwa jemand, der dieselbe Planstelle
|
||||||
|
// besetzt.
|
||||||
|
|
||||||
|
/** Bricht die Transaktion ab, ohne einen Fehler zu sein. */
|
||||||
|
class Rueckabwicklung extends Error {
|
||||||
|
constructor(readonly nutzlast: unknown) {
|
||||||
|
super("Prüflauf");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const maxDuration = 120;
|
||||||
|
|
||||||
|
type Antwort = {
|
||||||
|
ok: boolean;
|
||||||
|
geprueft: boolean;
|
||||||
|
blaetter: string[];
|
||||||
|
fehler: Befund[];
|
||||||
|
hinweise: Befund[];
|
||||||
|
anzahl: Record<string, number>;
|
||||||
|
bericht?: Ladebericht;
|
||||||
|
meldung?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const gate = await requireHrUser();
|
||||||
|
if ("denied" in gate) return gate.denied;
|
||||||
|
|
||||||
|
const form = await request.formData();
|
||||||
|
const nurPruefen = form.get("pruefen") === "1";
|
||||||
|
const dateien = form.getAll("datei").filter((f): f is File => f instanceof File);
|
||||||
|
|
||||||
|
if (dateien.length === 0) {
|
||||||
|
return NextResponse.json({ ok: false, meldung: "Keine Datei erhalten." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mehrere Dateien werden zusammengesetzt: eine Mappe mit allen Blättern
|
||||||
|
// oder eine CSV je Blatt sind derselbe Vorgang.
|
||||||
|
const blaetter: ImportSheet[] = [];
|
||||||
|
const lesefehler: string[] = [];
|
||||||
|
for (const datei of dateien) {
|
||||||
|
const ergebnis = await dateiLesen(datei.name, await datei.arrayBuffer());
|
||||||
|
blaetter.push(...ergebnis.blaetter);
|
||||||
|
lesefehler.push(...ergebnis.fehler);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lesefehler.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
geprueft: true,
|
||||||
|
blaetter: blaetter.map((b) => b.name),
|
||||||
|
fehler: lesefehler.map((m) => ({ blatt: "Datei", zeile: null, spalte: null, meldung: m })),
|
||||||
|
hinweise: [],
|
||||||
|
anzahl: {},
|
||||||
|
} satisfies Antwort,
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profil = await withUser(gate.userId, (tx) =>
|
||||||
|
tx.selectFrom("profiles").select(["full_name", "email"]).where("id", "=", gate.userId).executeTakeFirst()
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const antwort = await withUser(gate.userId, async (tx) => {
|
||||||
|
const bestand = await bestandLaden(tx);
|
||||||
|
const geprueft = pruefe(blaetter, bestand);
|
||||||
|
|
||||||
|
const basis: Antwort = {
|
||||||
|
ok: geprueft.fehler.length === 0,
|
||||||
|
geprueft: true,
|
||||||
|
blaetter: blaetter.map((b) => b.name),
|
||||||
|
fehler: geprueft.fehler,
|
||||||
|
hinweise: geprueft.hinweise,
|
||||||
|
anzahl: geprueft.anzahl,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fehler oder Prüflauf: die Transaktion wird zurückgerollt. Beim
|
||||||
|
// Prüflauf hat sie trotzdem echte Abfragen gemacht — der Bericht
|
||||||
|
// beruht also auf dem tatsächlichen Bestand, nicht auf einer Kopie.
|
||||||
|
if (!basis.ok || nurPruefen) throw new Rueckabwicklung({ ...basis, geprueft: nurPruefen || !basis.ok });
|
||||||
|
|
||||||
|
const bericht = await laden(tx, geprueft.datensatz, bestand, {
|
||||||
|
userId: gate.userId,
|
||||||
|
name: profil?.full_name || profil?.email || "Unbekannt",
|
||||||
|
});
|
||||||
|
return { ...basis, geprueft: false, bericht };
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(antwort);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Rueckabwicklung) {
|
||||||
|
const nutzlast = err.nutzlast as Antwort;
|
||||||
|
return NextResponse.json(nutzlast, { status: nutzlast.ok ? 200 : 422 });
|
||||||
|
}
|
||||||
|
// Ein echter Fehler beim Schreiben. Die Transaktion ist zurückgerollt,
|
||||||
|
// es steht also nichts Halbes in der Datenbank.
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
geprueft: false,
|
||||||
|
blaetter: blaetter.map((b) => b.name),
|
||||||
|
fehler: [],
|
||||||
|
hinweise: [],
|
||||||
|
anzahl: {},
|
||||||
|
meldung:
|
||||||
|
err instanceof Error
|
||||||
|
? `Der Import wurde vollständig zurückgenommen. Grund: ${err.message}`
|
||||||
|
: "Der Import wurde vollständig zurückgenommen.",
|
||||||
|
} satisfies Antwort,
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
app/api/import/template/route.ts
Normal file
96
app/api/import/template/route.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import ExcelJS from "exceljs";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||||
|
import { exportFilename, exportResponseHeaders } from "@/lib/export";
|
||||||
|
import { BLAETTER } from "@/lib/import/schema";
|
||||||
|
|
||||||
|
// Die Vorlage entsteht aus demselben Schema wie die Prüfung.
|
||||||
|
//
|
||||||
|
// Das ist der Punkt: eine von Hand gepflegte Beispieldatei läuft dem Code
|
||||||
|
// hinterher, und dann verlangt die Vorlage eine Spalte, die es nicht mehr
|
||||||
|
// gibt — oder umgekehrt. Hier kann das nicht passieren; kommt in schema.ts
|
||||||
|
// eine Spalte dazu, steht sie beim nächsten Herunterladen drin.
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const gate = await requireHrUser();
|
||||||
|
if ("denied" in gate) return gate.denied;
|
||||||
|
|
||||||
|
const mappe = new ExcelJS.Workbook();
|
||||||
|
mappe.creator = "Alpenwerk HR";
|
||||||
|
mappe.created = new Date();
|
||||||
|
|
||||||
|
const hinweise = mappe.addWorksheet("Hinweise");
|
||||||
|
hinweise.columns = [
|
||||||
|
{ header: "Blatt", width: 16 },
|
||||||
|
{ header: "Spalte", width: 26 },
|
||||||
|
{ header: "Pflicht", width: 9 },
|
||||||
|
{ header: "Format", width: 30 },
|
||||||
|
{ header: "Hinweis", width: 70 },
|
||||||
|
];
|
||||||
|
hinweise.getRow(1).font = { bold: true };
|
||||||
|
|
||||||
|
const formatText = (typ: (typeof BLAETTER)[number]["spalten"][number]["typ"]): string => {
|
||||||
|
switch (typ.art) {
|
||||||
|
case "datum":
|
||||||
|
return "Datum (31.12.2026)";
|
||||||
|
case "zahl":
|
||||||
|
return "Zahl (38,5)";
|
||||||
|
case "ganzzahl":
|
||||||
|
return "Ganze Zahl";
|
||||||
|
case "janein":
|
||||||
|
return "ja / nein";
|
||||||
|
case "liste":
|
||||||
|
return typ.werte ? `Mehrere mit Semikolon aus: ${typ.werte.join(", ")}` : "Mehrere mit Semikolon";
|
||||||
|
case "auswahl":
|
||||||
|
return typ.werte.join(" | ");
|
||||||
|
default:
|
||||||
|
return "Text";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const schema of BLAETTER) {
|
||||||
|
hinweise.addRow([schema.name, "", "", "", schema.zweck]).font = { bold: true };
|
||||||
|
for (const s of schema.spalten) {
|
||||||
|
hinweise.addRow([schema.name, s.name, s.pflicht ? "ja" : "", formatText(s.typ), s.hinweis]);
|
||||||
|
}
|
||||||
|
hinweise.addRow([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const schema of BLAETTER) {
|
||||||
|
const blatt = mappe.addWorksheet(schema.name);
|
||||||
|
blatt.columns = schema.spalten.map((s) => ({
|
||||||
|
header: s.name,
|
||||||
|
width: Math.max(12, Math.min(28, s.name.length + 4)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const kopf = blatt.getRow(1);
|
||||||
|
kopf.font = { bold: true };
|
||||||
|
kopf.eachCell((zelle, i) => {
|
||||||
|
const spalte = schema.spalten[i - 1];
|
||||||
|
if (!spalte) return;
|
||||||
|
// Pflichtspalten sichtbar markieren — sonst ist die erste Rückmeldung
|
||||||
|
// eine Fehlerliste statt eines Hinweises beim Ausfüllen.
|
||||||
|
if (spalte.pflicht) {
|
||||||
|
zelle.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFFDE7EF" } };
|
||||||
|
}
|
||||||
|
const teile = [spalte.pflicht ? "Pflichtfeld." : "Optional.", formatText(spalte.typ), spalte.hinweis].filter(Boolean);
|
||||||
|
zelle.note = teile.join("\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Eine Beispielzeile. Alles als Text, damit Excel nicht selbst
|
||||||
|
// interpretiert — der Leser deutet die Werte ohnehin.
|
||||||
|
const beispiel = schema.spalten.map((s) => s.beispiel);
|
||||||
|
if (beispiel.some(Boolean)) {
|
||||||
|
const zeile = blatt.addRow(beispiel);
|
||||||
|
zeile.font = { italic: true, color: { argb: "FF8A8A8A" } };
|
||||||
|
zeile.eachCell((z) => {
|
||||||
|
z.numFmt = "@";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
blatt.views = [{ state: "frozen", ySplit: 1 }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const puffer = await mappe.xlsx.writeBuffer();
|
||||||
|
const dateiname = exportFilename("import-vorlage", "xlsx");
|
||||||
|
return new NextResponse(new Blob([puffer as BlobPart]), { headers: exportResponseHeaders(dateiname, "xlsx") });
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
|
||||||
import { createClient } from "@/lib/supabase/server";
|
|
||||||
|
|
||||||
// Rückweg aus Entra ID. @supabase/ssr benutzt PKCE, das heisst der Anbieter
|
|
||||||
// liefert einen einmaligen Code, der hier gegen eine Sitzung getauscht wird.
|
|
||||||
// Ohne diese Route landet die Anmeldung in einer Schleife: der Code steht in
|
|
||||||
// der URL, aber es entsteht nie ein Sitzungscookie, und der Proxy schickt
|
|
||||||
// zurück auf /login.
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const { searchParams, origin } = request.nextUrl;
|
|
||||||
|
|
||||||
// Entra meldet abgelehnte Zustimmung oder gesperrte Konten als Fehler
|
|
||||||
// zurück. Der Text daraus wird nicht angezeigt — er ist fremdbestimmt und
|
|
||||||
// stünde sonst auf der echten, korrekt gebrandeten Anmeldeseite.
|
|
||||||
if (searchParams.get("error")) {
|
|
||||||
return NextResponse.redirect(`${origin}/login?error=sso_failed`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const code = searchParams.get("code");
|
|
||||||
if (!code) return NextResponse.redirect(`${origin}/login?error=sso_failed`);
|
|
||||||
|
|
||||||
const supabase = await createClient();
|
|
||||||
const { error } = await supabase.auth.exchangeCodeForSession(code);
|
|
||||||
if (error) return NextResponse.redirect(`${origin}/login?error=sso_failed`);
|
|
||||||
|
|
||||||
// Ob die Person HR-Zugriff hat, entscheidet nicht diese Route, sondern
|
|
||||||
// proxy.ts anhand von profiles.role/is_active — und darunter, unabhängig
|
|
||||||
// davon, die RLS-Policies. Hier wird nur die Sitzung hergestellt.
|
|
||||||
return NextResponse.redirect(`${origin}/`);
|
|
||||||
}
|
|
||||||
65
auth.ts
Normal file
65
auth.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import "server-only";
|
||||||
|
import NextAuth from "next-auth";
|
||||||
|
import { authConfig } from "@/lib/auth/config";
|
||||||
|
import { asSystem, sql } from "@/lib/db";
|
||||||
|
|
||||||
|
// Die vollständige Anmeldung — die Fassung, die die Datenbank kennt.
|
||||||
|
//
|
||||||
|
// Aufgeteilt ist sie, weil proxy.ts nur den Teil aus lib/auth/config.ts lädt.
|
||||||
|
// Hier kommt das dazu, was einmal pro Anmeldung passieren muss: aus der
|
||||||
|
// Kennung, die Entra ausstellt, eine Kennung machen, die diese Anwendung
|
||||||
|
// versteht.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt die app_users-Zeile an oder frischt sie auf und liefert die Kennung,
|
||||||
|
* die überall sonst als `userId` durchgereicht wird.
|
||||||
|
*
|
||||||
|
* Die Datenbankfunktion ist SECURITY DEFINER und darf genau dieses eine:
|
||||||
|
* app_users schreiben. Für den einen Schreibvorgang, für den es noch keinen
|
||||||
|
* Sitzungskontext geben kann, ist das der kleinstmögliche Hebel — früher lag
|
||||||
|
* hier ein Dienstschlüssel, der jede Zeile jeder Tabelle lesen konnte.
|
||||||
|
*/
|
||||||
|
async function upsertAppUser(externalId: string, email: string, fullName: string | null): Promise<string> {
|
||||||
|
const row = await asSystem(async (tx) => {
|
||||||
|
const result = await sql<{ id: string }>`
|
||||||
|
select app_upsert_user(${externalId}, ${email}, ${fullName}) as id
|
||||||
|
`.execute(tx);
|
||||||
|
return result.rows[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!row?.id) throw new Error("app_upsert_user() lieferte keine Kennung.");
|
||||||
|
return row.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const { handlers, auth, signIn, signOut } = NextAuth(() => {
|
||||||
|
const base = authConfig();
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
callbacks: {
|
||||||
|
// Die Rückrufe aus der Basis **behalten**, nicht ersetzen: dort liegt
|
||||||
|
// session(), das die Kennung aus dem Token auf die Sitzung legt. Ein
|
||||||
|
// schlichtes `callbacks: { jwt }` hätte es stillschweigend entfernt.
|
||||||
|
...base.callbacks,
|
||||||
|
async jwt({ token, profile }) {
|
||||||
|
// `profile` liegt nur beim ersten Durchlauf nach der Rückkehr von Entra
|
||||||
|
// vor. Danach wird das Token nur noch weitergereicht — die Datenbank
|
||||||
|
// wird also einmal pro Anmeldung befragt, nicht einmal pro Aufruf.
|
||||||
|
if (!profile) return token;
|
||||||
|
|
||||||
|
const externalId = typeof profile.oid === "string" ? profile.oid : null;
|
||||||
|
const email = [profile.email, profile.preferred_username, profile.upn].find(
|
||||||
|
(v): v is string => typeof v === "string" && v.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lieber abbrechen als eine Sitzung ohne Kennung ausstellen: die käme
|
||||||
|
// als `null` bei withUser() an, und die Policies gäben dann konsequent
|
||||||
|
// nichts zurück — was sich als „die Anwendung ist leer" zeigt statt als
|
||||||
|
// Anmeldefehler.
|
||||||
|
if (!externalId || !email) throw new Error("Entra lieferte weder oid noch E-Mail-Adresse.");
|
||||||
|
|
||||||
|
token.uid = await upsertAppUser(externalId, email, typeof profile.name === "string" ? profile.name : null);
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
140
components/audit/AuditDetail.tsx
Normal file
140
components/audit/AuditDetail.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { SlideOver } from "@/components/ui/SlideOver";
|
||||||
|
import { actionBadgeStyle } from "@/lib/colors";
|
||||||
|
import type { AuditChange } from "@/lib/supabase/types";
|
||||||
|
|
||||||
|
// Eine Protokollzeile zum Aufklappen.
|
||||||
|
//
|
||||||
|
// Die Liste zeigt, *dass* etwas geändert wurde; hier steht, *was*. Beides in
|
||||||
|
// der Tabelle unterzubringen ginge nicht — bei sieben geänderten Feldern
|
||||||
|
// wäre die Zeile höher als der Bildschirm.
|
||||||
|
|
||||||
|
export type AuditEintrag = {
|
||||||
|
id: string;
|
||||||
|
occurred_at: string;
|
||||||
|
actor_name: string;
|
||||||
|
action: string;
|
||||||
|
target_label: string;
|
||||||
|
target_employee_id: string | null;
|
||||||
|
details: string | null;
|
||||||
|
changes: AuditChange[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const zeitFormat = new Intl.DateTimeFormat("de-AT", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
timeZone: "Europe/Vienna",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Leerer Wert heisst „war nicht gesetzt“ — und das ist eine Aussage. */
|
||||||
|
function Wert({ text, art }: { text: string | null; art: "vorher" | "nachher" }) {
|
||||||
|
if (text === null || text === "") {
|
||||||
|
return <span className="text-ink-muted italic">leer</span>;
|
||||||
|
}
|
||||||
|
return <span className={art === "vorher" ? "text-ink-muted line-through decoration-ink-muted/40" : "text-ink"}>{text}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditDetail({ eintrag }: { eintrag: AuditEintrag }) {
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
const anzahl = eintrag.changes?.length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOffen(true)}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
className="w-full rounded px-2 py-1 text-left text-ink-muted hover:bg-brand-50 hover:text-ink
|
||||||
|
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||||
|
>
|
||||||
|
<span>{eintrag.details ?? "–"}</span>
|
||||||
|
{anzahl > 0 && (
|
||||||
|
<span className="ml-2 whitespace-nowrap rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-semibold text-brand-700">
|
||||||
|
{anzahl} {anzahl === 1 ? "Feld" : "Felder"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<SlideOver
|
||||||
|
open={offen}
|
||||||
|
onClose={() => setOffen(false)}
|
||||||
|
title={eintrag.target_label}
|
||||||
|
subtitle={`${eintrag.action} · ${zeitFormat.format(new Date(eintrag.occurred_at))}`}
|
||||||
|
>
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 text-sm">
|
||||||
|
<dt className="font-semibold text-ink-muted">Aktion</dt>
|
||||||
|
<dd>
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(eintrag.action)}`}>
|
||||||
|
{eintrag.action}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
<dt className="font-semibold text-ink-muted">Benutzer:in</dt>
|
||||||
|
<dd className="text-ink">{eintrag.actor_name}</dd>
|
||||||
|
<dt className="font-semibold text-ink-muted">Zeitpunkt</dt>
|
||||||
|
<dd className="tabular-nums text-ink">{zeitFormat.format(new Date(eintrag.occurred_at))}</dd>
|
||||||
|
{eintrag.target_employee_id && (
|
||||||
|
<>
|
||||||
|
<dt className="font-semibold text-ink-muted">Objekt</dt>
|
||||||
|
<dd>
|
||||||
|
<Link
|
||||||
|
href={`/employees/${eintrag.target_employee_id}`}
|
||||||
|
className="rounded font-semibold text-brand-700 hover:underline
|
||||||
|
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||||
|
>
|
||||||
|
{eintrag.target_label}
|
||||||
|
</Link>
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{eintrag.details && (
|
||||||
|
<p className="mt-5 rounded-md bg-surface px-3 py-2 text-sm text-ink-body">{eintrag.details}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3 className="mt-6 text-sm font-bold text-ink">Geänderte Felder</h3>
|
||||||
|
{anzahl > 0 ? (
|
||||||
|
<div className="mt-2 overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
|
||||||
|
<th className="py-2 pr-4">Feld</th>
|
||||||
|
<th className="py-2 pr-4">Vorher</th>
|
||||||
|
<th className="py-2">Nachher</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{eintrag.changes!.map((c, i) => (
|
||||||
|
<tr key={i} className="border-b border-border-subtle align-top last:border-0">
|
||||||
|
<td className="py-2 pr-4 font-semibold text-ink-body">{c.feld}</td>
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
<Wert text={c.vorher} art="vorher" />
|
||||||
|
</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<Wert text={c.nachher} art="nachher" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Kein Aufzählungszeichen für „nichts da“: der Grund ist wichtig,
|
||||||
|
// damit niemand einen Fehler vermutet.
|
||||||
|
<p className="mt-2 max-w-prose text-sm text-ink-muted">
|
||||||
|
Für diesen Eintrag liegen keine Feldwerte vor. Vorgänge wie Eintritt, Austritt oder Import erfassen keine
|
||||||
|
Einzelfelder — und Einträge von vor der Erweiterung des Protokolls haben nur die Feldnamen behalten, nicht
|
||||||
|
die Werte. Nachliefern lässt sich das nicht.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SlideOver>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -58,7 +58,11 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
|||||||
isValidSvnr(draft.svNummer, draft.birthDate || null);
|
isValidSvnr(draft.svNummer, draft.birthDate || null);
|
||||||
|
|
||||||
const stepValid = [
|
const stepValid = [
|
||||||
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId) && svNummerOk,
|
// E-Mail gehört zu den Pflichtfeldern, weil die Spalte NOT NULL ist. Ohne
|
||||||
|
// die Prüfung hier bricht erst die Datenbank ab — am Ende des vierten
|
||||||
|
// Schritts, nach allen Eingaben.
|
||||||
|
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId && draft.email.trim()) &&
|
||||||
|
svNummerOk,
|
||||||
Boolean(draft.positionId && draft.besetzung),
|
Boolean(draft.positionId && draft.besetzung),
|
||||||
Boolean(draft.entryDate && draft.workDays.length > 0),
|
Boolean(draft.entryDate && draft.workDays.length > 0),
|
||||||
true,
|
true,
|
||||||
@@ -86,6 +90,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
|||||||
gender: draft.gender,
|
gender: draft.gender,
|
||||||
birth_date: draft.birthDate,
|
birth_date: draft.birthDate,
|
||||||
sv_nummer: draft.svNummer || undefined,
|
sv_nummer: draft.svNummer || undefined,
|
||||||
|
email: draft.email.trim(),
|
||||||
phone: draft.phone || undefined,
|
phone: draft.phone || undefined,
|
||||||
position_id: draft.positionId,
|
position_id: draft.positionId,
|
||||||
location_id: draft.locationId,
|
location_id: draft.locationId,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function StepPerson({ draft, update, locations }: StepPersonProps) {
|
|||||||
birthDate={draft.birthDate || null}
|
birthDate={draft.birthDate || null}
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<TextField label="E-Mail (privat)" type="email" value={draft.email} onChange={(email) => update({ email })} />
|
<TextField label="E-Mail" required type="email" value={draft.email} onChange={(email) => update({ email })} />
|
||||||
<TextField label="Telefon" type="tel" value={draft.phone} onChange={(phone) => update({ phone })} />
|
<TextField label="Telefon" type="tel" value={draft.phone} onChange={(phone) => update({ phone })} />
|
||||||
</div>
|
</div>
|
||||||
<SelectField
|
<SelectField
|
||||||
|
|||||||
210
components/import/ImportWorkbench.tsx
Normal file
210
components/import/ImportWorkbench.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { CARD_CLASS } from "@/components/ui/Card";
|
||||||
|
|
||||||
|
// Der Ablauf hat bewusst zwei Schritte: prüfen, dann übernehmen.
|
||||||
|
//
|
||||||
|
// Ein Import ist nicht rückgängig zu machen. Wer 800 Zeilen schickt, soll
|
||||||
|
// vorher sehen, was entstehen würde — und bei einem Fehler die Zeilennummer
|
||||||
|
// lesen, nicht „Import fehlgeschlagen".
|
||||||
|
|
||||||
|
type Befund = { blatt: string; zeile: number | null; spalte: string | null; wert?: string; meldung: string };
|
||||||
|
|
||||||
|
type Antwort = {
|
||||||
|
ok: boolean;
|
||||||
|
geprueft: boolean;
|
||||||
|
blaetter: string[];
|
||||||
|
fehler: Befund[];
|
||||||
|
hinweise: Befund[];
|
||||||
|
anzahl: Record<string, number>;
|
||||||
|
bericht?: Record<string, number>;
|
||||||
|
meldung?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mehr als das zeigt niemand durch; der Rest steht in der Anzahl. */
|
||||||
|
const MAX_ANZEIGE = 200;
|
||||||
|
|
||||||
|
export function ImportWorkbench() {
|
||||||
|
const [dateien, setDateien] = useState<File[]>([]);
|
||||||
|
const [antwort, setAntwort] = useState<Antwort | null>(null);
|
||||||
|
const [laeuft, setLaeuft] = useState<"pruefen" | "uebernehmen" | null>(null);
|
||||||
|
const [fehlschlag, setFehlschlag] = useState<string | null>(null);
|
||||||
|
const eingabe = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
async function senden(nurPruefen: boolean) {
|
||||||
|
if (dateien.length === 0) return;
|
||||||
|
setLaeuft(nurPruefen ? "pruefen" : "uebernehmen");
|
||||||
|
setFehlschlag(null);
|
||||||
|
try {
|
||||||
|
const daten = new FormData();
|
||||||
|
for (const d of dateien) daten.append("datei", d);
|
||||||
|
if (nurPruefen) daten.append("pruefen", "1");
|
||||||
|
const antwort = await fetch("/api/import", { method: "POST", body: daten });
|
||||||
|
const inhalt = (await antwort.json()) as Antwort;
|
||||||
|
setAntwort(inhalt);
|
||||||
|
} catch {
|
||||||
|
setFehlschlag("Die Datei konnte nicht übertragen werden. Bitte erneut versuchen.");
|
||||||
|
} finally {
|
||||||
|
setLaeuft(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function neueAuswahl(liste: FileList | null) {
|
||||||
|
setDateien(liste ? Array.from(liste) : []);
|
||||||
|
// Ein alter Bericht zu einer neuen Datei ist schlimmer als keiner.
|
||||||
|
setAntwort(null);
|
||||||
|
setFehlschlag(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uebernommen = antwort?.bericht !== undefined;
|
||||||
|
const bereit = antwort?.ok === true && antwort.geprueft && !uebernommen;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<section className={`${CARD_CLASS} p-5`}>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-bold text-ink">Datei wählen</h2>
|
||||||
|
<p className="mt-1 max-w-prose text-sm text-ink-muted">
|
||||||
|
Eine Excel-Mappe mit den Blättern <strong>Standorte</strong>, <strong>Organisation</strong>,{" "}
|
||||||
|
<strong>Jobkatalog</strong>, <strong>Planstellen</strong>, <strong>Personen</strong>,{" "}
|
||||||
|
<strong>Historie</strong> und <strong>Angehörige</strong> — oder je Blatt eine CSV-Datei, deren Name dem
|
||||||
|
Blatt entspricht. Nicht jedes Blatt muss dabei sein.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/api/import/template"
|
||||||
|
className="shrink-0 rounded-md border border-border px-3 py-2 text-sm font-semibold text-ink hover:bg-brand-50
|
||||||
|
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||||
|
>
|
||||||
|
Vorlage herunterladen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||||
|
<input
|
||||||
|
ref={eingabe}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".xlsx,.xlsm,.csv"
|
||||||
|
onChange={(e) => neueAuswahl(e.target.files)}
|
||||||
|
className="block w-full max-w-md text-sm text-ink-muted file:mr-3 file:rounded-md file:border file:border-border
|
||||||
|
file:bg-surface file:px-3 file:py-2 file:text-sm file:font-semibold file:text-ink hover:file:bg-brand-50"
|
||||||
|
/>
|
||||||
|
<Button onClick={() => senden(true)} disabled={dateien.length === 0 || laeuft !== null}>
|
||||||
|
{laeuft === "pruefen" ? "Wird geprüft…" : "Prüfen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{dateien.length > 0 && (
|
||||||
|
<p className="mt-2 text-xs text-ink-muted">
|
||||||
|
{dateien.length === 1 ? dateien[0].name : `${dateien.length} Dateien`} ausgewählt
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{fehlschlag && (
|
||||||
|
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-4 py-3 text-sm text-danger-text">
|
||||||
|
{fehlschlag}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{antwort?.meldung && (
|
||||||
|
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-4 py-3 text-sm text-danger-text">
|
||||||
|
{antwort.meldung}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{antwort && !antwort.meldung && (
|
||||||
|
<section className={`${CARD_CLASS} p-5`}>
|
||||||
|
<h2 className="text-sm font-bold text-ink">{uebernommen ? "Übernommen" : "Ergebnis der Prüfung"}</h2>
|
||||||
|
|
||||||
|
<table className="mt-3 w-full max-w-md text-sm">
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(uebernommen ? antwort.bericht! : antwort.anzahl)
|
||||||
|
.filter(([, n]) => n > 0)
|
||||||
|
.map(([blatt, n]) => (
|
||||||
|
<tr key={blatt} className="border-b border-border-subtle last:border-0">
|
||||||
|
<td className="py-1.5 text-ink-body">{blatt}</td>
|
||||||
|
<td className="py-1.5 text-right font-semibold tabular-nums text-ink">{n}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{uebernommen ? (
|
||||||
|
<p className="mt-4 text-sm text-ink-muted">
|
||||||
|
Die Daten stehen jetzt in der Anwendung. Der Vorgang ist im Audit-Log vermerkt.
|
||||||
|
</p>
|
||||||
|
) : antwort.ok ? (
|
||||||
|
<>
|
||||||
|
<p className="mt-4 text-sm text-ink-body">
|
||||||
|
Keine Beanstandung. Beim Übernehmen entsteht genau das oben Gezeigte — alles in einem Zug oder gar
|
||||||
|
nichts.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Button onClick={() => senden(false)} disabled={laeuft !== null || !bereit}>
|
||||||
|
{laeuft === "uebernehmen" ? "Wird übernommen…" : "Übernehmen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="mt-4 text-sm font-semibold text-danger-text">
|
||||||
|
{antwort.fehler.length} Beanstandung{antwort.fehler.length === 1 ? "" : "en"} — es wurde nichts
|
||||||
|
geschrieben.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{antwort && antwort.fehler.length > 0 && (
|
||||||
|
<BefundListe titel="Zu beheben" befunde={antwort.fehler} art="fehler" />
|
||||||
|
)}
|
||||||
|
{antwort && antwort.hinweise.length > 0 && (
|
||||||
|
<BefundListe titel="Hinweise" befunde={antwort.hinweise} art="hinweis" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BefundListe({ titel, befunde, art }: { titel: string; befunde: Befund[]; art: "fehler" | "hinweis" }) {
|
||||||
|
const sichtbar = befunde.slice(0, MAX_ANZEIGE);
|
||||||
|
return (
|
||||||
|
<section className={`overflow-x-auto ${CARD_CLASS}`}>
|
||||||
|
<h2 className="px-5 pt-5 text-sm font-bold text-ink">
|
||||||
|
{titel} <span className="font-normal text-ink-muted">({befunde.length})</span>
|
||||||
|
</h2>
|
||||||
|
<table className="mt-3 w-full min-w-[640px] text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-y border-border bg-surface text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
|
||||||
|
<th className="px-5 py-2">Blatt</th>
|
||||||
|
<th className="px-3 py-2">Zeile</th>
|
||||||
|
<th className="px-3 py-2">Spalte</th>
|
||||||
|
<th className="px-3 py-2">Wert</th>
|
||||||
|
<th className="px-5 py-2">Was zu tun ist</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sichtbar.map((b, i) => (
|
||||||
|
<tr key={i} className="border-b border-border-subtle last:border-0">
|
||||||
|
<td className="whitespace-nowrap px-5 py-2 text-ink-body">{b.blatt}</td>
|
||||||
|
<td className="px-3 py-2 tabular-nums text-ink-muted">{b.zeile ?? "–"}</td>
|
||||||
|
<td className="px-3 py-2 text-ink-body">{b.spalte ?? "–"}</td>
|
||||||
|
<td className="max-w-[16rem] truncate px-3 py-2 text-ink-muted" title={b.wert}>
|
||||||
|
{b.wert ?? ""}
|
||||||
|
</td>
|
||||||
|
<td className={`px-5 py-2 ${art === "fehler" ? "text-danger-text" : "text-ink-muted"}`}>{b.meldung}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{befunde.length > sichtbar.length && (
|
||||||
|
<p className="px-5 py-3 text-xs text-ink-muted">
|
||||||
|
Weitere {befunde.length - sichtbar.length} nicht angezeigt. Oft hängen viele Meldungen an einer Ursache —
|
||||||
|
nach der Korrektur erneut prüfen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
components/positions/EditPositionModal.tsx
Normal file
138
components/positions/EditPositionModal.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { updatePosition } from "@/actions/positions";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { SelectField, TextField } from "@/components/ui/Field";
|
||||||
|
import { Modal } from "@/components/ui/Modal";
|
||||||
|
import { useToast } from "@/components/ui/Toast";
|
||||||
|
import type { OpenPositionResolved } from "@/lib/positions";
|
||||||
|
import type { UnitOption } from "./CreatePositionModal";
|
||||||
|
|
||||||
|
// Ändern statt löschen und neu anlegen.
|
||||||
|
//
|
||||||
|
// Die Planstellennummer steht in Ausschreibungen, Budgets und
|
||||||
|
// Protokolleinträgen. Wer wegen eines Tippfehlers in der Tätigkeit eine neue
|
||||||
|
// Nummer vergibt, macht die alten Bezüge wertlos — deshalb gibt es diesen
|
||||||
|
// Dialog.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erwartet eine Planstelle, keine „vielleicht keine".
|
||||||
|
*
|
||||||
|
* Die aufrufende Seite hängt einen `key` mit der Kennung daran und rendert
|
||||||
|
* ihn nur, solange etwas bearbeitet wird. Dadurch baut React den Dialog je
|
||||||
|
* Planstelle neu auf, und die Felder lassen sich direkt aus den Eigenschaften
|
||||||
|
* vorbelegen — statt sie in einem Effekt nachzuziehen, der beim zweiten
|
||||||
|
* Öffnen kurz die Werte der vorigen Stelle zeigt.
|
||||||
|
*/
|
||||||
|
export function EditPositionModal({
|
||||||
|
position,
|
||||||
|
units,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
position: OpenPositionResolved;
|
||||||
|
units: UnitOption[];
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
const [jobTitle, setJobTitle] = useState(position.title);
|
||||||
|
const [orgUnitId, setOrgUnitId] = useState(position.org_unit_id);
|
||||||
|
const [isChief, setIsChief] = useState(position.is_chief);
|
||||||
|
const [validFrom, setValidFrom] = useState(position.valid_from);
|
||||||
|
const [validTo, setValidTo] = useState(position.valid_to ?? "");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
const unit = units.find((u) => u.id === orgUnitId);
|
||||||
|
// Die eigene Leitung zählt nicht als Hindernis für sich selbst.
|
||||||
|
const chiefTaken = Boolean(unit?.hasChief) && !(position.is_chief && orgUnitId === position.org_unit_id);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!jobTitle.trim() || !orgUnitId || !validFrom) {
|
||||||
|
showToast("Tätigkeit, Einheit und Gültigkeitsbeginn sind Pflicht.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(true);
|
||||||
|
const result = await updatePosition({
|
||||||
|
position_id: position.id,
|
||||||
|
org_unit_id: orgUnitId,
|
||||||
|
job_title: jobTitle.trim(),
|
||||||
|
is_chief: isChief && !chiefTaken,
|
||||||
|
valid_from: validFrom,
|
||||||
|
valid_to: validTo || null,
|
||||||
|
});
|
||||||
|
setPending(false);
|
||||||
|
if (result.success) {
|
||||||
|
showToast("Planstelle geändert.");
|
||||||
|
router.refresh();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
// Die Meldungen der Datenbankfunktion sind für die Oberfläche
|
||||||
|
// geschrieben („Diese Planstelle ist vergeben …“) und werden gezeigt.
|
||||||
|
showToast(result.error ?? "Die Änderung war nicht möglich.", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
onClose={onClose}
|
||||||
|
title={`Planstelle ${position.position_number}`}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} pending={pending}>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<TextField label="Tätigkeit" required value={jobTitle} onChange={setJobTitle} />
|
||||||
|
|
||||||
|
<SelectField
|
||||||
|
label="Organisationseinheit"
|
||||||
|
required
|
||||||
|
value={orgUnitId}
|
||||||
|
onChange={setOrgUnitId}
|
||||||
|
placeholder="Bitte wählen…"
|
||||||
|
options={units.map((u) => ({
|
||||||
|
value: u.id,
|
||||||
|
label: `${" ".repeat(u.depth)}${u.name} (${u.unit_type})`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<TextField label="Gültig ab" required type="date" value={validFrom} onChange={setValidFrom} />
|
||||||
|
<TextField label="Gültig bis" type="date" value={validTo} onChange={setValidTo} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-2 text-sm text-ink-body">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5 h-4 w-4 rounded border-border text-brand-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||||
|
checked={isChief && !chiefTaken}
|
||||||
|
disabled={chiefTaken}
|
||||||
|
onChange={(e) => setIsChief(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Leitungsplanstelle
|
||||||
|
{chiefTaken && (
|
||||||
|
<span className="block text-xs text-ink-muted">
|
||||||
|
Für {unit?.name} besteht bereits eine Leitungsplanstelle.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p className="rounded-md bg-surface px-3 py-2 text-xs leading-relaxed text-ink-muted">
|
||||||
|
Die Planstellennummer bleibt. Ist die Stelle vergeben, lassen sich Einheit und Gültigkeitsende nicht ändern —
|
||||||
|
ein Wechsel der Einheit ist eine Versetzung und gehört zur Person, nicht zur Stelle.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,9 +6,10 @@ import { useState } from "react";
|
|||||||
import { deletePosition } from "@/actions/positions";
|
import { deletePosition } from "@/actions/positions";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
import { fmtDate, todayIso } from "@/lib/format";
|
import { fmtDate } from "@/lib/format";
|
||||||
import type { OpenPositionResolved } from "@/lib/positions";
|
import type { OpenPositionResolved } from "@/lib/positions";
|
||||||
import { CreatePositionModal, type UnitOption } from "./CreatePositionModal";
|
import { CreatePositionModal, type UnitOption } from "./CreatePositionModal";
|
||||||
|
import { EditPositionModal } from "./EditPositionModal";
|
||||||
|
|
||||||
type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number };
|
type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number };
|
||||||
|
|
||||||
@@ -21,8 +22,14 @@ export function PositionsPageClient({ openPositions, units }: PositionsPageClien
|
|||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<OpenPositionWithDays | null>(null);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
const today = todayIso();
|
|
||||||
|
// Getrennt, weil es zwei verschiedene Aussagen sind: „hier fehlt jemand"
|
||||||
|
// und „das entsteht erst". In einer Liste vermischt liest sich eine
|
||||||
|
// Planstelle zum 01.10. wie eine Vakanz, um die sich niemand kümmert.
|
||||||
|
const offen = openPositions.filter((p) => !p.future);
|
||||||
|
const kuenftig = openPositions.filter((p) => p.future);
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
setDeletingId(id);
|
setDeletingId(id);
|
||||||
@@ -36,53 +43,86 @@ export function PositionsPageClient({ openPositions, units }: PositionsPageClien
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Karte({ p }: { p: OpenPositionWithDays }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="relative flex flex-col rounded border border-border transition-colors focus-within:border-brand-500 hover:border-brand-500">
|
||||||
<div className="rounded border border-border bg-white p-4">
|
{/* Die ganze Karte öffnet den Änderungsdialog; der Papierkorb liegt
|
||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
absolut darüber. Ein Knopf im Knopf wäre ungültiges HTML und mit
|
||||||
<h2 className="text-sm font-bold text-ink">Unbesetzte Planstellen ({openPositions.length})</h2>
|
der Tastatur nicht erreichbar. */}
|
||||||
<Button onClick={() => setCreateOpen(true)}>
|
<button
|
||||||
<Plus className="h-4 w-4" />
|
type="button"
|
||||||
Planstelle anlegen
|
onClick={() => setEditing(p)}
|
||||||
</Button>
|
aria-label={`Planstelle ${p.position_number} (${p.title}) ändern`}
|
||||||
</div>
|
className="flex flex-col items-start rounded p-3 pr-10 text-left
|
||||||
{openPositions.length === 0 ? (
|
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||||
<p className="text-sm text-ink-muted">Derzeit ist jede Planstelle besetzt.</p>
|
>
|
||||||
) : (
|
<span className="text-sm font-semibold text-ink">{p.title}</span>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<span className="text-xs text-ink-muted">
|
||||||
{openPositions.map((p) => {
|
{p.position_number} · {p.orgLabel}
|
||||||
const notYetValid = p.valid_from > today;
|
</span>
|
||||||
return (
|
<span className="mt-1 text-xs text-ink-muted">
|
||||||
<div key={p.id} className="flex flex-col rounded border border-border p-3">
|
{p.is_chief ? "Leitungsplanstelle · " : ""}
|
||||||
<div className="flex items-start justify-between gap-2">
|
{p.future ? `gültig ab ${fmtDate(p.valid_from)}` : `seit ${p.daysOpen} Tagen unbesetzt`}
|
||||||
<div className="text-sm font-semibold text-ink">{p.title}</div>
|
</span>
|
||||||
|
{p.valid_to && <span className="text-xs font-semibold text-warning-text">endet am {fmtDate(p.valid_to)}</span>}
|
||||||
|
{p.managerName && <span className="text-xs text-ink-muted">berichtet an {p.managerName}</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="icon"
|
variant="icon"
|
||||||
onClick={() => handleDelete(p.id)}
|
onClick={() => handleDelete(p.id)}
|
||||||
pending={deletingId === p.id}
|
pending={deletingId === p.id}
|
||||||
aria-label={`Planstelle ${p.position_number} (${p.title}) entfernen`}
|
aria-label={`Planstelle ${p.position_number} (${p.title}) entfernen`}
|
||||||
className="-mr-1 -mt-1 hover:!text-danger-solid"
|
className="absolute right-2 top-2 hover:!text-danger-solid"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-ink-muted">
|
|
||||||
{p.position_number} · {p.orgLabel}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs text-ink-muted">
|
|
||||||
{p.is_chief ? "Leitungsplanstelle · " : ""}
|
|
||||||
seit {p.daysOpen} Tagen unbesetzt
|
|
||||||
</div>
|
|
||||||
{p.managerName && <div className="text-xs text-ink-muted">berichtet an {p.managerName}</div>}
|
|
||||||
{notYetValid && <div className="mt-1 text-xs font-semibold text-warning-text">Gültig ab {fmtDate(p.valid_from)}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<section className="rounded border border-border bg-white p-4">
|
||||||
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h2 className="text-sm font-bold text-ink">Unbesetzte Planstellen ({offen.length})</h2>
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Planstelle anlegen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{offen.length === 0 ? (
|
||||||
|
<p className="text-sm text-ink-muted">Derzeit ist jede geltende Planstelle besetzt oder vergeben.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{offen.map((p) => (
|
||||||
|
<Karte key={p.id} p={p} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{kuenftig.length > 0 && (
|
||||||
|
<section className="rounded border border-border bg-white p-4">
|
||||||
|
<h2 className="text-sm font-bold text-ink">Künftige Planstellen ({kuenftig.length})</h2>
|
||||||
|
<p className="mb-3 mt-1 text-xs text-ink-muted">
|
||||||
|
Beschlossen, aber noch nicht gültig. Sie zählen nicht als Vakanz und lassen sich bis zum Beginn ändern.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{kuenftig.map((p) => (
|
||||||
|
<Karte key={p.id} p={p} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} units={units} />
|
<CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} units={units} />
|
||||||
|
{/* `key` sorgt dafür, dass React je Planstelle einen frischen Dialog
|
||||||
|
baut — sonst blieben beim zweiten Öffnen die Werte des ersten
|
||||||
|
stehen. */}
|
||||||
|
{editing && (
|
||||||
|
<EditPositionModal key={editing.id} position={editing} units={units} onClose={() => setEditing(null)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BarChart3, Building2, History, LayoutGrid, Network, Users, X } from "lucide-react";
|
import { BarChart3, Building2, History, LayoutGrid, Network, Upload, Users, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -12,6 +12,7 @@ const NAV_ITEMS = [
|
|||||||
{ href: "/positions", label: "Positionen", icon: Building2 },
|
{ href: "/positions", label: "Positionen", icon: Building2 },
|
||||||
{ href: "/reports", label: "Berichte", icon: BarChart3 },
|
{ href: "/reports", label: "Berichte", icon: BarChart3 },
|
||||||
{ href: "/audit", label: "Audit-Log", icon: History },
|
{ href: "/audit", label: "Audit-Log", icon: History },
|
||||||
|
{ href: "/import", label: "Import", icon: Upload },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function isActiveRoute(pathname: string, href: string): boolean {
|
export function isActiveRoute(pathname: string, href: string): boolean {
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
|
# Ohne Build-Argumente: alles, was die Anwendung braucht — DATABASE_URL,
|
||||||
|
# AUTH_* — liest sie zur Laufzeit aus .env. Das Abbild ist damit für jede
|
||||||
|
# Umgebung dasselbe.
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
args:
|
|
||||||
# NEXT_PUBLIC_* vars are inlined into the browser bundle at build
|
|
||||||
# time, so they have to be passed here, not just in env_file below.
|
|
||||||
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
|
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# Anmeldung über Entra ID
|
# Anmeldung über Entra ID
|
||||||
|
|
||||||
Die Anwendung meldet ausschliesslich über Microsoft Entra ID an. Supabase Auth
|
Die Anwendung meldet ausschliesslich über Microsoft Entra ID an. Die
|
||||||
bleibt dabei die Sitzungsverwaltung — Entra ist der Anbieter, nicht der Ersatz.
|
Sitzungsverwaltung macht **Auth.js** (`auth.ts`, `lib/auth/config.ts`) — es gibt
|
||||||
|
keinen Anmeldedienst eines Anbieters mehr dazwischen.
|
||||||
|
|
||||||
**Das ist der Grund, warum der Umstieg klein ist:** `auth.uid()` liefert
|
**Warum das trotzdem eine kleine Änderung ist:** die Anmeldung liefert nach wie
|
||||||
weiterhin eine UUID, `profiles.id` trägt weiterhin `role` und `is_active`, und
|
vor nur eine UUID. `profiles.id` trägt weiterhin `role` und `is_active`, und
|
||||||
damit bleiben `is_hr_user()` und alle 58 RLS-Policies unverändert gültig. Die
|
damit bleiben `is_hr_user()` und alle 58 RLS-Policies unverändert gültig. Die
|
||||||
Sicherheitsgrenze wandert nicht in den Anwendungscode.
|
Sicherheitsgrenze wandert nicht in den Anwendungscode.
|
||||||
|
|
||||||
@@ -16,55 +17,69 @@ App-Registrierung, einmalig — angelegt im Mandanten *loudspring management Gmb
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Name | Alpenwerk HR |
|
| Name | Alpenwerk HR |
|
||||||
| Kontotypen | Nur ein Mandant |
|
| Kontotypen | Nur ein Mandant |
|
||||||
| Umleitungs-URI (Web) | `https://<projekt-ref>.supabase.co/auth/v1/callback` |
|
| Umleitungs-URI (Web) | `https://<produktion>/api/auth/callback/microsoft-entra-id` |
|
||||||
| Anwendungs-ID (Client) | `<client-id>` |
|
| Anwendungs-ID (Client) | `<client-id>` |
|
||||||
| Verzeichnis-ID (Mandant) | `<tenant-id>` |
|
| Verzeichnis-ID (Mandant) | `<tenant-id>` |
|
||||||
|
|
||||||
Die konkreten Werte stehen bewusst nicht hier, sondern in der
|
Die konkreten Werte stehen bewusst nicht hier, sondern in der
|
||||||
Übergabedokumentation. Sie sind zwar keine Geheimnisse — ohne Schlüssel gibt
|
Übergabedokumentation. Sie sind zwar keine Geheimnisse — ohne Client-Geheimnis
|
||||||
eine Projekt-URL nichts her, und RLS greift ohnehin —, aber sie zeigen auf die
|
gibt eine ID nichts her, und RLS greift ohnehin —, aber sie zeigen auf die echte
|
||||||
echte Umgebung, und dieses Repository wandert weiter als sie.
|
Umgebung, und dieses Repository wandert weiter als sie.
|
||||||
|
|
||||||
Der *Wert* des Client-Geheimnisses gehört ausschliesslich ins Supabase-Feld
|
Die Umleitungs-URI zeigt jetzt auf die **Anwendung selbst**. Für die lokale
|
||||||
„Secret Value" und in keine Datei im Projekt.
|
Entwicklung kommt `http://localhost:3000/api/auth/callback/microsoft-entra-id`
|
||||||
|
als zweite URI dazu; Entra erlaubt `http` nur für `localhost`.
|
||||||
Die Umleitungs-URI ist **Supabases** Callback, nicht der der Anwendung. Der
|
|
||||||
eigene Callback (`/auth/callback`) steht nur in der Redirect-Allowlist des
|
|
||||||
Supabase-Projekts.
|
|
||||||
|
|
||||||
Danach:
|
Danach:
|
||||||
|
|
||||||
1. **Zertifikate & Geheimnisse** → neues Client-Geheimnis. Der *Wert* wird
|
1. **Zertifikate & Geheimnisse** → neues Client-Geheimnis. Der *Wert* wird
|
||||||
gebraucht, nicht die Geheimnis-ID, und er ist nur einmal sichtbar.
|
gebraucht, nicht die Geheimnis-ID, und er ist nur einmal sichtbar.
|
||||||
2. **API-Berechtigungen** → `openid`, `profile`, `email` (Microsoft Graph,
|
2. **API-Berechtigungen** → `openid`, `profile`, `email` (Microsoft Graph,
|
||||||
delegiert), Administratorzustimmung erteilen.
|
delegiert), Administratorzustimmung erteilen. `User.Read` wird **nicht**
|
||||||
|
gebraucht: der eingebaute Anbieter von Auth.js fordert es an, um das
|
||||||
|
Profilbild aus dem Graph zu holen — `lib/auth/config.ts` schaltet beides ab.
|
||||||
3. **Tokenkonfiguration** → Gruppenanspruch, siehe unten.
|
3. **Tokenkonfiguration** → Gruppenanspruch, siehe unten.
|
||||||
|
|
||||||
## Einrichtung in Supabase
|
## Konfiguration der Anwendung
|
||||||
|
|
||||||
Authentication → Providers → Azure:
|
Vier Werte, alle server-seitig — nichts davon landet im Browser-Bundle:
|
||||||
|
|
||||||
| Feld | Wert |
|
| Variable | Wert |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Application (Client) ID | `<client-id>` |
|
| `AUTH_SECRET` | `npx auth secret` oder `openssl rand -base64 32` |
|
||||||
| Secret Value | der Wert aus „Zertifikate & Geheimnisse" |
|
| `AUTH_MICROSOFT_ENTRA_ID_ID` | Anwendungs-ID (Client) |
|
||||||
| Azure Tenant URL | `https://login.microsoftonline.com/<tenant-id>` |
|
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | der Wert aus „Zertifikate & Geheimnisse" |
|
||||||
|
| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | `https://login.microsoftonline.com/<tenant-id>/v2.0` |
|
||||||
|
|
||||||
Die Tenant URL ist bei „Nur ein Mandant" nicht optional. Bleibt sie leer,
|
Der Aussteller ist bei „Nur ein Mandant" **nicht optional**. Bleibt er leer,
|
||||||
benutzt Supabase `common`, und Entra weist die Anmeldung ab, weil die
|
benutzt Auth.js `common` — dann dürfte sich jedes Microsoft-Konto anmelden, auch
|
||||||
Registrierung nur den eigenen Mandanten akzeptiert.
|
ein privates Outlook-Konto. Die Freischaltung über `profiles` fängt das zwar ab,
|
||||||
|
aber die Eingangstür soll erst gar nicht so weit offenstehen.
|
||||||
|
|
||||||
Authentication → URL Configuration:
|
`AUTH_SECRET` verschlüsselt das Sitzungscookie. Ein Wechsel meldet alle ab — im
|
||||||
|
Ernstfall genau das gewünschte Mittel.
|
||||||
|
|
||||||
- Site URL: die Produktions-URL
|
## Was bei der ersten Anmeldung passiert
|
||||||
- Redirect URLs: `http://localhost:3000/auth/callback` und
|
|
||||||
`https://<produktion>/auth/callback`
|
1. Auth.js prüft das Token von Entra (`state`, `nonce`, Signatur, Aussteller).
|
||||||
|
2. Der Rückruf in `auth.ts` nimmt daraus die **`oid`** — nicht `sub`, nicht die
|
||||||
|
E-Mail. Die `oid` identifiziert dieselbe Person über Anwendungen hinweg und
|
||||||
|
überlebt Namens- und Adressänderungen.
|
||||||
|
3. `app_upsert_user(oid, email, name)` legt die `app_users`-Zeile an und liefert
|
||||||
|
die Kennung, die von da an in jeder Transaktion als `app.user_id` steht.
|
||||||
|
4. **Gibt es zu der Adresse bereits ein `profiles`-Eintrag, übernimmt die
|
||||||
|
Funktion dessen Kennung** statt eine neue zu vergeben. Das ist der Grund,
|
||||||
|
warum bestehende Zugänge nach der Umstellung weiterlaufen: Notizen,
|
||||||
|
Entwürfe und Protokolleinträge hängen an dieser ID.
|
||||||
|
|
||||||
|
Der Abgleich über die Adresse ist genau hier vertretbar und sonst nirgends: sie
|
||||||
|
kommt aus einem von Entra ausgestellten Token, nicht aus einem Formular. Wer sie
|
||||||
|
behauptet, hat sie bereits bewiesen.
|
||||||
|
|
||||||
## Freischaltung über die Entra-Gruppe
|
## Freischaltung über die Entra-Gruppe
|
||||||
|
|
||||||
Wer sich anmeldet, hat damit **noch keinen Zugriff**. Zugriff hat, wer eine
|
Wer sich anmeldet, hat damit **noch keinen Zugriff**. Zugriff hat, wer eine
|
||||||
`profiles`-Zeile mit `role = 'hr'` und `is_active = true` besitzt. Diese Zeile
|
`profiles`-Zeile mit `role = 'hr'` und `is_active = true` besitzt.
|
||||||
entsteht aus der Mitgliedschaft in einer Entra-Gruppe.
|
|
||||||
|
|
||||||
### Woher der Gruppen-Anspruch kommt
|
### Woher der Gruppen-Anspruch kommt
|
||||||
|
|
||||||
@@ -76,46 +91,47 @@ ist. Zwei Varianten:
|
|||||||
| Sicherheitsgruppen | frei | Schickt *alle* Sicherheitsgruppen mit. Ab etwa 200 Gruppen liefert Entra statt der Liste einen Verweis, und die Auswertung greift ins Leere. |
|
| Sicherheitsgruppen | frei | Schickt *alle* Sicherheitsgruppen mit. Ab etwa 200 Gruppen liefert Entra statt der Liste einen Verweis, und die Auswertung greift ins Leere. |
|
||||||
| Der Anwendung zugewiesene Gruppen | Entra ID P1 | Nur die zugewiesene Gruppe steht im Token. |
|
| Der Anwendung zugewiesene Gruppen | Entra ID P1 | Nur die zugewiesene Gruppe steht im Token. |
|
||||||
|
|
||||||
### Warum die Auswertung aus `auth.identities` liest, nicht aus `auth.users`
|
### Wo die Auswertung hingehört
|
||||||
|
|
||||||
Das ist kein Detail, sondern der Kern der Absicherung.
|
In den `jwt`-Rückruf in `auth.ts`, neben `app_upsert_user()` — dort liegt
|
||||||
|
`profile.groups` aus dem ID-Token vor.
|
||||||
|
|
||||||
`auth.users.raw_user_meta_data` ist **von der angemeldeten Person selbst
|
Das ist belastbar, und der Grund ist wichtig: das ID-Token ist von Entra
|
||||||
beschreibbar** — `supabase.auth.updateUser({ data: … })` schreibt genau dorthin.
|
signiert und wurde von Auth.js gegen den Aussteller geprüft. Die angemeldete
|
||||||
Läse die Freischaltung von dort, könnte sich jede:r Angemeldete den HR-Anspruch
|
Person kann seinen Inhalt nicht beeinflussen. (Unter GoTrue war dieselbe Stelle
|
||||||
selbst eintragen und hätte damit Zugriff auf sämtliche Personaldaten.
|
eine Falle: `auth.users.raw_user_meta_data` war von der Person selbst
|
||||||
|
beschreibbar, und eine Freischaltung, die von dort gelesen hätte, wäre
|
||||||
`auth.identities.identity_data` schreibt ausschliesslich GoTrue aus der Antwort
|
selbstbedienbar gewesen.)
|
||||||
des Anbieters. Nur das ist eine belastbare Quelle.
|
|
||||||
|
|
||||||
### Reihenfolge
|
### Reihenfolge
|
||||||
|
|
||||||
Der Trigger wird erst gebaut, wenn feststeht, wie der Anspruch tatsächlich
|
Gebaut wird das erst, wenn feststeht, wie der Anspruch tatsächlich ankommt — das
|
||||||
ankommt — das hängt an der gewählten Variante und an der Konfiguration des
|
hängt an der gewählten Variante und an der Konfiguration des Mandanten. Ablauf:
|
||||||
Mandanten. Ablauf:
|
|
||||||
|
|
||||||
1. SSO in Betrieb nehmen, einmal anmelden.
|
1. SSO in Betrieb nehmen, einmal anmelden.
|
||||||
2. `node --env-file=.env.local supabase/entra-claims.ts <e-mail>` zeigt, was in
|
2. Im `jwt`-Rückruf einmalig `console.log(profile)` — das zeigt die Ansprüche so,
|
||||||
`identity_data` gelandet ist.
|
wie der Mandant sie tatsächlich schickt.
|
||||||
3. Erst dann die Migration mit der konkreten Gruppen-ID schreiben.
|
3. Erst dann die Auswertung mit der konkreten Gruppen-ID schreiben.
|
||||||
|
|
||||||
Ohne Schritt 2 wäre die Migration geraten.
|
Ohne Schritt 2 wäre sie geraten. Bis dahin wird `profiles` von Hand gepflegt.
|
||||||
|
|
||||||
### Was die Gruppe nicht kann
|
### Was die Gruppe nicht kann
|
||||||
|
|
||||||
Die Mitgliedschaft steht im Token. Wer aus der Gruppe entfernt wird, verliert
|
Die Mitgliedschaft steht im Token. Wer aus der Gruppe entfernt wird, verliert den
|
||||||
den Zugriff deshalb **bei der nächsten Anmeldung**, nicht sofort. Für den
|
Zugriff deshalb **bei der nächsten Anmeldung**, nicht sofort. Für den sofortigen
|
||||||
sofortigen Entzug bleibt `profiles.is_active = false` das Mittel — das wirkt
|
Entzug bleibt `profiles.is_active = false` das Mittel — das wirkt beim nächsten
|
||||||
beim nächsten Datenbankzugriff, weil `is_hr_user()` die Spalte je Abfrage liest.
|
Datenbankzugriff, weil `is_hr_user()` die Spalte je Abfrage liest.
|
||||||
|
|
||||||
## Bestehende Zugänge
|
## Wer prüft was
|
||||||
|
|
||||||
Ein bestehendes Konto mit Passwort-Anmeldung und ein Entra-Konto derselben
|
| Stelle | Prüft | Wann |
|
||||||
Person sind für Supabase **zwei verschiedene Benutzer** mit verschiedenen IDs.
|
|---|---|---|
|
||||||
Die `profiles`-Zeile hängt an der alten ID; nach der ersten Entra-Anmeldung
|
| `proxy.ts` | Gibt es überhaupt eine Sitzung? | jede Anfrage |
|
||||||
zeigt sie ins Leere und die Person ist ausgesperrt.
|
| `app/(app)/layout.tsx` | `profiles.role` / `is_active` | jeder Seitenaufbau |
|
||||||
|
| `lib/auth/require-hr.ts` | dasselbe, für `/api/export/*` | jeder Aufruf |
|
||||||
|
| RLS-Policies | `is_hr_user()` | jede einzelne Abfrage |
|
||||||
|
|
||||||
`supabase/relink-profile.ts` hängt sie um. Es überträgt auch die
|
Der Proxy prüft die HR-Rechte **nicht** — er hat keine Datenbankverbindung. Sie
|
||||||
Fremdschlüssel, die auf die alte Benutzer-ID zeigen (`audit_log.actor_user_id`,
|
in das Sitzungstoken zu schreiben wäre schneller gewesen und hätte eine
|
||||||
`employee_notes.author_user_id`, …), sonst stünde in der Historie eine Kennung,
|
Behauptung eingefroren: eine entzogene Freischaltung wirkte dann erst mit dem
|
||||||
zu der es kein Konto mehr gibt.
|
nächsten Token. Bei einer Personalanwendung ist das die falsche Richtung.
|
||||||
|
|||||||
@@ -13,6 +13,50 @@ const eslintConfig = defineConfig([
|
|||||||
"build/**",
|
"build/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
// ── Der Datenbankzugriff bleibt in einer Hand ───────────────────
|
||||||
|
//
|
||||||
|
// Die Zugriffsrechte hängen an einer Sitzungsvariablen, die nur innerhalb
|
||||||
|
// einer Transaktion gesetzt werden darf (siehe lib/db/index.ts). Wer den
|
||||||
|
// Pool direkt benutzt, umgeht das — und die Abfrage läuft dann mit dem
|
||||||
|
// Kontext, den die vorherige Anfrage auf derselben gepoolten Verbindung
|
||||||
|
// hinterlassen hat.
|
||||||
|
//
|
||||||
|
// Das muss strukturell unmöglich sein, nicht per Konvention: eine
|
||||||
|
// Vereinbarung überlebt den nächsten Termindruck nicht.
|
||||||
|
{
|
||||||
|
files: ["**/*.ts", "**/*.tsx"],
|
||||||
|
// Tests dürfen: sie werden nicht ausgeliefert, und einige prüfen gerade
|
||||||
|
// die Einstellungen des Pools — das geht nicht, ohne ihn anzusehen.
|
||||||
|
ignores: ["lib/db/**", "tests/**", "supabase/**", "scripts/**"],
|
||||||
|
rules: {
|
||||||
|
"no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
paths: [
|
||||||
|
{
|
||||||
|
name: "pg",
|
||||||
|
message:
|
||||||
|
"Kein direkter Pool-Zugriff. Abfragen laufen über withUser() aus lib/db — nur dort wird der Sitzungskontext transaktionslokal gesetzt.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "kysely",
|
||||||
|
importNames: ["Kysely"],
|
||||||
|
message:
|
||||||
|
"Keine zweite Kysely-Instanz. lib/db exportiert withUser(); die Instanz selbst bleibt privat, damit keine Abfrage ohne Kontext möglich ist.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: ["**/lib/db/pool", "**/db/pool"],
|
||||||
|
message:
|
||||||
|
"Der Pool ist absichtlich nicht exportiert. Über lib/db gehen — withUser() erzwingt die Transaktion.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export default eslintConfig;
|
export default eslintConfig;
|
||||||
|
|||||||
139
lib/auth/config.ts
Normal file
139
lib/auth/config.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id";
|
||||||
|
import type { NextAuthConfig } from "next-auth";
|
||||||
|
|
||||||
|
// Der Teil der Anmeldung, der **ohne Datenbank** auskommt.
|
||||||
|
//
|
||||||
|
// Das ist keine Stilfrage: proxy.ts läuft je nach Betriebsart in einer
|
||||||
|
// Umgebung ohne Node-Module — dort gibt es kein `pg` und keine Verbindung.
|
||||||
|
// Würde der Proxy die vollständige Konfiguration laden, zöge er die
|
||||||
|
// Zugriffsschicht mit hinein und liesse sich nicht mehr ausliefern. Deshalb
|
||||||
|
// hier nur Anbieter und Sitzungsregeln; alles, was die Datenbank berührt,
|
||||||
|
// steht in auth.ts.
|
||||||
|
|
||||||
|
/** Wie lange eine Anmeldung ohne erneuten Besuch bei Entra gilt. */
|
||||||
|
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 9; // ein Arbeitstag
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Aussteller — mit Abbruch statt Rückfall.
|
||||||
|
*
|
||||||
|
* Ohne diese Prüfung setzt Auth.js bei fehlender Variablen stillschweigend
|
||||||
|
* `https://login.microsoftonline.com/common/v2.0` ein. Das ist beim ersten
|
||||||
|
* Ausprobieren aufgefallen: die Weiterleitung ging tatsächlich nach
|
||||||
|
* `/common/oauth2/v2.0/authorize`, und damit hätte sich **jedes**
|
||||||
|
* Microsoft-Konto anmelden dürfen, auch ein privates.
|
||||||
|
*
|
||||||
|
* Aufgefallen wäre das im Betrieb sonst nicht — die Anmeldung funktioniert
|
||||||
|
* ja, nur eben für zu viele. Ein vergessener Wert in der Deployment-Umgebung
|
||||||
|
* muss deshalb den Start verhindern, nicht die Tür aufmachen.
|
||||||
|
*
|
||||||
|
* In der Entwicklung bleibt es bei einer Warnung: dort ist nichts
|
||||||
|
* konfiguriert, und ein Abbruch beim Laden des Moduls nähme auch die
|
||||||
|
* Anmeldeseite mit.
|
||||||
|
*/
|
||||||
|
function tenantIssuer(): string | undefined {
|
||||||
|
const issuer = process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER;
|
||||||
|
if (issuer) return issuer;
|
||||||
|
|
||||||
|
const hinweis =
|
||||||
|
"AUTH_MICROSOFT_ENTRA_ID_ISSUER fehlt. Ohne Mandanten-Aussteller fiele die " +
|
||||||
|
"Anmeldung auf /common/ zurück und stünde jedem Microsoft-Konto offen.";
|
||||||
|
if (process.env.NODE_ENV === "production") throw new Error(hinweis);
|
||||||
|
console.warn(`[auth] ${hinweis}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Konfiguration als **Funktion**, nicht als Objekt.
|
||||||
|
*
|
||||||
|
* Auth.js wertet die Funktionsform pro Anfrage aus. Das ist hier nötig, weil
|
||||||
|
* tenantIssuer() in der Produktion abbricht: als Objekt gebaut liefe die
|
||||||
|
* Prüfung schon beim Import — und `next build` importiert jedes Route-Modul,
|
||||||
|
* um die Seitendaten einzusammeln. Der Bau bräuchte dann die
|
||||||
|
* Anmeldekonfiguration der Zielumgebung, und ein Abbild liesse sich nicht
|
||||||
|
* mehr einmal bauen und überall ausliefern.
|
||||||
|
*/
|
||||||
|
export function authConfig(): NextAuthConfig {
|
||||||
|
return {
|
||||||
|
providers: [
|
||||||
|
MicrosoftEntraID({
|
||||||
|
clientId: process.env.AUTH_MICROSOFT_ENTRA_ID_ID,
|
||||||
|
clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET,
|
||||||
|
issuer: tenantIssuer(),
|
||||||
|
|
||||||
|
// Der eingebaute Anbieter fordert zusätzlich `User.Read` an und holt
|
||||||
|
// damit das Profilbild aus dem Graph. Beides ist hier unerwünscht: eine
|
||||||
|
// Berechtigung, die niemand braucht, muss die Mandantenverwaltung
|
||||||
|
// trotzdem genehmigen — und das Bild landete base64-kodiert im
|
||||||
|
// Sitzungscookie, das dann in Teile zerfällt.
|
||||||
|
authorization: { params: { scope: "openid profile email" } },
|
||||||
|
profile(profile) {
|
||||||
|
return {
|
||||||
|
// Die `oid`, nicht `sub`: `sub` ist pro Anwendung verschieden, die
|
||||||
|
// `oid` identifiziert dieselbe Person über Anwendungen hinweg und
|
||||||
|
// überlebt Namens- und Adressänderungen.
|
||||||
|
id: profile.oid,
|
||||||
|
name: profile.name ?? null,
|
||||||
|
// `email` ist im Token ein optionaler Anspruch — je nach Mandant
|
||||||
|
// fehlt er. `preferred_username` bzw. `upn` tragen dann dieselbe
|
||||||
|
// Adresse. Ohne diesen Rückfall scheitert die Anmeldung in genau
|
||||||
|
// den Mandanten, die den Anspruch nicht ausdrücklich konfiguriert
|
||||||
|
// haben.
|
||||||
|
email: profile.email ?? profile.preferred_username ?? profile.upn ?? null,
|
||||||
|
image: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Eigene Seite statt der von Auth.js mitgelieferten: die Anmeldung ist die
|
||||||
|
// erste Seite, die jemand sieht, und soll aussehen wie die Anwendung.
|
||||||
|
pages: { signIn: "/login", error: "/login" },
|
||||||
|
|
||||||
|
session: { strategy: "jwt", maxAge: SESSION_MAX_AGE_SECONDS },
|
||||||
|
|
||||||
|
callbacks: {
|
||||||
|
// Muss **hier** stehen und nicht in auth.ts, obwohl es nur eine
|
||||||
|
// Zuweisung ist.
|
||||||
|
//
|
||||||
|
// proxy.ts baut eine eigene Auth.js-Instanz aus genau dieser Datei. Lag
|
||||||
|
// die Zuordnung in auth.ts, bekäme der Proxy die Standard-Sitzung ohne
|
||||||
|
// `id`, hielte jede angemeldete Person für nicht angemeldet und
|
||||||
|
// schickte sie zurück auf /login — obwohl das Cookie längst gesetzt
|
||||||
|
// ist. Genau so ist die erste echte Anmeldung in einer Schleife
|
||||||
|
// gelandet: Konto angelegt, Sitzung gültig, und trotzdem kam man nicht
|
||||||
|
// hinein.
|
||||||
|
//
|
||||||
|
// Datenbank braucht das nicht, es liest nur aus dem entschlüsselten
|
||||||
|
// Token — deshalb darf es am Rand laufen.
|
||||||
|
session({ session, token }) {
|
||||||
|
if (token.uid) session.user.id = token.uid;
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hinter Reverse Proxy und Container-Netzwerk kommt der Host aus dem
|
||||||
|
// Header. Ohne das verweigert Auth.js in der Produktion den Dienst, weil
|
||||||
|
// es die Herkunft nicht bestätigen kann.
|
||||||
|
trustHost: true,
|
||||||
|
|
||||||
|
// Auf der Anmeldeseite steht bewusst nur eine allgemeine Meldung — der
|
||||||
|
// Grund ist fremdbestimmt und gehört nicht auf eine Seite, die echt
|
||||||
|
// aussieht. Im Serverprotokoll gehört er dagegen hin, und zwar
|
||||||
|
// vollständig: ohne das scheitert die Anmeldung lautlos, der Browser
|
||||||
|
// springt auf /login zurück, und es gibt nichts zu lesen ausser einer
|
||||||
|
// 302. Genau so ist die erste Anmeldung hier fehlgeschlagen.
|
||||||
|
logger: {
|
||||||
|
error(err) {
|
||||||
|
console.error("[auth] Fehler:", err);
|
||||||
|
const cause = (err as { cause?: unknown }).cause;
|
||||||
|
// Der eigentliche Grund steckt oft eine Ebene tiefer — bei einem
|
||||||
|
// Fehler in einem Rückruf ist die äussere Meldung nur „Read more at
|
||||||
|
// …/errors#callback-route-error".
|
||||||
|
if (cause) console.error("[auth] Ursache:", cause);
|
||||||
|
},
|
||||||
|
warn(code) {
|
||||||
|
console.warn("[auth] Warnung:", code);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
26
lib/auth/require-hr.ts
Normal file
26
lib/auth/require-hr.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withUser } from "@/lib/db";
|
||||||
|
import { currentUserId } from "./session";
|
||||||
|
|
||||||
|
// Route Handlers under /api/export/* are outside the App Router layout tree,
|
||||||
|
// so app/(app)/layout.tsx's HR gate never runs for them — each one has to
|
||||||
|
// re-establish that the caller is an active HR user itself. RLS is still the
|
||||||
|
// real boundary (an unauthorized session simply reads nothing); this exists
|
||||||
|
// so those routes answer 401/403 instead of handing back an empty workbook.
|
||||||
|
|
||||||
|
export type HrGate = { denied: NextResponse } | { userId: string };
|
||||||
|
|
||||||
|
export async function requireHrUser(): Promise<HrGate> {
|
||||||
|
const userId = await currentUserId();
|
||||||
|
if (!userId) return { denied: NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }) };
|
||||||
|
|
||||||
|
const profile = await withUser(userId, (tx) =>
|
||||||
|
tx.selectFrom("profiles").select(["role", "is_active"]).where("id", "=", userId).executeTakeFirst()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (profile?.role !== "hr" || profile.is_active !== true) {
|
||||||
|
return { denied: NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }) };
|
||||||
|
}
|
||||||
|
return { userId };
|
||||||
|
}
|
||||||
32
lib/auth/session.ts
Normal file
32
lib/auth/session.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
|
||||||
|
// Der einzige Ort, an dem die Kennung der angemeldeten Person herkommt.
|
||||||
|
//
|
||||||
|
// Dass der Wechsel von GoTrue auf Auth.js eine Änderung an dieser Datei war
|
||||||
|
// und nicht an fünfzig Aufrufstellen, lag genau an dieser Bündelung: alles
|
||||||
|
// andere ruft `currentUserId()` auf und reicht den Wert an withUser() weiter.
|
||||||
|
//
|
||||||
|
// Der Wert ist app_users.id — nicht die `oid` von Entra. Die Zuordnung
|
||||||
|
// zwischen beiden macht app_upsert_user() bei der Anmeldung, und sie
|
||||||
|
// übernimmt für eine bereits bekannte Adresse die vorhandene profiles.id.
|
||||||
|
// Deshalb passt die Kennung weiterhin auf das, was app_current_user_id() in
|
||||||
|
// der Datenbank erwartet, und die 58 RLS-Policies merken vom Wechsel nichts.
|
||||||
|
|
||||||
|
export async function currentUserId(): Promise<string | null> {
|
||||||
|
const session = await auth();
|
||||||
|
return session?.user?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie currentUserId(), bricht aber ab, statt null zu liefern.
|
||||||
|
*
|
||||||
|
* Für Stellen, die ohne angemeldete Person keinen Sinn ergeben. Die
|
||||||
|
* Absicherung hängt trotzdem nicht daran: ohne Kontext geben die
|
||||||
|
* RLS-Policies nichts zurück, unabhängig davon, was der Anwendungscode tut.
|
||||||
|
*/
|
||||||
|
export async function requireUserId(): Promise<string> {
|
||||||
|
const id = await currentUserId();
|
||||||
|
if (!id) throw new Error("Nicht angemeldet.");
|
||||||
|
return id;
|
||||||
|
}
|
||||||
77
lib/db/index.ts
Normal file
77
lib/db/index.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Kysely, PostgresDialect, sql, type Transaction } from "kysely";
|
||||||
|
import { getPool } from "./pool";
|
||||||
|
import type { Schema } from "./schema";
|
||||||
|
|
||||||
|
// Der einzige Weg an die Datenbank.
|
||||||
|
//
|
||||||
|
// ═══ Warum das keine gewöhnliche Datenbankschicht ist ═══
|
||||||
|
//
|
||||||
|
// Die Zugriffsrechte liegen in der Datenbank: 58 RLS-Policies rufen
|
||||||
|
// is_hr_user() auf, und das fragt seit der Umstellung nicht mehr Supabase,
|
||||||
|
// sondern `current_setting('app.user_id')` — eine Sitzungsvariable.
|
||||||
|
//
|
||||||
|
// Sitzungsvariablen hängen an der *Verbindung*, nicht an der Anfrage. Und
|
||||||
|
// Verbindungen kommen aus einem Pool. Wird die Variable ohne Transaktion
|
||||||
|
// gesetzt, bleibt sie an der Verbindung kleben, und die nächste Anfrage, die
|
||||||
|
// dieselbe Verbindung zieht, läuft mit der Kennung der vorherigen Person —
|
||||||
|
// quer über Benutzer hinweg, in einer Personaldatenbank.
|
||||||
|
//
|
||||||
|
// Das ist die Art Fehler, die in keinem Test auffällt, den man nicht
|
||||||
|
// absichtlich dafür schreibt (tests/integration/session-context.test.ts tut
|
||||||
|
// genau das). Deshalb:
|
||||||
|
//
|
||||||
|
// 1. Die Kysely-Instanz wird **nicht exportiert**. Wer abfragen will, muss
|
||||||
|
// durch withUser() — und das öffnet immer eine Transaktion.
|
||||||
|
// 2. `set_config(..., true)` — das dritte Argument bedeutet
|
||||||
|
// transaktionslokal. Mit `false` wäre die ganze Vorsichtsmassnahme
|
||||||
|
// wirkungslos.
|
||||||
|
// 3. Eine ESLint-Regel verbietet den Import von `pg` und `./pool`
|
||||||
|
// ausserhalb dieses Verzeichnisses.
|
||||||
|
//
|
||||||
|
// Zusätzlich verbindet sich die Anwendung mit einer Datenbankrolle **ohne**
|
||||||
|
// BYPASSRLS. Fehlt der Kontext trotz allem, liefern die Policies nichts
|
||||||
|
// zurück — nicht alles.
|
||||||
|
|
||||||
|
// Der Pool wird als Funktion übergeben, nicht als fertige Instanz: Kysely
|
||||||
|
// ruft sie erst bei der ersten Abfrage auf. So verlangt der Import dieses
|
||||||
|
// Moduls noch keine Zugangsdaten — siehe getPool().
|
||||||
|
const db = new Kysely<Schema>({
|
||||||
|
dialect: new PostgresDialect({ pool: async () => getPool() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Tx = Transaction<Schema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt `fn` im Namen der angegebenen Person aus.
|
||||||
|
*
|
||||||
|
* `userId` ist die app_users.id. Für nicht angemeldete Zugriffe null — dann
|
||||||
|
* greift keine Policy und es kommt nichts zurück, was auch richtig ist.
|
||||||
|
*/
|
||||||
|
export async function withUser<T>(userId: string | null, fn: (tx: Tx) => Promise<T>): Promise<T> {
|
||||||
|
return db.transaction().execute(async (tx) => {
|
||||||
|
// Erste Anweisung der Transaktion, vor allem anderen.
|
||||||
|
await sql`select set_config('app.user_id', ${userId ?? ""}, true)`.execute(tx);
|
||||||
|
return fn(tx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Für Abläufe ohne angemeldete Person — heute nur der nächtliche Lauf für
|
||||||
|
* fällige Änderungen.
|
||||||
|
*
|
||||||
|
* Bewusst kein privilegierter Zugang: die Verbindung benutzt dieselbe Rolle
|
||||||
|
* ohne BYPASSRLS. Was hier laufen darf, muss als SECURITY-DEFINER-Funktion
|
||||||
|
* in der Datenbank stehen und dort selbst prüfen, was es tut. Ein
|
||||||
|
* Dienstschlüssel, der RLS aushebelt, existiert nicht mehr.
|
||||||
|
*/
|
||||||
|
export async function asSystem<T>(fn: (tx: Tx) => Promise<T>): Promise<T> {
|
||||||
|
return withUser(null, fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Für Migrations- und Wartungsskripte, die ausserhalb einer Anfrage laufen. */
|
||||||
|
export async function closeDb(): Promise<void> {
|
||||||
|
await db.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { sql };
|
||||||
102
lib/db/pool.ts
Normal file
102
lib/db/pool.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Pool, types } from "pg";
|
||||||
|
|
||||||
|
// ═══ Wie Werte aus der Datenbank ankommen ════════════════════════
|
||||||
|
//
|
||||||
|
// Der Wechsel des Zugriffswegs hat hier eine Falle hinterlassen, die kein
|
||||||
|
// Typprüfer und keiner der Tests fangen konnte.
|
||||||
|
//
|
||||||
|
// Die alte API-Schicht lieferte JSON: ein `date` kam als "2026-08-03" an,
|
||||||
|
// ein `numeric` als Zahl. Genau so steht es in lib/supabase/types.ts, und
|
||||||
|
// darauf baut die gesamte Anwendung — Sortierungen mit localeCompare,
|
||||||
|
// Vergleiche wie `entry_date <= stichtag`, das Ableiten des Status.
|
||||||
|
//
|
||||||
|
// Der `pg`-Treiber macht es anders herum: aus `date` wird ein Date-Objekt,
|
||||||
|
// aus `numeric` eine Zeichenkette. Die Deklarationen blieben dabei
|
||||||
|
// unverändert gültig — sie beschreiben ja nur, was der Code *glaubt*. Der
|
||||||
|
// Fehler zeigt sich erst zur Laufzeit, und im günstigen Fall als Absturz
|
||||||
|
// („a.date.localeCompare is not a function"). Im ungünstigen Fall gar
|
||||||
|
// nicht: ein Datumsvergleich zwischen Date und Zeichenkette wirft nicht, er
|
||||||
|
// liefert bloss das falsche Ergebnis.
|
||||||
|
//
|
||||||
|
// Deshalb wird der Treiber hier auf die Form zurückgestellt, die die Typen
|
||||||
|
// beschreiben. Das ist die kleinere und ehrlichere Änderung, als 49
|
||||||
|
// Abfragestellen umzuschreiben.
|
||||||
|
//
|
||||||
|
// Nebenbei löst es ein zweites Problem: `date` ist ein Kalendertag ohne
|
||||||
|
// Zeitzone. Als Date-Objekt bekäme er eine — Mitternacht in der Zone des
|
||||||
|
// Servers —, und ein Geburtsdatum verschöbe sich beim Formatieren um einen
|
||||||
|
// Tag. Dieselbe Klasse von Fehler wie im Seed.
|
||||||
|
types.setTypeParser(1082, (v) => v); // date → "YYYY-MM-DD", unverändert
|
||||||
|
types.setTypeParser(1184, (v) => new Date(v).toISOString()); // timestamptz → ISO-8601 mit Z
|
||||||
|
types.setTypeParser(1114, (v) => new Date(v + "Z").toISOString()); // timestamp ohne Zone
|
||||||
|
types.setTypeParser(1700, (v) => Number(v)); // numeric → Zahl
|
||||||
|
|
||||||
|
// Bewusst *nicht* umgestellt: int8 (bigint). Es kommt nur aus count() und
|
||||||
|
// wird überall mit Number() gelesen; als Zahl geparst verlöre es jenseits
|
||||||
|
// von 2^53 stillschweigend an Genauigkeit.
|
||||||
|
|
||||||
|
// Die einzige Stelle im Projekt, die `pg` importieren darf.
|
||||||
|
//
|
||||||
|
// Der Grund steht in lib/db/index.ts: eine Abfrage ausserhalb von withUser()
|
||||||
|
// läuft ohne Sitzungskontext und damit — je nachdem, was die vorherige
|
||||||
|
// Anfrage auf derselben gepoolten Verbindung hinterlassen hat — im Namen
|
||||||
|
// einer fremden Person. Deshalb wird der Pool nicht exportiert, sondern nur
|
||||||
|
// die Kysely-Instanz, die ihn benutzt, und eine ESLint-Regel verbietet den
|
||||||
|
// Import von `pg` und von dieser Datei überall sonst.
|
||||||
|
|
||||||
|
// Der Pool hängt am globalen Objekt, nicht nur am Modul.
|
||||||
|
//
|
||||||
|
// Im Entwicklungsbetrieb lädt Next.js geänderte Module neu. Ein modul-lokales
|
||||||
|
// `let` wäre danach leer, der alte Pool aber weiterhin am Leben — mit seinen
|
||||||
|
// Verbindungen. Nach ein paar Bearbeitungen ist das Kontingent des Anbieters
|
||||||
|
// aufgebraucht, und die Anwendung antwortet nur noch mit „max clients
|
||||||
|
// reached". Genau so ist sie hier stehengeblieben.
|
||||||
|
//
|
||||||
|
// In der Produktion gibt es kein Neuladen; dort ist die Zeile wirkungslos.
|
||||||
|
const globalForPool = globalThis as typeof globalThis & { __alpenwerkPool?: Pool };
|
||||||
|
|
||||||
|
let instance: Pool | undefined = globalForPool.__alpenwerkPool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Verbindungspool — erst beim ersten Zugriff angelegt, nicht beim Import.
|
||||||
|
*
|
||||||
|
* Der Unterschied ist nicht kosmetisch: `next build` importiert jedes Route-
|
||||||
|
* Modul, um die Seitendaten einzusammeln. Entstünde der Pool dabei, bräuchte
|
||||||
|
* schon der Bau Zugangsdaten zur Datenbank — ein Container-Abbild liesse sich
|
||||||
|
* in einer Baustrecke ohne Produktionsgeheimnisse nicht mehr erzeugen.
|
||||||
|
*/
|
||||||
|
export function getPool(): Pool {
|
||||||
|
if (instance) return instance;
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL;
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error(
|
||||||
|
"DATABASE_URL fehlt. Erwartet wird ein PostgreSQL-Verbindungsstring — " +
|
||||||
|
"die Anwendung spricht direkt mit der Datenbank, nicht über eine API-Schicht."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
instance = new Pool({
|
||||||
|
connectionString,
|
||||||
|
// Der Standard sind 10; bei serverseitigem Rendering hängt an jeder
|
||||||
|
// Anfrage genau eine Transaktion, und mehr Verbindungen als die Datenbank
|
||||||
|
// zulässt bringen nur Wartezeit an einer anderen Stelle.
|
||||||
|
max: Number(process.env.DATABASE_POOL_MAX ?? 10),
|
||||||
|
// Eine Anfrage, die länger braucht, ist kaputt und soll das melden statt
|
||||||
|
// eine Verbindung zu belegen.
|
||||||
|
statement_timeout: 20_000,
|
||||||
|
idle_in_transaction_session_timeout: 20_000,
|
||||||
|
connectionTimeoutMillis: 10_000,
|
||||||
|
// Verwaltete Anbieter (Azure, RDS, Supabase) verlangen TLS; lokal nicht.
|
||||||
|
ssl: process.env.DATABASE_SSL === "false" ? undefined : { rejectUnauthorized: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ein Fehler auf einer Leerlaufverbindung beendet sonst den Prozess.
|
||||||
|
instance.on("error", (err) => {
|
||||||
|
console.error("Unerwarteter Fehler auf einer Leerlaufverbindung:", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
globalForPool.__alpenwerkPool = instance;
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
56
lib/db/rpc.ts
Normal file
56
lib/db/rpc.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { sql, withUser, type Tx } from "./index";
|
||||||
|
import type { Database } from "@/lib/supabase/types";
|
||||||
|
|
||||||
|
// Aufruf einer Datenbankfunktion.
|
||||||
|
//
|
||||||
|
// Die Geschäftslogik liegt in PL/pgSQL — Eintritt, Versetzung, Austritt und
|
||||||
|
// die übrigen zehn Mutationen. Daran ändert der Wechsel des Zugriffswegs
|
||||||
|
// nichts: es fällt nur die API-Schicht dazwischen weg. Aufgerufen wird die
|
||||||
|
// Funktion jetzt unmittelbar, innerhalb der Transaktion, in der auch der
|
||||||
|
// Sitzungskontext gilt — ohne den würde require_hr_admin() darin abweisen.
|
||||||
|
|
||||||
|
export type MutationFn = keyof Database["public"]["Functions"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruft `fn(payload)` innerhalb der laufenden Transaktion auf.
|
||||||
|
*
|
||||||
|
* `payload` weglassen für die Funktionen ohne Argument —
|
||||||
|
* apply_due_pending_changes() ist die einzige. Mit einem jsonb-Argument
|
||||||
|
* aufgerufen fände Postgres keine passende Signatur.
|
||||||
|
*/
|
||||||
|
export async function callFunction(tx: Tx, fn: MutationFn, payload?: Record<string, unknown>): Promise<unknown> {
|
||||||
|
// Der Funktionsname stammt aus einer geschlossenen Aufzählung, nie aus
|
||||||
|
// einer Eingabe — sonst wäre die Verkettung hier eine Einladung.
|
||||||
|
const name = sql.raw(`"${fn}"`);
|
||||||
|
const query =
|
||||||
|
payload === undefined
|
||||||
|
? sql<{ result: unknown }>`select ${name}() as result`
|
||||||
|
: sql<{ result: unknown }>`select ${name}(${sql.val(JSON.stringify(payload))}::jsonb) as result`;
|
||||||
|
const result = await query.execute(tx);
|
||||||
|
return result.rows[0]?.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActionResult = { success: boolean; error?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Mutation im Namen der angemeldeten Person, mit der üblichen
|
||||||
|
* Fehlerbehandlung für Server Actions.
|
||||||
|
*
|
||||||
|
* Die Prüfung der Berechtigung passiert in der Funktion selbst
|
||||||
|
* (require_hr_admin) und unabhängig davon in den RLS-Policies — nicht hier.
|
||||||
|
*/
|
||||||
|
export async function runMutation(
|
||||||
|
userId: string | null,
|
||||||
|
fn: MutationFn,
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
await withUser(userId, (tx) => callFunction(tx, fn, payload));
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
// Die Meldungen der Funktionen sind für die Oberfläche geschrieben
|
||||||
|
// („Diese Planstelle ist bereits besetzt.") und werden durchgereicht.
|
||||||
|
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||||
|
}
|
||||||
|
}
|
||||||
49
lib/db/schema.ts
Normal file
49
lib/db/schema.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { ColumnType } from "kysely";
|
||||||
|
import type { Database } from "@/lib/supabase/types";
|
||||||
|
|
||||||
|
// Die Tabellenform für Kysely, abgeleitet aus der bestehenden
|
||||||
|
// Schemabeschreibung — nicht daneben gestellt.
|
||||||
|
//
|
||||||
|
// Zwei Beschreibungen desselben Schemas driften auseinander, und die eine
|
||||||
|
// hier ist bereits gegen die Migrationen abgesichert: `npm run types:check`
|
||||||
|
// vergleicht lib/supabase/types.ts Spalte für Spalte mit
|
||||||
|
// supabase/migrations/*.sql und schlägt in der CI fehl, wenn etwas fehlt.
|
||||||
|
// Diese Ableitung erbt diese Absicherung.
|
||||||
|
//
|
||||||
|
// Die Datei heisst noch lib/supabase/types.ts, weil sie aus der Zeit stammt,
|
||||||
|
// als PostgREST der Zugriffsweg war. Sie beschreibt reines PostgreSQL und
|
||||||
|
// wird beim Entfernen der Supabase-Pakete lediglich umbenannt.
|
||||||
|
|
||||||
|
type Tables = Database["public"]["Tables"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row/Insert/Update einer Tabelle in Kyselys ColumnType-Form.
|
||||||
|
*
|
||||||
|
* Kysely braucht die drei Richtungen getrennt: was beim Lesen herauskommt,
|
||||||
|
* was beim Einfügen erlaubt ist (Spalten mit Vorgabewert dürfen fehlen) und
|
||||||
|
* was beim Aktualisieren erlaubt ist.
|
||||||
|
*/
|
||||||
|
type Columns<T extends keyof Tables> = {
|
||||||
|
[K in keyof Tables[T]["Row"]]: K extends keyof Tables[T]["Insert"]
|
||||||
|
? ColumnType<
|
||||||
|
Tables[T]["Row"][K],
|
||||||
|
Tables[T]["Insert"][K],
|
||||||
|
K extends keyof Tables[T]["Update"] ? Tables[T]["Update"][K] : never
|
||||||
|
>
|
||||||
|
: // Spalten, die es nur beim Lesen gibt (von Triggern gesetzt).
|
||||||
|
ColumnType<Tables[T]["Row"][K], never, never>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DB = { [T in keyof Tables]: Columns<T> };
|
||||||
|
|
||||||
|
/** Die Tabelle, die auth.users ablöst. Noch nicht in der Alt-Beschreibung. */
|
||||||
|
export type AppUsersTable = {
|
||||||
|
id: ColumnType<string, string | undefined, never>;
|
||||||
|
external_id: string;
|
||||||
|
email: string;
|
||||||
|
full_name: ColumnType<string | null, string | null | undefined, string | null>;
|
||||||
|
created_at: ColumnType<string, string | undefined, never>;
|
||||||
|
last_seen_at: ColumnType<string | null, string | null | undefined, string | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Schema = DB & { app_users: AppUsersTable };
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Expression, ExpressionBuilder, SqlBool } from "kysely";
|
||||||
|
import type { Schema } from "./db/schema";
|
||||||
import type { EmploymentStatus } from "./supabase/types";
|
import type { EmploymentStatus } from "./supabase/types";
|
||||||
|
|
||||||
// The SQL counterpart of deriveStatusAsOf() in lib/reports.ts.
|
// The SQL counterpart of deriveStatusAsOf() in lib/reports.ts.
|
||||||
@@ -19,58 +21,59 @@ import type { EmploymentStatus } from "./supabase/types";
|
|||||||
// tests/integration/employee-status-filter.test.ts asserts the two agree
|
// tests/integration/employee-status-filter.test.ts asserts the two agree
|
||||||
// against a real database, which is the only place that can prove it.
|
// against a real database, which is the only place that can prove it.
|
||||||
|
|
||||||
type Filterable = {
|
type Eb = ExpressionBuilder<Schema, "employees">;
|
||||||
gt: (column: string, value: string) => Filterable;
|
|
||||||
lte: (column: string, value: string) => Filterable;
|
|
||||||
gte: (column: string, value: string) => Filterable;
|
|
||||||
or: (filters: string) => Filterable;
|
|
||||||
is: (column: string, value: null) => Filterable;
|
|
||||||
not: (column: string, operator: string, value: null) => Filterable;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** True once the person has started and has not left yet. */
|
/** True once the person has started and has not left yet. */
|
||||||
function employed<Q extends Filterable>(query: Q, asOf: string): Q {
|
function employed(eb: Eb, asOf: string): Expression<SqlBool> {
|
||||||
return query.lte("entry_date", asOf).or(`exit_date.is.null,exit_date.gt.${asOf}`) as Q;
|
return eb.and([eb("entry_date", "<=", asOf), eb.or([eb("exit_date", "is", null), eb("exit_date", ">", asOf)])]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Narrows a PostgREST query to the employees whose *derived* status on
|
* Die Bedingung für die Menge, deren *abgeleiteter* Status am Stichtag einer
|
||||||
* `asOf` is one of `statuses`. Only the combinations the UI offers are
|
* der genannten ist — oder null, wenn nicht eingeschränkt werden soll.
|
||||||
* supported; anything else is left unfiltered rather than silently applying
|
*
|
||||||
* a wrong one.
|
* Nur die Kombinationen, die die Oberfläche anbietet, sind abgedeckt. Für
|
||||||
|
* alles andere kommt null zurück: lieber nicht filtern als falsch filtern.
|
||||||
*/
|
*/
|
||||||
export function applyDerivedStatusFilter<Q extends Filterable>(query: Q, statuses: EmploymentStatus[], asOf: string): Q {
|
export function derivedStatusFilter(eb: Eb, statuses: EmploymentStatus[], asOf: string): Expression<SqlBool> | null {
|
||||||
const wanted = new Set(statuses);
|
const wanted = new Set(statuses);
|
||||||
if (wanted.size === 0) return query;
|
if (wanted.size === 0) return null;
|
||||||
|
|
||||||
// A single non-employed status is a straight date comparison.
|
// A single non-employed status is a straight date comparison.
|
||||||
if (wanted.size === 1 && wanted.has("Geplant")) return query.gt("entry_date", asOf) as Q;
|
if (wanted.size === 1 && wanted.has("Geplant")) return eb("entry_date", ">", asOf);
|
||||||
if (wanted.size === 1 && wanted.has("Ausgetreten")) return query.not("exit_date", "is", null).lte("exit_date", asOf) as Q;
|
if (wanted.size === 1 && wanted.has("Ausgetreten")) {
|
||||||
|
return eb.and([eb("exit_date", "is not", null), eb("exit_date", "<=", asOf)]);
|
||||||
|
}
|
||||||
|
|
||||||
const wantsAktiv = wanted.has("Aktiv");
|
const wantsAktiv = wanted.has("Aktiv");
|
||||||
const wantsKarenz = wanted.has("Karenz");
|
const wantsKarenz = wanted.has("Karenz");
|
||||||
|
|
||||||
if (wantsAktiv && wantsKarenz && wanted.size === 2) {
|
|
||||||
// Everyone employed today, whether or not they are on leave.
|
// Everyone employed today, whether or not they are on leave.
|
||||||
return employed(query, asOf);
|
if (wantsAktiv && wantsKarenz && wanted.size === 2) return employed(eb, asOf);
|
||||||
}
|
|
||||||
|
|
||||||
if (wantsKarenz && !wantsAktiv && wanted.size === 1) {
|
if (wantsKarenz && !wantsAktiv && wanted.size === 1) {
|
||||||
return employed(query, asOf)
|
return eb.and([
|
||||||
.not("karenz_start_date", "is", null)
|
employed(eb, asOf),
|
||||||
.lte("karenz_start_date", asOf)
|
eb("karenz_start_date", "is not", null),
|
||||||
.or(`karenz_return_date.is.null,karenz_return_date.gt.${asOf}`) as Q;
|
eb("karenz_start_date", "<=", asOf),
|
||||||
|
eb.or([eb("karenz_return_date", "is", null), eb("karenz_return_date", ">", asOf)]),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wantsAktiv && !wantsKarenz && wanted.size === 1) {
|
if (wantsAktiv && !wantsKarenz && wanted.size === 1) {
|
||||||
// Employed but *not* inside a karenz window: either no start date, a
|
// Employed but *not* inside a karenz window: either no start date, a
|
||||||
// start still ahead, or a return that has already happened.
|
// start still ahead, or a return that has already happened.
|
||||||
return employed(query, asOf).or(
|
return eb.and([
|
||||||
`karenz_start_date.is.null,karenz_start_date.gt.${asOf},karenz_return_date.lte.${asOf}`
|
employed(eb, asOf),
|
||||||
) as Q;
|
eb.or([
|
||||||
|
eb("karenz_start_date", "is", null),
|
||||||
|
eb("karenz_start_date", ">", asOf),
|
||||||
|
eb("karenz_return_date", "<=", asOf),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mixed selections spanning employed and non-employed states have no UI
|
// Mixed selections spanning employed and non-employed states have no UI
|
||||||
// path today; filtering on a guess would be worse than not filtering.
|
// path today; filtering on a guess would be worse than not filtering.
|
||||||
return query;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
335
lib/import/load.ts
Normal file
335
lib/import/load.ts
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { sql, type Tx } from "@/lib/db";
|
||||||
|
import { todayIso } from "@/lib/format";
|
||||||
|
import { deriveStatusAsOf } from "@/lib/reports";
|
||||||
|
import { normalizeSvnr } from "@/lib/svnr";
|
||||||
|
import type { Bestand, Datensatz, Zeile } from "./validate";
|
||||||
|
|
||||||
|
// Schreiben einer geprüften Datei.
|
||||||
|
//
|
||||||
|
// Der Aufruf steckt in **einer** Transaktion (siehe die Route). Das ist keine
|
||||||
|
// Vorsicht, sondern die Bedingung: eine halb geladene Organisation — Bereiche
|
||||||
|
// ohne Abteilungen, Planstellen ohne Personen — ist schlimmer als gar keine,
|
||||||
|
// weil sie nach Daten aussieht. Bricht irgendetwas ab, war nichts.
|
||||||
|
//
|
||||||
|
// Angelegt wird nur; aktualisiert wird nie. Was es schon gibt, hat die
|
||||||
|
// Prüfung vorher abgewiesen. Damit kann ein Tippfehler in einer Datei keine
|
||||||
|
// bestehenden Personaldaten überschreiben.
|
||||||
|
|
||||||
|
/** Wie viele Zeilen je INSERT. Postgres verträgt 65535 Parameter je Anweisung. */
|
||||||
|
const PORTION = 200;
|
||||||
|
|
||||||
|
export type Ladebericht = Record<string, number>;
|
||||||
|
|
||||||
|
const txt = (v: unknown): string | null => (typeof v === "string" && v !== "" ? v : null);
|
||||||
|
const zahl = (v: unknown): number | null => (typeof v === "number" ? v : null);
|
||||||
|
const bool = (v: unknown, ersatz: boolean): boolean => (typeof v === "boolean" ? v : ersatz);
|
||||||
|
const liste = (v: unknown): string[] | null => (Array.isArray(v) ? (v as string[]) : null);
|
||||||
|
|
||||||
|
async function einfuegen<T>(tx: Tx, tabelle: string, zeilen: T[]): Promise<void> {
|
||||||
|
for (let i = 0; i < zeilen.length; i += PORTION) {
|
||||||
|
await tx
|
||||||
|
// Die Tabellennamen stammen aus einer geschlossenen Aufzählung in
|
||||||
|
// diesem Modul, nie aus der Datei.
|
||||||
|
.insertInto(tabelle as never)
|
||||||
|
.values(zeilen.slice(i, i + PORTION) as never)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was bereits im System steht.
|
||||||
|
*
|
||||||
|
* Läuft in derselben Transaktion wie das Schreiben. Zwischen Prüfung und
|
||||||
|
* Schreiben könnte sonst jemand dieselbe Planstelle besetzen, und der Import
|
||||||
|
* liefe in den Teilindex statt in eine verständliche Meldung.
|
||||||
|
*/
|
||||||
|
export async function bestandLaden(tx: Tx): Promise<Bestand> {
|
||||||
|
const heute = todayIso();
|
||||||
|
|
||||||
|
const [standorte, einheiten, jobs, stellen, besetzungen, personen] = await Promise.all([
|
||||||
|
tx.selectFrom("locations").select(["id", "name"]).execute(),
|
||||||
|
tx.selectFrom("org_units").select(["id", "org_number"]).execute(),
|
||||||
|
tx.selectFrom("jobs").select(["id", "code"]).execute(),
|
||||||
|
tx.selectFrom("om_positions").select(["id", "position_number"]).execute(),
|
||||||
|
tx
|
||||||
|
.selectFrom("position_assignments")
|
||||||
|
.select(["position_id"])
|
||||||
|
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">=", heute)]))
|
||||||
|
.execute(),
|
||||||
|
tx.selectFrom("employees").select(["id", "personnel_number", "email", "sv_nummer"]).execute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const besetzt = new Set(besetzungen.map((b) => b.position_id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
standorte: new Map(standorte.map((l) => [l.name, l.id])),
|
||||||
|
orgNummern: new Map(einheiten.map((o) => [o.org_number, o.id])),
|
||||||
|
jobCodes: new Map(jobs.map((j) => [j.code, j.id])),
|
||||||
|
planstellen: new Map(stellen.map((p) => [p.position_number, { id: p.id, besetzt: besetzt.has(p.id) }])),
|
||||||
|
personalnummern: new Map(personen.map((e) => [e.personnel_number, e.id])),
|
||||||
|
emails: new Set(personen.map((e) => e.email.toLowerCase())),
|
||||||
|
svNummern: new Set(personen.filter((e) => e.sv_nummer).map((e) => normalizeSvnr(e.sv_nummer!))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordnet Einheiten so, dass jede nach ihrer übergeordneten kommt.
|
||||||
|
*
|
||||||
|
* Der Fremdschlüssel auf parent_id wird je Zeile geprüft, und eine Datei
|
||||||
|
* darf ihre Zeilen in beliebiger Reihenfolge führen — eine Mappe, die nach
|
||||||
|
* Bezeichnung sortiert ist, hätte sonst Pech.
|
||||||
|
*/
|
||||||
|
function elternZuerst(zeilen: Zeile[], bekannt: Set<string>): { sortiert: Zeile[]; ungeloest: Zeile[] } {
|
||||||
|
const offen = [...zeilen];
|
||||||
|
const sortiert: Zeile[] = [];
|
||||||
|
const erledigt = new Set(bekannt);
|
||||||
|
|
||||||
|
let fortschritt = true;
|
||||||
|
while (offen.length > 0 && fortschritt) {
|
||||||
|
fortschritt = false;
|
||||||
|
for (let i = offen.length - 1; i >= 0; i--) {
|
||||||
|
const eltern = txt(offen[i].werte.parent_org_number);
|
||||||
|
if (!eltern || erledigt.has(eltern)) {
|
||||||
|
const nummer = txt(offen[i].werte.org_number);
|
||||||
|
if (nummer) erledigt.add(nummer);
|
||||||
|
sortiert.push(offen[i]);
|
||||||
|
offen.splice(i, 1);
|
||||||
|
fortschritt = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Was übrig bleibt, hängt in einem Kreis — die Prüfung fängt den einfachen
|
||||||
|
// Fall (sich selbst übergeordnet), längere Ketten fallen hier auf.
|
||||||
|
return { sortiert, ungeloest: offen };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function laden(
|
||||||
|
tx: Tx,
|
||||||
|
datensatz: Datensatz,
|
||||||
|
bestand: Bestand,
|
||||||
|
akteur: { userId: string; name: string }
|
||||||
|
): Promise<Ladebericht> {
|
||||||
|
const hole = (name: string) => datensatz[name] ?? [];
|
||||||
|
const bericht: Ladebericht = {};
|
||||||
|
const heute = todayIso();
|
||||||
|
|
||||||
|
// ── Standorte ─────────────────────────────────────────────────
|
||||||
|
const standortId = new Map(bestand.standorte);
|
||||||
|
const neueStandorte = hole("Standorte").map((z) => ({
|
||||||
|
name: txt(z.werte.name)!,
|
||||||
|
country: txt(z.werte.country)!,
|
||||||
|
}));
|
||||||
|
if (neueStandorte.length) {
|
||||||
|
const zurueck = await tx.insertInto("locations").values(neueStandorte).returning(["id", "name"]).execute();
|
||||||
|
for (const r of zurueck) standortId.set(r.name, r.id);
|
||||||
|
}
|
||||||
|
bericht.Standorte = neueStandorte.length;
|
||||||
|
|
||||||
|
// ── Organisation ──────────────────────────────────────────────
|
||||||
|
const orgId = new Map(bestand.orgNummern);
|
||||||
|
const { sortiert, ungeloest } = elternZuerst(hole("Organisation"), new Set(orgId.keys()));
|
||||||
|
if (ungeloest.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Die übergeordneten Einheiten von ${ungeloest.length} Zeile(n) lassen sich nicht auflösen — vermutlich ein Kreis in der Spalte „Übergeordnet“.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const z of sortiert) {
|
||||||
|
const eltern = txt(z.werte.parent_org_number);
|
||||||
|
const r = await tx
|
||||||
|
.insertInto("org_units")
|
||||||
|
.values({
|
||||||
|
org_number: txt(z.werte.org_number)!,
|
||||||
|
name: txt(z.werte.name)!,
|
||||||
|
unit_type: txt(z.werte.unit_type) as never,
|
||||||
|
parent_id: eltern ? orgId.get(eltern)! : null,
|
||||||
|
valid_from: txt(z.werte.valid_from) ?? heute,
|
||||||
|
valid_to: txt(z.werte.valid_to),
|
||||||
|
})
|
||||||
|
.returning(["id", "org_number"])
|
||||||
|
.execute();
|
||||||
|
orgId.set(r[0].org_number, r[0].id);
|
||||||
|
}
|
||||||
|
bericht.Organisation = sortiert.length;
|
||||||
|
|
||||||
|
// ── Jobkatalog ────────────────────────────────────────────────
|
||||||
|
const jobId = new Map(bestand.jobCodes);
|
||||||
|
const neueJobs = hole("Jobkatalog").map((z) => ({ code: txt(z.werte.code)!, title: txt(z.werte.title)! }));
|
||||||
|
if (neueJobs.length) {
|
||||||
|
const zurueck = await tx.insertInto("jobs").values(neueJobs).returning(["id", "code"]).execute();
|
||||||
|
for (const r of zurueck) jobId.set(r.code, r.id);
|
||||||
|
}
|
||||||
|
bericht.Jobkatalog = neueJobs.length;
|
||||||
|
|
||||||
|
// ── Planstellen ───────────────────────────────────────────────
|
||||||
|
const stellenId = new Map([...bestand.planstellen].map(([nr, p]) => [nr, p.id]));
|
||||||
|
const neueStellen = hole("Planstellen").map((z) => ({
|
||||||
|
position_number: txt(z.werte.position_number)!,
|
||||||
|
org_unit_id: orgId.get(txt(z.werte.org_number)!)!,
|
||||||
|
job_id: jobId.get(txt(z.werte.job_code)!)!,
|
||||||
|
is_chief: bool(z.werte.is_chief, false),
|
||||||
|
valid_from: txt(z.werte.valid_from) ?? heute,
|
||||||
|
valid_to: txt(z.werte.valid_to),
|
||||||
|
}));
|
||||||
|
if (neueStellen.length) {
|
||||||
|
const zurueck = await tx
|
||||||
|
.insertInto("om_positions")
|
||||||
|
.values(neueStellen)
|
||||||
|
.returning(["id", "position_number"])
|
||||||
|
.execute();
|
||||||
|
for (const r of zurueck) stellenId.set(r.position_number, r.id);
|
||||||
|
}
|
||||||
|
bericht.Planstellen = neueStellen.length;
|
||||||
|
|
||||||
|
// ── Personen ──────────────────────────────────────────────────
|
||||||
|
const personId = new Map(bestand.personalnummern);
|
||||||
|
const personenZeilen = hole("Personen");
|
||||||
|
const besetzungen: { position_id: string; employee_id: string; valid_from: string; valid_to: string | null }[] = [];
|
||||||
|
|
||||||
|
for (const z of personenZeilen) {
|
||||||
|
const w = z.werte;
|
||||||
|
const eintritt = txt(w.entry_date)!;
|
||||||
|
const austritt = txt(w.exit_date);
|
||||||
|
const karenzVon = txt(w.karenz_start_date);
|
||||||
|
const karenzBis = txt(w.karenz_return_date);
|
||||||
|
|
||||||
|
const werte = {
|
||||||
|
personnel_number: zahl(w.personnel_number)!,
|
||||||
|
first_name: txt(w.first_name)!,
|
||||||
|
last_name: txt(w.last_name)!,
|
||||||
|
gender: txt(w.gender) as never,
|
||||||
|
birth_date: txt(w.birth_date)!,
|
||||||
|
sv_nummer: txt(w.sv_nummer) ? normalizeSvnr(txt(w.sv_nummer)!) : null,
|
||||||
|
nationality: txt(w.nationality) ?? "Österreich",
|
||||||
|
address: txt(w.address),
|
||||||
|
postal_code: txt(w.postal_code),
|
||||||
|
city: txt(w.city),
|
||||||
|
address_country: txt(w.address_country),
|
||||||
|
email: txt(w.email)!,
|
||||||
|
phone: txt(w.phone),
|
||||||
|
title_prefix: liste(w.title_prefix) ?? [],
|
||||||
|
title_suffix: liste(w.title_suffix) ?? [],
|
||||||
|
job_title: txt(w.job_title)!,
|
||||||
|
location_id: standortId.get(txt(w.location)!)!,
|
||||||
|
employment_type: (txt(w.employment_type) ?? "Vollzeit") as never,
|
||||||
|
weekly_hours: zahl(w.weekly_hours) ?? 38.5,
|
||||||
|
// Die Prüfung hat jeden Eintrag gegen die Wochentage abgeglichen und
|
||||||
|
// auf die Schreibweise der Datenbank gebracht; hier steht deshalb
|
||||||
|
// sicher nur Mo…So.
|
||||||
|
work_days: (liste(w.work_days) ?? ["Mo", "Di", "Mi", "Do", "Fr"]) as never,
|
||||||
|
contract_type: (txt(w.contract_type) ?? "unbefristet") as never,
|
||||||
|
contract_end_date: txt(w.contract_end_date),
|
||||||
|
paygrade: (txt(w.paygrade) ?? "B") as never,
|
||||||
|
collective_agreement: (txt(w.collective_agreement) ?? "Handel") as never,
|
||||||
|
worker_type: (txt(w.worker_type) ?? "Angestellte:r") as never,
|
||||||
|
monthly_salary_gross: zahl(w.monthly_salary_gross),
|
||||||
|
source: (txt(w.source) ?? "Extern") as never,
|
||||||
|
is_betriebsrat: bool(w.is_betriebsrat, false),
|
||||||
|
has_dienstwagen: bool(w.has_dienstwagen, false),
|
||||||
|
is_laterale_fuehrung: bool(w.is_laterale_fuehrung, false),
|
||||||
|
is_c_level: bool(w.is_c_level, false),
|
||||||
|
entry_date: eintritt,
|
||||||
|
exit_date: austritt,
|
||||||
|
exit_reason: txt(w.exit_reason),
|
||||||
|
karenz_start_date: karenzVon,
|
||||||
|
karenz_return_date: karenzBis,
|
||||||
|
absence_type: txt(w.absence_type),
|
||||||
|
// Der Status wird **abgeleitet**, nicht importiert. Stünde er in der
|
||||||
|
// Datei, könnte er den Daten widersprechen — jemand mit Austritt und
|
||||||
|
// Status „Aktiv“ —, und die Anwendung leitet ihn ohnehin überall aus
|
||||||
|
// denselben Datumsangaben ab.
|
||||||
|
status: deriveStatusAsOf(
|
||||||
|
{ entry_date: eintritt, exit_date: austritt, karenz_start_date: karenzVon, karenz_return_date: karenzBis },
|
||||||
|
heute
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Von Hand geschrieben statt über den Abfragebauer, wegen genau eines
|
||||||
|
// Wortes: OVERRIDING SYSTEM VALUE.
|
||||||
|
//
|
||||||
|
// personnel_number ist GENERATED ALWAYS AS IDENTITY — die Datenbank
|
||||||
|
// vergibt sie und weist einen eigenen Wert sonst ab. Für eine Übernahme
|
||||||
|
// aus einem Altsystem ist das die falsche Richtung: die Nummer steht auf
|
||||||
|
// Lohnzetteln, in Akten und auf Ausweisen. Ein Import, der sie neu
|
||||||
|
// würfelt, ist keine Übernahme.
|
||||||
|
const spalten = Object.keys(werte);
|
||||||
|
const r = await sql<{ id: string; personnel_number: number }>`
|
||||||
|
insert into employees (${sql.raw(spalten.map((s) => `"${s}"`).join(", "))})
|
||||||
|
overriding system value
|
||||||
|
values (${sql.join(Object.values(werte).map((v) => sql.val(v)))})
|
||||||
|
returning id, personnel_number
|
||||||
|
`.execute(tx);
|
||||||
|
|
||||||
|
personId.set(r.rows[0].personnel_number, r.rows[0].id);
|
||||||
|
|
||||||
|
const stelle = txt(w.position_number);
|
||||||
|
if (stelle) {
|
||||||
|
besetzungen.push({
|
||||||
|
position_id: stellenId.get(stelle)!,
|
||||||
|
employee_id: r.rows[0].id,
|
||||||
|
valid_from: eintritt,
|
||||||
|
// Beim Austritt endet die Besetzung — sonst gälte die Planstelle als
|
||||||
|
// belegt und liesse sich nicht nachbesetzen.
|
||||||
|
valid_to: austritt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bericht.Personen = personenZeilen.length;
|
||||||
|
|
||||||
|
// Den Zähler nachziehen. Ohne das vergibt die Datenbank für die nächste
|
||||||
|
// Neueinstellung eine Nummer, die der Import bereits verbraucht hat — und
|
||||||
|
// der eindeutige Index weist sie ab. Der Fehler träte erst Wochen später
|
||||||
|
// auf, beim ersten Eintritt nach der Übernahme.
|
||||||
|
if (personenZeilen.length > 0) {
|
||||||
|
await sql`
|
||||||
|
select setval(
|
||||||
|
pg_get_serial_sequence('employees', 'personnel_number'),
|
||||||
|
(select max(personnel_number) from employees)
|
||||||
|
)
|
||||||
|
`.execute(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
await einfuegen(tx, "position_assignments", besetzungen);
|
||||||
|
|
||||||
|
// ── Historie und Angehörige ───────────────────────────────────
|
||||||
|
const historie = hole("Historie").map((z) => ({
|
||||||
|
employee_id: personId.get(zahl(z.werte.personnel_number)!)!,
|
||||||
|
event_date: txt(z.werte.event_date)!,
|
||||||
|
event_type: txt(z.werte.event_type)! as never,
|
||||||
|
description: txt(z.werte.description)!,
|
||||||
|
}));
|
||||||
|
await einfuegen(tx, "employee_history", historie);
|
||||||
|
bericht.Historie = historie.length;
|
||||||
|
|
||||||
|
const angehoerige = hole("Angehörige").map((z) => ({
|
||||||
|
employee_id: personId.get(zahl(z.werte.personnel_number)!)!,
|
||||||
|
first_name: txt(z.werte.first_name)!,
|
||||||
|
last_name: txt(z.werte.last_name)!,
|
||||||
|
relationship: txt(z.werte.relationship)!,
|
||||||
|
birth_date: txt(z.werte.birth_date)!,
|
||||||
|
sv_nummer: txt(z.werte.sv_nummer) ? normalizeSvnr(txt(z.werte.sv_nummer)!) : null,
|
||||||
|
}));
|
||||||
|
await einfuegen(tx, "employee_dependents", angehoerige);
|
||||||
|
bericht.Angehörige = angehoerige.length;
|
||||||
|
|
||||||
|
// ── Protokoll ─────────────────────────────────────────────────
|
||||||
|
// Ein Eintrag für den ganzen Vorgang, nicht einer je Zeile: 800 Zeilen
|
||||||
|
// würden das Protokoll unlesbar machen, und der Vorgang ist ohnehin
|
||||||
|
// untrennbar — er ist eine Transaktion.
|
||||||
|
const zusammenfassung = Object.entries(bericht)
|
||||||
|
.filter(([, n]) => n > 0)
|
||||||
|
.map(([blatt, n]) => `${n} ${blatt}`)
|
||||||
|
.join(", ");
|
||||||
|
await tx
|
||||||
|
.insertInto("audit_log")
|
||||||
|
.values({
|
||||||
|
actor_user_id: akteur.userId,
|
||||||
|
actor_name: akteur.name,
|
||||||
|
action: "Import",
|
||||||
|
target_label: "Massenimport",
|
||||||
|
details: zusammenfassung || "nichts angelegt",
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return bericht;
|
||||||
|
}
|
||||||
242
lib/import/parse.ts
Normal file
242
lib/import/parse.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
// Einlesen einer Importdatei — XLSX oder CSV.
|
||||||
|
//
|
||||||
|
// Zwei Dinge macht diese Datei und sonst nichts: aus Bytes werden benannte
|
||||||
|
// Blätter mit Zeilen, und aus Zellen werden verlässliche Rohwerte. Was die
|
||||||
|
// Werte *bedeuten* dürfen, steht in schema.ts; ob sie stimmen, entscheidet
|
||||||
|
// validate.ts. Diese Trennung ist der Grund, warum sich das Format testen
|
||||||
|
// lässt, ohne eine Datenbank oder eine Tabellenkalkulation zu brauchen.
|
||||||
|
|
||||||
|
import ExcelJS from "exceljs";
|
||||||
|
|
||||||
|
/** Eine Zeile, mit der Nummer aus der Datei — ohne die ist ein Fehler nutzlos. */
|
||||||
|
export type ImportRow = {
|
||||||
|
/** Zeilennummer wie in Excel angezeigt, Kopfzeile ist 1. */
|
||||||
|
zeile: number;
|
||||||
|
werte: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImportSheet = {
|
||||||
|
name: string;
|
||||||
|
spalten: string[];
|
||||||
|
zeilen: ImportRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParseResult = {
|
||||||
|
blaetter: ImportSheet[];
|
||||||
|
/** Probleme beim Lesen selbst — kaputte Datei, leeres Blatt, doppelte Spalte. */
|
||||||
|
fehler: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trennzeichen einer CSV-Datei bestimmen.
|
||||||
|
*
|
||||||
|
* Deutschsprachiges Excel schreibt Semikolon, weil das Komma das
|
||||||
|
* Dezimaltrennzeichen ist. Eine feste Annahme auf `,` liest solche Dateien
|
||||||
|
* als eine einzige Spalte ein — und das sieht dann aus wie „die Datei hat
|
||||||
|
* keine der erwarteten Spalten", was in die Irre führt.
|
||||||
|
*
|
||||||
|
* Gezählt wird nur in der Kopfzeile, und nur ausserhalb von Anführungszeichen.
|
||||||
|
*/
|
||||||
|
export function trennzeichenErkennen(kopfzeile: string): string {
|
||||||
|
const kandidaten = [";", ",", "\t", "|"];
|
||||||
|
let bestes = ";";
|
||||||
|
let meiste = -1;
|
||||||
|
for (const k of kandidaten) {
|
||||||
|
let anzahl = 0;
|
||||||
|
let inAnfuehrung = false;
|
||||||
|
for (let i = 0; i < kopfzeile.length; i++) {
|
||||||
|
const c = kopfzeile[i];
|
||||||
|
if (c === '"') inAnfuehrung = !inAnfuehrung;
|
||||||
|
else if (c === k && !inAnfuehrung) anzahl++;
|
||||||
|
}
|
||||||
|
if (anzahl > meiste) {
|
||||||
|
meiste = anzahl;
|
||||||
|
bestes = k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSV nach RFC 4180, mit den Abweichungen, die in der Praxis vorkommen:
|
||||||
|
* Zeilenumbrüche innerhalb von Anführungszeichen, verdoppelte
|
||||||
|
* Anführungszeichen als Escape, CRLF wie LF.
|
||||||
|
*
|
||||||
|
* Eine eigene Zerlegung statt einer Bibliothek, weil genau diese drei Fälle
|
||||||
|
* das sind, woran naive Zerlegungen scheitern — und weil ein Feld mit einem
|
||||||
|
* Semikolon darin (eine Adresse, eine Beschreibung) sonst still die Spalten
|
||||||
|
* verschiebt und die Zeile plausibel falsch importiert wird.
|
||||||
|
*/
|
||||||
|
export function csvZerlegen(text: string, trennzeichen?: string): string[][] {
|
||||||
|
const ohneBom = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
||||||
|
const trenner = trennzeichen ?? trennzeichenErkennen(ohneBom.split(/\r?\n/, 1)[0] ?? "");
|
||||||
|
|
||||||
|
const zeilen: string[][] = [];
|
||||||
|
let feld = "";
|
||||||
|
let zeile: string[] = [];
|
||||||
|
let inAnfuehrung = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < ohneBom.length; i++) {
|
||||||
|
const c = ohneBom[i];
|
||||||
|
|
||||||
|
if (inAnfuehrung) {
|
||||||
|
if (c === '"') {
|
||||||
|
if (ohneBom[i + 1] === '"') {
|
||||||
|
feld += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inAnfuehrung = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
feld += c;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c === '"') {
|
||||||
|
inAnfuehrung = true;
|
||||||
|
} else if (c === trenner) {
|
||||||
|
zeile.push(feld);
|
||||||
|
feld = "";
|
||||||
|
} else if (c === "\n") {
|
||||||
|
zeile.push(feld);
|
||||||
|
zeilen.push(zeile);
|
||||||
|
zeile = [];
|
||||||
|
feld = "";
|
||||||
|
} else if (c !== "\r") {
|
||||||
|
feld += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Letzte Zeile ohne abschliessenden Umbruch.
|
||||||
|
if (feld !== "" || zeile.length > 0) {
|
||||||
|
zeile.push(feld);
|
||||||
|
zeilen.push(zeile);
|
||||||
|
}
|
||||||
|
return zeilen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leerzeichen weg, doppelte innen zusammenziehen — Kopfzeilen sind selten sauber. */
|
||||||
|
function spaltenName(roh: unknown): string {
|
||||||
|
return String(roh ?? "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zellwert als Zeichenkette.
|
||||||
|
*
|
||||||
|
* Datumswerte werden hier **nicht** interpretiert, sondern als ISO-Tag
|
||||||
|
* ausgegeben, wenn Excel sie bereits als Datum führt. Der Rest bleibt Text
|
||||||
|
* und wird erst in schema.ts gedeutet — dort weiss man, ob eine Spalte ein
|
||||||
|
* Datum sein soll, und kann einen Fehler melden statt zu raten.
|
||||||
|
*/
|
||||||
|
function zellText(wert: ExcelJS.CellValue): string {
|
||||||
|
if (wert === null || wert === undefined) return "";
|
||||||
|
if (wert instanceof Date) {
|
||||||
|
// Excel führt Datumswerte ohne Zeitzone; toISOString() würde sie über UTC
|
||||||
|
// schieben und in Österreich einen Tag zu früh ausgeben.
|
||||||
|
const m = String(wert.getUTCMonth() + 1).padStart(2, "0");
|
||||||
|
const t = String(wert.getUTCDate()).padStart(2, "0");
|
||||||
|
return `${wert.getUTCFullYear()}-${m}-${t}`;
|
||||||
|
}
|
||||||
|
if (typeof wert === "object") {
|
||||||
|
const o = wert as { text?: unknown; result?: unknown; richText?: { text: string }[]; error?: unknown };
|
||||||
|
if (Array.isArray(o.richText)) return o.richText.map((t) => t.text).join("");
|
||||||
|
// Formelzellen: das Ergebnis zählt, nicht die Formel. Eine Fehlerzelle
|
||||||
|
// (#NV, #WERT!) wird als Text durchgereicht und fällt in der Prüfung auf.
|
||||||
|
if (o.error !== undefined) return String(o.error);
|
||||||
|
if (o.result !== undefined) return zellText(o.result as ExcelJS.CellValue);
|
||||||
|
if (o.text !== undefined) return String(o.text);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return String(wert);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeilenAusMatrix(name: string, matrix: string[][]): { blatt: ImportSheet; fehler: string[] } {
|
||||||
|
const fehler: string[] = [];
|
||||||
|
const kopf = (matrix[0] ?? []).map(spaltenName);
|
||||||
|
|
||||||
|
// Doppelte Spaltennamen: die zweite überschriebe die erste stillschweigend.
|
||||||
|
const gesehen = new Set<string>();
|
||||||
|
for (const s of kopf) {
|
||||||
|
if (!s) continue;
|
||||||
|
if (gesehen.has(s)) fehler.push(`Blatt „${name}“: Spalte „${s}“ kommt mehrfach vor.`);
|
||||||
|
gesehen.add(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeilen: ImportRow[] = [];
|
||||||
|
for (let i = 1; i < matrix.length; i++) {
|
||||||
|
const roh = matrix[i];
|
||||||
|
const werte: Record<string, string> = {};
|
||||||
|
let leer = true;
|
||||||
|
for (let j = 0; j < kopf.length; j++) {
|
||||||
|
const spalte = kopf[j];
|
||||||
|
if (!spalte) continue;
|
||||||
|
const wert = (roh[j] ?? "").trim();
|
||||||
|
werte[spalte] = wert;
|
||||||
|
if (wert !== "") leer = false;
|
||||||
|
}
|
||||||
|
// Leerzeilen kommen in gepflegten Dateien ständig vor (Abstand, gelöschte
|
||||||
|
// Einträge) und sind keine Fehler.
|
||||||
|
if (!leer) zeilen.push({ zeile: i + 1, werte });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { blatt: { name, spalten: kopf.filter(Boolean), zeilen }, fehler };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest eine XLSX-Mappe; jedes Arbeitsblatt wird ein Blatt. */
|
||||||
|
export async function xlsxLesen(daten: ArrayBuffer): Promise<ParseResult> {
|
||||||
|
const mappe = new ExcelJS.Workbook();
|
||||||
|
try {
|
||||||
|
await mappe.xlsx.load(daten);
|
||||||
|
} catch {
|
||||||
|
return { blaetter: [], fehler: ["Die Datei liess sich nicht als Excel-Mappe lesen."] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const blaetter: ImportSheet[] = [];
|
||||||
|
const fehler: string[] = [];
|
||||||
|
|
||||||
|
mappe.eachSheet((arbeitsblatt) => {
|
||||||
|
const matrix: string[][] = [];
|
||||||
|
arbeitsblatt.eachRow({ includeEmpty: true }, (zeile) => {
|
||||||
|
const werte: string[] = [];
|
||||||
|
// `values` ist 1-basiert und hat an Position 0 eine Lücke.
|
||||||
|
const roh = zeile.values as ExcelJS.CellValue[];
|
||||||
|
for (let i = 1; i < roh.length; i++) werte.push(zellText(roh[i]));
|
||||||
|
matrix.push(werte);
|
||||||
|
});
|
||||||
|
if (matrix.length === 0) return;
|
||||||
|
const { blatt, fehler: f } = zeilenAusMatrix(arbeitsblatt.name.trim(), matrix);
|
||||||
|
blaetter.push(blatt);
|
||||||
|
fehler.push(...f);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { blaetter, fehler };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest eine CSV-Datei als *ein* Blatt.
|
||||||
|
*
|
||||||
|
* Den Blattnamen liefert der Dateiname, weil eine CSV keinen kennt: aus
|
||||||
|
* „Personen.csv" wird das Blatt „Personen". Damit lassen sich mehrere CSVs
|
||||||
|
* genau wie die Blätter einer Mappe zusammensetzen.
|
||||||
|
*/
|
||||||
|
export function csvLesen(text: string, dateiname: string): ParseResult {
|
||||||
|
const matrix = csvZerlegen(text);
|
||||||
|
if (matrix.length === 0) return { blaetter: [], fehler: [`„${dateiname}“ ist leer.`] };
|
||||||
|
const name = dateiname.replace(/\.[^.]+$/, "").trim();
|
||||||
|
const { blatt, fehler } = zeilenAusMatrix(name, matrix);
|
||||||
|
return { blaetter: [blatt], fehler };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erkennt am Dateinamen, welcher Leser zuständig ist. */
|
||||||
|
export async function dateiLesen(dateiname: string, daten: ArrayBuffer): Promise<ParseResult> {
|
||||||
|
const endung = dateiname.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] ?? "";
|
||||||
|
if (endung === "xlsx" || endung === "xlsm") return xlsxLesen(daten);
|
||||||
|
if (endung === "csv" || endung === "txt") return csvLesen(new TextDecoder("utf-8").decode(daten), dateiname);
|
||||||
|
return {
|
||||||
|
blaetter: [],
|
||||||
|
fehler: [`„${dateiname}“: unbekannte Dateiendung. Erwartet werden .xlsx oder .csv.`],
|
||||||
|
};
|
||||||
|
}
|
||||||
449
lib/import/schema.ts
Normal file
449
lib/import/schema.ts
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
import { ABSENCE_TYPES } from "@/lib/absence";
|
||||||
|
import { TITLE_PREFIXES, TITLE_SUFFIXES } from "@/lib/titles";
|
||||||
|
|
||||||
|
// Was in einer Importdatei stehen darf.
|
||||||
|
//
|
||||||
|
// Diese Datei ist die einzige Quelle für drei Dinge, die sonst
|
||||||
|
// auseinanderlaufen: die Prüfung beim Import, die Vorlage zum Herunterladen
|
||||||
|
// und die Hilfetexte in der Oberfläche. Eine Spalte, die hier nicht steht,
|
||||||
|
// gibt es nirgends — und eine, die hier dazukommt, taucht überall auf.
|
||||||
|
//
|
||||||
|
// Die Blattnamen sind zugleich die Dateinamen für den CSV-Weg: „Personen.csv"
|
||||||
|
// wird als Blatt „Personen" gelesen.
|
||||||
|
|
||||||
|
export type FeldArt =
|
||||||
|
| { art: "text" }
|
||||||
|
| { art: "datum" }
|
||||||
|
| { art: "zahl" }
|
||||||
|
| { art: "ganzzahl" }
|
||||||
|
| { art: "janein" }
|
||||||
|
| { art: "liste"; werte?: readonly string[] }
|
||||||
|
| { art: "auswahl"; werte: readonly string[] };
|
||||||
|
|
||||||
|
export type Spalte = {
|
||||||
|
/** Überschrift in der Datei. */
|
||||||
|
name: string;
|
||||||
|
/** Feld im Datensatz, den der Lader schreibt. */
|
||||||
|
ziel: string;
|
||||||
|
pflicht: boolean;
|
||||||
|
typ: FeldArt;
|
||||||
|
hinweis: string;
|
||||||
|
beispiel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BlattSchema = {
|
||||||
|
name: string;
|
||||||
|
zweck: string;
|
||||||
|
/**
|
||||||
|
* Spalte, an der eine Zeile erkennbar ist. Doppelte Werte darin sind ein
|
||||||
|
* Fehler — sonst überschriebe die zweite Zeile die erste, und welche
|
||||||
|
* gewinnt, hinge an der Reihenfolge in der Datei.
|
||||||
|
*/
|
||||||
|
schluessel: string | null;
|
||||||
|
spalten: Spalte[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] as const;
|
||||||
|
const VERHAELTNIS = ["Ehepartner:in", "Lebenspartner:in", "Kind", "Sonstige"] as const;
|
||||||
|
const EREIGNIS = [
|
||||||
|
"Eintritt",
|
||||||
|
"Beförderung",
|
||||||
|
"Versetzung",
|
||||||
|
"Karenz",
|
||||||
|
"Vertragsänderung",
|
||||||
|
"Stammdatenänderung",
|
||||||
|
"Austritt",
|
||||||
|
"Wiedereintritt",
|
||||||
|
"Reorganisation",
|
||||||
|
"Gehaltsanpassung",
|
||||||
|
"Rückkehr",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const BLATT_STANDORTE: BlattSchema = {
|
||||||
|
name: "Standorte",
|
||||||
|
zweck: "Betriebsstätten, auf die sich Personen beziehen.",
|
||||||
|
schluessel: "Bezeichnung",
|
||||||
|
spalten: [
|
||||||
|
{ name: "Bezeichnung", ziel: "name", pflicht: true, typ: { art: "text" }, hinweis: "Eindeutig.", beispiel: "Wien" },
|
||||||
|
{
|
||||||
|
name: "Land",
|
||||||
|
ziel: "country",
|
||||||
|
pflicht: true,
|
||||||
|
typ: { art: "text" },
|
||||||
|
hinweis: "Ausgeschrieben. Steuert unter anderem, ob eine SV-Nummer verlangt wird.",
|
||||||
|
beispiel: "Österreich",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BLATT_ORGANISATION: BlattSchema = {
|
||||||
|
name: "Organisation",
|
||||||
|
zweck: "Der Aufbau: Gesellschaft, Bereiche, Abteilungen, Teams.",
|
||||||
|
schluessel: "Orgnummer",
|
||||||
|
spalten: [
|
||||||
|
{
|
||||||
|
name: "Orgnummer",
|
||||||
|
ziel: "org_number",
|
||||||
|
pflicht: true,
|
||||||
|
typ: { art: "text" },
|
||||||
|
hinweis: "Eindeutig. Wird von Planstellen als Verweis benutzt.",
|
||||||
|
beispiel: "10000001",
|
||||||
|
},
|
||||||
|
{ name: "Bezeichnung", ziel: "name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Produktion" },
|
||||||
|
{
|
||||||
|
name: "Art",
|
||||||
|
ziel: "unit_type",
|
||||||
|
pflicht: true,
|
||||||
|
typ: { art: "auswahl", werte: ["Gesellschaft", "Bereich", "Abteilung", "Team"] },
|
||||||
|
hinweis: "Genau eine Zeile darf „Gesellschaft“ sein — sie ist die Wurzel.",
|
||||||
|
beispiel: "Bereich",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Übergeordnet",
|
||||||
|
ziel: "parent_org_number",
|
||||||
|
pflicht: false,
|
||||||
|
typ: { art: "text" },
|
||||||
|
hinweis: "Orgnummer der übergeordneten Einheit. Nur bei „Gesellschaft“ leer.",
|
||||||
|
beispiel: "10000000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Gültig ab",
|
||||||
|
ziel: "valid_from",
|
||||||
|
pflicht: false,
|
||||||
|
typ: { art: "datum" },
|
||||||
|
hinweis: "Leer = heute.",
|
||||||
|
beispiel: "01.01.2020",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Gültig bis",
|
||||||
|
ziel: "valid_to",
|
||||||
|
pflicht: false,
|
||||||
|
typ: { art: "datum" },
|
||||||
|
hinweis: "Leer = unbefristet.",
|
||||||
|
beispiel: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BLATT_JOBKATALOG: BlattSchema = {
|
||||||
|
name: "Jobkatalog",
|
||||||
|
zweck: "Die Tätigkeiten, auf die Planstellen verweisen.",
|
||||||
|
schluessel: "Jobcode",
|
||||||
|
spalten: [
|
||||||
|
{ name: "Jobcode", ziel: "code", pflicht: true, typ: { art: "text" }, hinweis: "Eindeutig.", beispiel: "50000123" },
|
||||||
|
{
|
||||||
|
name: "Bezeichnung",
|
||||||
|
ziel: "title",
|
||||||
|
pflicht: true,
|
||||||
|
typ: { art: "text" },
|
||||||
|
hinweis: "",
|
||||||
|
beispiel: "Maschinenführer:in",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BLATT_PLANSTELLEN: BlattSchema = {
|
||||||
|
name: "Planstellen",
|
||||||
|
zweck: "Die Stellen selbst — unabhängig davon, wer sie besetzt.",
|
||||||
|
schluessel: "Planstellennummer",
|
||||||
|
spalten: [
|
||||||
|
{
|
||||||
|
name: "Planstellennummer",
|
||||||
|
ziel: "position_number",
|
||||||
|
pflicht: true,
|
||||||
|
typ: { art: "text" },
|
||||||
|
hinweis: "Eindeutig. Personen verweisen darauf.",
|
||||||
|
beispiel: "60000124",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Orgnummer",
|
||||||
|
ziel: "org_number",
|
||||||
|
pflicht: true,
|
||||||
|
typ: { art: "text" },
|
||||||
|
hinweis: "Muss im Blatt „Organisation“ oder bereits im System stehen.",
|
||||||
|
beispiel: "10000001",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Jobcode",
|
||||||
|
ziel: "job_code",
|
||||||
|
pflicht: true,
|
||||||
|
typ: { art: "text" },
|
||||||
|
hinweis: "Muss im Blatt „Jobkatalog“ oder bereits im System stehen.",
|
||||||
|
beispiel: "50000123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Leitung",
|
||||||
|
ziel: "is_chief",
|
||||||
|
pflicht: false,
|
||||||
|
typ: { art: "janein" },
|
||||||
|
hinweis: "Ja = Leitungsstelle der Einheit. Höchstens eine je Einheit.",
|
||||||
|
beispiel: "nein",
|
||||||
|
},
|
||||||
|
{ name: "Gültig ab", ziel: "valid_from", pflicht: false, typ: { art: "datum" }, hinweis: "Leer = heute.", beispiel: "01.01.2020" },
|
||||||
|
{ name: "Gültig bis", ziel: "valid_to", pflicht: false, typ: { art: "datum" }, hinweis: "", beispiel: "" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BLATT_PERSONEN: BlattSchema = {
|
||||||
|
name: "Personen",
|
||||||
|
zweck: "Stammdaten, Vertrag und die Planstelle, auf der die Person sitzt.",
|
||||||
|
schluessel: "Personalnummer",
|
||||||
|
spalten: [
|
||||||
|
{
|
||||||
|
name: "Personalnummer",
|
||||||
|
ziel: "personnel_number",
|
||||||
|
pflicht: true,
|
||||||
|
typ: { art: "ganzzahl" },
|
||||||
|
hinweis: "Eindeutig. Historie und Angehörige verweisen darauf.",
|
||||||
|
beispiel: "2219",
|
||||||
|
},
|
||||||
|
{ name: "Vorname", ziel: "first_name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Sabine" },
|
||||||
|
{ name: "Nachname", ziel: "last_name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Aigner" },
|
||||||
|
{
|
||||||
|
name: "Geschlecht",
|
||||||
|
ziel: "gender",
|
||||||
|
pflicht: true,
|
||||||
|
typ: { art: "auswahl", werte: ["m", "w"] },
|
||||||
|
hinweis: "Wird für die SV-Nummer und die Anrede gebraucht.",
|
||||||
|
beispiel: "w",
|
||||||
|
},
|
||||||
|
{ name: "Geburtsdatum", ziel: "birth_date", pflicht: true, typ: { art: "datum" }, hinweis: "", beispiel: "15.08.1968" },
|
||||||
|
{
|
||||||
|
name: "SV-Nummer",
|
||||||
|
ziel: "sv_nummer",
|
||||||
|
pflicht: false,
|
||||||
|
typ: { art: "text" },
|
||||||
|
hinweis: "Zehnstellig. Prüfziffer und Geburtsdatum müssen zusammenpassen.",
|
||||||
|
beispiel: "7960 150868",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Staatsbürgerschaft",
|
||||||
|
ziel: "nationality",
|
||||||
|
pflicht: false,
|
||||||
|
typ: { art: "text" },
|
||||||
|
hinweis: "Leer = Österreich.",
|
||||||
|
beispiel: "Österreich",
|
||||||
|
},
|
||||||
|
{ name: "E-Mail", ziel: "email", pflicht: true, typ: { art: "text" }, hinweis: "Eindeutig.", beispiel: "s.aigner@example.at" },
|
||||||
|
{ name: "Telefon", ziel: "phone", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "+43 660 1234567" },
|
||||||
|
{ name: "Adresse", ziel: "address", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Hauptstraße 1" },
|
||||||
|
{ name: "PLZ", ziel: "postal_code", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "1010" },
|
||||||
|
{ name: "Ort", ziel: "city", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Wien" },
|
||||||
|
{ name: "Land", ziel: "address_country", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Österreich" },
|
||||||
|
{
|
||||||
|
name: "Titel vorangestellt",
|
||||||
|
ziel: "title_prefix",
|
||||||
|
pflicht: false,
|
||||||
|
// Feste Liste, weil die Datenbank eine CHECK-Bedingung darauf hat.
|
||||||
|
// Ohne die Aufzählung hier bräche der Import erst beim Schreiben ab.
|
||||||
|
typ: { art: "liste", werte: TITLE_PREFIXES },
|
||||||
|
hinweis: "Mehrere mit Semikolon. In einer CSV die Zelle in Anführungszeichen setzen.",
|
||||||
|
beispiel: "Mag.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Titel nachgestellt",
|
||||||
|
ziel: "title_suffix",
|
||||||
|
pflicht: false,
|
||||||
|
typ: { art: "liste", werte: TITLE_SUFFIXES },
|
||||||
|
hinweis: "Mehrere mit Semikolon.",
|
||||||
|
beispiel: "MSc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Tätigkeit",
|
||||||
|
ziel: "job_title",
|
||||||
|
pflicht: true,
|
||||||
|
typ: { art: "text" },
|
||||||
|
hinweis: "Die Bezeichnung der Person; kann von der Planstelle abweichen.",
|
||||||
|
beispiel: "Projektingenieur:in",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Standort",
|
||||||
|
ziel: "location",
|
||||||
|
pflicht: true,
|
||||||
|
typ: { art: "text" },
|
||||||
|
hinweis: "Muss im Blatt „Standorte“ oder bereits im System stehen.",
|
||||||
|
beispiel: "Wien",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Planstellennummer",
|
||||||
|
ziel: "position_number",
|
||||||
|
pflicht: true,
|
||||||
|
typ: { art: "text" },
|
||||||
|
hinweis: "Muss im Blatt „Planstellen“ oder bereits im System stehen und frei sein.",
|
||||||
|
beispiel: "60000124",
|
||||||
|
},
|
||||||
|
{ name: "Eintritt", ziel: "entry_date", pflicht: true, typ: { art: "datum" }, hinweis: "", beispiel: "01.03.2015" },
|
||||||
|
{
|
||||||
|
name: "Austritt",
|
||||||
|
ziel: "exit_date",
|
||||||
|
pflicht: false,
|
||||||
|
typ: { art: "datum" },
|
||||||
|
hinweis: "Leer = weiterhin beschäftigt. Muss nach dem Eintritt liegen.",
|
||||||
|
beispiel: "",
|
||||||
|
},
|
||||||
|
{ name: "Austrittsgrund", ziel: "exit_reason", pflicht: false, typ: { art: "text" }, hinweis: "Pflicht, wenn ein Austritt steht.", beispiel: "" },
|
||||||
|
{
|
||||||
|
name: "Beschäftigung",
|
||||||
|
ziel: "employment_type",
|
||||||
|
pflicht: false,
|
||||||
|
typ: { art: "auswahl", werte: ["Vollzeit", "Teilzeit"] },
|
||||||
|
hinweis: "Leer = Vollzeit.",
|
||||||
|
beispiel: "Vollzeit",
|
||||||
|
},
|
||||||
|
{ name: "Wochenstunden", ziel: "weekly_hours", pflicht: false, typ: { art: "zahl" }, hinweis: "Leer = 38,5.", beispiel: "38,5" },
|
||||||
|
{
|
||||||
|
name: "Arbeitstage",
|
||||||
|
ziel: "work_days",
|
||||||
|
pflicht: false,
|
||||||
|
typ: { art: "liste", werte: WOCHENTAGE },
|
||||||
|
hinweis: "Leer = Mo;Di;Mi;Do;Fr.",
|
||||||
|
beispiel: "Mo;Di;Mi;Do;Fr",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Vertragsart",
|
||||||
|
ziel: "contract_type",
|
||||||
|
pflicht: false,
|
||||||
|
typ: { art: "auswahl", werte: ["unbefristet", "befristet"] },
|
||||||
|
hinweis: "Leer = unbefristet.",
|
||||||
|
beispiel: "unbefristet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Befristet bis",
|
||||||
|
ziel: "contract_end_date",
|
||||||
|
pflicht: false,
|
||||||
|
typ: { art: "datum" },
|
||||||
|
hinweis: "Pflicht bei „befristet“, sonst leer.",
|
||||||
|
beispiel: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Verwendungsgruppe",
|
||||||
|
ziel: "paygrade",
|
||||||
|
pflicht: false,
|
||||||
|
typ: { art: "auswahl", werte: ["A", "B", "C", "D", "E", "F"] },
|
||||||
|
hinweis: "Leer = B.",
|
||||||
|
beispiel: "C",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Kollektivvertrag",
|
||||||
|
ziel: "collective_agreement",
|
||||||
|
pflicht: false,
|
||||||
|
typ: { art: "auswahl", werte: ["Handel", "Süßwaren"] },
|
||||||
|
hinweis: "Leer = Handel.",
|
||||||
|
beispiel: "Handel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Beschäftigtengruppe",
|
||||||
|
ziel: "worker_type",
|
||||||
|
pflicht: false,
|
||||||
|
typ: { art: "auswahl", werte: ["Angestellte:r", "Arbeiter:in"] },
|
||||||
|
hinweis: "Leer = Angestellte:r.",
|
||||||
|
beispiel: "Angestellte:r",
|
||||||
|
},
|
||||||
|
{ name: "Monatsgehalt brutto", ziel: "monthly_salary_gross", pflicht: false, typ: { art: "zahl" }, hinweis: "", beispiel: "3450,00" },
|
||||||
|
{
|
||||||
|
name: "Herkunft",
|
||||||
|
ziel: "source",
|
||||||
|
pflicht: false,
|
||||||
|
typ: { art: "auswahl", werte: ["Intern", "Extern"] },
|
||||||
|
hinweis: "Leer = Extern.",
|
||||||
|
beispiel: "Extern",
|
||||||
|
},
|
||||||
|
{ name: "Betriebsrat", ziel: "is_betriebsrat", pflicht: false, typ: { art: "janein" }, hinweis: "Leer = nein.", beispiel: "nein" },
|
||||||
|
{ name: "Dienstwagen", ziel: "has_dienstwagen", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
|
||||||
|
{ name: "Laterale Führung", ziel: "is_laterale_fuehrung", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
|
||||||
|
{ name: "C-Level", ziel: "is_c_level", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
|
||||||
|
{
|
||||||
|
name: "Abwesenheit ab",
|
||||||
|
ziel: "karenz_start_date",
|
||||||
|
pflicht: false,
|
||||||
|
typ: { art: "datum" },
|
||||||
|
hinweis: "Beginn einer Langzeitabwesenheit.",
|
||||||
|
beispiel: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Rückkehr geplant",
|
||||||
|
ziel: "karenz_return_date",
|
||||||
|
pflicht: false,
|
||||||
|
typ: { art: "datum" },
|
||||||
|
hinweis: "Pflicht, wenn „Abwesenheit ab“ steht.",
|
||||||
|
beispiel: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Abwesenheitsart",
|
||||||
|
ziel: "absence_type",
|
||||||
|
pflicht: false,
|
||||||
|
typ: { art: "auswahl", werte: ABSENCE_TYPES },
|
||||||
|
hinweis: "Pflicht, wenn „Abwesenheit ab“ steht.",
|
||||||
|
beispiel: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BLATT_HISTORIE: BlattSchema = {
|
||||||
|
name: "Historie",
|
||||||
|
zweck: "Ereignisse je Person. Alles, was vor dem Eintritt liegt, wird abgewiesen.",
|
||||||
|
schluessel: null,
|
||||||
|
spalten: [
|
||||||
|
{
|
||||||
|
name: "Personalnummer",
|
||||||
|
ziel: "personnel_number",
|
||||||
|
pflicht: true,
|
||||||
|
typ: { art: "ganzzahl" },
|
||||||
|
hinweis: "Muss im Blatt „Personen“ oder bereits im System stehen.",
|
||||||
|
beispiel: "2219",
|
||||||
|
},
|
||||||
|
{ name: "Datum", ziel: "event_date", pflicht: true, typ: { art: "datum" }, hinweis: "", beispiel: "01.03.2015" },
|
||||||
|
{
|
||||||
|
name: "Ereignis",
|
||||||
|
ziel: "event_type",
|
||||||
|
pflicht: true,
|
||||||
|
typ: { art: "auswahl", werte: EREIGNIS },
|
||||||
|
hinweis: "",
|
||||||
|
beispiel: "Eintritt",
|
||||||
|
},
|
||||||
|
{ name: "Beschreibung", ziel: "description", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Eintritt als Projektingenieur:in" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BLATT_ANGEHOERIGE: BlattSchema = {
|
||||||
|
name: "Angehörige",
|
||||||
|
zweck: "Angehörige je Person.",
|
||||||
|
schluessel: null,
|
||||||
|
spalten: [
|
||||||
|
{ name: "Personalnummer", ziel: "personnel_number", pflicht: true, typ: { art: "ganzzahl" }, hinweis: "", beispiel: "2219" },
|
||||||
|
{ name: "Vorname", ziel: "first_name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Julian" },
|
||||||
|
{ name: "Nachname", ziel: "last_name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Aigner" },
|
||||||
|
{
|
||||||
|
name: "Verhältnis",
|
||||||
|
ziel: "relationship",
|
||||||
|
pflicht: true,
|
||||||
|
typ: { art: "auswahl", werte: VERHAELTNIS },
|
||||||
|
hinweis: "",
|
||||||
|
beispiel: "Kind",
|
||||||
|
},
|
||||||
|
{ name: "Geburtsdatum", ziel: "birth_date", pflicht: true, typ: { art: "datum" }, hinweis: "", beispiel: "04.06.2014" },
|
||||||
|
{ name: "SV-Nummer", ziel: "sv_nummer", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reihenfolge = Ladereihenfolge.
|
||||||
|
*
|
||||||
|
* Standorte, Organisation und Jobkatalog müssen stehen, bevor Planstellen
|
||||||
|
* darauf verweisen können; Personen brauchen Planstellen; Historie und
|
||||||
|
* Angehörige brauchen Personen.
|
||||||
|
*/
|
||||||
|
export const BLAETTER: BlattSchema[] = [
|
||||||
|
BLATT_STANDORTE,
|
||||||
|
BLATT_ORGANISATION,
|
||||||
|
BLATT_JOBKATALOG,
|
||||||
|
BLATT_PLANSTELLEN,
|
||||||
|
BLATT_PERSONEN,
|
||||||
|
BLATT_HISTORIE,
|
||||||
|
BLATT_ANGEHOERIGE,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function blattSchema(name: string): BlattSchema | undefined {
|
||||||
|
const gesucht = name.trim().toLowerCase();
|
||||||
|
return BLAETTER.find((b) => b.name.toLowerCase() === gesucht);
|
||||||
|
}
|
||||||
405
lib/import/validate.ts
Normal file
405
lib/import/validate.ts
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
import { todayIso } from "@/lib/format";
|
||||||
|
import { normalizeSvnr, svnrErrorMessage, validateSvnr } from "@/lib/svnr";
|
||||||
|
import type { ImportSheet } from "./parse";
|
||||||
|
import { BLAETTER, blattSchema, type BlattSchema, type Spalte } from "./schema";
|
||||||
|
import { alsAufzaehlung, alsDatum, alsGanzzahl, alsJaNein, alsListe, alsZahl } from "./werte";
|
||||||
|
|
||||||
|
// Prüfung einer eingelesenen Datei.
|
||||||
|
//
|
||||||
|
// Zwei Grundsätze, beide bewusst:
|
||||||
|
//
|
||||||
|
// 1. **Es wird alles gemeldet, nicht das erste.** Wer eine Datei mit 800
|
||||||
|
// Zeilen hochlädt, will nicht achthundertmal hochladen. Deshalb sammelt
|
||||||
|
// jede Prüfung weiter, statt abzubrechen.
|
||||||
|
//
|
||||||
|
// 2. **Der Bestand kommt als Parameter, nicht aus der Datenbank.** Damit
|
||||||
|
// bleibt diese Datei rein und ohne Verbindung testbar — und die
|
||||||
|
// Abfragen stehen an einer Stelle, wo man sie sieht (load.ts).
|
||||||
|
|
||||||
|
export type Befund = {
|
||||||
|
blatt: string;
|
||||||
|
/** Zeilennummer wie in Excel; null für Probleme am ganzen Blatt. */
|
||||||
|
zeile: number | null;
|
||||||
|
spalte: string | null;
|
||||||
|
wert?: string;
|
||||||
|
meldung: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Was bereits in der Datenbank steht — für Verweise und Doppelprüfungen. */
|
||||||
|
export type Bestand = {
|
||||||
|
standorte: Map<string, string>;
|
||||||
|
orgNummern: Map<string, string>;
|
||||||
|
jobCodes: Map<string, string>;
|
||||||
|
/** Planstellennummer → { id, heuteBesetzt } */
|
||||||
|
planstellen: Map<string, { id: string; besetzt: boolean }>;
|
||||||
|
personalnummern: Map<number, string>;
|
||||||
|
emails: Set<string>;
|
||||||
|
svNummern: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LEERER_BESTAND: Bestand = {
|
||||||
|
standorte: new Map(),
|
||||||
|
orgNummern: new Map(),
|
||||||
|
jobCodes: new Map(),
|
||||||
|
planstellen: new Map(),
|
||||||
|
personalnummern: new Map(),
|
||||||
|
emails: new Set(),
|
||||||
|
svNummern: new Set(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Zeile = { zeile: number; werte: Record<string, unknown> };
|
||||||
|
export type Datensatz = Record<string, Zeile[]>;
|
||||||
|
|
||||||
|
export type Pruefergebnis = {
|
||||||
|
fehler: Befund[];
|
||||||
|
hinweise: Befund[];
|
||||||
|
datensatz: Datensatz;
|
||||||
|
/** Was angelegt würde, je Blatt. */
|
||||||
|
anzahl: Record<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function leseFeld(spalte: Spalte, roh: string): { wert: unknown; fehler: string | null } {
|
||||||
|
if (roh === "") return { wert: null, fehler: null };
|
||||||
|
|
||||||
|
switch (spalte.typ.art) {
|
||||||
|
case "text":
|
||||||
|
return { wert: roh, fehler: null };
|
||||||
|
case "datum": {
|
||||||
|
const d = alsDatum(roh);
|
||||||
|
return d ? { wert: d, fehler: null } : { wert: null, fehler: "Kein gültiges Datum. Erwartet: 31.12.2026 oder 2026-12-31." };
|
||||||
|
}
|
||||||
|
case "zahl": {
|
||||||
|
const n = alsZahl(roh);
|
||||||
|
return n !== null ? { wert: n, fehler: null } : { wert: null, fehler: "Keine Zahl." };
|
||||||
|
}
|
||||||
|
case "ganzzahl": {
|
||||||
|
const n = alsGanzzahl(roh);
|
||||||
|
return n !== null ? { wert: n, fehler: null } : { wert: null, fehler: "Keine ganze Zahl." };
|
||||||
|
}
|
||||||
|
case "janein": {
|
||||||
|
const b = alsJaNein(roh);
|
||||||
|
return b !== null ? { wert: b, fehler: null } : { wert: null, fehler: "Erwartet: ja oder nein." };
|
||||||
|
}
|
||||||
|
case "liste": {
|
||||||
|
const l = alsListe(roh);
|
||||||
|
if (spalte.typ.werte) {
|
||||||
|
const erlaubt = spalte.typ.werte;
|
||||||
|
const treffer: string[] = [];
|
||||||
|
for (const t of l) {
|
||||||
|
const k = alsAufzaehlung(t, erlaubt);
|
||||||
|
if (!k) return { wert: null, fehler: `„${t}“ ist unbekannt. Erlaubt: ${erlaubt.join(", ")}.` };
|
||||||
|
treffer.push(k);
|
||||||
|
}
|
||||||
|
return { wert: treffer, fehler: null };
|
||||||
|
}
|
||||||
|
return { wert: l, fehler: null };
|
||||||
|
}
|
||||||
|
case "auswahl": {
|
||||||
|
const w = alsAufzaehlung(roh, spalte.typ.werte);
|
||||||
|
return w ? { wert: w, fehler: null } : { wert: null, fehler: `Unbekannter Wert. Erlaubt: ${spalte.typ.werte.join(", ")}.` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function blattLesen(schema: BlattSchema, blatt: ImportSheet, fehler: Befund[]): Zeile[] {
|
||||||
|
const vorhanden = new Set(blatt.spalten);
|
||||||
|
for (const s of schema.spalten) {
|
||||||
|
if (s.pflicht && !vorhanden.has(s.name)) {
|
||||||
|
fehler.push({ blatt: schema.name, zeile: null, spalte: s.name, meldung: "Pflichtspalte fehlt." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const s of blatt.spalten) {
|
||||||
|
if (!schema.spalten.some((x) => x.name === s)) {
|
||||||
|
fehler.push({ blatt: schema.name, zeile: 1, spalte: s, meldung: "Unbekannte Spalte — wird nicht übernommen." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeilen: Zeile[] = [];
|
||||||
|
const schluesselGesehen = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const r of blatt.zeilen) {
|
||||||
|
const werte: Record<string, unknown> = {};
|
||||||
|
for (const s of schema.spalten) {
|
||||||
|
const roh = (r.werte[s.name] ?? "").trim();
|
||||||
|
if (roh === "" && s.pflicht) {
|
||||||
|
fehler.push({ blatt: schema.name, zeile: r.zeile, spalte: s.name, meldung: "Pflichtfeld ist leer." });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { wert, fehler: f } = leseFeld(s, roh);
|
||||||
|
if (f) fehler.push({ blatt: schema.name, zeile: r.zeile, spalte: s.name, wert: roh, meldung: f });
|
||||||
|
else werte[s.ziel] = wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.schluessel) {
|
||||||
|
const sp = schema.spalten.find((x) => x.name === schema.schluessel)!;
|
||||||
|
const k = werte[sp.ziel];
|
||||||
|
if (k !== undefined && k !== null) {
|
||||||
|
const schluessel = String(k);
|
||||||
|
const zuvor = schluesselGesehen.get(schluessel);
|
||||||
|
if (zuvor !== undefined) {
|
||||||
|
fehler.push({
|
||||||
|
blatt: schema.name,
|
||||||
|
zeile: r.zeile,
|
||||||
|
spalte: schema.schluessel,
|
||||||
|
wert: schluessel,
|
||||||
|
meldung: `Kommt bereits in Zeile ${zuvor} vor. Welche Zeile gälte, hinge an der Reihenfolge in der Datei.`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
schluesselGesehen.set(schluessel, r.zeile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zeilen.push({ zeile: r.zeile, werte });
|
||||||
|
}
|
||||||
|
return zeilen;
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
|
||||||
|
const n = (v: unknown): number | null => (typeof v === "number" ? v : null);
|
||||||
|
|
||||||
|
export function pruefe(blaetter: ImportSheet[], bestand: Bestand = LEERER_BESTAND): Pruefergebnis {
|
||||||
|
const fehler: Befund[] = [];
|
||||||
|
const hinweise: Befund[] = [];
|
||||||
|
const datensatz: Datensatz = {};
|
||||||
|
|
||||||
|
for (const blatt of blaetter) {
|
||||||
|
const schema = blattSchema(blatt.name);
|
||||||
|
if (!schema) {
|
||||||
|
hinweise.push({
|
||||||
|
blatt: blatt.name,
|
||||||
|
zeile: null,
|
||||||
|
spalte: null,
|
||||||
|
meldung: `Unbekanntes Blatt — wird übergangen. Erwartet: ${BLAETTER.map((b) => b.name).join(", ")}.`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
datensatz[schema.name] = blattLesen(schema, blatt, fehler);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hole = (name: string) => datensatz[name] ?? [];
|
||||||
|
const melde = (blatt: string, zeile: number, spalte: string | null, meldung: string, wert?: string) =>
|
||||||
|
fehler.push({ blatt, zeile, spalte, wert, meldung });
|
||||||
|
|
||||||
|
// ── Standorte und Organisation ────────────────────────────────
|
||||||
|
const standorte = new Set([...bestand.standorte.keys()]);
|
||||||
|
for (const z of hole("Standorte")) {
|
||||||
|
const name = s(z.werte.name);
|
||||||
|
if (name) {
|
||||||
|
if (bestand.standorte.has(name)) melde("Standorte", z.zeile, "Bezeichnung", "Gibt es bereits.", name);
|
||||||
|
standorte.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgNummern = new Set([...bestand.orgNummern.keys()]);
|
||||||
|
const orgArten = new Map<string, string>();
|
||||||
|
let wurzeln = 0;
|
||||||
|
for (const z of hole("Organisation")) {
|
||||||
|
const nr = s(z.werte.org_number);
|
||||||
|
const art = s(z.werte.unit_type);
|
||||||
|
if (nr) {
|
||||||
|
if (bestand.orgNummern.has(nr)) melde("Organisation", z.zeile, "Orgnummer", "Gibt es bereits.", nr);
|
||||||
|
orgNummern.add(nr);
|
||||||
|
if (art) orgArten.set(nr, art);
|
||||||
|
}
|
||||||
|
if (art === "Gesellschaft") wurzeln++;
|
||||||
|
}
|
||||||
|
for (const z of hole("Organisation")) {
|
||||||
|
const eltern = s(z.werte.parent_org_number);
|
||||||
|
const art = s(z.werte.unit_type);
|
||||||
|
if (art === "Gesellschaft") {
|
||||||
|
if (eltern) melde("Organisation", z.zeile, "Übergeordnet", "Die Gesellschaft ist die Wurzel und hat nichts über sich.");
|
||||||
|
} else if (!eltern) {
|
||||||
|
melde("Organisation", z.zeile, "Übergeordnet", "Pflicht für alles ausser der Gesellschaft.");
|
||||||
|
} else if (!orgNummern.has(eltern)) {
|
||||||
|
melde("Organisation", z.zeile, "Übergeordnet", "Steht weder in der Datei noch im System.", eltern);
|
||||||
|
} else if (eltern === s(z.werte.org_number)) {
|
||||||
|
melde("Organisation", z.zeile, "Übergeordnet", "Eine Einheit kann sich nicht selbst übergeordnet sein.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (wurzeln > 1) {
|
||||||
|
fehler.push({ blatt: "Organisation", zeile: null, spalte: "Art", meldung: `Es gibt ${wurzeln} Zeilen „Gesellschaft“; genau eine ist erlaubt.` });
|
||||||
|
}
|
||||||
|
if (wurzeln === 0 && bestand.orgNummern.size === 0 && hole("Organisation").length > 0) {
|
||||||
|
fehler.push({ blatt: "Organisation", zeile: null, spalte: "Art", meldung: "Keine Zeile „Gesellschaft“ — der Aufbau hätte keine Wurzel." });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Jobkatalog und Planstellen ────────────────────────────────
|
||||||
|
const jobCodes = new Set([...bestand.jobCodes.keys()]);
|
||||||
|
for (const z of hole("Jobkatalog")) {
|
||||||
|
const code = s(z.werte.code);
|
||||||
|
if (code) {
|
||||||
|
if (bestand.jobCodes.has(code)) melde("Jobkatalog", z.zeile, "Jobcode", "Gibt es bereits.", code);
|
||||||
|
jobCodes.add(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const planstellen = new Set([...bestand.planstellen.keys()]);
|
||||||
|
const leitungJeEinheit = new Map<string, number>();
|
||||||
|
for (const z of hole("Planstellen")) {
|
||||||
|
const nr = s(z.werte.position_number);
|
||||||
|
const org = s(z.werte.org_number);
|
||||||
|
const job = s(z.werte.job_code);
|
||||||
|
if (nr) {
|
||||||
|
if (bestand.planstellen.has(nr)) melde("Planstellen", z.zeile, "Planstellennummer", "Gibt es bereits.", nr);
|
||||||
|
planstellen.add(nr);
|
||||||
|
}
|
||||||
|
if (org && !orgNummern.has(org)) melde("Planstellen", z.zeile, "Orgnummer", "Steht weder in der Datei noch im System.", org);
|
||||||
|
if (job && !jobCodes.has(job)) melde("Planstellen", z.zeile, "Jobcode", "Steht weder in der Datei noch im System.", job);
|
||||||
|
if (z.werte.is_chief === true && org) {
|
||||||
|
const anzahl = (leitungJeEinheit.get(org) ?? 0) + 1;
|
||||||
|
leitungJeEinheit.set(org, anzahl);
|
||||||
|
if (anzahl === 2) {
|
||||||
|
melde("Planstellen", z.zeile, "Leitung", `Die Einheit ${org} hätte damit zwei Leitungsstellen.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const von = s(z.werte.valid_from);
|
||||||
|
const bis = s(z.werte.valid_to);
|
||||||
|
if (von && bis && bis < von) melde("Planstellen", z.zeile, "Gültig bis", "Liegt vor „Gültig ab“.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Personen ──────────────────────────────────────────────────
|
||||||
|
const heute = todayIso();
|
||||||
|
const personalnummern = new Set([...bestand.personalnummern.keys()]);
|
||||||
|
const emails = new Set([...bestand.emails]);
|
||||||
|
const svNummern = new Set([...bestand.svNummern]);
|
||||||
|
const belegtePlanstellen = new Map<string, number>();
|
||||||
|
const eintritte = new Map<number, string>();
|
||||||
|
|
||||||
|
for (const z of hole("Personen")) {
|
||||||
|
const pnr = n(z.werte.personnel_number);
|
||||||
|
const w = z.werte;
|
||||||
|
|
||||||
|
if (pnr !== null) {
|
||||||
|
if (bestand.personalnummern.has(pnr)) melde("Personen", z.zeile, "Personalnummer", "Gibt es bereits im System.", String(pnr));
|
||||||
|
personalnummern.add(pnr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = s(w.email)?.toLowerCase();
|
||||||
|
if (email) {
|
||||||
|
if (emails.has(email)) melde("Personen", z.zeile, "E-Mail", "Kommt bereits vor.", email);
|
||||||
|
emails.add(email);
|
||||||
|
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) melde("Personen", z.zeile, "E-Mail", "Sieht nicht wie eine Adresse aus.", email);
|
||||||
|
}
|
||||||
|
|
||||||
|
const geburt = s(w.birth_date);
|
||||||
|
const svRoh = s(w.sv_nummer);
|
||||||
|
if (svRoh) {
|
||||||
|
const f = validateSvnr(svRoh, geburt);
|
||||||
|
if (f) melde("Personen", z.zeile, "SV-Nummer", svnrErrorMessage(f), svRoh);
|
||||||
|
else {
|
||||||
|
const norm = normalizeSvnr(svRoh);
|
||||||
|
if (svNummern.has(norm)) melde("Personen", z.zeile, "SV-Nummer", "Kommt bereits vor.", svRoh);
|
||||||
|
svNummern.add(norm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (geburt && geburt > heute) melde("Personen", z.zeile, "Geburtsdatum", "Liegt in der Zukunft.", geburt);
|
||||||
|
|
||||||
|
const standort = s(w.location);
|
||||||
|
if (standort && !standorte.has(standort)) melde("Personen", z.zeile, "Standort", "Steht weder in der Datei noch im System.", standort);
|
||||||
|
|
||||||
|
const stelle = s(w.position_number);
|
||||||
|
if (stelle) {
|
||||||
|
if (!planstellen.has(stelle)) {
|
||||||
|
melde("Personen", z.zeile, "Planstellennummer", "Steht weder in der Datei noch im System.", stelle);
|
||||||
|
} else if (bestand.planstellen.get(stelle)?.besetzt) {
|
||||||
|
melde("Personen", z.zeile, "Planstellennummer", "Diese Planstelle ist bereits besetzt.", stelle);
|
||||||
|
}
|
||||||
|
const zuvor = belegtePlanstellen.get(stelle);
|
||||||
|
// Doppelbesetzung ist in diesem Modell nicht bloss unsauber, sondern
|
||||||
|
// verboten — die Datenbank hat dafür einen Teilindex.
|
||||||
|
if (zuvor !== undefined) melde("Personen", z.zeile, "Planstellennummer", `Wird bereits in Zeile ${zuvor} besetzt.`, stelle);
|
||||||
|
else belegtePlanstellen.set(stelle, z.zeile);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eintritt = s(w.entry_date);
|
||||||
|
const austritt = s(w.exit_date);
|
||||||
|
if (pnr !== null && eintritt) eintritte.set(pnr, eintritt);
|
||||||
|
if (eintritt && geburt && eintritt <= geburt) melde("Personen", z.zeile, "Eintritt", "Liegt vor dem Geburtsdatum.", eintritt);
|
||||||
|
// Gleicher Tag ist erlaubt — jemand, der den Dienst nicht antritt, tritt
|
||||||
|
// am selben Tag ein und aus. Die Datenbank sieht das genauso.
|
||||||
|
if (austritt && eintritt && austritt < eintritt) melde("Personen", z.zeile, "Austritt", "Liegt vor dem Eintritt.", austritt);
|
||||||
|
if (austritt && !s(w.exit_reason)) melde("Personen", z.zeile, "Austrittsgrund", "Pflicht, sobald ein Austritt steht.");
|
||||||
|
|
||||||
|
if (s(w.contract_type) === "befristet" && !s(w.contract_end_date)) {
|
||||||
|
melde("Personen", z.zeile, "Befristet bis", "Pflicht bei einem befristeten Vertrag.");
|
||||||
|
}
|
||||||
|
if (s(w.contract_type) !== "befristet" && s(w.contract_end_date)) {
|
||||||
|
melde("Personen", z.zeile, "Befristet bis", "Nur bei „befristet“ erlaubt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const abVon = s(w.karenz_start_date);
|
||||||
|
if (abVon) {
|
||||||
|
if (!s(w.karenz_return_date)) melde("Personen", z.zeile, "Rückkehr geplant", "Pflicht, sobald eine Abwesenheit beginnt.");
|
||||||
|
if (!s(w.absence_type)) melde("Personen", z.zeile, "Abwesenheitsart", "Pflicht, sobald eine Abwesenheit beginnt.");
|
||||||
|
if (eintritt && abVon < eintritt) melde("Personen", z.zeile, "Abwesenheit ab", "Liegt vor dem Eintritt.", abVon);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abVon && s(w.karenz_return_date) && s(w.karenz_return_date)! < (eintritt ?? "")) {
|
||||||
|
melde("Personen", z.zeile, "Rückkehr geplant", "Liegt vor dem Eintritt.", s(w.karenz_return_date)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vollzeit bedeutet in diesem Kollektivvertrag genau 38,5 Stunden, und
|
||||||
|
// Teilzeit alles darunter über null. Die Datenbank hat dafür eine
|
||||||
|
// CHECK-Bedingung; ohne diese Prüfung bräche der Import erst beim
|
||||||
|
// Schreiben ab — mit einer Meldung ohne Zeilennummer. Genau so ist der
|
||||||
|
// erste Durchstich gescheitert.
|
||||||
|
const stunden = n(w.weekly_hours);
|
||||||
|
const beschaeftigung = s(w.employment_type) ?? "Vollzeit";
|
||||||
|
if (stunden !== null) {
|
||||||
|
if (beschaeftigung === "Vollzeit" && stunden !== 38.5) {
|
||||||
|
melde("Personen", z.zeile, "Wochenstunden", "Vollzeit sind genau 38,5 Stunden. Für weniger „Teilzeit“ eintragen.", String(stunden));
|
||||||
|
}
|
||||||
|
if (beschaeftigung === "Teilzeit" && (stunden <= 0 || stunden >= 38.5)) {
|
||||||
|
melde("Personen", z.zeile, "Wochenstunden", "Teilzeit liegt zwischen 0 und 38,5 Stunden.", String(stunden));
|
||||||
|
}
|
||||||
|
} else if (beschaeftigung === "Teilzeit") {
|
||||||
|
melde("Personen", z.zeile, "Wochenstunden", "Pflicht bei Teilzeit — sonst gälten 38,5 und damit Vollzeit.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tage = w.work_days;
|
||||||
|
if (Array.isArray(tage) && tage.length === 0) {
|
||||||
|
melde("Personen", z.zeile, "Arbeitstage", "Mindestens ein Tag. Leer lassen für Mo–Fr.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Historie und Angehörige ───────────────────────────────────
|
||||||
|
for (const z of hole("Historie")) {
|
||||||
|
const pnr = n(z.werte.personnel_number);
|
||||||
|
if (pnr === null) continue;
|
||||||
|
if (!personalnummern.has(pnr)) {
|
||||||
|
melde("Historie", z.zeile, "Personalnummer", "Steht weder in der Datei noch im System.", String(pnr));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Nur für Personen aus derselben Datei; für bereits vorhandene kennt
|
||||||
|
// diese Funktion das Eintrittsdatum nicht, und die Datenbank prüft es
|
||||||
|
// ohnehin ein zweites Mal.
|
||||||
|
const eintritt = eintritte.get(pnr) ?? null;
|
||||||
|
const datum = s(z.werte.event_date);
|
||||||
|
// Die Datenbank weist das ohnehin ab (trg_history_not_before_entry) —
|
||||||
|
// aber mitten im Einfügen und mit einer Meldung ohne Zeilennummer.
|
||||||
|
if (eintritt && datum && datum < eintritt) {
|
||||||
|
melde("Historie", z.zeile, "Datum", `Liegt vor dem Eintritt am ${eintritt}.`, datum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const z of hole("Angehörige")) {
|
||||||
|
const pnr = n(z.werte.personnel_number);
|
||||||
|
if (pnr === null) continue;
|
||||||
|
if (!personalnummern.has(pnr)) {
|
||||||
|
melde("Angehörige", z.zeile, "Personalnummer", "Steht weder in der Datei noch im System.", String(pnr));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sv = s(z.werte.sv_nummer);
|
||||||
|
const geb = s(z.werte.birth_date);
|
||||||
|
if (sv) {
|
||||||
|
const f = validateSvnr(sv, geb);
|
||||||
|
if (f) melde("Angehörige", z.zeile, "SV-Nummer", svnrErrorMessage(f), sv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const anzahl: Record<string, number> = {};
|
||||||
|
for (const b of BLAETTER) anzahl[b.name] = (datensatz[b.name] ?? []).length;
|
||||||
|
|
||||||
|
return { fehler, hinweise, datensatz, anzahl };
|
||||||
|
}
|
||||||
125
lib/import/werte.ts
Normal file
125
lib/import/werte.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
// Aus einer Zelle wird ein Wert.
|
||||||
|
//
|
||||||
|
// Jede Funktion hier liefert entweder den Wert oder `null` — nie eine
|
||||||
|
// Näherung. Was sich nicht eindeutig lesen lässt, ist ein Fehler, den die
|
||||||
|
// Person in der Datei korrigieren soll. Raten wäre hier besonders teuer: aus
|
||||||
|
// „03.08.26" könnte 2026-08-03 oder 2003-08-26 werden, und beides sähe im
|
||||||
|
// Ergebnis unauffällig aus.
|
||||||
|
|
||||||
|
/** Kalendertag als „JJJJ-MM-TT". Ohne Zeitzone, weil ein Geburtstag keine hat. */
|
||||||
|
export type IsoTag = string;
|
||||||
|
|
||||||
|
function tagAusTeilen(jahr: number, monat: number, tag: number): IsoTag | null {
|
||||||
|
if (monat < 1 || monat > 12 || tag < 1 || tag > 31) return null;
|
||||||
|
const d = new Date(Date.UTC(jahr, monat - 1, tag));
|
||||||
|
// Fängt den 31. Februar: Date rechnet ihn stillschweigend in den 3. März um.
|
||||||
|
if (d.getUTCFullYear() !== jahr || d.getUTCMonth() !== monat - 1 || d.getUTCDate() !== tag) return null;
|
||||||
|
const m = String(monat).padStart(2, "0");
|
||||||
|
const t = String(tag).padStart(2, "0");
|
||||||
|
return `${jahr}-${m}-${t}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datum aus einer Zelle.
|
||||||
|
*
|
||||||
|
* Angenommen werden ISO (2026-08-03), österreichisch (3.8.2026, 03.08.2026)
|
||||||
|
* und mit Schrägstrich (03/08/2026). Zweistellige Jahre werden **abgelehnt**:
|
||||||
|
* bei Geburtsdaten liegt das Jahrhundert nicht fest, und eine Regel wie
|
||||||
|
* „unter 30 heisst 20xx" produziert lautlos Personen, die noch nicht geboren
|
||||||
|
* sind.
|
||||||
|
*/
|
||||||
|
export function alsDatum(roh: string): IsoTag | null {
|
||||||
|
const s = roh.trim();
|
||||||
|
if (!s) return null;
|
||||||
|
|
||||||
|
const iso = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
|
||||||
|
if (iso) return tagAusTeilen(Number(iso[1]), Number(iso[2]), Number(iso[3]));
|
||||||
|
|
||||||
|
const deutsch = s.match(/^(\d{1,2})[.\/](\d{1,2})[.\/](\d{4})\.?$/);
|
||||||
|
if (deutsch) return tagAusTeilen(Number(deutsch[3]), Number(deutsch[2]), Number(deutsch[1]));
|
||||||
|
|
||||||
|
// Excel-Serienzahl — kommt vor, wenn die Spalte nicht als Datum formatiert
|
||||||
|
// ist. Tag 1 ist der 01.01.1900, und Excel kennt einen 29.02.1900, den es
|
||||||
|
// nie gab; ab Serie 60 muss deshalb ein Tag abgezogen werden.
|
||||||
|
if (/^\d{1,6}$/.test(s)) {
|
||||||
|
const serie = Number(s);
|
||||||
|
if (serie >= 1 && serie < 100000) {
|
||||||
|
const tage = serie >= 60 ? serie - 1 : serie;
|
||||||
|
const d = new Date(Date.UTC(1899, 11, 31) + tage * 86400000);
|
||||||
|
return tagAusTeilen(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zahl aus einer Zelle, mit Komma **oder** Punkt als Dezimaltrenner.
|
||||||
|
*
|
||||||
|
* „1.234,5" und „1,234.5" sind beide gebräuchlich und bedeuten dasselbe. Das
|
||||||
|
* letzte Trennzeichen entscheidet, die übrigen sind Tausenderpunkte.
|
||||||
|
*/
|
||||||
|
export function alsZahl(roh: string): number | null {
|
||||||
|
let s = roh.trim().replace(/\s/g, "");
|
||||||
|
if (!s) return null;
|
||||||
|
s = s.replace(/€|EUR/gi, "");
|
||||||
|
|
||||||
|
const letztesKomma = s.lastIndexOf(",");
|
||||||
|
const letzterPunkt = s.lastIndexOf(".");
|
||||||
|
if (letztesKomma >= 0 && letzterPunkt >= 0) {
|
||||||
|
const dezimal = letztesKomma > letzterPunkt ? "," : ".";
|
||||||
|
const tausender = dezimal === "," ? "." : ",";
|
||||||
|
s = s.split(tausender).join("").replace(dezimal, ".");
|
||||||
|
} else if (letztesKomma >= 0) {
|
||||||
|
// Ein einzelnes Komma ist im deutschsprachigen Raum ein Dezimaltrenner,
|
||||||
|
// auch bei „1,234" — das sind 1,234 und nicht 1234.
|
||||||
|
s = s.replace(",", ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^-?\d*\.?\d+$/.test(s)) return null;
|
||||||
|
const n = Number(s);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alsGanzzahl(roh: string): number | null {
|
||||||
|
const n = alsZahl(roh);
|
||||||
|
return n !== null && Number.isInteger(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JA = new Set(["ja", "j", "x", "wahr", "true", "1", "y", "yes"]);
|
||||||
|
const NEIN = new Set(["nein", "n", "falsch", "false", "0", "no", "-"]);
|
||||||
|
|
||||||
|
/** Ja/Nein aus einer Zelle. Alles andere ist ein Fehler, nicht „nein". */
|
||||||
|
export function alsJaNein(roh: string): boolean | null {
|
||||||
|
const s = roh.trim().toLowerCase();
|
||||||
|
if (!s) return null;
|
||||||
|
if (JA.has(s)) return true;
|
||||||
|
if (NEIN.has(s)) return false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste aus einer Zelle — für Titel und Arbeitstage.
|
||||||
|
*
|
||||||
|
* Getrennt wird an Semikolon oder Komma. Leere Glieder fallen weg, damit
|
||||||
|
* „Mo, Di, " nicht in einem leeren Arbeitstag endet.
|
||||||
|
*/
|
||||||
|
export function alsListe(roh: string): string[] {
|
||||||
|
return roh
|
||||||
|
.split(/[;,]/)
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einen Wert einer Aufzählung zuordnen, unabhängig von Gross- und
|
||||||
|
* Kleinschreibung und von Leerzeichen.
|
||||||
|
*
|
||||||
|
* Zurück kommt die **kanonische** Schreibweise aus der Datenbank, nicht die
|
||||||
|
* aus der Datei: „vollzeit" wird zu „Vollzeit", weil die Spalte ein enum ist
|
||||||
|
* und alles andere die Zeile beim Einfügen abweisen würde.
|
||||||
|
*/
|
||||||
|
export function alsAufzaehlung<T extends string>(roh: string, erlaubt: readonly T[]): T | null {
|
||||||
|
const s = roh.trim().toLowerCase();
|
||||||
|
if (!s) return null;
|
||||||
|
return erlaubt.find((w) => w.toLowerCase() === s) ?? null;
|
||||||
|
}
|
||||||
40
lib/notes.ts
40
lib/notes.ts
@@ -1,26 +1,32 @@
|
|||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import type { Tx } from "./db";
|
||||||
import { fetchAllRows } from "./supabase/query";
|
|
||||||
import type { Database } from "./supabase/types";
|
import type { Database } from "./supabase/types";
|
||||||
|
|
||||||
export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & {
|
export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & {
|
||||||
employeeName: string;
|
employeeName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// "Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende
|
// „Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende
|
||||||
// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon wer
|
// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon, wer sie
|
||||||
// sie verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt). Zwei
|
// verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt).
|
||||||
// einfache Queries, in JS gemerged — gleiches Muster wie loadEventHistory
|
//
|
||||||
// in lib/reports-data.ts, da der handgeschriebene Database-Typ keine
|
// Früher zwei Abfragen, in JavaScript zusammengeführt, weil die API-Schicht
|
||||||
// relationalen Embeddings für eine einzelne verschachtelte Query kennt.
|
// für eine einzelne verschachtelte Abfrage keine Verknüpfung anbot. Am
|
||||||
export async function loadOpenNotes(supabase: SupabaseClient<Database>): Promise<OpenNote[]> {
|
// direkten Zugang ist es schlicht ein Join.
|
||||||
const [{ data: notes }, employees] = await Promise.all([
|
export async function loadOpenNotes(tx: Tx): Promise<OpenNote[]> {
|
||||||
supabase.from("employee_notes").select("*").eq("done", false).order("created_at", { ascending: false }),
|
const rows = await tx
|
||||||
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")),
|
.selectFrom("employee_notes as n")
|
||||||
]);
|
.leftJoin("employees as e", "e.id", "n.employee_id")
|
||||||
|
.selectAll("n")
|
||||||
|
.select(["e.first_name", "e.last_name"])
|
||||||
|
.where("n.done", "=", false)
|
||||||
|
.orderBy("n.created_at", "desc")
|
||||||
|
.execute();
|
||||||
|
|
||||||
const employeeById = new Map(employees.map((e) => [e.id, e]));
|
return rows.map((row) => {
|
||||||
return (notes ?? []).map((n) => {
|
const { first_name, last_name, ...note } = row;
|
||||||
const emp = employeeById.get(n.employee_id);
|
return {
|
||||||
return { ...n, employeeName: emp ? `${emp.first_name} ${emp.last_name}` : "Unbekannt" };
|
...(note as Database["public"]["Tables"]["employee_notes"]["Row"]),
|
||||||
|
employeeName: first_name && last_name ? `${first_name} ${last_name}` : "Unbekannt",
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
16
lib/org.ts
16
lib/org.ts
@@ -1,4 +1,4 @@
|
|||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import type { Tx } from "./db";
|
||||||
import type { Database } from "./supabase/types";
|
import type { Database } from "./supabase/types";
|
||||||
|
|
||||||
// Die Organisation ist ein Baum, keine drei Tabellen mehr. Alles, was früher
|
// Die Organisation ist ein Baum, keine drei Tabellen mehr. Alles, was früher
|
||||||
@@ -31,13 +31,17 @@ export type OrgMaps = {
|
|||||||
|
|
||||||
// Die Referenzdaten sind winzig (60 Einheiten, 5 Standorte) — sie werden
|
// Die Referenzdaten sind winzig (60 Einheiten, 5 Standorte) — sie werden
|
||||||
// ganz geladen und im Speicher verknüpft, statt je Zeile nachzuschlagen.
|
// ganz geladen und im Speicher verknüpft, statt je Zeile nachzuschlagen.
|
||||||
export async function loadOrgMaps(supabase: SupabaseClient<Database>): Promise<OrgMaps> {
|
export async function loadOrgMaps(tx: Tx): Promise<OrgMaps> {
|
||||||
const [{ data: units }, { data: locations }] = await Promise.all([
|
const [units, locations] = await Promise.all([
|
||||||
supabase.from("org_units").select("id, org_number, name, parent_id, unit_type").order("org_number"),
|
tx
|
||||||
supabase.from("locations").select("*").order("name"),
|
.selectFrom("org_units")
|
||||||
|
.select(["id", "org_number", "name", "parent_id", "unit_type"])
|
||||||
|
.orderBy("org_number")
|
||||||
|
.execute(),
|
||||||
|
tx.selectFrom("locations").selectAll().orderBy("name").execute(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return buildOrgMaps((units ?? []) as OrgUnit[], locations ?? []);
|
return buildOrgMaps(units as OrgUnit[], locations as Location[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Der reine Teil: aus den Zeilen den Baum bauen, ohne Datenbank. */
|
/** Der reine Teil: aus den Zeilen den Baum bauen, ohne Datenbank. */
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
||||||
import type { OrgEmployee, OrgVacancy } from "@/components/orgchart/types";
|
import type { OrgEmployee, OrgVacancy } from "@/components/orgchart/types";
|
||||||
|
import type { Tx } from "./db";
|
||||||
import { todayIso } from "./format";
|
import { todayIso } from "./format";
|
||||||
import { resolveReportingLines, type OmHolder, type OmUnit } from "./om-reporting";
|
import { resolveReportingLines, type OmHolder, type OmUnit } from "./om-reporting";
|
||||||
import { fetchAllRows } from "./supabase/query";
|
|
||||||
import type { Database } from "./supabase/types";
|
|
||||||
|
|
||||||
// Das Organigramm, wie es an einem Stichtag stand oder stehen wird.
|
// Das Organigramm, wie es an einem Stichtag stand oder stehen wird.
|
||||||
//
|
//
|
||||||
@@ -56,55 +54,76 @@ type AssignmentRow = { employee_id: string; position_id: string };
|
|||||||
|
|
||||||
type PendingRow = { employee_id: string; effective_date: string; payload: Record<string, unknown> };
|
type PendingRow = { employee_id: string; effective_date: string; payload: Record<string, unknown> };
|
||||||
|
|
||||||
export async function loadOrgAsOf(supabase: SupabaseClient<Database>, asOf: string): Promise<OrgAsOfResult> {
|
export async function loadOrgAsOf(tx: Tx, asOf: string): Promise<OrgAsOfResult> {
|
||||||
const today = todayIso();
|
const today = todayIso();
|
||||||
|
|
||||||
|
// Ohne die 1000-Zeilen-Grenze der API-Schicht fällt das seitenweise
|
||||||
|
// Nachladen weg: sechs Abfragen, jede vollständig.
|
||||||
const [units, positions, assignments, employees, pending, earliest] = await Promise.all([
|
const [units, positions, assignments, employees, pending, earliest] = await Promise.all([
|
||||||
fetchAllRows(() => supabase.from("org_units").select("id, parent_id").order("id")),
|
tx.selectFrom("org_units").select(["id", "parent_id"]).orderBy("id").execute(),
|
||||||
fetchAllRows(() =>
|
|
||||||
supabase
|
tx
|
||||||
.from("om_positions")
|
.selectFrom("om_positions as p")
|
||||||
.select("id, position_number, org_unit_id, is_chief, jobs!inner(title)")
|
.innerJoin("jobs as j", "j.id", "p.job_id")
|
||||||
.lte("valid_from", asOf)
|
.select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "j.title"])
|
||||||
.or(`valid_to.is.null,valid_to.gt.${asOf}`)
|
.where("p.valid_from", "<=", asOf)
|
||||||
.order("id")
|
.where((eb) => eb.or([eb("p.valid_to", "is", null), eb("p.valid_to", ">", asOf)]))
|
||||||
),
|
.orderBy("p.id")
|
||||||
fetchAllRows(() =>
|
.execute(),
|
||||||
supabase
|
|
||||||
.from("position_assignments")
|
tx
|
||||||
.select("employee_id, position_id")
|
.selectFrom("position_assignments")
|
||||||
.lte("valid_from", asOf)
|
.select(["employee_id", "position_id"])
|
||||||
.or(`valid_to.is.null,valid_to.gt.${asOf}`)
|
.where("valid_from", "<=", asOf)
|
||||||
.order("employee_id")
|
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)]))
|
||||||
),
|
.orderBy("employee_id")
|
||||||
fetchAllRows(() =>
|
.execute(),
|
||||||
supabase
|
|
||||||
.from("employees")
|
tx
|
||||||
.select("id, personnel_number, first_name, last_name, job_title, karenz_start_date, karenz_return_date, absence_type")
|
.selectFrom("employees")
|
||||||
.order("id")
|
.select([
|
||||||
),
|
"id",
|
||||||
|
"personnel_number",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"job_title",
|
||||||
|
"karenz_start_date",
|
||||||
|
"karenz_return_date",
|
||||||
|
"absence_type",
|
||||||
|
])
|
||||||
|
.orderBy("id")
|
||||||
|
.execute(),
|
||||||
|
|
||||||
asOf > today
|
asOf > today
|
||||||
? fetchAllRows(() =>
|
? tx
|
||||||
supabase
|
.selectFrom("pending_org_changes")
|
||||||
.from("pending_org_changes")
|
.select(["employee_id", "effective_date", "payload"])
|
||||||
.select("employee_id, effective_date, payload")
|
.where("status", "=", "pending")
|
||||||
.eq("status", "pending")
|
.where("effective_date", "<=", asOf)
|
||||||
.lte("effective_date", asOf)
|
.where("change_type", "in", [...PLACEMENT_CHANGES])
|
||||||
.in("change_type", [...PLACEMENT_CHANGES])
|
.orderBy("effective_date")
|
||||||
.order("effective_date")
|
.execute()
|
||||||
)
|
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
supabase.from("position_assignments").select("valid_from").order("valid_from").limit(1).maybeSingle(),
|
|
||||||
|
tx.selectFrom("position_assignments").select("valid_from").orderBy("valid_from").limit(1).executeTakeFirst(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return resolveOrgSnapshot({
|
return resolveOrgSnapshot({
|
||||||
asOf,
|
asOf,
|
||||||
units: units.map((u) => ({ id: u.id, parentId: u.parent_id })),
|
units: units.map((u) => ({ id: u.id, parentId: u.parent_id })),
|
||||||
positions: positions as unknown as PositionRow[],
|
// Der Join liefert den Jobtitel flach; die reine Funktion erwartet ihn
|
||||||
|
// verschachtelt, weil sie so auch aus einem Testbestand gefüttert wird.
|
||||||
|
positions: positions.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
position_number: p.position_number,
|
||||||
|
org_unit_id: p.org_unit_id,
|
||||||
|
is_chief: p.is_chief,
|
||||||
|
jobs: { title: p.title },
|
||||||
|
})),
|
||||||
assignments: assignments as AssignmentRow[],
|
assignments: assignments as AssignmentRow[],
|
||||||
employees: employees as EmployeeRow[],
|
employees: employees as EmployeeRow[],
|
||||||
pending: pending as PendingRow[],
|
pending: pending as PendingRow[],
|
||||||
historyStartsAt: earliest.data?.valid_from ?? null,
|
historyStartsAt: earliest?.valid_from ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import { sql, type Tx } from "./db";
|
||||||
import { fetchAllRows } from "./supabase/query";
|
|
||||||
import type { Database } from "./supabase/types";
|
|
||||||
|
|
||||||
// Wo jemand in der Organisation steht, steht nicht mehr auf der Person. Es
|
// Wo jemand in der Organisation steht, steht nicht mehr auf der Person. Es
|
||||||
// ergibt sich aus der Planstelle, die sie zum Stichtag innehat:
|
// ergibt sich aus der Planstelle, die sie zum Stichtag innehat:
|
||||||
@@ -24,30 +22,25 @@ export type Placement = {
|
|||||||
current: boolean;
|
current: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SELECT =
|
|
||||||
"employee_id, valid_from, valid_to, om_positions!inner(id, position_number, org_unit_id, is_chief, jobs!inner(title))";
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
employee_id: string;
|
employee_id: string;
|
||||||
valid_from: string;
|
valid_from: string;
|
||||||
valid_to: string | null;
|
valid_to: string | null;
|
||||||
om_positions: {
|
position_id: string;
|
||||||
id: string;
|
|
||||||
position_number: string;
|
position_number: string;
|
||||||
org_unit_id: string;
|
org_unit_id: string;
|
||||||
is_chief: boolean;
|
is_chief: boolean;
|
||||||
jobs: { title: string };
|
job_title: string;
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function toPlacement(row: Row, asOf: string): Placement {
|
function toPlacement(row: Row, asOf: string): Placement {
|
||||||
return {
|
return {
|
||||||
employeeId: row.employee_id,
|
employeeId: row.employee_id,
|
||||||
positionId: row.om_positions.id,
|
positionId: row.position_id,
|
||||||
positionNumber: row.om_positions.position_number,
|
positionNumber: row.position_number,
|
||||||
orgUnitId: row.om_positions.org_unit_id,
|
orgUnitId: row.org_unit_id,
|
||||||
isChief: row.om_positions.is_chief,
|
isChief: row.is_chief,
|
||||||
jobTitle: row.om_positions.jobs.title,
|
jobTitle: row.job_title,
|
||||||
validFrom: row.valid_from,
|
validFrom: row.valid_from,
|
||||||
validTo: row.valid_to,
|
validTo: row.valid_to,
|
||||||
current: row.valid_from <= asOf && (row.valid_to === null || row.valid_to > asOf),
|
current: row.valid_from <= asOf && (row.valid_to === null || row.valid_to > asOf),
|
||||||
@@ -76,17 +69,33 @@ export function pickPlacements(rows: Row[], asOf: string): Map<string, Placement
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadPlacements(
|
export async function loadPlacements(
|
||||||
supabase: SupabaseClient<Database>,
|
tx: Tx,
|
||||||
{ asOf, employeeIds }: { asOf: string; employeeIds?: string[] }
|
{ asOf, employeeIds }: { asOf: string; employeeIds?: string[] }
|
||||||
): Promise<Map<string, Placement>> {
|
): Promise<Map<string, Placement>> {
|
||||||
if (employeeIds?.length === 0) return new Map();
|
if (employeeIds?.length === 0) return new Map();
|
||||||
|
|
||||||
const rows = await fetchAllRows(() => {
|
// Ein Join statt einer eingebetteten Ressource. Und ohne die
|
||||||
const q = supabase.from("position_assignments").select(SELECT).order("employee_id");
|
// 1000-Zeilen-Grenze von PostgREST fällt das seitenweise Nachladen weg,
|
||||||
return employeeIds ? q.in("employee_id", employeeIds) : q;
|
// das es dafür brauchte.
|
||||||
});
|
let q = tx
|
||||||
|
.selectFrom("position_assignments as pa")
|
||||||
|
.innerJoin("om_positions as p", "p.id", "pa.position_id")
|
||||||
|
.innerJoin("jobs as j", "j.id", "p.job_id")
|
||||||
|
.select([
|
||||||
|
"pa.employee_id",
|
||||||
|
"pa.valid_from",
|
||||||
|
"pa.valid_to",
|
||||||
|
"p.id as position_id",
|
||||||
|
"p.position_number",
|
||||||
|
"p.org_unit_id",
|
||||||
|
"p.is_chief",
|
||||||
|
"j.title as job_title",
|
||||||
|
])
|
||||||
|
.orderBy("pa.employee_id");
|
||||||
|
|
||||||
return pickPlacements(rows as unknown as Row[], asOf);
|
if (employeeIds) q = q.where("pa.employee_id", "in", employeeIds);
|
||||||
|
|
||||||
|
return pickPlacements((await q.execute()) as Row[], asOf);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Abgeleitete Berichtslinie ──────────────────────────────────────
|
// ── Abgeleitete Berichtslinie ──────────────────────────────────────
|
||||||
@@ -105,11 +114,30 @@ export type ReportingLine = {
|
|||||||
acting_manager_id: string | null;
|
acting_manager_id: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `filter` schränkt die Funktion selbst ein, nicht das Ergebnis im Speicher —
|
||||||
|
* bei der Detailseite wandern damit neun Zeilen über die Leitung statt
|
||||||
|
* achthundert.
|
||||||
|
*/
|
||||||
export async function loadReportingLines(
|
export async function loadReportingLines(
|
||||||
supabase: SupabaseClient<Database>,
|
tx: Tx,
|
||||||
asOf: string
|
asOf: string,
|
||||||
): Promise<Map<string, ReportingLine>> {
|
filter?: { employeeId?: string; actingManagerId?: string }
|
||||||
const { data, error } = await supabase.rpc("om_reporting_lines", { p_as_of: asOf });
|
): Promise<ReportingLine[]> {
|
||||||
if (error) throw new Error(`Berichtslinie konnte nicht geladen werden: ${error.message}`);
|
const conditions = [sql`true`];
|
||||||
return new Map(((data ?? []) as ReportingLine[]).map((l) => [l.employee_id, l]));
|
if (filter?.employeeId) conditions.push(sql`employee_id = ${filter.employeeId}::uuid`);
|
||||||
|
if (filter?.actingManagerId) conditions.push(sql`acting_manager_id = ${filter.actingManagerId}::uuid`);
|
||||||
|
|
||||||
|
const result = await sql<ReportingLine>`
|
||||||
|
select * from om_reporting_lines(${asOf}::date)
|
||||||
|
where ${sql.join(conditions, sql` and `)}
|
||||||
|
`.execute(tx);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wie loadReportingLines, aber als Karte über die Personen-Kennung. */
|
||||||
|
export async function loadReportingLineMap(tx: Tx, asOf: string): Promise<Map<string, ReportingLine>> {
|
||||||
|
const lines = await loadReportingLines(tx, asOf);
|
||||||
|
return new Map(lines.map((l) => [l.employee_id, l]));
|
||||||
}
|
}
|
||||||
|
|||||||
152
lib/positions.ts
152
lib/positions.ts
@@ -1,8 +1,6 @@
|
|||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import type { Tx } from "./db";
|
||||||
import { todayIso } from "./format";
|
import { todayIso } from "./format";
|
||||||
import { breadcrumbLabel, loadOrgMaps, type OrgMaps } from "./org";
|
import { breadcrumbLabel, loadOrgMaps, type OrgMaps } from "./org";
|
||||||
import { fetchAllRows } from "./supabase/query";
|
|
||||||
import type { Database } from "./supabase/types";
|
|
||||||
|
|
||||||
// Eine offene Stelle ist keine eigene Sache mehr. Sie ist eine Planstelle
|
// Eine offene Stelle ist keine eigene Sache mehr. Sie ist eine Planstelle
|
||||||
// ohne laufende Besetzung — Vakanz ist eine Eigenschaft der Planstelle, kein
|
// ohne laufende Besetzung — Vakanz ist eine Eigenschaft der Planstelle, kein
|
||||||
@@ -16,6 +14,17 @@ export type OpenPositionResolved = {
|
|||||||
org_unit_id: string;
|
org_unit_id: string;
|
||||||
is_chief: boolean;
|
is_chief: boolean;
|
||||||
valid_from: string;
|
valid_from: string;
|
||||||
|
valid_to: string | null;
|
||||||
|
/** Die Einheit selbst — für den Änderungsdialog, der sie vorbelegt. */
|
||||||
|
org_unit_name: string;
|
||||||
|
/**
|
||||||
|
* Ob die Planstelle heute schon gilt.
|
||||||
|
*
|
||||||
|
* Eine, die erst zum 01.10. entsteht, ist nicht „seit 2 Tagen unbesetzt" —
|
||||||
|
* sie ist geplant. Beides in einer Liste zu zeigen ist richtig, beides
|
||||||
|
* gleich zu benennen wäre falsch.
|
||||||
|
*/
|
||||||
|
future: boolean;
|
||||||
/** Wer die Stelle nach der Berichtslinie führen wird. */
|
/** Wer die Stelle nach der Berichtslinie führen wird. */
|
||||||
managerName: string | null;
|
managerName: string | null;
|
||||||
orgLabel: string;
|
orgLabel: string;
|
||||||
@@ -23,16 +32,6 @@ export type OpenPositionResolved = {
|
|||||||
vacantSince: string;
|
vacantSince: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PositionRow = {
|
|
||||||
id: string;
|
|
||||||
position_number: string;
|
|
||||||
org_unit_id: string;
|
|
||||||
is_chief: boolean;
|
|
||||||
valid_from: string;
|
|
||||||
jobs: { title: string };
|
|
||||||
position_assignments: { employee_id: string; valid_from: string; valid_to: string | null }[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wer eine unbesetzte Planstelle führen würde: die Leitung der eigenen
|
* Wer eine unbesetzte Planstelle führen würde: die Leitung der eigenen
|
||||||
* Einheit, für eine Leitungsplanstelle die der übergeordneten — dieselbe
|
* Einheit, für eine Leitungsplanstelle die der übergeordneten — dieselbe
|
||||||
@@ -43,70 +42,109 @@ function managerUnitFor(maps: OrgMaps, orgUnitId: string, isChief: boolean): str
|
|||||||
return maps.units.get(orgUnitId)?.parent_id ?? null;
|
return maps.units.get(orgUnitId)?.parent_id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadOpenPositions(supabase: SupabaseClient<Database>): Promise<OpenPositionResolved[]> {
|
export async function loadOpenPositions(tx: Tx): Promise<OpenPositionResolved[]> {
|
||||||
const asOf = todayIso();
|
const asOf = todayIso();
|
||||||
|
|
||||||
const [orgMaps, positions] = await Promise.all([
|
const [orgMaps, open] = await Promise.all([
|
||||||
loadOrgMaps(supabase),
|
loadOrgMaps(tx),
|
||||||
fetchAllRows(() =>
|
// Unbesetzt heisst: keine Zuordnung, die noch gilt — **auch keine, die
|
||||||
supabase
|
// erst beginnt.**
|
||||||
.from("om_positions")
|
//
|
||||||
.select(
|
// Der Unterschied ist kein Feinschliff. Wer unterschrieben hat und am
|
||||||
"id, position_number, org_unit_id, is_chief, valid_from, jobs!inner(title), position_assignments(employee_id, valid_from, valid_to)"
|
// 24.09. anfängt, belegt die Planstelle heute schon; sie steht nur noch
|
||||||
|
// nicht besetzt da. Die frühere Fassung fragte „sitzt heute jemand
|
||||||
|
// darauf?" und listete solche Stellen als offen — mit „seit 2 Tagen
|
||||||
|
// unbesetzt" daneben. Aus dieser Liste speist sich auch die Auswahl im
|
||||||
|
// Einstellungsassistenten, also lud sie dazu ein, dieselbe Stelle ein
|
||||||
|
// zweites Mal zu besetzen. Aufgefallen wäre das erst am Teilindex der
|
||||||
|
// Datenbank, nach dem Gespräch mit der zweiten Person.
|
||||||
|
//
|
||||||
|
// Eine beendete Zuordnung (valid_to in der Vergangenheit) gibt die Stelle
|
||||||
|
// dagegen wieder frei — deshalb bleibt die Bedingung auf valid_to.
|
||||||
|
//
|
||||||
|
// Als NOT EXISTS in der Datenbank statt als Filter über alle Planstellen
|
||||||
|
// im Speicher.
|
||||||
|
tx
|
||||||
|
.selectFrom("om_positions as p")
|
||||||
|
.innerJoin("jobs as j", "j.id", "p.job_id")
|
||||||
|
.select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "p.valid_from", "p.valid_to", "j.title"])
|
||||||
|
// Künftige Planstellen bleiben drin. Sie sind der Grund, warum diese
|
||||||
|
// Ansicht existiert: eine Stelle, die zum Quartalswechsel entsteht,
|
||||||
|
// muss vorher sichtbar und planbar sein. Vorher fielen sie durch das
|
||||||
|
// Raster und tauchten am Stichtag unangekündigt auf.
|
||||||
|
.where((eb) => eb.or([eb("p.valid_to", "is", null), eb("p.valid_to", ">", asOf)]))
|
||||||
|
.where((eb) =>
|
||||||
|
eb.not(
|
||||||
|
eb.exists(
|
||||||
|
eb
|
||||||
|
.selectFrom("position_assignments as a")
|
||||||
|
.select("a.id")
|
||||||
|
.whereRef("a.position_id", "=", "p.id")
|
||||||
|
.where((e2) => e2.or([e2("a.valid_to", "is", null), e2("a.valid_to", ">", asOf)]))
|
||||||
)
|
)
|
||||||
.lte("valid_from", asOf)
|
)
|
||||||
.or(`valid_to.is.null,valid_to.gt.${asOf}`)
|
)
|
||||||
.order("position_number")
|
.orderBy("p.position_number")
|
||||||
),
|
.execute(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const open = (positions as unknown as PositionRow[]).filter(
|
|
||||||
(p) => !p.position_assignments.some((a) => a.valid_from <= asOf && (a.valid_to === null || a.valid_to > asOf))
|
|
||||||
);
|
|
||||||
if (open.length === 0) return [];
|
if (open.length === 0) return [];
|
||||||
|
|
||||||
// Die Leitung der zuständigen Einheit — genau die Planstellen, die als
|
const positionIds = open.map((p) => p.id);
|
||||||
// Leitung markiert und laufend besetzt sind.
|
|
||||||
const chiefUnitIds = Array.from(
|
|
||||||
new Set(open.map((p) => managerUnitFor(orgMaps, p.org_unit_id, p.is_chief)).filter((id): id is string => Boolean(id)))
|
|
||||||
);
|
|
||||||
const chiefs = chiefUnitIds.length
|
|
||||||
? ((await fetchAllRows(() =>
|
|
||||||
supabase
|
|
||||||
.from("om_positions")
|
|
||||||
.select("org_unit_id, position_assignments!inner(employees!inner(first_name, last_name), valid_to)")
|
|
||||||
.eq("is_chief", true)
|
|
||||||
.in("org_unit_id", chiefUnitIds)
|
|
||||||
.is("position_assignments.valid_to", null)
|
|
||||||
)) as unknown as {
|
|
||||||
org_unit_id: string;
|
|
||||||
position_assignments: { employees: { first_name: string; last_name: string } }[];
|
|
||||||
}[])
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const chiefNameByUnit = new Map(
|
// Zwei Nachschläge: seit wann die Stelle leer steht, und wer sie führen
|
||||||
chiefs.flatMap((c) => {
|
// würde.
|
||||||
const holder = c.position_assignments[0]?.employees;
|
const [ended, chiefs] = await Promise.all([
|
||||||
return holder ? [[c.org_unit_id, `${holder.first_name} ${holder.last_name}`] as const] : [];
|
tx
|
||||||
})
|
.selectFrom("position_assignments")
|
||||||
|
.select(["position_id", "valid_to"])
|
||||||
|
.where("position_id", "in", positionIds)
|
||||||
|
.where("valid_to", "is not", null)
|
||||||
|
.execute(),
|
||||||
|
(async () => {
|
||||||
|
const chiefUnitIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
open
|
||||||
|
.map((p) => managerUnitFor(orgMaps, p.org_unit_id, p.is_chief))
|
||||||
|
.filter((id): id is string => Boolean(id))
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
if (chiefUnitIds.length === 0) return [];
|
||||||
|
return tx
|
||||||
|
.selectFrom("om_positions as p")
|
||||||
|
.innerJoin("position_assignments as a", "a.position_id", "p.id")
|
||||||
|
.innerJoin("employees as e", "e.id", "a.employee_id")
|
||||||
|
.select(["p.org_unit_id", "e.first_name", "e.last_name"])
|
||||||
|
.where("p.is_chief", "=", true)
|
||||||
|
.where("p.valid_to", "is", null)
|
||||||
|
.where("a.valid_to", "is", null)
|
||||||
|
.where("p.org_unit_id", "in", chiefUnitIds)
|
||||||
|
.execute();
|
||||||
|
})(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const lastEndByPosition = new Map<string, string>();
|
||||||
|
for (const e of ended) {
|
||||||
|
const prev = lastEndByPosition.get(e.position_id);
|
||||||
|
if (e.valid_to && (!prev || e.valid_to > prev)) lastEndByPosition.set(e.position_id, e.valid_to);
|
||||||
|
}
|
||||||
|
const chiefNameByUnit = new Map(chiefs.map((c) => [c.org_unit_id, `${c.first_name} ${c.last_name}`]));
|
||||||
|
|
||||||
return open.map((p) => {
|
return open.map((p) => {
|
||||||
const ended = p.position_assignments
|
|
||||||
.map((a) => a.valid_to)
|
|
||||||
.filter((d): d is string => d !== null)
|
|
||||||
.sort();
|
|
||||||
const managerUnit = managerUnitFor(orgMaps, p.org_unit_id, p.is_chief);
|
const managerUnit = managerUnitFor(orgMaps, p.org_unit_id, p.is_chief);
|
||||||
return {
|
return {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
position_number: p.position_number,
|
position_number: p.position_number,
|
||||||
title: p.jobs.title,
|
title: p.title,
|
||||||
org_unit_id: p.org_unit_id,
|
org_unit_id: p.org_unit_id,
|
||||||
is_chief: p.is_chief,
|
is_chief: p.is_chief,
|
||||||
valid_from: p.valid_from,
|
valid_from: p.valid_from,
|
||||||
|
valid_to: p.valid_to,
|
||||||
|
org_unit_name: orgMaps.units.get(p.org_unit_id)?.name ?? "",
|
||||||
|
future: p.valid_from > asOf,
|
||||||
managerName: managerUnit ? (chiefNameByUnit.get(managerUnit) ?? null) : null,
|
managerName: managerUnit ? (chiefNameByUnit.get(managerUnit) ?? null) : null,
|
||||||
orgLabel: breadcrumbLabel(orgMaps, p.org_unit_id),
|
orgLabel: breadcrumbLabel(orgMaps, p.org_unit_id),
|
||||||
vacantSince: ended.at(-1) ?? p.valid_from,
|
vacantSince: lastEndByPosition.get(p.id) ?? p.valid_from,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import type { Tx } from "./db";
|
||||||
import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org";
|
import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org";
|
||||||
import { loadPlacements } from "./placement";
|
import { loadPlacements } from "./placement";
|
||||||
import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports";
|
import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports";
|
||||||
import { fetchAllRows } from "./supabase/query";
|
import type { EmploymentType, HistoryEventType } from "./supabase/types";
|
||||||
import type { Database, EmploymentType, HistoryEventType } from "./supabase/types";
|
|
||||||
|
|
||||||
// Shared by the Berichte page and /api/export/* so they can never drift on
|
// Shared by the Berichte page and /api/export/* so they can never drift on
|
||||||
// what "the current view" means — same filters, same stichtag/event-window
|
// what "the current view" means — same filters, same stichtag/event-window
|
||||||
@@ -40,13 +39,13 @@ export function lookupsFromOrgMaps(orgMaps: OrgMaps, locations: { id: string; na
|
|||||||
return { divisionName, departmentName, teamName, locationName: new Map(locations.map((l) => [l.id, l.name])) };
|
return { divisionName, departmentName, teamName, locationName: new Map(locations.map((l) => [l.id, l.name])) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadOrgLookups(supabase: SupabaseClient<Database>): Promise<{
|
export async function loadOrgLookups(tx: Tx): Promise<{
|
||||||
lookups: OrgLookups;
|
lookups: OrgLookups;
|
||||||
orgMaps: OrgMaps;
|
orgMaps: OrgMaps;
|
||||||
divisions: { id: string; name: string }[];
|
divisions: { id: string; name: string }[];
|
||||||
locations: { id: string; name: string }[];
|
locations: { id: string; name: string }[];
|
||||||
}> {
|
}> {
|
||||||
const orgMaps = await loadOrgMaps(supabase);
|
const orgMaps = await loadOrgMaps(tx);
|
||||||
const locations = orgMaps.locationList.map((l) => ({ id: l.id, name: l.name }));
|
const locations = orgMaps.locationList.map((l) => ({ id: l.id, name: l.name }));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -60,37 +59,61 @@ export async function loadOrgLookups(supabase: SupabaseClient<Database>): Promis
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const SNAPSHOT_EMPLOYEE_COLUMNS =
|
const SNAPSHOT_EMPLOYEE_COLUMNS = [
|
||||||
"id, first_name, last_name, job_title, location_id, employment_type, contract_type, entry_date, exit_date, weekly_hours, source, paygrade, birth_date, gender, karenz_start_date, karenz_return_date, worker_type, collective_agreement, work_days, is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level";
|
"id",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"job_title",
|
||||||
|
"location_id",
|
||||||
|
"employment_type",
|
||||||
|
"contract_type",
|
||||||
|
"entry_date",
|
||||||
|
"exit_date",
|
||||||
|
"weekly_hours",
|
||||||
|
"source",
|
||||||
|
"paygrade",
|
||||||
|
"birth_date",
|
||||||
|
"gender",
|
||||||
|
"karenz_start_date",
|
||||||
|
"karenz_return_date",
|
||||||
|
"worker_type",
|
||||||
|
"collective_agreement",
|
||||||
|
"work_days",
|
||||||
|
"is_betriebsrat",
|
||||||
|
"has_dienstwagen",
|
||||||
|
"is_laterale_fuehrung",
|
||||||
|
"is_c_level",
|
||||||
|
] as const;
|
||||||
|
|
||||||
// employee_id -> number of employee_dependents rows. Selects only the FK
|
// Anzahl der Angehörigen je Person. Nur der Fremdschlüssel wird gelesen —
|
||||||
// column (no dependent PII needed) since only per-employee counts feed the
|
// für die Berichtsdimensionen zählt die Anzahl, nicht wer es ist.
|
||||||
// has_dependents/avg_dependents report dimensions; counted client-side
|
export async function loadDependentsCounts(tx: Tx): Promise<Map<string, number>> {
|
||||||
// since the Supabase JS client has no `count(*) group by employee_id`
|
// Am direkten Zugang zählt die Datenbank, statt dass die Anwendung alle
|
||||||
// shorthand. Shared by the Bestand pivot and the full employees export.
|
// Zeilen holt und sie selbst durchgeht.
|
||||||
export async function loadDependentsCounts(supabase: SupabaseClient<Database>): Promise<Map<string, number>> {
|
const rows = await tx
|
||||||
const rows = await fetchAllRows(() => supabase.from("employee_dependents").select("employee_id").order("employee_id"));
|
.selectFrom("employee_dependents")
|
||||||
const counts = new Map<string, number>();
|
.select(({ fn }) => ["employee_id", fn.countAll<string>().as("anzahl")])
|
||||||
for (const d of rows) counts.set(d.employee_id, (counts.get(d.employee_id) ?? 0) + 1);
|
.groupBy("employee_id")
|
||||||
return counts;
|
.execute();
|
||||||
|
return new Map(rows.map((r) => [r.employee_id, Number(r.anzahl)]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bestand zum Stichtag: Status *und* Einordnung werden auf `asOf` aufgelöst.
|
// Bestand zum Stichtag: Status *und* Einordnung werden auf `asOf` aufgelöst.
|
||||||
export async function loadSnapshotEmployees(supabase: SupabaseClient<Database>, filters: SnapshotFilters): Promise<ReportEmployee[]> {
|
export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): Promise<ReportEmployee[]> {
|
||||||
const asOf = filters.asOf || todayIso();
|
const asOf = filters.asOf || todayIso();
|
||||||
|
|
||||||
function snapshotQuery() {
|
function snapshotQuery() {
|
||||||
let query = supabase.from("employees").select(SNAPSHOT_EMPLOYEE_COLUMNS).order("id");
|
let q = tx.selectFrom("employees").select([...SNAPSHOT_EMPLOYEE_COLUMNS]).orderBy("id");
|
||||||
if (filters.location) query = query.eq("location_id", filters.location);
|
if (filters.location) q = q.where("location_id", "=", filters.location);
|
||||||
if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType);
|
if (filters.employment) q = q.where("employment_type", "=", filters.employment as EmploymentType);
|
||||||
return query;
|
return q;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [data, dependentsCounts, placements, orgMaps] = await Promise.all([
|
const [data, dependentsCounts, placements, orgMaps] = await Promise.all([
|
||||||
fetchAllRows(snapshotQuery),
|
snapshotQuery().execute(),
|
||||||
loadDependentsCounts(supabase),
|
loadDependentsCounts(tx),
|
||||||
loadPlacements(supabase, { asOf }),
|
loadPlacements(tx, { asOf }),
|
||||||
filters.division ? loadOrgMaps(supabase) : Promise.resolve(null),
|
filters.division ? loadOrgMaps(tx) : Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Der Bereichsfilter meint den ganzen Teilbaum: „Produktion" schliesst
|
// Der Bereichsfilter meint den ganzen Teilbaum: „Produktion" schliesst
|
||||||
@@ -146,40 +169,38 @@ export async function loadSnapshotEmployees(supabase: SupabaseClient<Database>,
|
|||||||
// from/to: "" (unset) falls back to the current calendar year; the literal
|
// from/to: "" (unset) falls back to the current calendar year; the literal
|
||||||
// sentinel EVENT_DATE_OPEN means that side of the interval is intentionally
|
// sentinel EVENT_DATE_OPEN means that side of the interval is intentionally
|
||||||
// unbounded (e.g. "alle Ereignisse bis heute", no start date).
|
// unbounded (e.g. "alle Ereignisse bis heute", no start date).
|
||||||
export async function loadEventHistory(supabase: SupabaseClient<Database>, filters: EventFilters): Promise<ReportEvent[]> {
|
export async function loadEventHistory(tx: Tx, filters: EventFilters): Promise<ReportEvent[]> {
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
const from = filters.from === EVENT_DATE_OPEN ? undefined : filters.from || `${currentYear}-01-01`;
|
const from = filters.from === EVENT_DATE_OPEN ? undefined : filters.from || `${currentYear}-01-01`;
|
||||||
const to = filters.to === EVENT_DATE_OPEN ? undefined : filters.to || `${currentYear}-12-31`;
|
const to = filters.to === EVENT_DATE_OPEN ? undefined : filters.to || `${currentYear}-12-31`;
|
||||||
|
|
||||||
function historyQuery() {
|
function historyQuery() {
|
||||||
let query = supabase.from("employee_history").select("employee_id, event_date, event_type, description").order("id");
|
let q = tx
|
||||||
if (from) query = query.gte("event_date", from);
|
.selectFrom("employee_history")
|
||||||
if (to) query = query.lte("event_date", to);
|
.select(["employee_id", "event_date", "event_type", "description"])
|
||||||
if (filters.eventType) query = query.eq("event_type", filters.eventType);
|
.orderBy("id");
|
||||||
return query;
|
if (from) q = q.where("event_date", ">=", from);
|
||||||
|
if (to) q = q.where("event_date", "<=", to);
|
||||||
|
if (filters.eventType) q = q.where("event_type", "=", filters.eventType);
|
||||||
|
return q;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [history, employees, assignments, orgMaps] = await Promise.all([
|
const [history, employees, assignments, orgMaps] = await Promise.all([
|
||||||
fetchAllRows(historyQuery),
|
historyQuery().execute(),
|
||||||
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name, job_title, location_id").order("id")),
|
tx.selectFrom("employees").select(["id", "first_name", "last_name", "job_title", "location_id"]).orderBy("id").execute(),
|
||||||
fetchAllRows(() =>
|
tx
|
||||||
supabase
|
.selectFrom("position_assignments as a")
|
||||||
.from("position_assignments")
|
.innerJoin("om_positions as p", "p.id", "a.position_id")
|
||||||
.select("employee_id, valid_from, valid_to, om_positions!inner(org_unit_id)")
|
.select(["a.employee_id", "a.valid_from", "a.valid_to", "p.org_unit_id"])
|
||||||
.order("employee_id")
|
.orderBy("a.employee_id")
|
||||||
),
|
.execute(),
|
||||||
filters.division ? loadOrgMaps(supabase) : Promise.resolve(null),
|
filters.division ? loadOrgMaps(tx) : Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const spans = new Map<string, { from: string; to: string | null; unitId: string }[]>();
|
const spans = new Map<string, { from: string; to: string | null; unitId: string }[]>();
|
||||||
for (const a of assignments as unknown as {
|
for (const a of assignments) {
|
||||||
employee_id: string;
|
|
||||||
valid_from: string;
|
|
||||||
valid_to: string | null;
|
|
||||||
om_positions: { org_unit_id: string };
|
|
||||||
}[]) {
|
|
||||||
const list = spans.get(a.employee_id) ?? [];
|
const list = spans.get(a.employee_id) ?? [];
|
||||||
list.push({ from: a.valid_from, to: a.valid_to, unitId: a.om_positions.org_unit_id });
|
list.push({ from: a.valid_from, to: a.valid_to, unitId: a.org_unit_id });
|
||||||
spans.set(a.employee_id, list);
|
spans.set(a.employee_id, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +214,7 @@ export async function loadEventHistory(supabase: SupabaseClient<Database>, filte
|
|||||||
if (filters.location && emp.location_id !== filters.location) continue;
|
if (filters.location && emp.location_id !== filters.location) continue;
|
||||||
|
|
||||||
const unitId =
|
const unitId =
|
||||||
spans.get(h.employee_id)?.find((s) => s.from <= h.event_date && (s.to === null || s.to > h.event_date))?.unitId ?? null;
|
spans.get(h.employee_id)?.find((s2) => s2.from <= h.event_date && (s2.to === null || s2.to > h.event_date))?.unitId ?? null;
|
||||||
if (allowedUnits && (!unitId || !allowedUnits.has(unitId))) continue;
|
if (allowedUnits && (!unitId || !allowedUnits.has(unitId))) continue;
|
||||||
|
|
||||||
events.push({
|
events.push({
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import "server-only";
|
|
||||||
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
|
|
||||||
import type { Database } from "./types";
|
|
||||||
|
|
||||||
// Service-role client: bypasses RLS entirely. Server-only — never import this
|
|
||||||
// from a Client Component or anything bundled for the browser. The
|
|
||||||
// "server-only" import makes an accidental client-side import a build error
|
|
||||||
// instead of a runtime one.
|
|
||||||
export function createAdminClient() {
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
||||||
|
|
||||||
if (!supabaseUrl) {
|
|
||||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL");
|
|
||||||
}
|
|
||||||
if (!serviceRoleKey) {
|
|
||||||
throw new Error("Missing SUPABASE_SERVICE_ROLE_KEY");
|
|
||||||
}
|
|
||||||
|
|
||||||
return createSupabaseClient<Database>(supabaseUrl, serviceRoleKey, {
|
|
||||||
auth: { autoRefreshToken: false, persistSession: false },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import type { Database } from "./types";
|
|
||||||
|
|
||||||
// Route Handlers under /api/export/* are outside the App Router layout tree,
|
|
||||||
// so app/(app)/layout.tsx's HR gate never runs for them — each one has to
|
|
||||||
// re-establish that the caller is an active HR user itself. RLS is still the
|
|
||||||
// real boundary (an unauthorized session simply reads nothing); this exists
|
|
||||||
// so those routes answer 401/403 instead of handing back an empty workbook.
|
|
||||||
export async function requireHrUser(supabase: SupabaseClient<Database>): Promise<NextResponse | null> {
|
|
||||||
const {
|
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
if (!user) return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
|
||||||
|
|
||||||
const { data: profile } = await supabase.from("profiles").select("role, is_active").eq("id", user.id).maybeSingle();
|
|
||||||
if (profile?.role !== "hr" || profile.is_active !== true) {
|
|
||||||
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { createBrowserClient } from "@supabase/ssr";
|
|
||||||
import type { Database } from "./types";
|
|
||||||
|
|
||||||
// For use in Client Components that need interactivity (filters, live
|
|
||||||
// hints, etc). Server Components/Actions should use lib/supabase/server.ts.
|
|
||||||
// Only ever reads NEXT_PUBLIC_* vars — this file is bundled for the browser.
|
|
||||||
export function createClient() {
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
|
||||||
|
|
||||||
if (!supabaseUrl) {
|
|
||||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL");
|
|
||||||
}
|
|
||||||
if (!supabaseAnonKey) {
|
|
||||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_ANON_KEY");
|
|
||||||
}
|
|
||||||
|
|
||||||
return createBrowserClient<Database>(supabaseUrl, supabaseAnonKey);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
// PostgREST's .or() filter syntax treats "," "(" and ")" as structural
|
|
||||||
// delimiters between conditions. A raw user-supplied search term containing
|
|
||||||
// them (e.g. from a search box or ?q= param) can break out of the intended
|
|
||||||
// column conditions and append arbitrary extra filters to the query. Strip
|
|
||||||
// them before interpolating — harmless for real name/title searches, which
|
|
||||||
// never legitimately contain them.
|
|
||||||
export function sanitizeIlikeTerm(term: string): string {
|
|
||||||
return term.replace(/[,()]/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostgREST caps every response at db.max_rows (1000, see
|
|
||||||
// supabase/config.toml) and does so *silently* — a query over ~800 employees
|
|
||||||
// or the employee_history log just stops returning rows, and a report or
|
|
||||||
// export built from it is quietly wrong rather than failing. Anything that
|
|
||||||
// aggregates a whole table has to page explicitly; anything that renders a
|
|
||||||
// bounded list (an employee page, the audit log) uses .range() directly and
|
|
||||||
// does not need this.
|
|
||||||
const PAGE_SIZE = 1000;
|
|
||||||
|
|
||||||
type PagedQuery<Row> = {
|
|
||||||
range: (from: number, to: number) => PromiseLike<{ data: Row[] | null; error: unknown }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function fetchAllRows<Row>(buildQuery: () => PagedQuery<Row>): Promise<Row[]> {
|
|
||||||
const rows: Row[] = [];
|
|
||||||
for (let page = 0; ; page++) {
|
|
||||||
const { data, error } = await buildQuery().range(page * PAGE_SIZE, (page + 1) * PAGE_SIZE - 1);
|
|
||||||
if (error || !data) break;
|
|
||||||
rows.push(...data);
|
|
||||||
if (data.length < PAGE_SIZE) break;
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import "server-only";
|
|
||||||
import { createServerClient } from "@supabase/ssr";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import type { Database } from "./types";
|
|
||||||
|
|
||||||
// For use in Server Components and Server Actions. Respects the signed-in
|
|
||||||
// user's session, so all reads/writes go through RLS as that user. Uses only
|
|
||||||
// the anon key (never the service role key) — the user's own session cookie
|
|
||||||
// is what determines access, via RLS.
|
|
||||||
export async function createClient() {
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
|
||||||
|
|
||||||
if (!supabaseUrl) {
|
|
||||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL");
|
|
||||||
}
|
|
||||||
if (!supabaseAnonKey) {
|
|
||||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_ANON_KEY");
|
|
||||||
}
|
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
|
|
||||||
return createServerClient<Database>(supabaseUrl, supabaseAnonKey, {
|
|
||||||
cookies: {
|
|
||||||
getAll() {
|
|
||||||
return cookieStore.getAll();
|
|
||||||
},
|
|
||||||
setAll(cookiesToSet) {
|
|
||||||
try {
|
|
||||||
cookiesToSet.forEach(({ name, value, options }) =>
|
|
||||||
cookieStore.set(name, value, options)
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// Called from a Server Component during render — safe to ignore
|
|
||||||
// because proxy.ts refreshes the session cookie on every request.
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -12,6 +12,16 @@ export type GenderType = "m" | "w";
|
|||||||
export type WorkerType = "Angestellte:r" | "Arbeiter:in";
|
export type WorkerType = "Angestellte:r" | "Arbeiter:in";
|
||||||
export type CollectiveAgreement = "Handel" | "Süßwaren";
|
export type CollectiveAgreement = "Handel" | "Süßwaren";
|
||||||
export type Weekday = "Mo" | "Di" | "Mi" | "Do" | "Fr" | "Sa" | "So";
|
export type Weekday = "Mo" | "Di" | "Mi" | "Do" | "Fr" | "Sa" | "So";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine einzelne Feldänderung im Protokoll.
|
||||||
|
*
|
||||||
|
* `vorher` und `nachher` sind bewusst Text: die Datenbank stellt jeden Typ
|
||||||
|
* über ::text dar, damit ein Datum, eine Zahl und eine Liste von Arbeitstagen
|
||||||
|
* in derselben Spalte nebeneinander stehen können. Für die Anzeige reicht
|
||||||
|
* das; gerechnet wird damit nicht.
|
||||||
|
*/
|
||||||
|
export type AuditChange = { feld: string; vorher: string | null; nachher: string | null };
|
||||||
export type RelationshipType = "Ehepartner:in" | "Lebenspartner:in" | "Kind" | "Sonstige";
|
export type RelationshipType = "Ehepartner:in" | "Lebenspartner:in" | "Kind" | "Sonstige";
|
||||||
export type NoteCategory = "Allgemein" | "Vertraulich" | "Personalgespräch" | "Wiedervorlage" | "Lob / Anerkennung";
|
export type NoteCategory = "Allgemein" | "Vertraulich" | "Personalgespräch" | "Wiedervorlage" | "Lob / Anerkennung";
|
||||||
// Single HR-only role (see docs/decisions/0001-hr-only-access.md). Kept as a
|
// Single HR-only role (see docs/decisions/0001-hr-only-access.md). Kept as a
|
||||||
@@ -267,6 +277,13 @@ export type Database = {
|
|||||||
target_label: string;
|
target_label: string;
|
||||||
target_employee_id: string | null;
|
target_employee_id: string | null;
|
||||||
details: string | null;
|
details: string | null;
|
||||||
|
/**
|
||||||
|
* Feldweise Änderungen. Null bei Einträgen aus der Zeit vor
|
||||||
|
* 20260803140000_audit_changes_detail.sql — dort wurden nur die
|
||||||
|
* Feldnamen behalten, nicht die Werte, und das lässt sich nicht
|
||||||
|
* nachliefern.
|
||||||
|
*/
|
||||||
|
changes: AuditChange[] | null;
|
||||||
};
|
};
|
||||||
Insert: {
|
Insert: {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -277,6 +294,7 @@ export type Database = {
|
|||||||
target_label: string;
|
target_label: string;
|
||||||
target_employee_id?: string | null;
|
target_employee_id?: string | null;
|
||||||
details?: string | null;
|
details?: string | null;
|
||||||
|
changes?: AuditChange[] | null;
|
||||||
};
|
};
|
||||||
Update: Partial<Database["public"]["Tables"]["audit_log"]["Insert"]>;
|
Update: Partial<Database["public"]["Tables"]["audit_log"]["Insert"]>;
|
||||||
};
|
};
|
||||||
@@ -439,6 +457,7 @@ export type Database = {
|
|||||||
// Planstelle anlegen bzw. schliessen — im OM-Modell Operationen auf
|
// Planstelle anlegen bzw. schliessen — im OM-Modell Operationen auf
|
||||||
// om_positions, nicht mehr auf einer eigenen Ausschreibungstabelle.
|
// om_positions, nicht mehr auf einer eigenen Ausschreibungstabelle.
|
||||||
create_position: { Args: { payload: Record<string, unknown> }; Returns: string };
|
create_position: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||||
|
update_position: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||||
delete_position: { Args: { payload: Record<string, unknown> }; Returns: void };
|
delete_position: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||||
is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean };
|
is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean };
|
||||||
apply_due_pending_changes: { Args: Record<string, never>; Returns: number };
|
apply_due_pending_changes: { Args: Record<string, never>; Returns: number };
|
||||||
|
|||||||
@@ -2,25 +2,28 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
// Report-only rather than enforcing, deliberately: the policy is derived from
|
// Report-only rather than enforcing, deliberately: the policy is derived from
|
||||||
// what this app is known to load — its own bundle, the self-hosted Nunito
|
// what this app is known to load — its own bundle, the self-hosted Nunito
|
||||||
// files from next/font, and the Supabase project from
|
// files from next/font, and nothing else — but an unenforced policy that logs
|
||||||
// NEXT_PUBLIC_SUPABASE_URL — but an unenforced policy that logs violations is
|
// violations is worth more than a guessed one that blanks the app for every
|
||||||
// worth more than a guessed one that blanks the app for every HR user.
|
// HR user. Promote it to `Content-Security-Policy` once the reports come back
|
||||||
// Promote it to `Content-Security-Policy` once the reports come back clean.
|
// clean.
|
||||||
//
|
//
|
||||||
// 'unsafe-inline' on script-src is not laziness: Next.js inlines its
|
// 'unsafe-inline' on script-src is not laziness: Next.js inlines its
|
||||||
// bootstrap and hydration payload as inline <script> tags, so a nonce-based
|
// bootstrap and hydration payload as inline <script> tags, so a nonce-based
|
||||||
// policy means threading a per-request nonce through proxy.ts — a separate
|
// policy means threading a per-request nonce through proxy.ts — a separate
|
||||||
// change, and the reason this starts in report-only.
|
// change, and the reason this starts in report-only.
|
||||||
function contentSecurityPolicy(): string {
|
function contentSecurityPolicy(): string {
|
||||||
let supabaseOrigin = "";
|
// Die Anmeldung schickt ein Formular an /api/auth/signin/…, und von dort
|
||||||
|
// geht es per Weiterleitung zum Anmeldeserver. `form-action` gilt auch für
|
||||||
|
// die Weiterleitungen nach einem Formularversand — steht der Aussteller
|
||||||
|
// nicht darin, bricht der Browser die Anmeldung ab. Genau hier wäre die
|
||||||
|
// Nur-Bericht-Fassung später eine böse Überraschung.
|
||||||
|
let issuerOrigin = "";
|
||||||
try {
|
try {
|
||||||
supabaseOrigin = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL ?? "").origin;
|
issuerOrigin = new URL(process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER ?? "").origin;
|
||||||
} catch {
|
} catch {
|
||||||
supabaseOrigin = "";
|
issuerOrigin = "";
|
||||||
}
|
}
|
||||||
// Supabase Auth refreshes tokens over https and Realtime opens a websocket
|
const formAction = ["'self'", issuerOrigin].filter(Boolean).join(" ");
|
||||||
// against the same host.
|
|
||||||
const connect = ["'self'", supabaseOrigin, supabaseOrigin.replace(/^http/, "ws")].filter(Boolean).join(" ");
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
@@ -28,10 +31,13 @@ function contentSecurityPolicy(): string {
|
|||||||
"style-src 'self' 'unsafe-inline'",
|
"style-src 'self' 'unsafe-inline'",
|
||||||
"img-src 'self' data: blob:",
|
"img-src 'self' data: blob:",
|
||||||
"font-src 'self'",
|
"font-src 'self'",
|
||||||
`connect-src ${connect}`,
|
// Die Anwendung spricht im Browser mit niemandem ausser sich selbst: die
|
||||||
|
// Datenbank erreicht nur der Server, und seit die API-Schicht weg ist,
|
||||||
|
// gibt es keine Gegenstelle mehr, die von aussen angesprochen würde.
|
||||||
|
"connect-src 'self'",
|
||||||
"frame-ancestors 'self'",
|
"frame-ancestors 'self'",
|
||||||
"base-uri 'self'",
|
"base-uri 'self'",
|
||||||
"form-action 'self'",
|
`form-action ${formAction}`,
|
||||||
"object-src 'none'",
|
"object-src 'none'",
|
||||||
].join("; ");
|
].join("; ");
|
||||||
}
|
}
|
||||||
|
|||||||
265
package-lock.json
generated
265
package-lock.json
generated
@@ -13,8 +13,11 @@
|
|||||||
"@supabase/supabase-js": "^2.110.8",
|
"@supabase/supabase-js": "^2.110.8",
|
||||||
"@xyflow/react": "^12.11.2",
|
"@xyflow/react": "^12.11.2",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
|
"kysely": "^0.29.4",
|
||||||
"lucide-react": "^1.26.0",
|
"lucide-react": "^1.26.0",
|
||||||
"next": "^16.2.11",
|
"next": "^16.2.11",
|
||||||
|
"next-auth": "^5.0.0-beta.32",
|
||||||
|
"pg": "^8.22.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"server-only": "^0.0.1"
|
"server-only": "^0.0.1"
|
||||||
@@ -25,6 +28,7 @@
|
|||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^24.13.3",
|
"@types/node": "^24.13.3",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitest/coverage-v8": "^4.1.10",
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
@@ -112,6 +116,35 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@auth/core": {
|
||||||
|
"version": "0.41.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.3.tgz",
|
||||||
|
"integrity": "sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@panva/hkdf": "^1.2.1",
|
||||||
|
"jose": "^6.0.6",
|
||||||
|
"oauth4webapi": "^3.3.0",
|
||||||
|
"preact": "10.24.3",
|
||||||
|
"preact-render-to-string": "6.5.11"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@simplewebauthn/browser": "^9.0.1",
|
||||||
|
"@simplewebauthn/server": "^9.0.2",
|
||||||
|
"nodemailer": "^7.0.7 || ^8.0.5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@simplewebauthn/browser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@simplewebauthn/server": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"nodemailer": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
@@ -1771,6 +1804,15 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@panva/hkdf": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||||
@@ -2834,6 +2876,18 @@
|
|||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pg": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"pg-protocol": "*",
|
||||||
|
"pg-types": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.17",
|
"version": "19.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||||
@@ -6895,7 +6949,6 @@
|
|||||||
"version": "6.2.3",
|
"version": "6.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||||
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
@@ -7116,6 +7169,15 @@
|
|||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kysely": {
|
||||||
|
"version": "0.29.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.4.tgz",
|
||||||
|
"integrity": "sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/language-subtag-registry": {
|
"node_modules/language-subtag-registry": {
|
||||||
"version": "0.3.23",
|
"version": "0.3.23",
|
||||||
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
|
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
|
||||||
@@ -7871,6 +7933,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-auth": {
|
||||||
|
"version": "5.0.0-beta.32",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.32.tgz",
|
||||||
|
"integrity": "sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@auth/core": "0.41.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@simplewebauthn/browser": "^9.0.1",
|
||||||
|
"@simplewebauthn/server": "^9.0.2",
|
||||||
|
"next": "^14.0.0-0 || ^15.0.0 || ^16.0.0",
|
||||||
|
"nodemailer": "^7.0.7 || ^8.0.5",
|
||||||
|
"react": "^18.2.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@simplewebauthn/browser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@simplewebauthn/server": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"nodemailer": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-exports-info": {
|
"node_modules/node-exports-info": {
|
||||||
"version": "1.6.2",
|
"version": "1.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz",
|
||||||
@@ -7909,6 +7998,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/oauth4webapi": {
|
||||||
|
"version": "3.8.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
|
||||||
|
"integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -8198,6 +8296,95 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||||
|
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.14.0",
|
||||||
|
"pg-pool": "^3.14.0",
|
||||||
|
"pg-protocol": "^1.15.0",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||||
|
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||||
|
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -8255,6 +8442,64 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/preact": {
|
||||||
|
"version": "10.24.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
||||||
|
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/preact-render-to-string": {
|
||||||
|
"version": "6.5.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz",
|
||||||
|
"integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"preact": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -8961,6 +9206,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stable-hash": {
|
"node_modules/stable-hash": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||||
@@ -10188,6 +10442,15 @@
|
|||||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/xtend": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -24,8 +24,11 @@
|
|||||||
"@supabase/supabase-js": "^2.110.8",
|
"@supabase/supabase-js": "^2.110.8",
|
||||||
"@xyflow/react": "^12.11.2",
|
"@xyflow/react": "^12.11.2",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
|
"kysely": "^0.29.4",
|
||||||
"lucide-react": "^1.26.0",
|
"lucide-react": "^1.26.0",
|
||||||
"next": "^16.2.11",
|
"next": "^16.2.11",
|
||||||
|
"next-auth": "^5.0.0-beta.32",
|
||||||
|
"pg": "^8.22.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"server-only": "^0.0.1"
|
"server-only": "^0.0.1"
|
||||||
@@ -36,6 +39,7 @@
|
|||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^24.13.3",
|
"@types/node": "^24.13.3",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitest/coverage-v8": "^4.1.10",
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
|
|||||||
116
proxy.ts
116
proxy.ts
@@ -1,73 +1,81 @@
|
|||||||
import { createServerClient } from "@supabase/ssr";
|
import NextAuth from "next-auth";
|
||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { authConfig } from "@/lib/auth/config";
|
||||||
|
|
||||||
// Next.js 16 renamed Middleware to Proxy (same mechanism, new filename/export).
|
// Next.js 16 renamed Middleware to Proxy (same mechanism, new filename and
|
||||||
// This is the app's single entry-point gate (spec
|
// export). Das hier ist die vorderste Tür: wer keine Sitzung hat, landet auf
|
||||||
// §2.2): unauthenticated users are sent to /login, and — this is the part
|
// /login statt in der Anwendung.
|
||||||
// that used to be missing — authenticated users who are NOT an active,
|
//
|
||||||
// explicitly-provisioned HR user are sent to /login too, with an error
|
// Gebaut wird dafür eine *zweite*, absichtlich unvollständige Auth.js-Instanz
|
||||||
// message, instead of being let through. Previously this only checked for
|
// — nur aus lib/auth/config.ts, ohne die Rückrufe aus auth.ts. Der Grund ist
|
||||||
// a Supabase Auth session at all, which meant any signed-in user (even one
|
// handfest: auth.ts spricht mit der Datenbank, und der Proxy läuft je nach
|
||||||
// with no profile row, or the old "manager" role) could open the app.
|
// Betriebsart in einer Umgebung ohne Node-Module. Die Instanz hier liest das
|
||||||
// This UI-layer gate is defense in depth, not the real boundary — every
|
// Sitzungscookie und sonst nichts.
|
||||||
// table is independently RLS-gated on is_hr_user() regardless of what this
|
//
|
||||||
// proxy does.
|
// Deshalb prüft der Proxy auch nur, *ob* jemand angemeldet ist — nicht mehr,
|
||||||
export async function proxy(request: NextRequest) {
|
// ob die Person HR-Rechte hat. Das ist keine Lücke, sondern eine Verschiebung
|
||||||
let response = NextResponse.next({ request });
|
// an die Stellen, die es wahrheitsgemäss beantworten können:
|
||||||
|
//
|
||||||
|
// • app/(app)/layout.tsx fragt profiles bei jedem Aufbau frisch ab und
|
||||||
|
// leitet auf /login?error=no_hr_access um,
|
||||||
|
// • die Route Handler unter /api/export/* tun dasselbe über requireHrUser(),
|
||||||
|
// • und darunter, unabhängig von allem Anwendungscode, entscheiden die
|
||||||
|
// RLS-Policies über is_hr_user().
|
||||||
|
//
|
||||||
|
// Die Alternative — role und is_active ins Sitzungstoken schreiben — hätte
|
||||||
|
// den Proxy schneller gemacht und dafür eine Behauptung eingefroren: eine
|
||||||
|
// entzogene Freischaltung wirkte erst mit dem nächsten Token. Bei einer
|
||||||
|
// Personalanwendung ist das die falsche Richtung.
|
||||||
|
// Als Funktion übergeben, nicht als Objekt: siehe authConfig().
|
||||||
|
const { auth } = NextAuth(() => authConfig());
|
||||||
|
|
||||||
const supabase = createServerClient(
|
const gate = auth((request) => {
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
const { pathname } = request.nextUrl;
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
||||||
{
|
|
||||||
cookies: {
|
|
||||||
getAll() {
|
|
||||||
return request.cookies.getAll();
|
|
||||||
},
|
|
||||||
setAll(cookiesToSet) {
|
|
||||||
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
|
|
||||||
response = NextResponse.next({ request });
|
|
||||||
cookiesToSet.forEach(({ name, value, options }) => response.cookies.set(name, value, options));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
// Auth.js' eigene Endpunkte müssen durch, bevor es eine Sitzung gibt —
|
||||||
data: { user },
|
// dort entsteht sie ja erst. Ohne diese Ausnahme leitet der Proxy den
|
||||||
} = await supabase.auth.getUser();
|
// Rückweg aus Entra nach /login um und die Anmeldung kommt nie zustande.
|
||||||
|
if (pathname.startsWith("/api/auth")) return NextResponse.next();
|
||||||
|
|
||||||
const isLoginRoute = request.nextUrl.pathname.startsWith("/login");
|
const isLoginRoute = pathname.startsWith("/login");
|
||||||
|
const isSignedIn = Boolean(request.auth?.user?.id);
|
||||||
|
|
||||||
// Der Rückweg aus Entra muss durch, bevor es eine Sitzung gibt — dort wird
|
if (!isSignedIn) {
|
||||||
// sie ja erst hergestellt. Ohne diese Ausnahme leitet der Gate den Code
|
if (isLoginRoute) return NextResponse.next();
|
||||||
// nach /login um und die Anmeldung kommt nie zustande.
|
|
||||||
if (request.nextUrl.pathname.startsWith("/auth/callback")) return response;
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
if (isLoginRoute) return response;
|
|
||||||
const url = request.nextUrl.clone();
|
const url = request.nextUrl.clone();
|
||||||
url.pathname = "/login";
|
url.pathname = "/login";
|
||||||
return NextResponse.redirect(url);
|
url.search = "";
|
||||||
}
|
|
||||||
|
|
||||||
const { data: profile } = await supabase.from("profiles").select("role, is_active").eq("id", user.id).maybeSingle();
|
|
||||||
const isActiveHr = profile?.role === "hr" && profile?.is_active === true;
|
|
||||||
|
|
||||||
if (!isActiveHr) {
|
|
||||||
if (isLoginRoute) return response;
|
|
||||||
const url = request.nextUrl.clone();
|
|
||||||
url.pathname = "/login";
|
|
||||||
url.searchParams.set("error", "no_hr_access");
|
|
||||||
return NextResponse.redirect(url);
|
return NextResponse.redirect(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoginRoute) {
|
if (isLoginRoute) {
|
||||||
|
// Angemeldet und trotzdem auf /login: nur weiterschicken, wenn keine
|
||||||
|
// Meldung ansteht. Sonst geriete jemand ohne HR-Freischaltung in eine
|
||||||
|
// Schleife — das Layout leitet nach /login?error=no_hr_access, der Proxy
|
||||||
|
// zurück auf /, das Layout wieder … und der Grund wäre nie zu lesen.
|
||||||
|
if (request.nextUrl.searchParams.has("error")) return NextResponse.next();
|
||||||
const url = request.nextUrl.clone();
|
const url = request.nextUrl.clone();
|
||||||
url.pathname = "/";
|
url.pathname = "/";
|
||||||
|
url.search = "";
|
||||||
return NextResponse.redirect(url);
|
return NextResponse.redirect(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return NextResponse.next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zwei Eigenheiten auf einmal, beide erst beim Ausprobieren aufgefallen:
|
||||||
|
//
|
||||||
|
// 1. Next.js sucht hier eine *Funktionsdeklaration* namens `proxy` (oder
|
||||||
|
// einen Default-Export) und erkennt `export const proxy = auth(…)` nicht.
|
||||||
|
// Jede Anfrage lief in einen 404 — und `next build` meldete Erfolg und
|
||||||
|
// listete den Proxy sogar auf.
|
||||||
|
//
|
||||||
|
// 2. In der Funktionsform liefert `auth(handler)` den Handler erst als
|
||||||
|
// Zusage. Ohne `await` steht hier ein Promise, und der Aufruf scheitert
|
||||||
|
// mit „gate is not a function". Auf einem gewöhnlichen Funktionswert ist
|
||||||
|
// `await` wirkungslos, das `await` ist also in beiden Fällen richtig.
|
||||||
|
export async function proxy(...args: Parameters<Awaited<typeof gate>>) {
|
||||||
|
return (await gate)(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
// Zeigt, was Entra ID beim Anmelden tatsächlich mitgeschickt hat.
|
|
||||||
//
|
|
||||||
// Run with: node --env-file=.env.local supabase/entra-claims.ts <e-mail>
|
|
||||||
//
|
|
||||||
// Die Freischaltung über eine Entra-Gruppe hängt daran, wie der Anspruch im
|
|
||||||
// Token heisst und wie er aussieht — das unterscheidet sich je nachdem, ob im
|
|
||||||
// Mandanten „Sicherheitsgruppen" oder „der Anwendung zugewiesene Gruppen"
|
|
||||||
// eingestellt ist. Diese Ausgabe ist die Grundlage für den Trigger; ohne sie
|
|
||||||
// wäre er geraten.
|
|
||||||
|
|
||||||
import { createClient } from "@supabase/supabase-js";
|
|
||||||
|
|
||||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
||||||
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) {
|
|
||||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in the environment");
|
|
||||||
}
|
|
||||||
|
|
||||||
const email = process.argv[2];
|
|
||||||
if (!email) {
|
|
||||||
console.error("Aufruf: node --env-file=.env.local supabase/entra-claims.ts <e-mail>");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
|
|
||||||
auth: { autoRefreshToken: false, persistSession: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data, error } = await supabase.auth.admin.listUsers({ perPage: 1000 });
|
|
||||||
if (error) throw new Error(error.message);
|
|
||||||
|
|
||||||
const matches = data.users.filter((u) => u.email?.toLowerCase() === email.toLowerCase());
|
|
||||||
if (matches.length === 0) {
|
|
||||||
console.error(`Kein Konto zu ${email}. Vorhanden:`);
|
|
||||||
for (const u of data.users) console.error(` ${u.email}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mehrere Treffer sind der Normalfall in der Umstellungsphase: das alte Konto
|
|
||||||
// mit Passwort und das neue über Entra sind für Supabase zwei Benutzer.
|
|
||||||
for (const user of matches) {
|
|
||||||
console.log(`\n── ${user.email} ──`);
|
|
||||||
console.log(` id: ${user.id}`);
|
|
||||||
console.log(` erstellt: ${user.created_at}`);
|
|
||||||
console.log(` Anbieter: ${user.identities?.map((i) => i.provider).join(", ") || "keiner"}`);
|
|
||||||
|
|
||||||
for (const identity of user.identities ?? []) {
|
|
||||||
console.log(`\n identity_data (${identity.provider}) — von GoTrue aus der Antwort des Anbieters:`);
|
|
||||||
console.log(
|
|
||||||
Object.entries(identity.identity_data ?? {})
|
|
||||||
.map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
|
|
||||||
.join("\n") || " (leer)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zum Vergleich, und als Warnung: hierher schreibt auch updateUser(), also
|
|
||||||
// die angemeldete Person selbst. Als Grundlage für eine Freischaltung ist
|
|
||||||
// das unbrauchbar.
|
|
||||||
console.log("\n raw_user_meta_data — auch von der Person selbst beschreibbar, NICHT als Quelle verwenden:");
|
|
||||||
console.log(
|
|
||||||
Object.entries(user.user_metadata ?? {})
|
|
||||||
.map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
|
|
||||||
.join("\n") || " (leer)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: profiles } = await supabase.from("profiles").select("id, email, role, is_active").eq("email", email);
|
|
||||||
console.log(`\n── profiles zu ${email} ──`);
|
|
||||||
for (const p of profiles ?? []) console.log(` ${p.id} role=${p.role} is_active=${p.is_active}`);
|
|
||||||
if (!profiles?.length) console.log(" (keine Zeile — damit besteht kein Zugriff)");
|
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
-- Schritt 1 auf dem Weg weg von Supabase: eigene Benutzertabelle und ein
|
||||||
|
-- eigener Sitzungskontext.
|
||||||
|
--
|
||||||
|
-- Ziel ist ein Schema, das auf jedem PostgreSQL ab 15 läuft — Azure Flexible
|
||||||
|
-- Server, RDS, Cloud SQL, eigenes Blech. Heute hängt genau eine Sache an
|
||||||
|
-- Supabase: `auth.uid()`, die Kennung der angemeldeten Person. Sie steckt in
|
||||||
|
-- 72 Zeilen SQL, aber für die Absicherung zählt nur eine Stelle —
|
||||||
|
-- is_hr_user(), das alle 58 RLS-Policies aufrufen.
|
||||||
|
--
|
||||||
|
-- Diese Migration ist bewusst **additiv und beidseitig lauffähig**: die
|
||||||
|
-- Anwendung läuft danach unverändert auf Supabase weiter, während die neue
|
||||||
|
-- Zugriffsschicht daneben entsteht. Ein Umbau, der beide Enden gleichzeitig
|
||||||
|
-- bewegt, lässt sich nicht testen.
|
||||||
|
|
||||||
|
-- ═══ 1. Sitzungskontext ══════════════════════════════════════════
|
||||||
|
-- Wer gerade angemeldet ist, kommt künftig aus einer Sitzungsvariablen, die
|
||||||
|
-- die Zugriffsschicht **transaktionslokal** setzt (siehe lib/db).
|
||||||
|
--
|
||||||
|
-- Warum plpgsql und nicht `language sql`: eine SQL-Funktion wird beim Anlegen
|
||||||
|
-- geparst, und `auth.uid()` existiert auf einem gewöhnlichen PostgreSQL
|
||||||
|
-- nicht — die Funktion liesse sich dort gar nicht erst erzeugen. plpgsql löst
|
||||||
|
-- den Aufruf erst zur Laufzeit auf, und der Ausnahmeblock fängt die fehlende
|
||||||
|
-- Funktion ab. Genau das macht diese Migration auf beiden Systemen anwendbar.
|
||||||
|
create or replace function app_current_user_id()
|
||||||
|
returns uuid
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public, pg_temp
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_id uuid;
|
||||||
|
begin
|
||||||
|
-- Vorrang hat der eigene Kontext. `true` als zweites Argument heisst:
|
||||||
|
-- fehlt die Variable, kommt null statt eines Fehlers.
|
||||||
|
v_id := nullif(current_setting('app.user_id', true), '')::uuid;
|
||||||
|
if v_id is not null then
|
||||||
|
return v_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Übergangsweise: solange die Anmeldung noch über GoTrue läuft. Fällt in
|
||||||
|
-- der Abschlussmigration weg, zusammen mit den Fremdschlüsseln auf
|
||||||
|
-- auth.users.
|
||||||
|
begin
|
||||||
|
execute 'select auth.uid()' into v_id;
|
||||||
|
exception
|
||||||
|
when undefined_function or invalid_schema_name or undefined_table then
|
||||||
|
v_id := null;
|
||||||
|
end;
|
||||||
|
return v_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
comment on function app_current_user_id() is
|
||||||
|
'Kennung der angemeldeten Person: erst app.user_id aus der Sitzung, ersatzweise auth.uid(). Der zweite Zweig ist Übergang.';
|
||||||
|
|
||||||
|
-- Zugeteilt wird nur an Rollen, die es auch gibt. Auf einem gewöhnlichen
|
||||||
|
-- PostgreSQL existieren anon/authenticated/service_role nicht, und ein
|
||||||
|
-- `grant` auf eine unbekannte Rolle bricht die Migration ab — dieselbe Datei
|
||||||
|
-- liefe dort also nicht. Genau das soll sie aber.
|
||||||
|
do $$
|
||||||
|
declare r text;
|
||||||
|
begin
|
||||||
|
foreach r in array array['anon', 'authenticated', 'service_role'] loop
|
||||||
|
if exists (select 1 from pg_roles where rolname = r) then
|
||||||
|
execute format('grant execute on function app_current_user_id() to %I', r);
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ═══ 2. Benutzertabelle ══════════════════════════════════════════
|
||||||
|
-- Tritt an die Stelle von auth.users. Die neun Fremdschlüssel, die heute
|
||||||
|
-- dorthin zeigen, wandern in der Abschlussmigration hierher.
|
||||||
|
create table if not exists app_users (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
-- Die `oid` aus dem Entra-Token. Unveränderlich, anders als die E-Mail:
|
||||||
|
-- eine Namensänderung darf nicht zu einem neuen Konto führen.
|
||||||
|
external_id text not null unique,
|
||||||
|
email text not null,
|
||||||
|
full_name text,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
last_seen_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
comment on table app_users is
|
||||||
|
'Ersetzt auth.users. external_id ist die oid des Identitätsanbieters, nicht die E-Mail.';
|
||||||
|
|
||||||
|
create index if not exists app_users_email_idx on app_users (lower(email));
|
||||||
|
|
||||||
|
alter table app_users enable row level security;
|
||||||
|
|
||||||
|
-- Sich selbst sehen darf jede:r Angemeldete; alles andere ist HR-Sache.
|
||||||
|
-- Ohne diese Policy käme die Anmeldung nicht an die eigene Zeile.
|
||||||
|
drop policy if exists "app_users_select_own" on app_users;
|
||||||
|
create policy "app_users_select_own" on app_users
|
||||||
|
for select using (id = app_current_user_id() or is_hr_user());
|
||||||
|
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if exists (select 1 from pg_roles where rolname = 'anon') then
|
||||||
|
execute 'grant select on table app_users to anon';
|
||||||
|
end if;
|
||||||
|
if exists (select 1 from pg_roles where rolname = 'authenticated') then
|
||||||
|
execute 'grant select on table app_users to authenticated';
|
||||||
|
end if;
|
||||||
|
if exists (select 1 from pg_roles where rolname = 'service_role') then
|
||||||
|
execute 'grant all on table app_users to service_role';
|
||||||
|
end if;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ═══ 3. Die eine Brücke umlegen ══════════════════════════════════
|
||||||
|
-- Ab hier fragt die Absicherung nicht mehr Supabase, sondern den eigenen
|
||||||
|
-- Kontext. Die 58 Policies bleiben Wort für Wort unverändert — sie rufen
|
||||||
|
-- weiterhin is_hr_user() auf und merken davon nichts.
|
||||||
|
create or replace function is_hr_user()
|
||||||
|
returns boolean
|
||||||
|
language sql
|
||||||
|
security definer
|
||||||
|
set search_path = public, pg_temp
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
select exists (
|
||||||
|
select 1 from profiles p
|
||||||
|
where p.id = app_current_user_id() and p.role = 'hr' and p.is_active = true
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function current_hr_user_id()
|
||||||
|
returns uuid
|
||||||
|
language sql
|
||||||
|
security definer
|
||||||
|
set search_path = public, pg_temp
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
select p.id from profiles p
|
||||||
|
where p.id = app_current_user_id() and p.role = 'hr' and p.is_active = true;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function current_actor_name()
|
||||||
|
returns text
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
set search_path = public, pg_temp
|
||||||
|
as $$
|
||||||
|
select coalesce(p.full_name, p.email, 'Unbekannt')
|
||||||
|
from profiles p where p.id = app_current_user_id();
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ═══ 4. Die fünf Policies mit direktem auth.uid() ════════════════
|
||||||
|
-- Die übrigen 53 laufen über is_hr_user() und brauchen nichts.
|
||||||
|
drop policy if exists "profiles_select_own" on profiles;
|
||||||
|
create policy "profiles_select_own" on profiles
|
||||||
|
for select using (app_current_user_id() = id);
|
||||||
|
|
||||||
|
-- Die Namen stammen aus 20260714120000_hr_only_access.sql: „_owner", nicht
|
||||||
|
-- „_own". Mit dem falschen Namen bricht die Migration bei create policy ab.
|
||||||
|
drop policy if exists "hire_drafts_owner" on hire_drafts;
|
||||||
|
create policy "hire_drafts_owner" on hire_drafts
|
||||||
|
for all
|
||||||
|
using (created_by = app_current_user_id() and is_hr_user())
|
||||||
|
with check (created_by = app_current_user_id() and is_hr_user());
|
||||||
|
|
||||||
|
drop policy if exists "saved_reports_owner" on saved_reports;
|
||||||
|
create policy "saved_reports_owner" on saved_reports
|
||||||
|
for all
|
||||||
|
using (created_by = app_current_user_id() and is_hr_user())
|
||||||
|
with check (created_by = app_current_user_id() and is_hr_user());
|
||||||
|
|
||||||
|
-- ═══ 5. Gegenprobe ═══════════════════════════════════════════════
|
||||||
|
-- Ohne Kontext und ohne Anmeldung darf is_hr_user() nicht wahr sein. Das
|
||||||
|
-- klingt selbstverständlich und ist genau der Fehler, der eine ganze
|
||||||
|
-- Datenbank öffnet.
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
perform set_config('app.user_id', '', true);
|
||||||
|
if is_hr_user() then
|
||||||
|
raise exception 'is_hr_user() liefert ohne Sitzungskontext true — Abbruch.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
perform set_config('app.user_id', gen_random_uuid()::text, true);
|
||||||
|
if is_hr_user() then
|
||||||
|
raise exception 'is_hr_user() liefert für eine unbekannte Kennung true — Abbruch.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Aufräumen: die Einstellung gilt bis zum Ende dieser Transaktion, und
|
||||||
|
-- was danach in derselben Sitzung läuft, soll sie nicht erben.
|
||||||
|
perform set_config('app.user_id', '', true);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
117
supabase/migrations/20260731090000_app_upsert_user.sql
Normal file
117
supabase/migrations/20260731090000_app_upsert_user.sql
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
-- Schritt 2: die Anmeldung braucht einen Weg, ihre Zeile in app_users
|
||||||
|
-- anzulegen — bevor es einen Sitzungskontext gibt.
|
||||||
|
--
|
||||||
|
-- Das ist das Henne-Ei-Problem jeder eigenen Anmeldung: app.user_id kann erst
|
||||||
|
-- gesetzt werden, wenn die Kennung feststeht, und die entsteht genau hier.
|
||||||
|
-- Bisher löste ein Dienstschlüssel mit BYPASSRLS solche Fälle. Den gibt es
|
||||||
|
-- nicht mehr, und er soll auch nicht zurückkommen — eine Verbindung, die
|
||||||
|
-- alles darf, ist für einen einzigen Schreibvorgang ein zu grosser Hebel.
|
||||||
|
--
|
||||||
|
-- Stattdessen: eine SECURITY-DEFINER-Funktion mit genau einer Befugnis.
|
||||||
|
-- Sie schreibt in app_users und liest lesend in profiles — sonst nichts. Wer
|
||||||
|
-- sie aufruft, bekommt eine UUID zurück und sonst keine Auskunft.
|
||||||
|
create or replace function app_upsert_user(
|
||||||
|
p_external_id text,
|
||||||
|
p_email text,
|
||||||
|
p_full_name text
|
||||||
|
)
|
||||||
|
returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public, pg_temp
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_id uuid;
|
||||||
|
begin
|
||||||
|
if p_external_id is null or btrim(p_external_id) = '' then
|
||||||
|
raise exception 'Externe Kennung fehlt.';
|
||||||
|
end if;
|
||||||
|
if p_email is null or btrim(p_email) = '' then
|
||||||
|
raise exception 'E-Mail-Adresse fehlt.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Bekanntes Konto: nur nachziehen, was sich beim Anbieter geändert haben
|
||||||
|
-- kann. Die Kennung bleibt, auch wenn Name oder Adresse wechseln — daran
|
||||||
|
-- hängen Notizen, Entwürfe und Protokolleinträge.
|
||||||
|
update app_users
|
||||||
|
set email = p_email,
|
||||||
|
full_name = coalesce(p_full_name, full_name),
|
||||||
|
last_seen_at = now()
|
||||||
|
where external_id = p_external_id
|
||||||
|
returning id into v_id;
|
||||||
|
|
||||||
|
if v_id is not null then
|
||||||
|
return v_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Erstanmeldung. Gibt es zu dieser Adresse bereits ein Profil, wird dessen
|
||||||
|
-- Kennung übernommen statt einer neuen: profiles.id ist heute die
|
||||||
|
-- auth.users.id, und neun Fremdschlüssel zeigen darauf. Eine frisch
|
||||||
|
-- vergebene UUID würde die Person von ihrer eigenen Vorgeschichte trennen
|
||||||
|
-- — sie wäre angemeldet, hätte aber weder Rolle noch Freischaltung.
|
||||||
|
--
|
||||||
|
-- Der Abgleich über die Adresse ist hier vertretbar und sonst nirgends:
|
||||||
|
-- die Adresse kommt aus einem von Entra ausgestellten Token, nicht aus
|
||||||
|
-- einem Formular. Wer sie behauptet, hat sie bereits bewiesen.
|
||||||
|
select p.id into v_id
|
||||||
|
from profiles p
|
||||||
|
where lower(p.email) = lower(p_email)
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
v_id := coalesce(v_id, gen_random_uuid());
|
||||||
|
|
||||||
|
begin
|
||||||
|
insert into app_users (id, external_id, email, full_name, last_seen_at)
|
||||||
|
values (v_id, p_external_id, p_email, p_full_name, now());
|
||||||
|
exception
|
||||||
|
when unique_violation then
|
||||||
|
-- Zwei gleichzeitige Erstanmeldungen desselben Kontos. Die zweite
|
||||||
|
-- findet die Zeile, die die erste gerade angelegt hat.
|
||||||
|
select id into v_id from app_users where external_id = p_external_id;
|
||||||
|
if v_id is null then
|
||||||
|
raise;
|
||||||
|
end if;
|
||||||
|
end;
|
||||||
|
|
||||||
|
return v_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
comment on function app_upsert_user(text, text, text) is
|
||||||
|
'Legt die app_users-Zeile zur Erstanmeldung an und liefert die Kennung. Übernimmt bei bekannter E-Mail die vorhandene profiles.id.';
|
||||||
|
|
||||||
|
-- app_users trägt RLS und hat bewusst keine Schreib-Policy: die Anwendung
|
||||||
|
-- kommt an die Tabelle nur durch diese Funktion. Ein Fehler im Anwendungscode
|
||||||
|
-- kann dort also nichts anlegen, ändern oder löschen.
|
||||||
|
do $$
|
||||||
|
declare r text;
|
||||||
|
begin
|
||||||
|
foreach r in array array['anon', 'authenticated', 'service_role'] loop
|
||||||
|
if exists (select 1 from pg_roles where rolname = r) then
|
||||||
|
execute format('grant execute on function app_upsert_user(text, text, text) to %I', r);
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ═══ Gegenprobe ══════════════════════════════════════════════════
|
||||||
|
-- Zweimal dieselbe externe Kennung muss dieselbe UUID ergeben. Wäre es nicht
|
||||||
|
-- so, bekäme jede Anmeldung ein neues Konto und niemand behielte seine
|
||||||
|
-- Rolle — ein Fehler, der sich erst Wochen später als „meine Notizen sind
|
||||||
|
-- weg" zeigt.
|
||||||
|
do $$
|
||||||
|
declare
|
||||||
|
v_first uuid;
|
||||||
|
v_second uuid;
|
||||||
|
v_probe text := 'probe-' || gen_random_uuid()::text;
|
||||||
|
begin
|
||||||
|
v_first := app_upsert_user(v_probe, v_probe || '@example.invalid', 'Probe');
|
||||||
|
v_second := app_upsert_user(v_probe, v_probe || '@example.invalid', 'Probe');
|
||||||
|
|
||||||
|
if v_first is distinct from v_second then
|
||||||
|
raise exception 'app_upsert_user() vergibt bei zweiter Anmeldung eine neue Kennung — Abbruch.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
delete from app_users where id = v_first;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
42
supabase/migrations/20260803120000_import_sequence_grant.sql
Normal file
42
supabase/migrations/20260803120000_import_sequence_grant.sql
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
-- Die Anwendungsrolle muss die Personalnummern-Sequenz fortschreiben dürfen.
|
||||||
|
--
|
||||||
|
-- Hintergrund: employees.personnel_number ist GENERATED ALWAYS AS IDENTITY.
|
||||||
|
-- Der Massenimport übernimmt die Nummern aus der Quelldatei — sie stehen auf
|
||||||
|
-- Lohnzetteln, in Akten und auf Ausweisen, ein Import darf sie nicht neu
|
||||||
|
-- vergeben — und schreibt danach den Zähler auf das neue Maximum.
|
||||||
|
--
|
||||||
|
-- Ohne diesen Schritt vergäbe die Datenbank bei der nächsten Neueinstellung
|
||||||
|
-- eine Nummer, die der Import bereits verbraucht hat. Der eindeutige Index
|
||||||
|
-- weist sie ab, und zwar erst Wochen später beim ersten Eintritt nach der
|
||||||
|
-- Übernahme — weit weg von der Ursache.
|
||||||
|
--
|
||||||
|
-- `setval()` verlangt UPDATE auf der Sequenz. `usage, select` reicht nicht;
|
||||||
|
-- genau daran ist der erste Durchstich gescheitert.
|
||||||
|
do $$
|
||||||
|
declare
|
||||||
|
v_sequenz text := pg_get_serial_sequence('public.employees', 'personnel_number');
|
||||||
|
r text;
|
||||||
|
begin
|
||||||
|
if v_sequenz is null then
|
||||||
|
raise exception 'Sequenz zu employees.personnel_number nicht gefunden.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Nur an Rollen, die es gibt: dieselbe Datei soll auf einem gewöhnlichen
|
||||||
|
-- PostgreSQL ohne die Supabase-Rollen laufen.
|
||||||
|
foreach r in array array['alpenwerk_app', 'authenticated', 'service_role'] loop
|
||||||
|
if exists (select 1 from pg_roles where rolname = r) then
|
||||||
|
execute format('grant usage, select, update on sequence %s to %I', v_sequenz, r);
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Damit künftige Sequenzen in diesem Schema dieselben Rechte bekommen und
|
||||||
|
-- der nächste Import nicht an derselben Stelle stehenbleibt.
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if exists (select 1 from pg_roles where rolname = 'alpenwerk_app') then
|
||||||
|
execute 'alter default privileges in schema public grant usage, select, update on sequences to alpenwerk_app';
|
||||||
|
end if;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
193
supabase/migrations/20260803140000_audit_changes_detail.sql
Normal file
193
supabase/migrations/20260803140000_audit_changes_detail.sql
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
-- Das Protokoll soll sagen, *was* sich geändert hat, nicht nur *welches Feld*.
|
||||||
|
--
|
||||||
|
-- Bisher stand im Audit-Log „Adresse, wirksam ab 30.07.2026". Damit lässt
|
||||||
|
-- sich nicht nachvollziehen, was vorher dort stand — und genau das ist die
|
||||||
|
-- Frage, die man einem Personalprotokoll stellt.
|
||||||
|
--
|
||||||
|
-- Die Werte sind im Moment der Änderung beide vorhanden: v_old trägt den
|
||||||
|
-- alten Datensatz, die Nutzlast den neuen. Sie wurden nur nicht behalten.
|
||||||
|
--
|
||||||
|
-- **Rückwirkend geht das nicht.** Für die bestehenden Einträge wurde
|
||||||
|
-- Vorher/Nachher nie erfasst; sie bleiben, wie sie sind.
|
||||||
|
|
||||||
|
alter table audit_log add column if not exists changes jsonb;
|
||||||
|
|
||||||
|
comment on column audit_log.changes is
|
||||||
|
'Feldweise Änderungen als [{feld, vorher, nachher}]. Null bei Einträgen von vor dieser Migration.';
|
||||||
|
|
||||||
|
-- ═══ Hilfsfunktion ═══════════════════════════════════════════════
|
||||||
|
-- Hängt eine Änderung an, wenn sich der Wert wirklich unterscheidet.
|
||||||
|
--
|
||||||
|
-- Verglichen wird über coalesce auf Leerstring: null und '' sind in diesem
|
||||||
|
-- Schema beide „nicht gesetzt", und ein Wechsel zwischen beiden ist keine
|
||||||
|
-- Änderung, die jemanden interessiert.
|
||||||
|
create or replace function app_aenderung(p_liste jsonb, p_feld text, p_vorher text, p_nachher text)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
immutable
|
||||||
|
set search_path = public, pg_temp
|
||||||
|
as $$
|
||||||
|
select case
|
||||||
|
when coalesce(p_vorher, '') = coalesce(p_nachher, '') then p_liste
|
||||||
|
else p_liste || jsonb_build_object('feld', p_feld, 'vorher', p_vorher, 'nachher', p_nachher)
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Die Feldnamen aus einer Änderungsliste — für die Kurzfassung in `details`,
|
||||||
|
-- damit bestehende Ansichten unverändert weiterlaufen.
|
||||||
|
create or replace function app_aenderungsfelder(p_liste jsonb)
|
||||||
|
returns text
|
||||||
|
language sql
|
||||||
|
immutable
|
||||||
|
set search_path = public, pg_temp
|
||||||
|
as $$
|
||||||
|
select string_agg(x->>'feld', ', ') from jsonb_array_elements(coalesce(p_liste, '[]'::jsonb)) x;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ═══ Stammdaten- und Vertragsänderung ════════════════════════════
|
||||||
|
create or replace function change_employee_data(payload jsonb)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
set search_path = public, pg_temp
|
||||||
|
as $function$
|
||||||
|
declare
|
||||||
|
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||||
|
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
|
||||||
|
v_old employees%rowtype;
|
||||||
|
v_name text;
|
||||||
|
v_person_changes jsonb := '[]'::jsonb;
|
||||||
|
v_contract_changes jsonb := '[]'::jsonb;
|
||||||
|
v_person jsonb := payload->'person';
|
||||||
|
v_contract jsonb := payload->'contract';
|
||||||
|
v_role jsonb := payload->'role';
|
||||||
|
v_immediate boolean;
|
||||||
|
v_new_work_days text[];
|
||||||
|
v_new_title_prefix text[];
|
||||||
|
v_new_title_suffix text[];
|
||||||
|
begin
|
||||||
|
perform require_hr_admin();
|
||||||
|
select * into v_old from employees where id = v_employee_id;
|
||||||
|
v_name := v_old.first_name || ' ' || v_old.last_name;
|
||||||
|
v_immediate := v_effective_date <= current_date;
|
||||||
|
|
||||||
|
-- Der `?`-Test bleibt: ein fehlender Schlüssel heisst „nicht übermittelt",
|
||||||
|
-- nicht „geleert". Ohne ihn würde jedes nicht gesendete Feld als Änderung
|
||||||
|
-- auf null gemeldet.
|
||||||
|
if v_person ? 'first_name' then v_person_changes := app_aenderung(v_person_changes, 'Vorname', v_old.first_name, v_person->>'first_name'); end if;
|
||||||
|
if v_person ? 'last_name' then v_person_changes := app_aenderung(v_person_changes, 'Nachname', v_old.last_name, v_person->>'last_name'); end if;
|
||||||
|
if v_person ? 'gender' then v_person_changes := app_aenderung(v_person_changes, 'Geschlecht', v_old.gender::text, v_person->>'gender'); end if;
|
||||||
|
-- Datumswerte über ::date::text vergleichen, damit „2026-8-3" und
|
||||||
|
-- „2026-08-03" nicht als Änderung gelten.
|
||||||
|
if v_person ? 'birth_date' then v_person_changes := app_aenderung(v_person_changes, 'Geburtsdatum', v_old.birth_date::text, (nullif(v_person->>'birth_date','')::date)::text); end if;
|
||||||
|
if v_person ? 'sv_nummer' then v_person_changes := app_aenderung(v_person_changes, 'SV-Nummer', v_old.sv_nummer, v_person->>'sv_nummer'); end if;
|
||||||
|
if v_person ? 'nationality' then v_person_changes := app_aenderung(v_person_changes, 'Staatsbürgerschaft', v_old.nationality, v_person->>'nationality'); end if;
|
||||||
|
if v_person ? 'address' then v_person_changes := app_aenderung(v_person_changes, 'Adresse', v_old.address, v_person->>'address'); end if;
|
||||||
|
if v_person ? 'postal_code' then v_person_changes := app_aenderung(v_person_changes, 'Postleitzahl', v_old.postal_code, v_person->>'postal_code'); end if;
|
||||||
|
if v_person ? 'city' then v_person_changes := app_aenderung(v_person_changes, 'Ort', v_old.city, v_person->>'city'); end if;
|
||||||
|
if v_person ? 'address_country' then v_person_changes := app_aenderung(v_person_changes, 'Land', v_old.address_country, v_person->>'address_country'); end if;
|
||||||
|
if v_person ? 'email' then v_person_changes := app_aenderung(v_person_changes, 'E-Mail', v_old.email, v_person->>'email'); end if;
|
||||||
|
if v_person ? 'phone' then v_person_changes := app_aenderung(v_person_changes, 'Telefon', v_old.phone, v_person->>'phone'); end if;
|
||||||
|
|
||||||
|
if v_person ? 'title_prefix' then
|
||||||
|
v_new_title_prefix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_prefix') elem), '{}');
|
||||||
|
v_person_changes := app_aenderung(v_person_changes, 'Titel (vorangestellt)',
|
||||||
|
array_to_string(v_old.title_prefix, ', '), array_to_string(v_new_title_prefix, ', '));
|
||||||
|
end if;
|
||||||
|
if v_person ? 'title_suffix' then
|
||||||
|
v_new_title_suffix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_suffix') elem), '{}');
|
||||||
|
v_person_changes := app_aenderung(v_person_changes, 'Titel (nachgestellt)',
|
||||||
|
array_to_string(v_old.title_suffix, ', '), array_to_string(v_new_title_suffix, ', '));
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_contract ? 'employment_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_contract->>'employment_type'); end if;
|
||||||
|
-- Über ::numeric::text, damit „38.50" und „38.5" gleich zählen.
|
||||||
|
if v_contract ? 'weekly_hours' then v_contract_changes := app_aenderung(v_contract_changes, 'Wochenstunden', v_old.weekly_hours::text, (nullif(v_contract->>'weekly_hours','')::numeric)::text); end if;
|
||||||
|
if v_contract ? 'contract_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Vertragsart', v_old.contract_type::text, v_contract->>'contract_type'); end if;
|
||||||
|
if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if;
|
||||||
|
|
||||||
|
if v_role ? 'worker_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Angestellte:r/Arbeiter:in', v_old.worker_type::text, v_role->>'worker_type'); end if;
|
||||||
|
if v_role ? 'collective_agreement' then v_contract_changes := app_aenderung(v_contract_changes, 'Kollektivvertrag', v_old.collective_agreement::text, v_role->>'collective_agreement'); end if;
|
||||||
|
if v_role ? 'work_days' then
|
||||||
|
v_new_work_days := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}');
|
||||||
|
v_contract_changes := app_aenderung(v_contract_changes, 'Arbeitstage',
|
||||||
|
array_to_string(v_old.work_days, ', '), array_to_string(v_new_work_days, ', '));
|
||||||
|
end if;
|
||||||
|
if v_role ? 'is_betriebsrat' then v_contract_changes := app_aenderung(v_contract_changes, 'Betriebsrat', v_old.is_betriebsrat::text, v_role->>'is_betriebsrat'); end if;
|
||||||
|
if v_role ? 'has_dienstwagen' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen', v_old.has_dienstwagen::text, v_role->>'has_dienstwagen'); end if;
|
||||||
|
if v_role ? 'is_laterale_fuehrung' then v_contract_changes := app_aenderung(v_contract_changes, 'Laterale Führung', v_old.is_laterale_fuehrung::text, v_role->>'is_laterale_fuehrung'); end if;
|
||||||
|
if v_role ? 'is_c_level' then v_contract_changes := app_aenderung(v_contract_changes, 'C-Level', v_old.is_c_level::text, v_role->>'is_c_level'); end if;
|
||||||
|
|
||||||
|
if v_immediate then
|
||||||
|
update employees set
|
||||||
|
first_name = coalesce(v_person->>'first_name', first_name),
|
||||||
|
last_name = coalesce(v_person->>'last_name', last_name),
|
||||||
|
gender = coalesce((v_person->>'gender')::gender_type, gender),
|
||||||
|
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
|
||||||
|
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
|
||||||
|
nationality = coalesce(v_person->>'nationality', nationality),
|
||||||
|
address = coalesce(v_person->>'address', address),
|
||||||
|
postal_code = coalesce(v_person->>'postal_code', postal_code),
|
||||||
|
city = coalesce(v_person->>'city', city),
|
||||||
|
address_country = coalesce(v_person->>'address_country', address_country),
|
||||||
|
email = coalesce(v_person->>'email', email),
|
||||||
|
phone = coalesce(v_person->>'phone', phone),
|
||||||
|
title_prefix = case when v_person ? 'title_prefix' then v_new_title_prefix else title_prefix end,
|
||||||
|
title_suffix = case when v_person ? 'title_suffix' then v_new_title_suffix else title_suffix end,
|
||||||
|
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
|
||||||
|
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
|
||||||
|
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
|
||||||
|
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end,
|
||||||
|
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
|
||||||
|
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
|
||||||
|
work_days = case when v_role ? 'work_days' then v_new_work_days else work_days end,
|
||||||
|
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
|
||||||
|
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
|
||||||
|
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
|
||||||
|
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level)
|
||||||
|
where id = v_employee_id;
|
||||||
|
elsif jsonb_array_length(v_person_changes) > 0 or jsonb_array_length(v_contract_changes) > 0 then
|
||||||
|
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
|
||||||
|
values (v_employee_id, 'contract_change', v_effective_date, payload);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if jsonb_array_length(v_person_changes) > 0 then
|
||||||
|
insert into employee_history (employee_id, event_date, event_type, description)
|
||||||
|
values (v_employee_id, v_effective_date, 'Stammdatenänderung',
|
||||||
|
'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date);
|
||||||
|
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||||
|
values (app_current_user_id(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id,
|
||||||
|
app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if jsonb_array_length(v_contract_changes) > 0 then
|
||||||
|
insert into employee_history (employee_id, event_date, event_type, description)
|
||||||
|
values (v_employee_id, v_effective_date, 'Vertragsänderung',
|
||||||
|
'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date);
|
||||||
|
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||||
|
values (app_current_user_id(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id,
|
||||||
|
app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
|
||||||
|
end if;
|
||||||
|
end;
|
||||||
|
$function$;
|
||||||
|
|
||||||
|
-- ═══ Gegenprobe ══════════════════════════════════════════════════
|
||||||
|
-- Die Hilfsfunktion muss Gleiches übergehen und Ungleiches behalten —
|
||||||
|
-- inklusive des Falls null gegen Leerstring, der sonst als Änderung im
|
||||||
|
-- Protokoll landet und niemandem etwas sagt.
|
||||||
|
do $$
|
||||||
|
declare v jsonb;
|
||||||
|
begin
|
||||||
|
v := app_aenderung('[]'::jsonb, 'Ort', 'Wien', 'Wien');
|
||||||
|
if jsonb_array_length(v) <> 0 then raise exception 'app_aenderung() meldet eine Änderung, wo keine ist.'; end if;
|
||||||
|
|
||||||
|
v := app_aenderung('[]'::jsonb, 'Ort', null, '');
|
||||||
|
if jsonb_array_length(v) <> 0 then raise exception 'app_aenderung() wertet null gegen Leerstring als Änderung.'; end if;
|
||||||
|
|
||||||
|
v := app_aenderung('[]'::jsonb, 'Ort', 'Wien', 'Graz');
|
||||||
|
if jsonb_array_length(v) <> 1 or v->0->>'vorher' <> 'Wien' or v->0->>'nachher' <> 'Graz' then
|
||||||
|
raise exception 'app_aenderung() hält Vorher/Nachher nicht fest.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if app_aenderungsfelder(v) <> 'Ort' then raise exception 'app_aenderungsfelder() liefert die Feldnamen nicht.'; end if;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
84
supabase/migrations/20260805100000_fix_stale_type_casts.sql
Normal file
84
supabase/migrations/20260805100000_fix_stale_type_casts.sql
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
-- Zwei Funktionen casten auf Typen, die es nicht mehr gibt.
|
||||||
|
--
|
||||||
|
-- hire_employee ::weekday[]
|
||||||
|
-- apply_due_pending_changes ::relationship_type
|
||||||
|
--
|
||||||
|
-- Beide Typen waren einmal Aufzählungen und wurden später durch `text` mit
|
||||||
|
-- einer CHECK-Bedingung ersetzt (chk_work_days_valid). Die Funktionen wurden
|
||||||
|
-- dabei nicht nachgezogen.
|
||||||
|
--
|
||||||
|
-- PL/pgSQL löst Typen in eingebetteten SQL-Anweisungen erst beim **Ausführen**
|
||||||
|
-- auf. Die Funktionen liessen sich deshalb anlegen und scheitern erst im
|
||||||
|
-- Betrieb — jede Neueinstellung mit „type weekday[] does not exist", und der
|
||||||
|
-- nächtliche Lauf, sobald eine fällige Angehörigen-Änderung darin vorkommt.
|
||||||
|
-- Genau deshalb hat es niemand beim Einspielen gemerkt.
|
||||||
|
--
|
||||||
|
-- Ersetzt wird gezielt der Cast an der **aktuellen** Definition, statt beide
|
||||||
|
-- Funktionen abzuschreiben: 165 Zeilen fremden Code neu zu tippen, um zwei
|
||||||
|
-- Wörter zu ändern, ist die grössere Fehlerquelle.
|
||||||
|
|
||||||
|
create or replace function app_cast_ersetzen(p_funktion text, p_alt text, p_neu text)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
set search_path = public, pg_temp
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_def text;
|
||||||
|
begin
|
||||||
|
select pg_get_functiondef(p.oid) into v_def
|
||||||
|
from pg_proc p
|
||||||
|
join pg_namespace n on n.oid = p.pronamespace
|
||||||
|
where n.nspname = 'public' and p.proname = p_funktion
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_def is null then
|
||||||
|
raise exception 'Funktion %() nicht gefunden.', p_funktion;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if position(p_alt in v_def) = 0 then
|
||||||
|
raise notice '%(): % kommt nicht (mehr) vor — nichts zu tun.', p_funktion, p_alt;
|
||||||
|
return;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
execute replace(v_def, p_alt, p_neu);
|
||||||
|
raise notice '%(): % -> %', p_funktion, p_alt, p_neu;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
select app_cast_ersetzen('hire_employee', '::weekday[]', '::text[]');
|
||||||
|
select app_cast_ersetzen('apply_due_pending_changes', '::relationship_type', '::text');
|
||||||
|
|
||||||
|
-- Das Werkzeug wird nicht aufbewahrt: eine Funktion, die beliebigen Text in
|
||||||
|
-- eine Funktionsdefinition schreibt und ausführt, soll nicht dauerhaft im
|
||||||
|
-- Schema stehen.
|
||||||
|
drop function app_cast_ersetzen(text, text, text);
|
||||||
|
|
||||||
|
-- ═══ Gegenprobe ══════════════════════════════════════════════════
|
||||||
|
-- Keine Funktion im Schema darf mehr auf einen Typ casten, den es nicht
|
||||||
|
-- gibt. Das prüft nicht nur die zwei bekannten Stellen, sondern schliesst
|
||||||
|
-- aus, dass beim Ersetzen eine dritte übersehen wurde.
|
||||||
|
do $$
|
||||||
|
declare
|
||||||
|
r record;
|
||||||
|
v_treffer text;
|
||||||
|
begin
|
||||||
|
for r in
|
||||||
|
select p.proname, pg_get_functiondef(p.oid) as def
|
||||||
|
from pg_proc p
|
||||||
|
join pg_namespace n on n.oid = p.pronamespace
|
||||||
|
where n.nspname = 'public' and p.prokind = 'f'
|
||||||
|
loop
|
||||||
|
for v_treffer in
|
||||||
|
select m[1] from regexp_matches(r.def, '::\s*([a-z_][a-z0-9_]*)\s*(?:\[\])?', 'gi') m
|
||||||
|
loop
|
||||||
|
if not exists (select 1 from pg_type where typname = lower(v_treffer))
|
||||||
|
and lower(v_treffer) not in (
|
||||||
|
'text', 'int', 'integer', 'boolean', 'bool', 'date', 'uuid', 'jsonb', 'json',
|
||||||
|
'numeric', 'timestamptz', 'varchar', 'bigint', 'smallint', 'real', 'interval', 'time'
|
||||||
|
) then
|
||||||
|
raise exception 'Funktion %() castet auf unbekannten Typ „%".', r.proname, v_treffer;
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
end loop;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
-- auth.uid() aus den Geschäftsfunktionen entfernen.
|
||||||
|
--
|
||||||
|
-- Die Anwendung verbindet sich als eigene Rolle ohne BYPASSRLS. Diese Rolle
|
||||||
|
-- hat kein Recht auf das Schema `auth` — und jede Funktion, die dort etwas
|
||||||
|
-- aufruft, scheitert mit „permission denied for schema auth".
|
||||||
|
--
|
||||||
|
-- Das trifft **jede schreibende Aktion**: Einstellen, Versetzen, Befördern,
|
||||||
|
-- Austritt, Notizen, Planstellen. Aufgefallen ist es beim Nachstellen einer
|
||||||
|
-- Neueinstellung; zuvor lief alles über eine Rolle, die das Schema sehen
|
||||||
|
-- durfte.
|
||||||
|
--
|
||||||
|
-- app_current_user_id() liefert dasselbe und funktioniert auf jedem
|
||||||
|
-- PostgreSQL — es liest den transaktionslokalen Sitzungskontext, den lib/db
|
||||||
|
-- setzt.
|
||||||
|
|
||||||
|
create or replace function app_uid_ersetzen(p_funktion text)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
set search_path = public, pg_temp
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_def text;
|
||||||
|
begin
|
||||||
|
select pg_get_functiondef(p.oid) into v_def
|
||||||
|
from pg_proc p
|
||||||
|
join pg_namespace n on n.oid = p.pronamespace
|
||||||
|
where n.nspname = 'public' and p.proname = p_funktion
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_def is null then
|
||||||
|
raise exception 'Funktion %() nicht gefunden.', p_funktion;
|
||||||
|
end if;
|
||||||
|
if position('auth.uid()' in v_def) = 0 then
|
||||||
|
raise notice '%(): bereits umgestellt.', p_funktion;
|
||||||
|
return;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
execute replace(v_def, 'auth.uid()', 'app_current_user_id()');
|
||||||
|
raise notice '%(): umgestellt.', p_funktion;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
select app_uid_ersetzen('add_employee_dependent');
|
||||||
|
select app_uid_ersetzen('add_employee_note');
|
||||||
|
select app_uid_ersetzen('adjust_karenz_return');
|
||||||
|
select app_uid_ersetzen('complete_employee_note');
|
||||||
|
select app_uid_ersetzen('create_position');
|
||||||
|
select app_uid_ersetzen('delete_employee_dependent');
|
||||||
|
select app_uid_ersetzen('delete_position');
|
||||||
|
select app_uid_ersetzen('hire_employee');
|
||||||
|
select app_uid_ersetzen('promote_employee');
|
||||||
|
select app_uid_ersetzen('record_karenz_return');
|
||||||
|
select app_uid_ersetzen('rehire_employee');
|
||||||
|
select app_uid_ersetzen('start_karenz');
|
||||||
|
select app_uid_ersetzen('terminate_employee');
|
||||||
|
select app_uid_ersetzen('transfer_employee');
|
||||||
|
|
||||||
|
drop function app_uid_ersetzen(text);
|
||||||
|
|
||||||
|
-- ═══ Den Rückfall selbst absichern ═══════════════════════════════
|
||||||
|
-- app_current_user_id() behält seinen Übergangszweig auf auth.uid(), fängt
|
||||||
|
-- aber bisher nur „Funktion fehlt". Für eine Rolle ohne Recht auf das Schema
|
||||||
|
-- kommt stattdessen insufficient_privilege — und die Funktion warf, statt
|
||||||
|
-- null zu liefern. Das fiel nicht auf, weil der Zweig nur ohne
|
||||||
|
-- Sitzungskontext erreicht wird; genau dann soll aber „niemand angemeldet"
|
||||||
|
-- herauskommen und kein Fehler.
|
||||||
|
create or replace function app_current_user_id()
|
||||||
|
returns uuid
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public, pg_temp
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_id uuid;
|
||||||
|
begin
|
||||||
|
v_id := nullif(current_setting('app.user_id', true), '')::uuid;
|
||||||
|
if v_id is not null then
|
||||||
|
return v_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
begin
|
||||||
|
execute 'select auth.uid()' into v_id;
|
||||||
|
exception
|
||||||
|
when undefined_function or invalid_schema_name or undefined_table or insufficient_privilege then
|
||||||
|
v_id := null;
|
||||||
|
end;
|
||||||
|
return v_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ═══ Gegenprobe ══════════════════════════════════════════════════
|
||||||
|
do $$
|
||||||
|
declare r record;
|
||||||
|
begin
|
||||||
|
for r in
|
||||||
|
select p.proname
|
||||||
|
from pg_proc p
|
||||||
|
join pg_namespace n on n.oid = p.pronamespace
|
||||||
|
where n.nspname = 'public' and p.prokind = 'f'
|
||||||
|
and p.proname <> 'app_current_user_id'
|
||||||
|
and pg_get_functiondef(p.oid) like '%auth.uid()%'
|
||||||
|
loop
|
||||||
|
raise exception 'Funktion %() ruft weiterhin auth.uid() auf.', r.proname;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
-- Ohne Kontext muss die Kennung null sein und darf nicht werfen.
|
||||||
|
perform set_config('app.user_id', '', true);
|
||||||
|
if app_current_user_id() is not null then
|
||||||
|
raise exception 'app_current_user_id() liefert ohne Kontext eine Kennung.';
|
||||||
|
end if;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
-- Klammern in hire_employee.
|
||||||
|
--
|
||||||
|
-- Der Protokolleintrag baute den Namen so:
|
||||||
|
--
|
||||||
|
-- payload->>'first_name' || ' ' || payload->>'last_name'
|
||||||
|
--
|
||||||
|
-- In PostgreSQL bindet `||` **stärker** als `->>`. Gelesen wird also
|
||||||
|
--
|
||||||
|
-- payload ->> ('first_name' || ' ' || payload) ->> 'last_name'
|
||||||
|
--
|
||||||
|
-- und das endet in „operator does not exist: text ->> unknown". Jede
|
||||||
|
-- Neueinstellung scheiterte daran.
|
||||||
|
--
|
||||||
|
-- Warum es niemandem auffiel: davor stand in derselben Anweisung ein Aufruf
|
||||||
|
-- von auth.uid(). Die Rechteprüfung auf das Schema `auth` schlug schon
|
||||||
|
-- während der Analyse fehl, und der Parser kam nie bis zu diesem Ausdruck.
|
||||||
|
-- Ein Fehler hat den anderen verdeckt — beide mussten weg, damit eine
|
||||||
|
-- Einstellung durchläuft.
|
||||||
|
|
||||||
|
do $$
|
||||||
|
declare
|
||||||
|
v_def text;
|
||||||
|
v_alt constant text := 'payload->>''first_name'' || '' '' || payload->>''last_name''';
|
||||||
|
v_neu constant text := '(payload->>''first_name'') || '' '' || (payload->>''last_name'')';
|
||||||
|
begin
|
||||||
|
select pg_get_functiondef(p.oid) into v_def
|
||||||
|
from pg_proc p
|
||||||
|
join pg_namespace n on n.oid = p.pronamespace
|
||||||
|
where n.nspname = 'public' and p.proname = 'hire_employee'
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_def is null then
|
||||||
|
raise exception 'hire_employee() nicht gefunden.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if position(v_alt in v_def) = 0 then
|
||||||
|
raise notice 'hire_employee(): bereits geklammert.';
|
||||||
|
else
|
||||||
|
execute replace(v_def, v_alt, v_neu);
|
||||||
|
raise notice 'hire_employee(): geklammert.';
|
||||||
|
end if;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ═══ Gegenprobe ══════════════════════════════════════════════════
|
||||||
|
-- Der Ausdruck selbst, in beiden Lesarten. Ohne Klammern wirft er; mit
|
||||||
|
-- Klammern kommt der Name heraus. Das hält die Regel fest, damit sie beim
|
||||||
|
-- nächsten Mal nicht neu entdeckt werden muss.
|
||||||
|
do $$
|
||||||
|
declare
|
||||||
|
v_payload jsonb := '{"first_name": "Anna", "last_name": "Berger"}'::jsonb;
|
||||||
|
v_name text;
|
||||||
|
begin
|
||||||
|
v_name := (v_payload->>'first_name') || ' ' || (v_payload->>'last_name');
|
||||||
|
if v_name <> 'Anna Berger' then
|
||||||
|
raise exception 'Geklammert ergibt „%" statt „Anna Berger".', v_name;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
begin
|
||||||
|
execute $probe$ select ('{"a":"x"}'::jsonb)->>'a' || ' ' || ('{"a":"x"}'::jsonb)->>'a' $probe$;
|
||||||
|
raise exception 'Ungeklammert wirft nicht mehr — die Vorrangregel hat sich geändert, die Prüfung ist wertlos geworden.';
|
||||||
|
exception
|
||||||
|
when undefined_function then null; -- erwartet: text ->> unknown
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
132
supabase/migrations/20260805130000_update_position.sql
Normal file
132
supabase/migrations/20260805130000_update_position.sql
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
-- Eine Planstelle ändern.
|
||||||
|
--
|
||||||
|
-- Bisher liess sie sich nur anlegen und löschen. Ein Tippfehler in der
|
||||||
|
-- Tätigkeit oder ein falsches Gültigkeitsdatum bedeutete: löschen und neu —
|
||||||
|
-- mit neuer Planstellennummer. Die Nummer steht aber in Stellenausschreibungen
|
||||||
|
-- und Budgets, und die Protokollspur reisst ab.
|
||||||
|
--
|
||||||
|
-- Was hier bewusst **nicht** geht, steht als Sperre drin, nicht als
|
||||||
|
-- Anmerkung — siehe die drei Prüfungen unten.
|
||||||
|
create or replace function update_position(payload jsonb)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
set search_path = public, pg_temp
|
||||||
|
as $function$
|
||||||
|
declare
|
||||||
|
v_id uuid := (payload->>'position_id')::uuid;
|
||||||
|
v_alt om_positions%rowtype;
|
||||||
|
v_alt_titel text;
|
||||||
|
v_alt_einheit text;
|
||||||
|
v_org_unit_id uuid;
|
||||||
|
v_job_title text := nullif(trim(payload->>'job_title'), '');
|
||||||
|
v_is_chief boolean;
|
||||||
|
v_valid_from date;
|
||||||
|
v_valid_to date;
|
||||||
|
v_job_id uuid;
|
||||||
|
v_unit_name text;
|
||||||
|
v_besetzt boolean;
|
||||||
|
v_changes jsonb := '[]'::jsonb;
|
||||||
|
begin
|
||||||
|
perform require_hr_admin();
|
||||||
|
|
||||||
|
select * into v_alt from om_positions where id = v_id;
|
||||||
|
if v_alt.id is null then
|
||||||
|
raise exception 'Die Planstelle existiert nicht.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select title into v_alt_titel from jobs where id = v_alt.job_id;
|
||||||
|
select name into v_alt_einheit from org_units where id = v_alt.org_unit_id;
|
||||||
|
|
||||||
|
-- Fehlende Schlüssel heissen „unverändert", nicht „leeren".
|
||||||
|
v_org_unit_id := coalesce(nullif(payload->>'org_unit_id','')::uuid, v_alt.org_unit_id);
|
||||||
|
v_job_title := coalesce(v_job_title, v_alt_titel);
|
||||||
|
v_is_chief := coalesce((payload->>'is_chief')::boolean, v_alt.is_chief);
|
||||||
|
v_valid_from := coalesce(nullif(payload->>'valid_from','')::date, v_alt.valid_from);
|
||||||
|
v_valid_to := case when payload ? 'valid_to' then nullif(payload->>'valid_to','')::date else v_alt.valid_to end;
|
||||||
|
|
||||||
|
select name into v_unit_name from org_units where id = v_org_unit_id;
|
||||||
|
if v_unit_name is null then
|
||||||
|
raise exception 'Die Organisationseinheit existiert nicht.';
|
||||||
|
end if;
|
||||||
|
if v_valid_to is not null and v_valid_to < v_valid_from then
|
||||||
|
raise exception 'Das Ende der Gültigkeit liegt vor ihrem Beginn.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select exists (
|
||||||
|
select 1 from position_assignments
|
||||||
|
where position_id = v_id and (valid_to is null or valid_to > current_date)
|
||||||
|
) into v_besetzt;
|
||||||
|
|
||||||
|
-- (1) Die Einheit einer vergebenen Planstelle zu wechseln wäre eine
|
||||||
|
-- Versetzung — mit allem, was dazugehört: Historie, Berichtslinie,
|
||||||
|
-- Protokoll. Das gehört in transfer_employee und nicht hierher, sonst
|
||||||
|
-- wandert jemand lautlos in eine andere Abteilung.
|
||||||
|
if v_besetzt and v_org_unit_id is distinct from v_alt.org_unit_id then
|
||||||
|
raise exception 'Diese Planstelle ist vergeben. Für einen Wechsel der Einheit die Versetzung benutzen.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- (2) Ein Ende, während noch jemand darauf sitzt, hinterlässt eine
|
||||||
|
-- Besetzung ohne Planstelle.
|
||||||
|
if v_besetzt and v_valid_to is not null then
|
||||||
|
raise exception 'Diese Planstelle ist vergeben und kann kein Ende der Gültigkeit bekommen.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- (3) Je Einheit nur eine gültige Leitung. Der Unique-Index fängt das auch,
|
||||||
|
-- aber mit einer Meldung, die in der Oberfläche nichts erklärt.
|
||||||
|
if v_is_chief and exists (
|
||||||
|
select 1 from om_positions
|
||||||
|
where org_unit_id = v_org_unit_id and is_chief and valid_to is null and id <> v_id
|
||||||
|
) then
|
||||||
|
raise exception 'Für % besteht bereits eine Leitungsplanstelle.', v_unit_name;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Gleiche Tätigkeit, ein Katalogeintrag — dieselbe Regel wie beim Anlegen.
|
||||||
|
select id into v_job_id from jobs where lower(title) = lower(v_job_title);
|
||||||
|
if v_job_id is null then
|
||||||
|
insert into jobs (code, title)
|
||||||
|
values ('J' || lpad((select count(*) + 1 from jobs)::text, 4, '0'), v_job_title)
|
||||||
|
returning id into v_job_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_changes := app_aenderung(v_changes, 'Tätigkeit', v_alt_titel, v_job_title);
|
||||||
|
v_changes := app_aenderung(v_changes, 'Organisationseinheit', v_alt_einheit, v_unit_name);
|
||||||
|
v_changes := app_aenderung(v_changes, 'Leitungsplanstelle', v_alt.is_chief::text, v_is_chief::text);
|
||||||
|
v_changes := app_aenderung(v_changes, 'Gültig ab', v_alt.valid_from::text, v_valid_from::text);
|
||||||
|
v_changes := app_aenderung(v_changes, 'Gültig bis', v_alt.valid_to::text, v_valid_to::text);
|
||||||
|
|
||||||
|
-- Nichts geändert ist ein Ergebnis, kein Erfolg.
|
||||||
|
--
|
||||||
|
-- Vorher kehrte die Funktion hier stumm zurück, und die Oberfläche meldete
|
||||||
|
-- „Planstelle geändert." Wer etwas eingetragen hatte, das unterwegs
|
||||||
|
-- verworfen wurde — etwa das Leitungshäkchen, das bei bereits vergebener
|
||||||
|
-- Leitung nicht durchkommt —, sah eine Erfolgsmeldung und eine unveränderte
|
||||||
|
-- Liste. Das ist genau die Rückmeldung, die einen suchen lässt.
|
||||||
|
if jsonb_array_length(v_changes) = 0 then
|
||||||
|
raise exception 'Es wurde nichts geändert.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update om_positions
|
||||||
|
set org_unit_id = v_org_unit_id,
|
||||||
|
job_id = v_job_id,
|
||||||
|
is_chief = v_is_chief,
|
||||||
|
valid_from = v_valid_from,
|
||||||
|
valid_to = v_valid_to
|
||||||
|
where id = v_id;
|
||||||
|
|
||||||
|
insert into audit_log (actor_user_id, actor_name, action, target_label, details, changes)
|
||||||
|
values (app_current_user_id(), current_actor_name(), 'Planstelle geändert',
|
||||||
|
v_job_title || ' (' || v_unit_name || ')',
|
||||||
|
app_aenderungsfelder(v_changes), v_changes);
|
||||||
|
end;
|
||||||
|
$function$;
|
||||||
|
|
||||||
|
do $$
|
||||||
|
declare r text;
|
||||||
|
begin
|
||||||
|
foreach r in array array['anon', 'authenticated', 'service_role', 'alpenwerk_app'] loop
|
||||||
|
if exists (select 1 from pg_roles where rolname = r) then
|
||||||
|
execute format('grant execute on function update_position(jsonb) to %I', r);
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
// Hängt eine bestehende profiles-Zeile auf die Entra-Identität derselben
|
|
||||||
// Person um.
|
|
||||||
//
|
|
||||||
// Run with: node --env-file=.env.local supabase/relink-profile.ts <e-mail> [--apply]
|
|
||||||
//
|
|
||||||
// Ein Konto mit Passwort-Anmeldung und das Entra-Konto derselben Person sind
|
|
||||||
// für Supabase zwei Benutzer mit verschiedenen IDs. Die profiles-Zeile hängt an
|
|
||||||
// der alten; nach der ersten Anmeldung über Entra zeigt sie ins Leere und die
|
|
||||||
// Person ist ausgesperrt — mit „Kein HR-Zugriff", obwohl sie HR ist.
|
|
||||||
//
|
|
||||||
// Ohne --apply wird nur angezeigt, was passieren würde.
|
|
||||||
|
|
||||||
import { createClient } from "@supabase/supabase-js";
|
|
||||||
|
|
||||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
||||||
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) {
|
|
||||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in the environment");
|
|
||||||
}
|
|
||||||
|
|
||||||
const email = process.argv[2];
|
|
||||||
const apply = process.argv.includes("--apply");
|
|
||||||
if (!email) {
|
|
||||||
console.error("Aufruf: node --env-file=.env.local supabase/relink-profile.ts <e-mail> [--apply]");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
|
|
||||||
auth: { autoRefreshToken: false, persistSession: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Die Fremdschlüssel auf auth.users(id). Sie zeigen sonst weiter auf das alte
|
|
||||||
// Konto, und in der Historie stünde eine Kennung ohne Konto dahinter.
|
|
||||||
const REFERENCES: { table: string; column: string }[] = [
|
|
||||||
{ table: "audit_log", column: "actor_user_id" },
|
|
||||||
{ table: "employee_notes", column: "author_user_id" },
|
|
||||||
{ table: "employee_notes", column: "done_by" },
|
|
||||||
{ table: "hire_drafts", column: "created_by" },
|
|
||||||
{ table: "saved_reports", column: "created_by" },
|
|
||||||
{ table: "pending_org_changes", column: "created_by" },
|
|
||||||
{ table: "profiles", column: "created_by" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const { data: userList, error } = await supabase.auth.admin.listUsers({ perPage: 1000 });
|
|
||||||
if (error) throw new Error(error.message);
|
|
||||||
|
|
||||||
const accounts = userList.users.filter((u) => u.email?.toLowerCase() === email.toLowerCase());
|
|
||||||
const entra = accounts.find((u) => u.identities?.some((i) => i.provider === "azure"));
|
|
||||||
const alt = accounts.find((u) => u.id !== entra?.id);
|
|
||||||
|
|
||||||
if (!entra) {
|
|
||||||
console.error(`Kein Entra-Konto zu ${email}. Bitte zuerst einmal über „Mit Firmenkonto anmelden" anmelden.`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
if (!alt) {
|
|
||||||
console.log(`Zu ${email} gibt es nur das Entra-Konto (${entra.id}) — nichts umzuhängen.`);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: profile } = await supabase.from("profiles").select("*").eq("id", alt.id).maybeSingle();
|
|
||||||
if (!profile) {
|
|
||||||
console.error(`Das alte Konto ${alt.id} hat keine profiles-Zeile. Nichts umzuhängen.`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`alt: ${alt.id} (${alt.identities?.map((i) => i.provider).join(", ")})`);
|
|
||||||
console.log(`neu: ${entra.id} (azure)`);
|
|
||||||
console.log(`Rolle: ${profile.role}, aktiv: ${profile.is_active}`);
|
|
||||||
|
|
||||||
if (!apply) {
|
|
||||||
console.log("\nTrockenlauf. Mit --apply ausführen.");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Neue Zeile zuerst: profiles.id verweist auf auth.users(id), und die alte
|
|
||||||
// Zeile fällt erst, wenn die neue steht — sonst gibt es einen Moment ohne
|
|
||||||
// HR-Konto, und niemand könnte eines mehr freischalten.
|
|
||||||
const { error: insertError } = await supabase.from("profiles").insert({
|
|
||||||
...profile,
|
|
||||||
id: entra.id,
|
|
||||||
email: entra.email ?? profile.email,
|
|
||||||
});
|
|
||||||
if (insertError) throw new Error(`profiles-Zeile anlegen fehlgeschlagen: ${insertError.message}`);
|
|
||||||
console.log("profiles-Zeile für das Entra-Konto angelegt.");
|
|
||||||
|
|
||||||
for (const ref of REFERENCES) {
|
|
||||||
const { error: updateError, count } = await supabase
|
|
||||||
.from(ref.table)
|
|
||||||
.update({ [ref.column]: entra.id }, { count: "exact" })
|
|
||||||
.eq(ref.column, alt.id);
|
|
||||||
if (updateError) {
|
|
||||||
console.warn(` ${ref.table}.${ref.column}: ${updateError.message}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
console.log(` ${ref.table}.${ref.column}: ${count ?? 0} Zeile(n) umgehängt`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: deleteProfileError } = await supabase.from("profiles").delete().eq("id", alt.id);
|
|
||||||
if (deleteProfileError) throw new Error(`alte profiles-Zeile löschen fehlgeschlagen: ${deleteProfileError.message}`);
|
|
||||||
|
|
||||||
const { error: deleteUserError } = await supabase.auth.admin.deleteUser(alt.id);
|
|
||||||
if (deleteUserError) {
|
|
||||||
// Kein Abbruch: der Zugriff hängt an profiles, und die ist bereits
|
|
||||||
// umgehängt. Das alte Konto ist damit wirkungslos, nur nicht aufgeräumt.
|
|
||||||
console.warn(`Altes Konto konnte nicht gelöscht werden: ${deleteUserError.message}`);
|
|
||||||
} else {
|
|
||||||
console.log("Altes Konto gelöscht.");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("\nFertig. Die Anmeldung läuft jetzt über das Firmenkonto.");
|
|
||||||
@@ -835,6 +835,17 @@ function pruefeInvarianten() {
|
|||||||
for (const h of history) {
|
for (const h of history) {
|
||||||
const e = employees.find((x) => x.id === h.employee_id)!;
|
const e = employees.find((x) => x.id === h.employee_id)!;
|
||||||
if (e.exit_date && h.event_date > e.exit_date) throw new Error(`${e.email}: Ereignis ${h.event_type} nach dem Austritt`);
|
if (e.exit_date && h.event_date > e.exit_date) throw new Error(`${e.email}: Ereignis ${h.event_type} nach dem Austritt`);
|
||||||
|
// Die Gegenrichtung — und die hat gefehlt.
|
||||||
|
//
|
||||||
|
// trg_history_not_before_entry weist jede Zeile ab, deren event_date vor
|
||||||
|
// dem Eintritt liegt. insertInChunks bricht beim ersten Fehler ab, und
|
||||||
|
// employee_history ist die *letzte* Tabelle im Seed: alles davor war
|
||||||
|
// bereits geschrieben. Ergebnis war eine Datenbank mit 852 Personen und
|
||||||
|
// null Historie — und das sah nicht nach einem Abbruch aus, sondern nach
|
||||||
|
// einer Anwendung, die eben wenig Historie zeigt.
|
||||||
|
if (h.event_date < e.entry_date) {
|
||||||
|
throw new Error(`${e.email}: Ereignis ${h.event_type} am ${h.event_date} liegt vor dem Eintritt am ${e.entry_date}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { Kysely, PostgresDialect } from "kysely";
|
||||||
import { applyDerivedStatusFilter } from "@/lib/employee-status-filter";
|
import { Pool } from "pg";
|
||||||
|
import { afterAll, describe, expect, it } from "vitest";
|
||||||
|
import { derivedStatusFilter } from "@/lib/employee-status-filter";
|
||||||
|
import type { Schema } from "@/lib/db/schema";
|
||||||
import { todayIso } from "@/lib/format";
|
import { todayIso } from "@/lib/format";
|
||||||
import { deriveStatusAsOf } from "@/lib/reports";
|
import { deriveStatusAsOf } from "@/lib/reports";
|
||||||
import type { EmploymentStatus } from "@/lib/supabase/types";
|
import type { EmploymentStatus } from "@/lib/supabase/types";
|
||||||
import { adminClient } from "./helpers";
|
|
||||||
|
|
||||||
// lib/employee-status-filter.ts is a SQL restatement of deriveStatusAsOf():
|
// lib/employee-status-filter.ts is a SQL restatement of deriveStatusAsOf():
|
||||||
// the employee list pages in the database and cannot derive status in JS, so
|
// the employee list pages in the database and cannot derive status in JS, so
|
||||||
@@ -13,22 +15,36 @@ import { adminClient } from "./helpers";
|
|||||||
//
|
//
|
||||||
// Only a real database can settle it, so this runs both over the whole
|
// Only a real database can settle it, so this runs both over the whole
|
||||||
// seeded roster and demands the same set of ids.
|
// seeded roster and demands the same set of ids.
|
||||||
describe("derived status filter matches deriveStatusAsOf", () => {
|
//
|
||||||
|
// Eigene Verbindung statt der Zugriffsschicht: geprüft wird die Bedingung,
|
||||||
|
// nicht die Berechtigung. Mit RLS dazwischen liefe der Test gegen eine
|
||||||
|
// gefilterte Teilmenge und bewiese nichts über die Regel.
|
||||||
|
const db = new Kysely<Schema>({
|
||||||
|
dialect: new PostgresDialect({ pool: new Pool({ connectionString: process.env.DATABASE_URL, max: 2 }) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!process.env.DATABASE_URL)("derived status filter matches deriveStatusAsOf", () => {
|
||||||
const asOf = todayIso();
|
const asOf = todayIso();
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
async function idsFromDatabase(statuses: EmploymentStatus[]): Promise<Set<string>> {
|
async function idsFromDatabase(statuses: EmploymentStatus[]): Promise<Set<string>> {
|
||||||
const query = adminClient.from("employees").select("id");
|
const rows = await db
|
||||||
const { data, error } = await applyDerivedStatusFilter(query, statuses, asOf);
|
.selectFrom("employees")
|
||||||
if (error) throw new Error(error.message);
|
.select("id")
|
||||||
return new Set((data ?? []).map((r) => r.id));
|
.where((eb) => derivedStatusFilter(eb, statuses, asOf) ?? eb.val(true))
|
||||||
|
.execute();
|
||||||
|
return new Set(rows.map((r) => r.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function idsFromDerivation(statuses: EmploymentStatus[]): Promise<Set<string>> {
|
async function idsFromDerivation(statuses: EmploymentStatus[]): Promise<Set<string>> {
|
||||||
const { data, error } = await adminClient
|
const rows = await db
|
||||||
.from("employees")
|
.selectFrom("employees")
|
||||||
.select("id, entry_date, exit_date, karenz_start_date, karenz_return_date");
|
.select(["id", "entry_date", "exit_date", "karenz_start_date", "karenz_return_date"])
|
||||||
if (error) throw new Error(error.message);
|
.execute();
|
||||||
return new Set((data ?? []).filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))).map((e) => e.id));
|
return new Set(rows.filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))).map((e) => e.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectSameSet(statuses: EmploymentStatus[]) {
|
async function expectSameSet(statuses: EmploymentStatus[]) {
|
||||||
@@ -68,12 +84,11 @@ describe("derived status filter matches deriveStatusAsOf", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns everyone when no status is selected", async () => {
|
it("returns everyone when no status is selected", async () => {
|
||||||
const { count: total } = await adminClient.from("employees").select("id", { count: "exact", head: true });
|
const total = await db
|
||||||
const { count: filtered } = await applyDerivedStatusFilter(
|
.selectFrom("employees")
|
||||||
adminClient.from("employees").select("id", { count: "exact", head: true }),
|
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
||||||
[],
|
.executeTakeFirstOrThrow();
|
||||||
asOf
|
const filtered = await idsFromDatabase([]);
|
||||||
);
|
expect(filtered.size).toBe(Number(total.anzahl));
|
||||||
expect(filtered).toBe(total);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
118
tests/integration/session-context.test.ts
Normal file
118
tests/integration/session-context.test.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { Kysely, PostgresDialect, sql } from "kysely";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
// Der Test, ohne den die Zugriffsschicht eine Behauptung wäre.
|
||||||
|
//
|
||||||
|
// Die Zugriffsrechte hängen an `current_setting('app.user_id')`. Diese
|
||||||
|
// Variable gehört der *Verbindung*, nicht der Anfrage — und Verbindungen
|
||||||
|
// kommen aus einem Pool. Wird sie ohne Transaktion gesetzt, sieht die
|
||||||
|
// nächste Anfrage auf derselben Verbindung die Kennung der vorherigen
|
||||||
|
// Person.
|
||||||
|
//
|
||||||
|
// Deshalb läuft hier ein Pool mit **genau einer** Verbindung: so ist
|
||||||
|
// garantiert, dass zwei aufeinanderfolgende Zugriffe dieselbe physische
|
||||||
|
// Verbindung benutzen. Mit mehreren Verbindungen würde der Test die Lücke
|
||||||
|
// zufällig mal treffen und mal nicht — und wäre damit wertlos.
|
||||||
|
|
||||||
|
const CONNECTION = process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
// Ohne Datenbank lässt sich lokal nichts prüfen — in der CI wäre ein
|
||||||
|
// stillschweigend übersprungener Sicherheitstest allerdings schlimmer als
|
||||||
|
// gar keiner. Deshalb schlägt dort schon das Fehlen des Verbindungsstrings
|
||||||
|
// fehl, statt eine grüne Anzeige vorzutäuschen.
|
||||||
|
describe("Voraussetzung", () => {
|
||||||
|
it.runIf(process.env.CI)("DATABASE_URL ist in der CI gesetzt", () => {
|
||||||
|
expect(
|
||||||
|
CONNECTION,
|
||||||
|
"DATABASE_URL fehlt. Ohne sie wird der Nachweis zum Sitzungskontext übersprungen — " +
|
||||||
|
"und genau dieser Nachweis trägt die Zugriffsabsicherung."
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString: CONNECTION, max: 1 });
|
||||||
|
const db = new Kysely<Record<string, never>>({ dialect: new PostgresDialect({ pool }) });
|
||||||
|
|
||||||
|
/** Die Fassung aus lib/db: transaktionslokal. */
|
||||||
|
async function withUser<T>(userId: string | null, fn: () => Promise<T>): Promise<T> {
|
||||||
|
return db.transaction().execute(async (tx) => {
|
||||||
|
await sql`select set_config('app.user_id', ${userId ?? ""}, true)`.execute(tx);
|
||||||
|
return fn();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function currentUserOnConnection(): Promise<string | null> {
|
||||||
|
const r = await sql<{ v: string | null }>`select nullif(current_setting('app.user_id', true), '') as v`.execute(db);
|
||||||
|
return r.rows[0]?.v ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!CONNECTION)("Sitzungskontext über eine gepoolte Verbindung", () => {
|
||||||
|
const alice = randomUUID();
|
||||||
|
const bob = randomUUID();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Ein Pool mit einer Verbindung: der Test ist nur dann aussagekräftig,
|
||||||
|
// wenn beide Zugriffe garantiert dieselbe benutzen.
|
||||||
|
await sql`select 1`.execute(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sieht innerhalb der Transaktion die eigene Kennung", async () => {
|
||||||
|
const seen = await withUser(alice, async () => {
|
||||||
|
const r = await sql<{ v: string }>`select current_setting('app.user_id', true) as v`.execute(db);
|
||||||
|
return r.rows[0].v;
|
||||||
|
});
|
||||||
|
expect(seen).toBe(alice);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt nach der Transaktion nichts an der Verbindung zurück", async () => {
|
||||||
|
// Das ist der eigentliche Befund: nach dem Commit muss die Variable weg
|
||||||
|
// sein. Bliebe sie stehen, liefe die nächste Anfrage im Namen von Alice.
|
||||||
|
await withUser(alice, async () => {
|
||||||
|
await sql`select 1`.execute(db);
|
||||||
|
});
|
||||||
|
expect(await currentUserOnConnection()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trägt die Kennung nicht von einer Anfrage in die nächste", async () => {
|
||||||
|
await withUser(alice, async () => {
|
||||||
|
await sql`select 1`.execute(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
const seenByBob = await withUser(bob, async () => {
|
||||||
|
const r = await sql<{ v: string }>`select current_setting('app.user_id', true) as v`.execute(db);
|
||||||
|
return r.rows[0].v;
|
||||||
|
});
|
||||||
|
expect(seenByBob).toBe(bob);
|
||||||
|
expect(seenByBob).not.toBe(alice);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hinterlässt auch nach einem Fehler keinen Kontext", async () => {
|
||||||
|
// Ein Rollback muss die Variable genauso zurücknehmen wie ein Commit —
|
||||||
|
// sonst wird ausgerechnet der Fehlerfall zur Lücke.
|
||||||
|
await expect(
|
||||||
|
withUser(alice, async () => {
|
||||||
|
await sql`select 1 / 0`.execute(db);
|
||||||
|
})
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(await currentUserOnConnection()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zeigt, dass eine Einstellung ohne Transaktion tatsächlich hängen bleibt", async () => {
|
||||||
|
// Die Gegenprobe. Sie belegt, dass die Vorsichtsmassnahme kein Ritual
|
||||||
|
// ist: ohne das dritte Argument `true` überlebt die Kennung die Anfrage
|
||||||
|
// und die nächste Person erbt sie.
|
||||||
|
await sql`select set_config('app.user_id', ${alice}, false)`.execute(db);
|
||||||
|
expect(await currentUserOnConnection()).toBe(alice);
|
||||||
|
|
||||||
|
// Aufräumen, sonst verfälscht dieser Test die anderen.
|
||||||
|
await sql`select set_config('app.user_id', '', false)`.execute(db);
|
||||||
|
expect(await currentUserOnConnection()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
68
tests/unit/db-type-parsers.test.ts
Normal file
68
tests/unit/db-type-parsers.test.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { types } from "pg";
|
||||||
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Warum es diesen Test gibt.
|
||||||
|
//
|
||||||
|
// lib/supabase/types.ts sagt für 16 Spalten `string` und für zwei `number`.
|
||||||
|
// Der `pg`-Treiber liefert von Haus aus das Gegenteil: aus `date` wird ein
|
||||||
|
// Date-Objekt, aus `numeric` eine Zeichenkette.
|
||||||
|
//
|
||||||
|
// Diese Abweichung ist für Typprüfer und Tests unsichtbar — die
|
||||||
|
// Deklarationen beschreiben, was der Code glaubt, nicht was ankommt. tsc war
|
||||||
|
// sauber, 187 Tests waren grün, und die Anwendung stürzte auf der ersten
|
||||||
|
// Seite ab („a.date.localeCompare is not a function"). Der Absturz war noch
|
||||||
|
// der freundliche Fall; ein Vergleich zwischen Date und Zeichenkette wirft
|
||||||
|
// nicht, er liefert bloss das falsche Ergebnis.
|
||||||
|
//
|
||||||
|
// Geprüft wird deshalb die einzige Stelle, an der beides zusammenkommt: die
|
||||||
|
// Parser, die lib/db/pool.ts beim Import registriert.
|
||||||
|
|
||||||
|
vi.mock("server-only", () => ({}));
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Der Import allein registriert die Parser — deshalb reicht er als
|
||||||
|
// Vorbereitung, und deshalb würde ein Entfernen der Zeilen hier auffallen.
|
||||||
|
await import("@/lib/db/pool");
|
||||||
|
});
|
||||||
|
|
||||||
|
const parse = (oid: number, raw: string) => types.getTypeParser(oid)(raw);
|
||||||
|
|
||||||
|
describe("Typumwandlung des Treibers", () => {
|
||||||
|
it("gibt `date` unverändert als Kalendertag zurück", () => {
|
||||||
|
// Nicht `new Date(...)`: ein Kalendertag hat keine Zeitzone. Als
|
||||||
|
// Date-Objekt bekäme er Mitternacht in der Zone des Servers, und ein
|
||||||
|
// Geburtsdatum verschöbe sich beim Formatieren um einen Tag — in
|
||||||
|
// Österreich immer, weil MEZ östlich von UTC liegt.
|
||||||
|
expect(parse(1082, "1968-08-15")).toBe("1968-08-15");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt `numeric` als Zahl zurück", () => {
|
||||||
|
// Die FTE-Kachel rechnet damit. Als Zeichenkette ergäbe die Summe
|
||||||
|
// "744.4" + "0.5" = "744.40.5" statt 744.9 — ohne jede Fehlermeldung.
|
||||||
|
expect(parse(1700, "744.4")).toBe(744.4);
|
||||||
|
expect(parse(1700, "38.5")).toBe(38.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt `timestamptz` als ISO-8601 mit Z zurück", () => {
|
||||||
|
const v = parse(1184, "2026-08-03 07:55:46.659+00");
|
||||||
|
expect(v).toBe("2026-08-03T07:55:46.659Z");
|
||||||
|
// Muss für Date verwertbar bleiben — die Protokollansicht formatiert
|
||||||
|
// damit. Die Rohform des Treibers („… +00") ist kein Format, das der
|
||||||
|
// Standard kennt.
|
||||||
|
expect(Number.isNaN(new Date(v as string).getTime())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt `int8` als Zeichenkette", () => {
|
||||||
|
// Kommt nur aus count() und wird überall mit Number() gelesen. Als Zahl
|
||||||
|
// geparst verlöre es jenseits von 2^53 stillschweigend an Genauigkeit.
|
||||||
|
expect(parse(20, "852")).toBe("852");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sortiert ISO-Zeitstempel als Text chronologisch", () => {
|
||||||
|
// Darauf beruht jedes orderBy im Anwendungscode, das auf Zeichenketten
|
||||||
|
// arbeitet. Gilt nur, weil das Format feste Breite hat und in UTC steht.
|
||||||
|
const frueher = parse(1184, "2026-08-03 07:55:46.659+00") as string;
|
||||||
|
const spaeter = parse(1184, "2026-08-03 09:12:01.000+00") as string;
|
||||||
|
expect(frueher.localeCompare(spaeter)).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
300
tests/unit/import-validate.test.ts
Normal file
300
tests/unit/import-validate.test.ts
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ImportSheet } from "@/lib/import/parse";
|
||||||
|
import { LEERER_BESTAND, pruefe, type Bestand } from "@/lib/import/validate";
|
||||||
|
|
||||||
|
// Die Prüfung entscheidet, ob 800 fremde Zeilen in eine Personaldatenbank
|
||||||
|
// dürfen. Getestet wird deshalb nicht, dass gute Dateien durchgehen — das
|
||||||
|
// wäre die leichte Hälfte —, sondern dass jede einzelne Regel eine schlechte
|
||||||
|
// Datei aufhält, und zwar mit Zeilennummer.
|
||||||
|
|
||||||
|
function blatt(name: string, spalten: string[], zeilen: string[][]): ImportSheet {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
spalten,
|
||||||
|
zeilen: zeilen.map((werte, i) => ({
|
||||||
|
zeile: i + 2,
|
||||||
|
werte: Object.fromEntries(spalten.map((s, j) => [s, werte[j] ?? ""])),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORG_SPALTEN = ["Orgnummer", "Bezeichnung", "Art", "Übergeordnet"];
|
||||||
|
const PERSON_SPALTEN = [
|
||||||
|
"Personalnummer",
|
||||||
|
"Vorname",
|
||||||
|
"Nachname",
|
||||||
|
"Geschlecht",
|
||||||
|
"Geburtsdatum",
|
||||||
|
"E-Mail",
|
||||||
|
"Tätigkeit",
|
||||||
|
"Standort",
|
||||||
|
"Planstellennummer",
|
||||||
|
"Eintritt",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Ein Bestand, in dem Standort und Planstelle bereits existieren. */
|
||||||
|
const BESTAND: Bestand = {
|
||||||
|
...LEERER_BESTAND,
|
||||||
|
standorte: new Map([["Wien", "l1"]]),
|
||||||
|
planstellen: new Map([
|
||||||
|
["60000001", { id: "p1", besetzt: false }],
|
||||||
|
["60000002", { id: "p2", besetzt: false }],
|
||||||
|
["60000009", { id: "p9", besetzt: true }],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const person = (pnr: string, mail: string, stelle: string, extra: Partial<Record<string, string>> = {}) => [
|
||||||
|
pnr,
|
||||||
|
"Sabine",
|
||||||
|
"Aigner",
|
||||||
|
"w",
|
||||||
|
"15.08.1968",
|
||||||
|
mail,
|
||||||
|
"Projektingenieur:in",
|
||||||
|
extra.Standort ?? "Wien",
|
||||||
|
stelle,
|
||||||
|
extra.Eintritt ?? "01.03.2015",
|
||||||
|
];
|
||||||
|
|
||||||
|
function meldungen(f: { blatt: string; zeile: number | null; spalte: string | null; meldung: string }[]) {
|
||||||
|
return f.map((x) => `${x.blatt}/${x.zeile ?? "-"}/${x.spalte ?? "-"}: ${x.meldung}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Aufbau der Organisation", () => {
|
||||||
|
it("besteht auf genau einer Wurzel", () => {
|
||||||
|
const zwei = pruefe(
|
||||||
|
[blatt("Organisation", ORG_SPALTEN, [["1", "A", "Gesellschaft", ""], ["2", "B", "Gesellschaft", ""]])],
|
||||||
|
LEERER_BESTAND
|
||||||
|
);
|
||||||
|
expect(meldungen(zwei.fehler).join(" ")).toContain("2 Zeilen „Gesellschaft“");
|
||||||
|
|
||||||
|
const keine = pruefe([blatt("Organisation", ORG_SPALTEN, [["2", "B", "Bereich", "1"]])], LEERER_BESTAND);
|
||||||
|
expect(meldungen(keine.fehler).join(" ")).toContain("keine Wurzel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist eine übergeordnete Einheit ab, die es nicht gibt", () => {
|
||||||
|
const r = pruefe(
|
||||||
|
[blatt("Organisation", ORG_SPALTEN, [["1", "A", "Gesellschaft", ""], ["2", "B", "Bereich", "99"]])],
|
||||||
|
LEERER_BESTAND
|
||||||
|
);
|
||||||
|
expect(meldungen(r.fehler)).toContain("Organisation/3/Übergeordnet: Steht weder in der Datei noch im System.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist eine Einheit ab, die sich selbst übergeordnet ist", () => {
|
||||||
|
const r = pruefe(
|
||||||
|
[blatt("Organisation", ORG_SPALTEN, [["1", "A", "Gesellschaft", ""], ["2", "B", "Bereich", "2"]])],
|
||||||
|
LEERER_BESTAND
|
||||||
|
);
|
||||||
|
expect(meldungen(r.fehler).join(" ")).toContain("kann sich nicht selbst übergeordnet sein");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nennt bei einer doppelten Orgnummer die frühere Zeile", () => {
|
||||||
|
const r = pruefe(
|
||||||
|
[
|
||||||
|
blatt("Organisation", ORG_SPALTEN, [
|
||||||
|
["1", "A", "Gesellschaft", ""],
|
||||||
|
["2", "B", "Bereich", "1"],
|
||||||
|
["2", "C", "Bereich", "1"],
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
LEERER_BESTAND
|
||||||
|
);
|
||||||
|
expect(meldungen(r.fehler).join(" ")).toContain("Kommt bereits in Zeile 3 vor");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Personen", () => {
|
||||||
|
it("lässt eine saubere Zeile durch", () => {
|
||||||
|
const r = pruefe([blatt("Personen", PERSON_SPALTEN, [person("2219", "a@example.at", "60000001")])], BESTAND);
|
||||||
|
expect(r.fehler).toEqual([]);
|
||||||
|
expect(r.anzahl.Personen).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verhindert, dass zwei Zeilen dieselbe Planstelle besetzen", () => {
|
||||||
|
// Die Datenbank hat dafür einen Teilindex — ohne diese Prüfung bräche
|
||||||
|
// der Import erst beim Schreiben ab, ohne Zeilennummer.
|
||||||
|
const r = pruefe(
|
||||||
|
[
|
||||||
|
blatt("Personen", PERSON_SPALTEN, [
|
||||||
|
person("1", "a@example.at", "60000001"),
|
||||||
|
person("2", "b@example.at", "60000001"),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
BESTAND
|
||||||
|
);
|
||||||
|
expect(meldungen(r.fehler)).toContain("Personen/3/Planstellennummer: Wird bereits in Zeile 2 besetzt.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist eine bereits besetzte Planstelle ab", () => {
|
||||||
|
const r = pruefe([blatt("Personen", PERSON_SPALTEN, [person("1", "a@example.at", "60000009")])], BESTAND);
|
||||||
|
expect(meldungen(r.fehler)).toContain("Personen/2/Planstellennummer: Diese Planstelle ist bereits besetzt.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prüft die SV-Nummer gegen das Geburtsdatum", () => {
|
||||||
|
const spalten = [...PERSON_SPALTEN, "SV-Nummer"];
|
||||||
|
const gut = pruefe(
|
||||||
|
[blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "7960 150868"]])],
|
||||||
|
BESTAND
|
||||||
|
);
|
||||||
|
expect(gut.fehler).toEqual([]);
|
||||||
|
|
||||||
|
const falsch = pruefe(
|
||||||
|
[blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "7960 010170"]])],
|
||||||
|
BESTAND
|
||||||
|
);
|
||||||
|
expect(falsch.fehler.some((f) => f.spalte === "SV-Nummer")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verlangt einen Grund zum Austritt und einen Austritt nach dem Eintritt", () => {
|
||||||
|
const spalten = [...PERSON_SPALTEN, "Austritt", "Austrittsgrund"];
|
||||||
|
const ohneGrund = pruefe(
|
||||||
|
[blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "01.01.2020", ""]])],
|
||||||
|
BESTAND
|
||||||
|
);
|
||||||
|
expect(meldungen(ohneGrund.fehler).join(" ")).toContain("Austrittsgrund");
|
||||||
|
|
||||||
|
const zuFrueh = pruefe(
|
||||||
|
[blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "01.01.2010", "Kündigung"]])],
|
||||||
|
BESTAND
|
||||||
|
);
|
||||||
|
expect(meldungen(zuFrueh.fehler).join(" ")).toContain("Liegt vor dem Eintritt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt Ein- und Austritt am selben Tag zu", () => {
|
||||||
|
// Wer den Dienst nicht antritt, tritt am selben Tag ein und aus. Die
|
||||||
|
// CHECK-Bedingung der Datenbank erlaubt das ausdrücklich; die Prüfung
|
||||||
|
// hier war strenger und hätte einen echten Fall abgewiesen.
|
||||||
|
const spalten = [...PERSON_SPALTEN, "Austritt", "Austrittsgrund"];
|
||||||
|
const r = pruefe(
|
||||||
|
[blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "01.03.2015", "Dienstantritt nicht erfolgt"]])],
|
||||||
|
BESTAND
|
||||||
|
);
|
||||||
|
expect(r.fehler).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bindet die Wochenstunden an die Beschäftigungsart", () => {
|
||||||
|
// Vollzeit sind genau 38,5 — die Datenbank hat dafür eine
|
||||||
|
// CHECK-Bedingung. Ohne diese Prüfung bricht der Import erst beim
|
||||||
|
// Schreiben ab, und die Meldung nennt keine Zeile.
|
||||||
|
const spalten = [...PERSON_SPALTEN, "Beschäftigung", "Wochenstunden"];
|
||||||
|
const falsch = pruefe(
|
||||||
|
[blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "Vollzeit", "20,5"]])],
|
||||||
|
BESTAND
|
||||||
|
);
|
||||||
|
expect(meldungen(falsch.fehler).join(" ")).toContain("Vollzeit sind genau 38,5");
|
||||||
|
|
||||||
|
const teilzeitOhne = pruefe(
|
||||||
|
[blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "Teilzeit", ""]])],
|
||||||
|
BESTAND
|
||||||
|
);
|
||||||
|
expect(meldungen(teilzeitOhne.fehler).join(" ")).toContain("Pflicht bei Teilzeit");
|
||||||
|
|
||||||
|
const gut = pruefe(
|
||||||
|
[blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "Teilzeit", "20,5"]])],
|
||||||
|
BESTAND
|
||||||
|
);
|
||||||
|
expect(gut.fehler).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt nur Titel aus der hinterlegten Liste zu", () => {
|
||||||
|
const spalten = [...PERSON_SPALTEN, "Titel vorangestellt"];
|
||||||
|
const erfunden = pruefe(
|
||||||
|
[blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "Doktor"]])],
|
||||||
|
BESTAND
|
||||||
|
);
|
||||||
|
expect(meldungen(erfunden.fehler).join(" ")).toContain("Doktor");
|
||||||
|
|
||||||
|
const echt = pruefe([blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "Mag."]])], BESTAND);
|
||||||
|
expect(echt.fehler).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verlangt ein Ende nur bei befristeten Verträgen — und dort immer", () => {
|
||||||
|
const spalten = [...PERSON_SPALTEN, "Vertragsart", "Befristet bis"];
|
||||||
|
const fehlt = pruefe(
|
||||||
|
[blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "befristet", ""]])],
|
||||||
|
BESTAND
|
||||||
|
);
|
||||||
|
expect(meldungen(fehlt.fehler).join(" ")).toContain("Pflicht bei einem befristeten Vertrag");
|
||||||
|
|
||||||
|
const zuviel = pruefe(
|
||||||
|
[blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "unbefristet", "01.01.2030"]])],
|
||||||
|
BESTAND
|
||||||
|
);
|
||||||
|
expect(meldungen(zuviel.fehler).join(" ")).toContain("Nur bei „befristet“ erlaubt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet einen unbekannten Standort statt ihn anzulegen", () => {
|
||||||
|
const r = pruefe(
|
||||||
|
[blatt("Personen", PERSON_SPALTEN, [person("1", "a@example.at", "60000001", { Standort: "Graz" })])],
|
||||||
|
BESTAND
|
||||||
|
);
|
||||||
|
expect(meldungen(r.fehler)).toContain("Personen/2/Standort: Steht weder in der Datei noch im System.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sammelt alle Fehler, statt beim ersten aufzuhören", () => {
|
||||||
|
// Bei 800 Zeilen ist das der Unterschied zwischen einmal korrigieren und
|
||||||
|
// achthundertmal hochladen.
|
||||||
|
const r = pruefe(
|
||||||
|
[
|
||||||
|
blatt("Personen", PERSON_SPALTEN, [
|
||||||
|
person("1", "kaputt", "60000001"),
|
||||||
|
person("1", "b@example.at", "99999999"),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
BESTAND
|
||||||
|
);
|
||||||
|
const zeilen = new Set(r.fehler.map((f) => f.zeile));
|
||||||
|
expect(zeilen.has(2)).toBe(true);
|
||||||
|
expect(zeilen.has(3)).toBe(true);
|
||||||
|
expect(r.fehler.length).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Historie", () => {
|
||||||
|
const H = ["Personalnummer", "Datum", "Ereignis", "Beschreibung"];
|
||||||
|
|
||||||
|
it("weist ein Ereignis vor dem Eintritt ab", () => {
|
||||||
|
// Genau der Fall, an dem der Seed einmal stillschweigend gescheitert ist.
|
||||||
|
const r = pruefe(
|
||||||
|
[
|
||||||
|
blatt("Personen", PERSON_SPALTEN, [person("2219", "a@example.at", "60000001")]),
|
||||||
|
blatt("Historie", H, [["2219", "01.01.2010", "Eintritt", "Eintritt"]]),
|
||||||
|
],
|
||||||
|
BESTAND
|
||||||
|
);
|
||||||
|
expect(meldungen(r.fehler)).toContain("Historie/2/Datum: Liegt vor dem Eintritt am 2015-03-01.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist ein Ereignis zu einer unbekannten Person ab", () => {
|
||||||
|
const r = pruefe([blatt("Historie", H, [["999", "01.01.2020", "Eintritt", "x"]])], BESTAND);
|
||||||
|
expect(meldungen(r.fehler)).toContain("Historie/2/Personalnummer: Steht weder in der Datei noch im System.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist einen unbekannten Ereignistyp ab und nennt die erlaubten", () => {
|
||||||
|
const r = pruefe(
|
||||||
|
[
|
||||||
|
blatt("Personen", PERSON_SPALTEN, [person("2219", "a@example.at", "60000001")]),
|
||||||
|
blatt("Historie", H, [["2219", "01.04.2015", "Gehaltserhöhung", "x"]]),
|
||||||
|
],
|
||||||
|
BESTAND
|
||||||
|
);
|
||||||
|
const m = meldungen(r.fehler).join(" ");
|
||||||
|
expect(m).toContain("Unbekannter Wert");
|
||||||
|
expect(m).toContain("Gehaltsanpassung");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Blätter und Spalten", () => {
|
||||||
|
it("meldet eine fehlende Pflichtspalte am Blatt statt an jeder Zeile", () => {
|
||||||
|
const r = pruefe([blatt("Jobkatalog", ["Jobcode"], [["50000001"]])], LEERER_BESTAND);
|
||||||
|
expect(meldungen(r.fehler)).toContain("Jobkatalog/-/Bezeichnung: Pflichtspalte fehlt.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("übergeht ein unbekanntes Blatt mit Hinweis statt mit Fehler", () => {
|
||||||
|
// Mappen enthalten oft ein „Deckblatt“ oder „Tabelle1“. Das ist kein
|
||||||
|
// Grund, den ganzen Import zu verweigern.
|
||||||
|
const r = pruefe([blatt("Deckblatt", ["A"], [["x"]])], LEERER_BESTAND);
|
||||||
|
expect(r.fehler).toEqual([]);
|
||||||
|
expect(meldungen(r.hinweise).join(" ")).toContain("Unbekanntes Blatt");
|
||||||
|
});
|
||||||
|
});
|
||||||
137
tests/unit/import-werte.test.ts
Normal file
137
tests/unit/import-werte.test.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { alsAufzaehlung, alsDatum, alsGanzzahl, alsJaNein, alsListe, alsZahl } from "@/lib/import/werte";
|
||||||
|
import { csvZerlegen, trennzeichenErkennen } from "@/lib/import/parse";
|
||||||
|
|
||||||
|
// Eine Importdatei kommt aus fremder Hand — aus einem Altsystem, aus Excel,
|
||||||
|
// von einer Steuerberatung. Die Fälle hier sind keine Randfälle, sondern das,
|
||||||
|
// was tatsächlich in solchen Dateien steht.
|
||||||
|
|
||||||
|
describe("Datum aus einer Zelle", () => {
|
||||||
|
it("liest ISO und österreichische Schreibweise", () => {
|
||||||
|
expect(alsDatum("2026-08-03")).toBe("2026-08-03");
|
||||||
|
expect(alsDatum("3.8.2026")).toBe("2026-08-03");
|
||||||
|
expect(alsDatum("03.08.2026")).toBe("2026-08-03");
|
||||||
|
expect(alsDatum("03/08/2026")).toBe("2026-08-03");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist zweistellige Jahre ab, statt das Jahrhundert zu raten", () => {
|
||||||
|
// „15.08.68" ist als Geburtsdatum 1968 und als Vertragsende 2068. Wer
|
||||||
|
// hier eine Regel erfindet, legt lautlos Personen an, die noch nicht
|
||||||
|
// geboren sind.
|
||||||
|
expect(alsDatum("15.08.68")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist einen Tag ab, den es nicht gibt", () => {
|
||||||
|
// Date rechnet den 31.02. stillschweigend in den 03.03. um — genau die
|
||||||
|
// Sorte Korrektur, die niemand bemerkt.
|
||||||
|
expect(alsDatum("31.02.2026")).toBeNull();
|
||||||
|
expect(alsDatum("2026-13-01")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liest die Serienzahl, die Excel in unformatierten Spalten hinterlässt", () => {
|
||||||
|
// 1 = 01.01.1900. Ab 60 verschiebt Excels erfundener 29.02.1900 alles um
|
||||||
|
// einen Tag; 61 ist deshalb der 01.03.1900 und nicht der 02.03.
|
||||||
|
expect(alsDatum("1")).toBe("1900-01-01");
|
||||||
|
expect(alsDatum("61")).toBe("1900-03-01");
|
||||||
|
// Zwei allgemein bekannte Anker, damit die Umrechnung nachprüfbar ist
|
||||||
|
// und nicht nur zu sich selbst passt.
|
||||||
|
expect(alsDatum("25569")).toBe("1970-01-01");
|
||||||
|
expect(alsDatum("44927")).toBe("2023-01-01");
|
||||||
|
expect(alsDatum("46237")).toBe("2026-08-03");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liefert für Unfug null statt eines Datums", () => {
|
||||||
|
expect(alsDatum("")).toBeNull();
|
||||||
|
expect(alsDatum("sofort")).toBeNull();
|
||||||
|
expect(alsDatum("3.8.")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Zahl aus einer Zelle", () => {
|
||||||
|
it("versteht Komma und Punkt als Dezimaltrenner", () => {
|
||||||
|
expect(alsZahl("38,5")).toBe(38.5);
|
||||||
|
expect(alsZahl("38.5")).toBe(38.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("versteht beide Tausendertrennungen", () => {
|
||||||
|
expect(alsZahl("1.234,50")).toBe(1234.5);
|
||||||
|
expect(alsZahl("1,234.50")).toBe(1234.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liest ein einzelnes Komma deutsch", () => {
|
||||||
|
// „1,234" sind hierzulande 1,234 — nicht 1234. Andersherum wäre ein
|
||||||
|
// Monatsgehalt um Faktor 1000 daneben.
|
||||||
|
expect(alsZahl("1,234")).toBe(1.234);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verträgt Währungszeichen und Leerzeichen", () => {
|
||||||
|
expect(alsZahl(" 3 450,00 € ")).toBe(3450);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist Text ab", () => {
|
||||||
|
expect(alsZahl("Vollzeit")).toBeNull();
|
||||||
|
expect(alsZahl("38,5h")).toBeNull();
|
||||||
|
expect(alsGanzzahl("38,5")).toBeNull();
|
||||||
|
expect(alsGanzzahl("2219")).toBe(2219);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Ja/Nein aus einer Zelle", () => {
|
||||||
|
it("versteht die üblichen Schreibweisen", () => {
|
||||||
|
for (const j of ["ja", "JA", "j", "x", "X", "wahr", "true", "1"]) expect(alsJaNein(j)).toBe(true);
|
||||||
|
for (const n of ["nein", "N", "falsch", "false", "0", "-"]) expect(alsJaNein(n)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liefert für Unbekanntes null statt „nein“", () => {
|
||||||
|
// Stillschweigend „nein“ anzunehmen hiesse, aus einem Tippfehler in der
|
||||||
|
// Spalte „Betriebsrat“ einen fehlenden Kündigungsschutz zu machen.
|
||||||
|
expect(alsJaNein("vielleicht")).toBeNull();
|
||||||
|
expect(alsJaNein("")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Liste und Aufzählung", () => {
|
||||||
|
it("trennt an Semikolon und Komma und wirft Leeres weg", () => {
|
||||||
|
expect(alsListe("Mo; Di ,Mi;")).toEqual(["Mo", "Di", "Mi"]);
|
||||||
|
expect(alsListe("")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bringt Aufzählungswerte auf die Schreibweise der Datenbank", () => {
|
||||||
|
expect(alsAufzaehlung("vollzeit", ["Vollzeit", "Teilzeit"])).toBe("Vollzeit");
|
||||||
|
expect(alsAufzaehlung(" TEILZEIT ", ["Vollzeit", "Teilzeit"])).toBe("Teilzeit");
|
||||||
|
expect(alsAufzaehlung("halbtags", ["Vollzeit", "Teilzeit"])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CSV", () => {
|
||||||
|
it("erkennt das Semikolon, das deutsches Excel schreibt", () => {
|
||||||
|
expect(trennzeichenErkennen("Personalnummer;Vorname;Nachname")).toBe(";");
|
||||||
|
expect(trennzeichenErkennen("Personalnummer,Vorname,Nachname")).toBe(",");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt ein Trennzeichen innerhalb von Anführungszeichen in Ruhe", () => {
|
||||||
|
// Ohne das verschiebt eine Adresse mit Semikolon alle folgenden Spalten
|
||||||
|
// — und die Zeile wird plausibel falsch importiert statt abgewiesen.
|
||||||
|
const [kopf, zeile] = csvZerlegen('Nr;Adresse;Ort\n1;"Hauptstr. 1; Stiege 2";Wien\n');
|
||||||
|
expect(kopf).toEqual(["Nr", "Adresse", "Ort"]);
|
||||||
|
expect(zeile).toEqual(["1", "Hauptstr. 1; Stiege 2", "Wien"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("versteht verdoppelte Anführungszeichen und Umbrüche im Feld", () => {
|
||||||
|
const zeilen = csvZerlegen('A;B\n1;"sagt ""hallo""\nin zwei Zeilen"\n');
|
||||||
|
expect(zeilen[1]).toEqual(["1", 'sagt "hallo"\nin zwei Zeilen']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verträgt CRLF und eine fehlende letzte Zeilenschaltung", () => {
|
||||||
|
expect(csvZerlegen("A;B\r\n1;2")).toEqual([
|
||||||
|
["A", "B"],
|
||||||
|
["1", "2"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("entfernt die Byte-Reihenfolge-Markierung von Excel", () => {
|
||||||
|
// Ohne das heisst die erste Spalte „Personalnummer“ und wird als
|
||||||
|
// fehlend gemeldet — ein Fehler, der beim Ansehen der Datei unsichtbar ist.
|
||||||
|
expect(csvZerlegen("Personalnummer;Vorname")[0]).toEqual(["Personalnummer", "Vorname"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
95
tests/unit/proxy.test.ts
Normal file
95
tests/unit/proxy.test.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Geprüft wird die Wegeführung im Proxy, nicht Auth.js.
|
||||||
|
//
|
||||||
|
// Der Wrapper `auth(handler)` gehört Auth.js und hängt die entschlüsselte
|
||||||
|
// Sitzung als `request.auth` an; das ist fremder Code und hier durch eine
|
||||||
|
// Attrappe ersetzt. Was hier zur Debatte steht, ist die Entscheidung, die
|
||||||
|
// proxy.ts daraus ableitet — und die hat eine Schleife als Fehlerfall, die
|
||||||
|
// sich im Betrieb als „die Anmeldeseite lädt endlos neu" zeigt und beim
|
||||||
|
// Lesen des Codes leicht übersehen wird.
|
||||||
|
//
|
||||||
|
// Die Attrappe gibt den umschlossenen Handler bewusst als **Zusage** zurück,
|
||||||
|
// weil Auth.js das in der Funktionsform ebenfalls tut. Eine Attrappe, die
|
||||||
|
// ihn direkt liefert, wäre freundlicher als die Wirklichkeit und liesse ein
|
||||||
|
// fehlendes `await` in proxy.ts durchgehen — genau der Fehler, der beim
|
||||||
|
// ersten Ausprobieren jede Anfrage in einen 404 laufen liess.
|
||||||
|
vi.mock("next-auth", () => ({
|
||||||
|
default: () => ({
|
||||||
|
auth: async (handler: (req: NextRequest) => unknown) => (req: NextRequest) => handler(req),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type Session = { user: { id: string } } | null;
|
||||||
|
|
||||||
|
// Bewusst locker getippt: Auth.js gibt dem umschlossenen Handler die
|
||||||
|
// Signatur eines Route Handlers (Anfrage plus Kontext), und der zweite
|
||||||
|
// Parameter spielt hier keine Rolle.
|
||||||
|
let proxy: (req: NextRequest) => unknown;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const mod = await import("@/proxy");
|
||||||
|
proxy = mod.proxy as unknown as typeof proxy;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function visit(path: string, session: Session): Promise<Response> {
|
||||||
|
const request = new NextRequest(`http://localhost${path}`);
|
||||||
|
Object.assign(request, { auth: session });
|
||||||
|
return (await proxy(request)) as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIGNED_IN: Session = { user: { id: "11111111-1111-1111-1111-111111111111" } };
|
||||||
|
|
||||||
|
/** NextResponse.next() setzt diesen Header; eine Weiterleitung tut es nicht. */
|
||||||
|
function passedThrough(res: Response): boolean {
|
||||||
|
return res.headers.get("x-middleware-next") === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("proxy", () => {
|
||||||
|
it("schickt eine Anfrage ohne Sitzung auf die Anmeldeseite", async () => {
|
||||||
|
const res = await visit("/employees", null);
|
||||||
|
expect(res.status).toBe(307);
|
||||||
|
expect(new URL(res.headers.get("location")!).pathname).toBe("/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt die Anmeldeseite ohne Sitzung durch", async () => {
|
||||||
|
expect(passedThrough(await visit("/login", null))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt Auth.js' eigene Endpunkte ohne Sitzung durch", async () => {
|
||||||
|
// Ohne diese Ausnahme landet der Rückweg von Entra auf /login und die
|
||||||
|
// Anmeldung kommt nie zustande — der Code steht in der URL, aber es
|
||||||
|
// entsteht nie ein Sitzungscookie.
|
||||||
|
expect(passedThrough(await visit("/api/auth/callback/microsoft-entra-id", null))).toBe(true);
|
||||||
|
expect(passedThrough(await visit("/api/auth/signin", null))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt eine angemeldete Anfrage durch", async () => {
|
||||||
|
expect(passedThrough(await visit("/employees", SIGNED_IN))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holt eine angemeldete Person von der Anmeldeseite zurück in die Anwendung", async () => {
|
||||||
|
const res = await visit("/login", SIGNED_IN);
|
||||||
|
expect(res.status).toBe(307);
|
||||||
|
expect(new URL(res.headers.get("location")!).pathname).toBe("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("belässt eine angemeldete Person auf /login, solange eine Meldung ansteht", async () => {
|
||||||
|
// Der Fall: angemeldet, aber ohne HR-Freischaltung. Das Layout leitet
|
||||||
|
// nach /login?error=no_hr_access. Würde der Proxy auch das nach /
|
||||||
|
// zurückschicken, liefen beide gegeneinander und der Grund wäre nie zu
|
||||||
|
// lesen.
|
||||||
|
expect(passedThrough(await visit("/login?error=no_hr_access", SIGNED_IN))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nimmt eine Sitzung ohne Kennung nicht als angemeldet", async () => {
|
||||||
|
// Kommt vor, wenn das Token aus einer älteren Fassung stammt: gültig
|
||||||
|
// signiert, aber ohne `uid`. Ohne diese Prüfung käme die Person durch
|
||||||
|
// und liefe in Abfragen mit userId = null — die geben nichts zurück, und
|
||||||
|
// die Anwendung sähe aus, als wären alle Daten weg.
|
||||||
|
const res = await visit("/employees", { user: {} } as unknown as Session);
|
||||||
|
expect(res.status).toBe(307);
|
||||||
|
expect(new URL(res.headers.get("location")!).pathname).toBe("/login");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,14 +9,18 @@ import {
|
|||||||
parseMeasure,
|
parseMeasure,
|
||||||
parseSplitDimension,
|
parseSplitDimension,
|
||||||
} from "@/lib/reports";
|
} from "@/lib/reports";
|
||||||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
|
||||||
|
|
||||||
// The route under test imports lib/supabase/admin.ts, which is guarded by
|
// Die Cron-Route lädt lib/db, das über `import "server-only"` abgesichert
|
||||||
// `import "server-only"` — that throws when loaded outside Next's own
|
// ist und beim Laden eine DATABASE_URL verlangt — beides gibt es unter
|
||||||
// server compilation (e.g. here, under plain Vitest/Node). Mock it out:
|
// reinem Vitest nicht. Weggemockt: geprüft wird hier nur der
|
||||||
// these tests only exercise the auth guard, which returns before the real
|
// Geheimnis-Abgleich, der zurückkehrt, bevor irgendeine Verbindung
|
||||||
// admin client is ever created.
|
// entsteht.
|
||||||
vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: vi.fn() }));
|
// `import "server-only"` wirft ausserhalb der Server-Übersetzung von Next —
|
||||||
|
// also auch hier. Der Riegel ist im Betrieb richtig; für den Test wird das
|
||||||
|
// Modul zu einer leeren Hülle.
|
||||||
|
vi.mock("server-only", () => ({}));
|
||||||
|
vi.mock("@/lib/db", () => ({ asSystem: vi.fn(), withUser: vi.fn() }));
|
||||||
|
vi.mock("@/lib/db/rpc", () => ({ callFunction: vi.fn() }));
|
||||||
|
|
||||||
describe("sanitizeForSpreadsheetCell", () => {
|
describe("sanitizeForSpreadsheetCell", () => {
|
||||||
it("prefixes values that would be read as a formula by Excel/Sheets", () => {
|
it("prefixes values that would be read as a formula by Excel/Sheets", () => {
|
||||||
@@ -37,20 +41,11 @@ describe("sanitizeForSpreadsheetCell", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sanitizeIlikeTerm", () => {
|
// Der Test zu sanitizeIlikeTerm ist entfallen, weil die Funktion es ist.
|
||||||
it("strips PostgREST or-filter delimiter characters", () => {
|
// Sie entschärfte Zeichen, die in der Filtersyntax der alten API-Schicht
|
||||||
expect(sanitizeIlikeTerm("a,b")).toBe("ab");
|
// strukturelle Bedeutung hatten. Am direkten Datenbankzugang wird der
|
||||||
expect(sanitizeIlikeTerm("a(b)c")).toBe("abc");
|
// Suchbegriff als Parameter gebunden — ein Komma oder eine Klammer darin
|
||||||
// An attempt to close the current ilike condition and append another
|
// ist schlicht ein Zeichen. Die Lücke ist nicht abgesichert, sondern weg.
|
||||||
// column filter is neutralized by removing the delimiters, not escaped
|
|
||||||
// into a differently-structured (but still injected) query.
|
|
||||||
expect(sanitizeIlikeTerm("x),sv_nummer.ilike.%")).toBe("xsv_nummer.ilike.%");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves a normal search term untouched", () => {
|
|
||||||
expect(sanitizeIlikeTerm("Gruber")).toBe("Gruber");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("report query-string parsing", () => {
|
describe("report query-string parsing", () => {
|
||||||
it("falls back to a known dimension instead of passing an unknown one through", () => {
|
it("falls back to a known dimension instead of passing an unknown one through", () => {
|
||||||
@@ -143,15 +138,15 @@ describe("cron auth guard (/api/cron/apply-pending-changes)", () => {
|
|||||||
|
|
||||||
describe("protected export route without a session (/api/export/employees)", () => {
|
describe("protected export route without a session (/api/export/employees)", () => {
|
||||||
it("returns 401 instead of running the query when there is no authenticated user", async () => {
|
it("returns 401 instead of running the query when there is no authenticated user", async () => {
|
||||||
vi.doMock("@/lib/supabase/server", () => ({
|
// Gestellt wird die Sitzung, nicht die Datenbank: die Route muss mit 401
|
||||||
createClient: async () => ({
|
// antworten, *bevor* sie eine Verbindung aufbaut. Käme sie bis zur
|
||||||
auth: { getUser: async () => ({ data: { user: null } }) },
|
// Abfrage, liefe der Test ohne DATABASE_URL in einen Verbindungsfehler
|
||||||
}),
|
// statt in die Zusicherung — und bewiese nichts.
|
||||||
}));
|
vi.doMock("@/auth", () => ({ auth: async () => null }));
|
||||||
const { GET } = await import("@/app/api/export/employees/route");
|
const { GET } = await import("@/app/api/export/employees/route");
|
||||||
const request = new NextRequest("http://localhost/api/export/employees");
|
const request = new NextRequest("http://localhost/api/export/employees");
|
||||||
const res = await GET(request);
|
const res = await GET(request);
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
vi.doUnmock("@/lib/supabase/server");
|
vi.doUnmock("@/auth");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
25
types/next-auth.d.ts
vendored
Normal file
25
types/next-auth.d.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { DefaultSession } from "next-auth";
|
||||||
|
|
||||||
|
// Auth.js kennt von Haus aus keine `id` auf der Sitzung — und `user.id` aus
|
||||||
|
// dem Adapter gibt es nur mit Datenbank-Adapter, den diese Anwendung nicht
|
||||||
|
// benutzt. Hier steht deshalb die eine Ergänzung, die alles andere trägt:
|
||||||
|
// die app_users-Kennung, die in withUser() geht und über die jede RLS-Policy
|
||||||
|
// entscheidet.
|
||||||
|
|
||||||
|
declare module "next-auth" {
|
||||||
|
interface Session {
|
||||||
|
user: { id: string } & DefaultSession["user"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ergänzt wird `@auth/core/jwt` und nicht `next-auth/jwt`: letzteres reicht
|
||||||
|
// die Deklaration nur mit `export *` durch, und eine Ergänzung dort legt eine
|
||||||
|
// zweite, unverbundene Schnittstelle an, statt die vorhandene zu erweitern.
|
||||||
|
// Der Typprüfer meldet das als „Type '{}' is not assignable to type
|
||||||
|
// 'string'" — eine Fehlermeldung, die nicht danach aussieht.
|
||||||
|
declare module "@auth/core/jwt" {
|
||||||
|
interface JWT {
|
||||||
|
/** app_users.id — nicht die `oid` von Entra. */
|
||||||
|
uid?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user