Compare commits

..

13 Commits

Author SHA1 Message Date
0d8af7cdf0 Refuse a save that changes nothing instead of reporting success
update_position returned quietly when no field differed, and the interface
answered "Planstelle geändert." — a confirmation for something that had not
happened. It now raises, and the message says so.

This is reachable without the user doing anything wrong: the chief checkbox
is dropped on the way out when the unit already has a chief position, so a
save consisting only of that tick arrives as an empty change set. The reply
was a green toast and an unchanged list, which sends someone looking in the
wrong place.

It also separates the two explanations for "I saved and nothing happened",
which is why it went in now: an empty change set is refused in red, so a
green confirmation with a stale card can only mean the page did not reload.

Verified against the live database: an unchanged payload is refused, a
changed one goes through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 14:57:31 +02:00
a87c688c0a Show positions that do not exist yet, and let them be corrected
Two gaps in the positions view, both reported from use.

A position dated into the future was invisible. loadOpenPositions required
valid_from <= today, so a position decided now and effective at the quarter
boundary appeared nowhere until the day it began. The database already held
one — 60000824 "Neue Position", effective 01.09. — created through the
application and shown on no screen since.

Future positions now have their own section rather than joining the vacancy
list. They are a different statement: "nobody is here" and "this does not
exist yet" should not be counted together, and a position starting 01.10.
read as a vacancy nobody was filling.

Positions could only be created and deleted. Fixing a typo in the job title
meant deleting and recreating — with a new position number, which appears in
job postings, budgets and audit entries, and whose trail then breaks.
update_position keeps the number and records old and new values per field,
using the audit detail added earlier today.

Three things it refuses, as guards rather than remarks:

  - Moving an occupied position to another unit. That is a transfer, with
    history and reporting line, and belongs to the person — otherwise
    someone changes department silently.
  - Ending an occupied position, which would leave an assignment without
    one.
  - A second chief position in a unit, or an end before the start.

Verified against the live database, all rolled back: each guard fires with
its own message, the permitted edits go through, the audit entry carries the
changed fields. Open positions stay at 9 and the future one now appears in
its own section.

ESLint caught me priming the dialog's fields from an effect. Replaced by a
key on the component, so React rebuilds it per position and the fields
initialise from props — which also removes the flash of the previous
position's values on second open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 12:26:12 +02:00
e44f71a60d Stop offering positions that are already spoken for, and let hiring work again
Two reports, four defects, all of them in the way of ordinary use.

A position with a signed starter is not vacant. loadOpenPositions asked "is
anyone on it today?", so three positions whose new holders begin in
September and October were listed as open, labelled "vacant for 2 days".
That same list feeds the hire wizard, so it invited filling a position a
second time — discovered at the partial unique index, after the second
interview. Vacancy now means no assignment that still stands, including one
that has not started. An assignment that ended still frees the position.

Hiring was broken three times over, each fault hidden behind the previous
one:

  1. hire_employee cast to ::weekday[], a type that no longer exists — it
     was replaced by text plus a CHECK constraint and the function was never
     updated. apply_due_pending_changes had the same problem with
     ::relationship_type, which would have broken the nightly run.
     PL/pgSQL resolves types in embedded statements at execution time, so
     both functions were created without complaint and failed only in use.
  2. Fourteen functions called auth.uid(). The application connects as a
     role with no rights on the auth schema, so every write — hire,
     transfer, promote, exit, notes, positions — failed with "permission
     denied for schema auth". They now use app_current_user_id(), which is
     where #23 was heading anyway. Its own fallback also caught only
     "function missing" and now catches the privilege error too, so a call
     without session context returns null instead of raising.
  3. The audit line built a name as `payload->>'a' || ' ' || payload->>'b'`.
     `||` binds tighter than `->>`, so Postgres reads
     `payload ->> ('a' || ' ' || payload) ->> 'b'`. The ACL failure above
     had aborted analysis before the parser ever reached it.

And the wizard collected an email, showed it in the summary, and dropped it:
the server action's signature had no such field. employees.email is NOT
NULL, so every hire that got past the three faults above would have failed
there. It is now passed through and required in step one, rather than
refused by the database at the end of step four.

Verified against the live database, each rolled back: a hire now creates the
employee, the assignment, the history entry and an audit line reading "Probe
Einstellung"; open positions drop from 13 to 10, and the three that
disappear are exactly the ones with a starter.

Migrations rewrite the affected functions in place rather than restating
them — retyping 165 lines of working PL/pgSQL to change two words is the
larger risk. Each one asserts the result afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 12:10:17 +02:00
1271cef879 Record what a change was, not only which field it touched
The audit log said "Adresse, wirksam ab 30.07.2026". That names the field
and hides the answer: what did it say before? For a personnel record that is
the question the log exists to answer.

Both values are in hand at the moment of the change — v_old holds the row as
it was, the payload holds what is being written. change_employee_data
already compared them to decide whether to mention the field at all, then
dropped them. It now keeps them in audit_log.changes as
[{feld, vorher, nachher}], and derives the old one-line text from the same
array so existing views are unaffected.

Clicking a row opens the detail. Fields with no previous value read "leer"
rather than showing an empty cell, because "was not set" is itself a
statement.

Two honest limits, both stated in the panel rather than left to look like a
bug:

  - Existing entries cannot be enriched. The values were never captured;
    there is nothing to recover.
  - Hire, exit and import record no individual fields, so they show none.

The rewritten function also drops auth.uid() for app_current_user_id(),
which works on either system — one of the last few call sites before #23.

Caught while writing this: my scripted edit of types.ts silently did nothing
and my own check reported success, because the pattern matched
pending_org_changes. Redone with the editor. That is the second time a
regex-driven edit has lied about its result in this project.

Not verified end to end: the migration needs privileges I no longer hold
after the database password was rotated. Until it is applied the audit page
will not load, since it selects a column that does not exist yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:54:03 +02:00
3926f1bb80 Load a whole organisation from a file, or none of it
Second half of the mass import: the transactional loader, the /import page
and a template generated from the same schema the validation uses.

Everything happens in one transaction. A half-loaded organisation — areas
without departments, positions without people — is worse than none, because
it looks like data. The dry run is the same code path with a rollback at the
end, so the report is built against the real current state rather than a
copy, and nothing is cached between checking and committing: the file is
sent twice. That costs one upload and avoids server-side state that can
expire, fill up, or be confused between two people.

Personnel numbers are taken from the file, not reassigned. personnel_number
is GENERATED ALWAYS AS IDENTITY, so this needs OVERRIDING SYSTEM VALUE and a
hand-written insert — worth it, because the number is on payslips, in files
and on badges. An import that reissues it is not a migration. The identity
counter is advanced afterwards; without that the next hire draws a number
the import already used, and the unique index refuses it weeks later, far
from the cause.

Three defects the first real run against the database exposed, none of which
typecheck, lint or 231 tests could have found:

  - weekly_hours is bound to employment type by a CHECK constraint: full time
    is exactly 38.5. The import reached the insert and was rolled back. Now
    it is a finding with a row number.
  - Titles are restricted to a fixed list by another CHECK. Same treatment.
  - setval() needs UPDATE on the sequence, which `usage, select` does not
    grant. Migration 20260803120000 adds it; until it is applied, an import
    containing people will fail at the last step and take itself back.

I also had exit_date > entry_date where the database has >=. Someone who
never starts enters and leaves the same day; the stricter rule would have
rejected a real case.

Verified against the live database through the actual route and session: a
file with four deliberate faults produced exactly four findings, each with
sheet, row and column, and the rollback left nothing behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:40:33 +02:00
16b37244c8 Read an import file without guessing what it means
First half of the mass import: a file becomes named sheets with typed rows,
and every rule that could reject a row is stated in one place.

Nothing here touches a database. The parser turns bytes into sheets, the
schema says which columns exist, and validation reports findings — the
existing state is passed in as a parameter. That is what makes 36 tests
possible without a connection, and the rules are the part worth testing.

Three decisions where the easy choice would have been silent corruption:

  - A two-digit year is refused. "15.08.68" is 1968 as a birth date and 2068
    as a contract end, and any rule invented here creates people not yet
    born.
  - "31.02.2026" is refused. Date turns it into March 3rd without complaint.
  - An unrecognised value in a yes/no column is an error, not "no". Read the
    other way, a typo in "Betriebsrat" quietly removes someone's dismissal
    protection.

CSV is parsed rather than split. German Excel writes semicolons because the
comma is the decimal separator, so the delimiter is sniffed from the header;
a semicolon inside a quoted address would otherwise shift every following
column and import the row plausibly wrong. Quoted newlines, doubled quotes
and the byte-order mark Excel prepends are all handled — the last one makes
the first column read as "?Personalnummer", which is invisible in an editor.

Validation collects every finding instead of stopping at the first. With 800
rows that is the difference between correcting once and uploading eight
hundred times.

One rule earns its place from experience: a history event dated before the
entry it belongs to is refused here, with a row number, because the database
refuses it too — mid-insert, without one.

My own slip, caught by the type checker: `a ?? b ? c : d` does not mean what
it looks like; ?? binds tighter than the conditional.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:27:49 +02:00
73656461d1 Catch a seed event dated before the entry it belongs to
pruefeInvarianten() checked that no history event falls after an exit, but
not that none falls before an entry — and the database enforces exactly
that, in trg_history_not_before_entry.

That gap explains why the live database holds 852 people and no history at
all. employee_history is the last table the seed writes and insertInChunks
throws on the first rejected chunk, so everything before it was already
committed while every one of the ~950 events was lost. The result did not
look like an aborted run. It looked like an application that shows little
history.

The cause was the timezone bug in isoDate() that this seed already
documents: dates built from local parts but formatted through UTC land a day
early in Austria, which put every "Eintritt" one day before the entry date it
was derived from. That is fixed; the database was simply never rebuilt.

A dry run now reports 951 events and no violation, so the current code is
sound. The check stays because it turns this class of failure into a refusal
before the wipe instead of an abort halfway through it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 13:48:01 +02:00
99e50fbbf9 Stop hoarding connections the provider will not give twice
The app died with "max clients reached in session mode - pool_size: 15".
Two causes, both real, neither visible without a live database.

The connection string pointed at the pooler's session mode, which pins one
backend per client and caps at 15 on Supabase. Every query here already runs
inside a transaction and the session context is set transaction-locally, so
transaction mode is not a workaround but the mode this design was written
for. Verified: 20 concurrent transactions, all 852 rows, 0.4s — and still
nothing without a session context.

The second cause was the dev server. Next.js re-evaluates changed modules,
so a module-local `let` was empty afterwards while the previous pool stayed
alive holding its connections. An afternoon of editing exhausted the quota.
The pool now hangs off globalThis, which is inert in production where
nothing reloads.

Documented in .env.example and DEPLOYMENT.md, because a deployment that
picks port 5432 fails this way under load and not before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 10:28:36 +02:00
4d8e3f7154 Restore the shapes the application was written against
The app runs against a real database for the first time since the port, and
two things were broken. Both were invisible to typecheck, lint, 192 tests
and the build.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 10:11:34 +02:00
b9efe81bec Keep database snapshots out of the repository
Before applying the five pending migrations I took a full snapshot of the
live database — every table plus the source of all 61 functions. It sits in
.backups/ and holds 852 personnel records, so it must never be committed.

The ignore rule comes first, on its own, rather than riding along with the
next change: a snapshot that is already staged when someone remembers to
add the rule is a snapshot that has been in a commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 09:57:25 +02:00
2ba9b37aa7 Hand the front door to Entra, and keep the keys out of the build
Auth.js replaces GoTrue. The sign-in still goes to the same Entra tenant,
but nothing sits between the app and the identity provider any more — the
code exchange, state, nonce and the session cookie are ours.

lib/auth/session.ts stays the only place that knows where a user id comes
from, which is why this was one file and not fifty. What it returns is now
app_users.id. app_upsert_user() maps the Entra `oid` onto it, and for an
address that already has a profiles row it adopts that id instead of
minting a new one — otherwise everyone would have been signed in and cut
off from their own notes, drafts and audit trail at the same time.

That upsert is the one write that cannot have a session context yet: the
id is what it produces. It runs as a SECURITY DEFINER function that may
touch app_users and nothing else, which is a far smaller lever than the
service key that used to answer this class of problem.

The proxy no longer checks HR rights. It has no database connection, and
putting role/is_active in the token would have frozen the claim until the
next sign-in. The check moved to where it can read the current truth: the
app layout on every render, requireHrUser() for the export routes, and
underneath both, RLS.

Two things only came out by running it:

  - `export const proxy = auth(…)` is not a function declaration, so
    Next.js never found it and every request 404'd. `next build` reported
    success and listed the proxy. In the function config form auth() also
    returns the handler as a promise, so it needs an await. The proxy test
    now mocks it as a promise for that reason — a friendlier mock would
    let the same bug back in.

  - A missing AUTH_MICROSOFT_ENTRA_ID_ISSUER silently falls back to
    /common/, and the redirect really did go there. That would let any
    Microsoft account sign in, including a private one, and it would never
    look broken. It now refuses to start in production.

Neither build nor image needs credentials any more: the pool is created on
first use, the auth config is evaluated per request, and there are no
NEXT_PUBLIC_* values left to bake in. One image now runs in every
environment.

Verified: typecheck, lint, 187 tests, build, and by hand in the browser —
/employees redirects to /login, and the sign-in button reaches the Entra
page with PKCE and the callback URL that goes into the app registration.
Not verified against a real database; there is still no DATABASE_URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 14:57:32 +02:00
b3a0af2b8f Talk to PostgreSQL directly, and let the pooled connection forget
Zweiter Schritt weg von Supabase. Sämtliche 49 Lesezugriffe und alle
Mutationen laufen jetzt über lib/db statt über die REST-Schicht: Kysely auf
einem pg-Pool, jede Abfrage in einer Transaktion, in der zuerst
app.user_id gesetzt wird. Die Anmeldung hängt noch an GoTrue — sie liefert
die Kennung, die in withUser() geht. Damit war der Umbau in zwei Hälften
teilbar und die Anwendung durchgehend lauffähig.

Was dabei ersatzlos verschwindet:

  - fetchAllRows. Es gab die Funktion nur, weil PostgREST jede Antwort bei
    1000 Zeilen still abschneidet und ein Bericht dann leise falsch war.
    Am direkten Zugang ist eine Abfrage eine Abfrage.
  - sanitizeIlikeTerm samt Test. Sie entschärfte Zeichen, die in der
    Filtersyntax strukturelle Bedeutung hatten; jetzt wird der Suchbegriff
    als Parameter gebunden und ein Komma ist ein Komma. Die Lücke ist nicht
    abgesichert, sondern weg.
  - lib/supabase/admin.ts. Der Dienstschlüssel, der RLS aushebelte, hatte
    genau einen Aufrufer — den nächtlichen Lauf. Der benutzt jetzt dieselbe
    Rolle ohne BYPASSRLS und ruft eine SECURITY-DEFINER-Funktion auf, die
    selbst prüft, was sie tut. Es gibt keinen privilegierten Zugang mehr.

Nebenbei besser geworden, weil der direkte Zugang es erlaubt:

  - Eine Seite ist eine Transaktion. Das Layout etwa liest Profil,
    Planstellen, Standorte, Entwürfe und Notizen auf einem einheitlichen
    Lesestand statt in fünf unabhängigen Anfragen.
  - Der Bereichsfilter der Mitarbeiterliste ist ein EXISTS statt einer
    eingebetteten Ressource mit !inner — eine Person mit mehreren
    Zuordnungen über die Zeit erschien dort mehrfach.
  - Seitenweise Listen sortieren zusätzlich nach id. Bei gleichem Nachnamen
    oder gleichem Zeitstempel war die Reihenfolge vorher unbestimmt, und
    dieselbe Zeile konnte auf zwei Seiten erscheinen oder auf keiner.
  - Angehörige werden in der Datenbank gezählt statt alle Zeilen zu holen.
  - Namen an Ereigniszeilen kommen aus einem Join statt aus einem
    Nachschlag, der ausserhalb der Transaktion lag.

Der Statusfilter ist mitgezogen: dieselbe Regel wie deriveStatusAsOf,
Klausel für Klausel, jetzt als Kysely-Ausdruck. Der Integrationstest, der
beide über den gesamten Bestand vergleicht, läuft weiter — mit eigener
Verbindung, denn geprüft wird die Bedingung, nicht die Berechtigung.

Zwei Fehler auf dem Weg, beide vom Typprüfer gefangen: apply_due_pending_
changes() nimmt kein Argument, wurde von callFunction aber mit jsonb
aufgerufen — Postgres hätte keine passende Signatur gefunden. Und der
Sicherheitstest lädt jetzt Module mit `import "server-only"`, was ausserhalb
der Server-Übersetzung wirft.

Typecheck, Lint, Build und 180 Tests sind grün. Ungeprüft bleibt der Lauf
gegen eine echte Datenbank — dafür fehlt eine DATABASE_URL.
2026-07-31 08:45:26 +02:00
a66263a96e Put the session context under the app's own control
Erster Schritt weg von Supabase hin zu "läuft auf jedem PostgreSQL".

Gemessen sitzt die Kopplung nicht dort, wo der Begriff "Supabase-Projekt"
sie vermuten lässt: das Schema ist reines PostgreSQL, und von 58 RLS-Policies
rufen nur fünf auth.uid() direkt auf. Die übrigen 53 gehen über is_hr_user().
Diese eine Funktion ist die Brücke — wird sie umgelegt, folgt der Rest.

Die Migration legt sie um. app_current_user_id() liest jetzt zuerst
current_setting('app.user_id') und fällt nur ersatzweise auf auth.uid()
zurück. Deshalb plpgsql statt language sql: eine SQL-Funktion wird beim
Anlegen geparst, und auth.uid() gibt es auf einem gewöhnlichen PostgreSQL
nicht — die Migration liesse sich dort gar nicht erst anwenden. Der
Ausnahmeblock fängt das ab, und damit läuft dieselbe Migration auf beiden
Systemen. Der Rückfall verschwindet mit der Abschlussmigration.

Dazu app_users als Nachfolger von auth.users, external_id ist die oid des
Anbieters statt der E-Mail: eine Namensänderung darf kein zweites Konto
erzeugen.

Die neue Zugriffsschicht ist Kysely auf einem pg-Pool. Was daran zählt, ist
nicht der Query-Builder, sondern was er verhindert:

  - Die Kysely-Instanz wird nicht exportiert. Wer abfragen will, geht durch
    withUser() — und das öffnet immer eine Transaktion.
  - set_config(..., true) ist transaktionslokal. Ohne das dritte Argument
    bliebe die Kennung an der gepoolten Verbindung kleben und die nächste
    Anfrage liefe im Namen der vorherigen Person. In einer Personaldatenbank.
  - Eine ESLint-Regel verbietet den Import von pg und von lib/db/pool
    ausserhalb von lib/db. Nachgewiesen: eine Testdatei mit beiden Importen
    erzeugt zwei Fehler.
  - Einen privilegierten Zugang gibt es nicht mehr. asSystem() benutzt
    dieselbe Rolle ohne BYPASSRLS; was ohne angemeldete Person laufen darf,
    muss als SECURITY-DEFINER-Funktion in der Datenbank stehen.

tests/integration/session-context.test.ts läuft gegen einen Pool mit genau
einer Verbindung — sonst träfe er die Lücke mal und mal nicht. Er prüft, dass
nach Commit *und* nach Rollback nichts an der Verbindung zurückbleibt, und
belegt in einer Gegenprobe, dass eine Einstellung ohne Transaktion tatsächlich
hängen bleibt. Ein Sicherheitstest, der sich mangels DATABASE_URL selbst
überspringt, wäre schlimmer als keiner: in der CI schlägt schon das Fehlen
des Verbindungsstrings fehl.

Beim Schreiben der Migration stellte sich heraus, dass die Policies
hire_drafts_owner und saved_reports_owner heissen, nicht _own. Mit dem
geratenen Namen hätte drop policy nichts getroffen und create policy wäre mit
"already exists" abgebrochen.

Typecheck, Lint und 182 Tests sind grün. Die Anwendung läuft unverändert
weiter — sie benutzt die neue Schicht noch nicht.
2026-07-30 19:01:39 +02:00
88 changed files with 6278 additions and 1327 deletions

View File

@@ -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

View File

@@ -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
View File

@@ -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/

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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");
} }

View File

@@ -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: {

View File

@@ -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." };
}
} }

View File

@@ -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);
} }

View File

@@ -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." };
}
} }

View File

@@ -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.

View File

@@ -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

View File

@@ -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
View 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>
);
}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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,

View File

@@ -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>
); );

View File

@@ -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 (

View 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;

View File

@@ -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 });
} }

View File

@@ -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.

View File

@@ -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);

View File

@@ -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
View 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 }
);
}
}

View 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") });
}

View File

@@ -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
View 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;
},
},
};
});

View 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>
</>
);
}

View File

@@ -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,

View File

@@ -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

View 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>
);
}

View 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>
);
}

View File

@@ -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>
); );
} }

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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.

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 };

View File

@@ -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
View 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
View 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
View 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
View 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 MoFr.");
}
}
// ── 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
View 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;
}

View File

@@ -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",
};
}); });
} }

View File

@@ -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. */

View File

@@ -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,
}); });
} }

View File

@@ -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]));
} }

View File

@@ -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,
}; };
}); });
} }

View File

@@ -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({

View File

@@ -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 },
});
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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.
}
},
},
});
}

View File

@@ -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 };

View File

@@ -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
View File

@@ -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",

View File

@@ -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
View File

@@ -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 = {

View File

@@ -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)");

View File

@@ -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;
$$;

View 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;
$$;

View 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;
$$;

View 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;
$$;

View 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;
$$;

View File

@@ -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;
$$;

View File

@@ -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;
$$;

View 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;
$$;

View File

@@ -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.");

View File

@@ -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}`);
}
} }
} }

View File

@@ -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);
}); });
}); });

View 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();
});
});

View 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);
});
});

View 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");
});
});

View 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
View 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");
});
});

View File

@@ -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
View 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;
}
}