Commit Graph

18 Commits

Author SHA1 Message Date
05d56bf3b9 Niederlassung in der Zuordnung, und die Rueckfragen zum Workshop
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m1s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m6s
Anforderung 8. "Zuordnung" liess mehr als eine Lesart zu; umgesetzt ist die,
die am wenigsten voraussetzt: in der Personalakte steht die Niederlassung
jetzt im Reiter Organisation neben Einheit und Kostenstelle. Vorher war sie
nur im Stammdatenblatt zu finden, bei der Privatadresse — dort sucht
niemand den Arbeitsort.

Anders als Einheit und Kostenstelle haengt sie an der Person und nicht an
der Planstelle. Sie steht deshalb auch dann da, wenn es keine laufende
Besetzung gibt.

Dazu docs/rueckfragen-workshop-2026-09.md: sieben Stellen, an denen die
Formulierung mehr als eine Lesart zuliess, mit der jeweils getroffenen
Entscheidung und ihrer Begruendung. Damit bleibt keine Auslegung
unausgesprochen, und jede laesst sich ohne Umbau umdrehen.
2026-09-15 22:58:25 +02:00
d2d5e1dabb Drei Befunde aus dem Test: Logo, Geplant-Filter, Anstehend-Farben
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m27s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m2s
**Das Logo auf der Anmeldeseite.** Statt des Schriftzugs stand dort der
Ersatztext "Manner". Die SVG-Fassung war gueltiges XML, lag im Repository und
war committet — warum sie im Betrieb nicht geladen wurde, laesst sich von hier
aus nicht feststellen; dafuer braucht es die Antwort des Servers auf die URL.

Statt das weiter zu raten, faellt die Angriffsflaeche weg: es gibt jetzt eine
Datei, manner-logo.png, der Schriftzug mit durchsichtigem Grund. Kein XML, kein
Beschnitt, kein eingebackenes Feld. Das Blau darin ist #164194, also der Wert
aus §1.1.

Damit verschwinden auch die zwei Fassungen. §1.2 laesst den Schriftzug nur zur
Gaenze auf Rosa zu, und das Manual kennt dafuer zwei Lagen: auf Weiss gehoert
er nach §4.1 in ein rechteckiges rosa Feld, in einem grossflaechigen rosa
Umfeld nach §3.1 nur mit Freiraum. Beides entsteht jetzt aus derselben Datei —
das Feld zeichnet das Bauteil, aus derselben Polsterung wie den Freiraum.

**Der Filter "Geplant" fand Ausgetretene.** Die Ableitung pruefte den Eintritt
vor dem Austritt, und wer einen Eintritt in der Zukunft hatte, galt als
geplant — auch wenn der Austritt laengst verbucht war. Das trifft genau den
No-Show (Migration 20260814100000): eingestellt, nie erschienen, Austritt vor
dem Eintrittstag. Im Bestand sind das Zeilen mit Eintritt 01.10.2026, die der
Filter mitzaehlte, waehrend die Liste daneben "Ausgetreten" anzeigte.
employees.status, das die SQL-Funktion beim Austritt setzt, sagte von Anfang
an das Richtige; falsch war die Ableitung in der Anwendung.

Ein abgeschlossener Austritt wird jetzt zuerst geprueft: er beendet das
Verhaeltnis, gleichgueltig ob der Eintritt schon war oder noch kommt. Ein
Austritt, der selbst noch bevorsteht, nimmt den Eintritt nicht zurueck — wer
am 01.10. anfaengt und am 31.12. aufhoert, ist heute geplant. Die SQL-Fassung
in lib/employee-status-filter.ts bildet dieselbe Reihenfolge ab.

Keine Migration: beide Fassungen der Regel liegen in TypeScript. In SQL wird
nur der Karenz-Teil wiederholt, fuer die Fuehrungslinie, und der ist nicht
betroffen.

**Die vier Anstehend-Chips.** Zwei davon standen in der Markenfarbe, weil die
Farbe ueber den Beschriftungstext aus der Tabelle der Protokoll-Aktionen
geholt wurde — und die kennt eine andere Sprache: "Neueinstellung", nicht
"Eintritt". Wer dort nicht steht, bekam den neutralen Chip. Ein Nachschlagen,
das bei einem Fehlschlag still etwas Plausibles liefert, faellt eben nicht auf.

Die vier haben jetzt eine eigene Zuordnung, nach dem Wert verschluesselt und
nicht nach der Beschriftung: Eintritt gruen, Austritt rot, Wiedervorlage gelb,
Rueckkehr violett. Tuerkis waere fuer die Rueckkehr die naheliegendere Lesart
gewesen, kam gegen das Gruen des Eintritts aber nur auf dE 13.0; Violett steht
mit 30.8 eindeutig daneben. Schwaechstes Paar der vier: 14.2, schwaechster
Kontrast 5.49:1.

Zehn Tests dazu, darunter die drei Faelle, an denen der Filter gescheitert war.

Lint, Typen, Schemaabgleich, 562 Tests und der Build sind sauber. Im Browser
nicht gesehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:42:42 +02:00
8016d317be Das Logo ohne Feld auf Rosa, und kein Schwarz mehr auf der Marke
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m16s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m15s
Zwei Dinge, die beim Ansehen aufgefallen sind.

**Das Rechteck auf dem Panel.** Das Logo bringt sein rosa Feld selbst mit, und
auf der rosa Flaeche stand es als Rechteck in leicht anderem Ton darauf. Der
Ton war dabei derselbe — verschoben hat ihn die Lichtblende, die ueber der
Panelflaeche liegt und unter dem Logo endete.

Das Manual kennt fuer den Schriftzug zwei Faelle, und sie brauchen
verschiedene Dateien: auf Weiss gehoert er nach §4.1 in ein rechteckiges rosa
Feld, in einem grossflaechigen rosa Umfeld dagegen wird er nach §3.1 nur mit
Freiraum integriert — die Flaeche ist ja schon da. Es gibt deshalb jetzt
manner-logo-auf-rosa.svg ohne eigenes Feld, und die rosa Flaechen benutzen es.
Damit bleibt nichts mehr uebrig, wo die Blende enden koennte.

Die Blende selbst ist weg. Sie war Weiss auf Rosa und hellte die Markenfarbe
so weit auf, dass sie kaum noch die Markenfarbe war; geblieben ist ein feines
Raster und eine leichte Tiefe zur unteren Ecke, beides aus ink.

**Schwarz auf der Marke.** "Uebersicht", "Berichte" und der Rest der Kopfzeile
standen in ink. Das traegt zwar (7.47:1), sieht aber aus wie Text, der aus
Versehen auf der Marke gelandet ist. Alles Geschriebene auf Rosa steht jetzt
in brand-700: 6.60:1, und es ist dieselbe Paarung, aus der der Schriftzug
selbst besteht — blaue Lettern auf rosa Feld.

Dabei noch eine Schwaeche gefunden: das Abzeichen an der Glocke kam als
danger-solid auf dem rosa Band nur auf 2.78:1 und blieb unter den 3:1, die
WCAG 1.4.11 fuer ein bedeutungstragendes Element verlangt. Mit danger-text
sind es 3.26:1, und die Ziffer darin steht mit 7.12:1 besser als vorher.

**Und ausgemistet.** Die Logodatei wog 74 kB, weil die Vorlage aus Seite 3 des
Manuals gezogen wurde und die ganze Seite mitgenommen hat: 219 Elemente, die
den Fliesstext jener Seite zeichnen ("Der Manner Schriftzug ... darf nicht
veraendert werden"), unsichtbar rosa auf Rosa oder ausserhalb des Beschnitts.
Das eigentliche Logo sind vier Pfade. Beide Fassungen wiegen jetzt 18,7 kB.

Lint, Typen, Schemaabgleich, 552 Tests und der Build sind sauber. Im Browser
nicht gesehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:44:01 +02:00
3987e8f54f Rosa fuehrt, Blau bedient
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m1s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m1s
Die Flaechenverteilung war verkehrt herum. Blau lag auf dem Groessten, was die
Anmeldeseite zu vergeben hat, und Rosa nur in Andeutungen daneben — bei einer
Marke, deren Schriftzug auf Rosa steht und deren Manual Rosa zuerst nennt.

Sichtbar wurde es am Logo. Es bringt sein rosa Feld selbst mit, weil §1.2 den
Schriftzug nur zur Gaenze auf Rosa zulaesst. Auf einer blauen Flaeche steht
dieses Feld als ausgeschnittenes Rechteck darauf. Auf Weiss ist es dagegen
richtig — §4.1 verlangt fuer weissen Untergrund genau das, ein rechteckiges
rosa Feld. Falsch war also nicht das Logo, sondern die blaue Flaeche darunter.

Rosa bekommen jetzt: das Panel der Anmeldeseite und das Band ueber der
Anwendung aus Kopfzeile und Logokopf der Seitenleiste. Beide zusammen sind ein
durchgehender Streifen, in dem das Logo aufgeht statt darauf zu liegen — was
§3.1 mit "in einem grossflaechigen rosa Umfeld eingebettet" meint.

Blau bleibt, was es ist: Knopf, Link, Fokusring, ausgewaehlter Eintrag. Das ist
dasselbe Verhaeltnis, aus dem der Schriftzug besteht — blaue Lettern auf rosa
Feld — und es ist das einzige, das traegt. Weiss auf Rosa sind 2.18:1.

Auf den rosa Flaechen steht deshalb nichts Weisses und nichts Gedaempftes mehr.
ink kommt auf 7.47:1, brand-700 auf 6.60:1; ink-muted lag bei 2.33:1 und ist
aus Kopfzeile, Glocke und Logokopf verschwunden. Gestuft wird ueber Groesse und
Gewicht statt ueber Transparenz — eine aufgehellte Schrift ist genau das, was
auf dieser Flaeche durchfaellt.

Das Raster auf dem Panel ist von Weiss auf ink gewechselt: auf Rosa war es
nicht mehr zu sehen.

Lint, Typen, Schemaabgleich, 552 Tests und der Build sind sauber. Im Browser
nicht gesehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:47:26 +02:00
f7a5c48615 Die Oberflaeche auf das Manner-CD umstellen
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m45s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Farben, Schrift, Logo und Symbole folgen jetzt dem CD Manual 2022. An der
Logik aendert sich nichts: keine Migration, keine Abfrage, keine Berechtigung.

Die zwei Markenfarben stehen in §1.1 als #F69686 (Rosa) und #164194 (Blau).
Welche davon was traegt, ist nicht gewaehlt, sondern nachgerechnet: Weiss auf
Rosa kommt auf 2.18:1 und faellt damit auch fuer Grossschrift durch, Blau auf
Rosa auf 4.34:1 und reicht fuer das Logo, nicht fuer Text. Schwarz auf Rosa
sind 7.47:1, Weiss auf Blau 9.47:1. Rosa ist deshalb Flaeche, Blau ist
Interaktion — zwei Skalen, brand-* und accent-*, statt einer geteilten.

Von den 26 Stellen mit brand-50/100/200 sind nur sieben auf accent gewandert.
Der Rest meint einen Zustand und keinen Markenton: die Zeile unter dem Zeiger,
der gewaehlte Listeneintrag, der aktive Menuepunkt. Gegen die warme
Seitenflaeche ist ein kuehler Ton dort deutlicher, und Blau heisst in dieser
Oberflaeche ab jetzt "reagiert auf dich". Nach accent gingen die Faelle ohne
eigene Bedeutung — "Geplant", "Offen", der neutrale Protokoll-Chip — und die
offene Planstelle im Organigramm, die vorher ein blasses Blau war und damit
wie eine schwaechere Person aussah.

Die Funktionsfarben bleiben, was sie sind. Das Manual regelt die Identitaet,
nicht die Rueckmeldung: Rot heisst Fehler, weil die Benutzerin das mitbringt.
`info` bleibt bewusst tuerkis — Blau saehe ab jetzt bedienbar aus, und gemessen
kaeme ein blaues info dem violetten Chip auf dE 6.8 nahe, also nicht
unterscheidbar. Violett ist dabei nachgezogen: gegen den Fehler-Chip stand es
bei dE 8.0, "Austritt" und "Befoerderung" waren im Vorbeigehen dieselbe blasse
Flaeche. Jetzt dE 15.0.

Die Schrift ist Barlow. Nachgezaehlt ist das Manual zu 95 % in DIN gesetzt
(Regular 79 %, Bold 16 %); Helvetica Neue steht nur in den Visitenkarten und im
Claim. DIN laesst sich nicht ausliefern — eine Drucklizenz deckt keinen Webfont
—, und die freien Nachbauten der DIN 1451 sind Schilderschriften, bei 14 px in
einer langen Tabelle schlechter lesbar als das, was sie ersetzen. Die Variable
heisst --font-din und nicht --font-barlow: liegt eines Tages eine Web-Lizenz
vor, ist der Wechsel diese eine Deklaration.

Nebenbei zwei Dinge repariert, die vorher schon falsch waren. Die Umrandung von
Eingabefeldern, Knoepfen und Suchfeldern lag bei 1.30:1 und damit unter den 3:1,
die WCAG 1.4.11 fuer Bedienelemente verlangt; border-strong bringt 3.56:1. Und
das mitgelieferte favicon.ico liess sich gar nicht bauen: eingebettet waren
24-Bit-RGB-PNG, waehrend der Kopf 32 bpp behauptete. Alle Rastersymbole liegen
jetzt als RGBA vor, und ihr Blau ist auf den CD-Wert gezogen — samt der
kantengeglaetteten Raender, indem je Pixel der Blauanteil bestimmt und neu
gemischt wurde.

Das Logo ist das Markenlogo (§4.1), nicht das Unternehmenslogo, das §3.2 fuer
eine Anwendung mit der AG als Absender vorsaehe — es liegt nicht vor. Der
Freiraum X/3 steckt im Bauteil selbst und nicht in den Aufrufstellen, sonst
haengt seine Einhaltung daran, dass jede einzelne daran denkt.

docs/farbschema.html zeigt Token, Kontraste und Bauteile nebeneinander und
laesst sich ohne Server oeffnen.

Nicht im Browser gesehen: Anmeldung laeuft ueber das Firmenkonto und die
Datenbank ist von hier nicht erreichbar. Lint, Typen, Schemaabgleich, 524 Tests
und der Build sind sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:26:21 +02:00
f17d299045 Lehrling, and a field that stops being named after its values
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 10m51s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
worker_type had two values because the field was named after them:
"Angestellte:r / Arbeiter:in". Lehrlinge are the third social-insurance
category in Austria; until now they were filed as one of the other two,
which they are not -- and which skewed every report grouped by this
column by exactly those people.

Adding the value is one line. The label was the work: the field was
called after its two values in six places, and each of them becomes
wrong with a third. They now read "Beschaeftigtengruppe", the name the
import has used all along.

One label deliberately keeps the old wording: app_feld_karte() in the
database. That string is not a caption there but a key -- stored rows in
employee_history and pending_changes carry it, and the map is how
reverting or correcting a history entry finds the field again. Renaming
it without rewriting those rows would make every older entry for this
field unrevertable, and nobody would notice until they tried.

The migration's assertion reads pg_enum rather than comparing against
'Lehrling'::worker_type: migrations run inside a transaction, and
Postgres refuses to use a freshly added enum value in the transaction
that added it. This has not been run against a live database here -- the
CI migration job is the first real execution.

Three hand-kept lists of the same enum (reports, import, the form) now
have a test holding them to one another, each mutation-checked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:28:49 +02:00
eeaf210e78 Let the same choice open notes and drafts
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m47s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m10s
The picker in the bell now governs both lists, so note_subscriptions is
renamed to colleague_subscriptions -- a name that only mentions notes would
mislead the next reader.

Reading and writing a draft now reach differently far. hire_drafts_owner
(for all) is split into four policies: select lets in your own drafts and
those of the people you added, while insert/update/delete stay with the
owner. A draft is unfinished work with no lock and no history; two people
writing into the same row would overwrite each other silently.

That split forces a change in the actions: a policy does not reject a write,
it lets it hit no rows. saveHireDraft and deleteHireDraft now read the row
count instead of reporting success over a row that never changed.

The card shows a foreign draft with its author and without Fortsetzen or
Loeschen -- offering a button that reliably ends in a database error is a
promise without cover.

check-schema-types.mjs learns `alter table ... rename to`; without it the
drift check reports one rename as two errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:05:40 +02:00
b87c8ad64c Remove Supabase
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m40s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
The database moved to a container of our own; the platform is gone.
This takes out what was left of it — and, where the leftovers were load
bearing, moves rather than deletes.

Moved, not deleted:

  supabase/migrations/  -> db/migrations/      the schema's source of truth
  supabase/build-org.ts -> scripts/build-org.ts
  lib/supabase/types.ts -> lib/types.ts        52 import sites repointed

The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`,
and simply renaming the schema would have left the runner facing an empty
table: it would have called all 67 migrations pending and replayed them
against a database that is long since current. So the runner now creates
`migrationen.schema_migrations` and, once, copies the old rows across —
guarded so a second run does nothing and a fresh database skips it entirely.
Only then does migration 20260907100000 drop the old schema.

Deleted: the CLI config, the seed, the historical schema/function dumps
(nothing read them), scripts/umzug-von-supabase.sh (the move is done), and
both Supabase packages plus the CLI. Nothing in the application imported
them — the build now succeeds with no environment variables at all, which
is the proof.

Integration tests: six of them signed in through Supabase Auth and asserted
against the anon key and the service role. That model is gone, so the tests
were not portable — they are deleted. session-context and
employee-status-filter already ran on pg and are untouched; om-reporting is
ported to a direct connection because it guards a real risk (the reporting
line rule exists twice, once in SQL and once in TypeScript).

CI: the integration job started a Supabase stack. It now runs a postgres
service, applies deploy/db-init and every migration to an empty database —
that was the valuable part, and it still holds — then checks that a second
run is a no-op, which is what proves the bookkeeping works.

Docs: security-review.md audited a service-role key, a cookie adapter and
auth.users, none of which exist. Restating findings about removed components
would suggest today's system had been reviewed; it has not. It now records
what was removed and says a fresh review is due. data-model.md was already
marked obsolete and described the pre-OM schema; azure-migration.md was a
plan for a route not taken. Both deleted.

Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the
packages. Integration tests skip cleanly with no database. Migration SQL and
the runner are reviewed but NOT executed: no Docker here, and the old
instance no longer resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:43:22 +02:00
e958bb5c6b Stop pretending Vercel is an option
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m51s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m15s
It was never used. The repository lives on a self-hosted Gitea, which
Vercel's git integration cannot connect to at all — so the documented
route amounted to "mirror to GitHub first", and nobody did.

vercel.json is gone, and with it the branch in next.config.ts that
switched off `output: "standalone"` when the VERCEL variable was
present. That branch was the only functional trace; everything else was
documentation and comments describing a second deployment path that did
not exist.

DEPLOYMENT.md loses its "two supported ways" framing and the whole
Vercel section — about fifty lines. Several statements next to it were
stale for a different reason and are corrected in the same pass: the
outbound-firewall table still listed Supabase's pooler (the database is
a container now, nothing leaves the server), the prerequisites still
demanded an existing Supabase project, and the .env table still asked
for a pooler connection string instead of the two new passwords.

The nightly job is described as what it is — a container in
docker-compose.yml — rather than as a replacement for Vercel Cron.

Migrations keep their references: two comments from July mention Vercel
Cron, and they describe what was true when they were written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:02:40 +02:00
08d2740690 Let planned changes be taken back and corrected too
Deleting and correcting a history entry stopped at the present: anything
not yet effective stayed put. That was not a principle, it was a missing
link. A planned change lives as a payload in pending_org_changes, and
nothing tied it to the history row — only a person and a date, and the
data already holds an Eintritt and a Vertragsänderung sharing one. So
employee_history now carries pending_id, set by change_employee_data
when it schedules something.

One planned change can carry two history rows: Stammdaten and Vertrag
are kept apart but scheduled together. Taking one back therefore strips
only that group's fields from the payload, and cancels the operation
only when nothing is left. Correcting one rewrites its group and the
effective date, and touches no employee data — the change has not
happened yet.

An entry stays on its side of the present. Pulling a planned change into
today, or pushing an effective one into the future, would mean adjusting
the employee record and the pending payload in opposite directions;
that is what the real operations are for.

Existing rows were linked where exactly one running operation matched
the person and date and no other row had claimed it. All five of them
matched. Anything ambiguous would have kept the old refusal, which now
says the actual reason.

The edit dialog surfaced a bug in useDialogFocus that predates it: the
effect depended on the identity of onClose, which almost every caller
rebuilds on render, so it re-ran after each keystroke and its cleanup
pulled focus back to whatever opened the dialog. Any dialog with a text
field would have accepted one character. It never showed because until
now no dialog kept its own state next to its own onClose.

Rehearsed against real data: a two-row planned change corrected, one row
taken back with the operation continuing on the rest, the second taken
back with the operation cancelled, and both refusals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:35:51 +02:00
6297288c13 Let an entry be taken back, along with what it did
HR can now delete a history entry, but only where deleting one is an
honest thing to do — and deleting it also undoes it.

The rule they asked for is the interesting part: the last valid change
wins. Deleting an entry walks its fields one at a time. If a later entry
touched the same field, the current value stays — that later change is
the one in force. Otherwise the field goes back to what the deleted
entry recorded as its "before". So the middle of three entries can be
removed without an old value overwriting a newer one.

Four kinds of entry refuse to be deleted, each saying why in the place
the button would have been. Eintritt anchors the timeline. Transfers,
promotions, absences and exits moved positions and status — they have
proper operations for that, and guessing backwards is how you corrupt an
org chart. Anything not yet effective hangs off a planned change, and
that link is not trustworthy: there is no key between a history row and
its pending row, only a person and a date, and the data already has an
Eintritt and a Vertragsänderung sharing one. Matching on the date would
eventually cancel a change nobody meant. And entries from before the
history carried values have nothing to fall back to.

Confirmation is not "are you sure" — that question gets a reflex yes by
the third time. The dialog says what will be different afterwards: which
field goes back to which value, and which one stays because something
later claimed it.

employee_history keeps its append-only policies; delete_history_entry is
SECURITY DEFINER and checks the permission itself in its first line. The
audit log keeps the deletion with the values that were removed, and the
audit log genuinely cannot be edited.

The rule lives twice — in SQL and in lib/history.ts. The database is the
authority; the copy exists so the UI can hide a button that would fail
and print the reason instead. Rehearsed against real data in a
rolled-back transaction first: the later change held, the untouched
field reverted, all four refusals fired.

Also corrected in the data catalogue: I had written that
require_hr_admin was called by nothing. It guards all sixteen mutating
functions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 20:56:48 +02:00
578ce696f0 Correct the policy count in the places that quote it
Six comments and doc lines put the number of RLS policies at 58. It is
21 — counted from pg_policy while building the data catalogue. The
figure appears in load-bearing prose ("all 58 policies call
is_hr_user()", "all 58 policies stay unchanged"), where being wrong by a
factor of three invites someone to go looking for the missing thirty-
seven.

The two occurrences inside supabase/migrations/ stay as they are. That
file already ran against the database; its comments record what was
believed at the time, and editing them would make the file differ from
what was applied for no gain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 17:33:54 +02:00
64eb155dda Write down what is actually in the database
The one document describing the schema, docs/data-model.md, predates two
rebuilds. It names divisions/departments/teams and a positions table that
no longer exist, describes Supabase auth with an anon key and a service
role that were removed, and puts the policy count at 58 when it is 21.
Anyone reading it to understand the data would have been misled on every
count.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 07:46:49 +02:00
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
730521ee79 Keep the environment's identifiers out of the repository
Projekt-Ref, Entra-Client- und Tenant-ID standen im Klartext in der
SSO-Anleitung. Geheimnisse sind das nicht — ohne Schlüssel gibt eine
Projekt-URL nichts her, und RLS greift unabhängig davon. Sie zeigen aber auf
die laufende Umgebung, und dieses Repository wandert weiter als sie: es geht
gleich auf einen eigenen Git-Server und später an den Kunden.

Jetzt Platzhalter; die Werte gehören in die Übergabedokumentation. In der
Historie stehen sie weiterhin — das sauber zu entfernen hiesse, die Historie
neu zu schreiben, und das passiert nicht nebenbei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:39:20 +02:00
2cce101c4b Sign in with Entra ID, and give the login screen something to look at
Die Anmeldung läuft über das Firmenkonto. Supabase Auth bleibt dabei die
Sitzungsverwaltung — Entra ist der Anbieter, nicht der Ersatz. Genau deshalb
ist der Eingriff klein: auth.uid() liefert weiterhin 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 Sicherheitsgrenze wandert nicht in
den Anwendungscode.

Der Passwort-Pfad ist weg, nicht deaktiviert. Ein zweiter Anmeldeweg neben dem
Firmenkonto hebelt jede Vorgabe des Mandanten aus — Mehrfaktor, bedingten
Zugriff, Sperrung beim Austritt.

Dazu die Rückweg-Route /auth/callback, die den PKCE-Code gegen eine Sitzung
tauscht, und eine Ausnahme im Proxy: ohne sie leitet der Gate den Code nach
/login um, weil es die Sitzung ja erst danach gibt, und die Anmeldung kommt
nie zustande. Ob jemand HR-Zugriff hat, entscheidet weiterhin nicht die Route,
sondern profiles.role/is_active und darunter die Policies.

Zwei Werkzeuge für die Umstellung:

  - relink-profile.ts hängt eine bestehende profiles-Zeile auf die
    Entra-Identität um. Ein Passwort-Konto und das Entra-Konto derselben
    Person sind für Supabase zwei Benutzer mit verschiedenen IDs; ohne das
    zeigt die profiles-Zeile nach der ersten SSO-Anmeldung ins Leere und man
    sperrt sich aus. Die Fremdschlüssel auf auth.users wandern mit, sonst
    stünde in der Historie eine Kennung ohne Konto dahinter.
  - entra-claims.ts zeigt, was der Anbieter tatsächlich mitgeschickt hat.
    Die geplante Freischaltung über eine Entra-Gruppe hängt daran, wie der
    Anspruch heisst und aussieht, und das unterscheidet sich je nach
    Tokenkonfiguration des Mandanten. Der Trigger wird erst danach gebaut,
    sonst wäre er geraten.

Beim Auswerten der Gruppe später gilt: die Quelle ist auth.identities.
identity_data, nie raw_user_meta_data. Letzteres beschreibt die angemeldete
Person über updateUser() selbst — läse die Freischaltung von dort, könnte sich
jede:r Angemeldete HR-Rechte eintragen. Steht so in docs/entra-sso.md.

Die Anmeldeseite war eine Box im leeren Rosa. Jetzt zweispaltig: links eine
Markenfläche, rechts die Anmeldung; unter 1024px fällt die Fläche weg und die
Wortmarke rückt über die Karte. Die Microsoft-Schaltfläche ist bewusst nicht
mehr in der Hausfarbe — magenta las sich als Aktion *innerhalb* dieser
Anwendung, während sie auf eine fremde Anmeldeseite springt. Weiss mit
grauem Rand ist Microsofts eigene Vorgabe und das Muster, das man
wiedererkennt. Dazu ein Wartezustand für den Sprung und eine Fehlermeldung,
die erklärt, was zu tun ist, statt nur "Kein HR-Zugriff" zu behaupten.

Nachgemessen im laufenden Server statt geschätzt: 656/624 auf 1280px,
Markenfläche in brand-700, Schaltfläche 45px hoch, kein Querlauf auf 375px.
Typecheck, Lint, Build und 182 Tests sind grün.
2026-07-27 20:34:00 +02:00
c2366e3408 Make the cut-over script safe to paste, and record the Azure design
The mapping table was declared ON COMMIT DROP. In the Supabase SQL editor
the transaction boundaries are not ours to assume, and a mapping table that
vanished between the two inserts would leave positions without assignments
and be miserable to diagnose. It is now dropped explicitly once both inserts
have run.

docs/azure-migration.md is the design for the Azure move, for review before
any code changes.

Its main finding corrects what I said when I laid out the options: I claimed
that dropping Supabase would push the security boundary into application
code. It does not. auth.uid() appears 70 times, but only one of them matters
— inside is_hr_user(), which all 58 policies call. Swapping the source of
the user id there leaves every policy valid, so the database stays the
boundary.

The risk moves elsewhere, and the design says so plainly: the user id
arrives via set_config(..., true), which is transaction-local. Outside a
transaction it sticks to the pooled connection, and the next request on that
connection runs as the previous user. So the plan makes that structurally
impossible — a single access function that owns the transaction, a lint rule
against importing the pool anywhere else, a database role without BYPASSRLS
so a missing context returns nothing rather than everything, and a test that
sends two requests over one pooled connection to prove the second cannot see
the first.
2026-07-27 14:39:57 +02:00
79f0e19bf8 Org assignment history, mobile support, and a correctness pass
Data model
- employee_assignments records org placement over time (valid_from/valid_to),
  written by a trigger on `employees` rather than inside each RPC: ~70
  `update employees` statements spread over fifteen migrations mean per-call
  bookkeeping would miss paths today and again with every future RPC. A
  partial unique index enforces the one-open-interval invariant the trigger
  relies on when closing the current row.
- The Organigramm gains a Stichtag (default today). Membership comes from
  entry/exit/karenz, past placement from the new history, future placement
  projected from pending_org_changes. Placements predating the migration are
  backfilled with today's values and flagged as such in the UI, since
  employee_history only ever stored free text and cannot be reconstructed.

Correctness
- Reports and exports silently truncated at PostgREST's 1000-row cap
  (db.max_rows); employee_history is already past it at ~800 staff. Every
  whole-table read now pages explicitly.
- XLSX date cells were a day early: ExcelJS converts a Date to an Excel
  serial straight off getTime(), so a Date built at local midnight lands on
  the previous day's serial in any positive-offset zone.
- Date handling is pinned to Europe/Vienna throughout, and date-only strings
  are formatted without a Date round-trip. The dashboard's YTD window was
  built by round-tripping a local Date through toISOString(), which shifted
  it a day early and dropped 31 December entirely.
- Export routes parsed measure/group/split/eventType with unchecked `as`
  casts, so an unknown value reached column headers as `undefined` and the
  Content-Disposition filename. Parsed against the label maps now, with the
  filename slugged as a backstop.
- toXlsx keyed columns by header text, silently dropping the second of any
  two columns sharing a name — split columns take their header from data.
- The org chart tree walks had no cycle guard; nothing in the schema forbids
  a manager_id cycle, and one would hang the tab rather than misreport.
- The login page reflected ?error= verbatim, letting anyone put arbitrary
  text on the real sign-in screen; messages are looked up by code now.
- React Flow needs elementsSelectable on, or it sets pointer-events:none on
  the whole node and the expand control stops responding.

UI
- Mobile: the shell was unusable below lg — a fixed 236px margin pushed
  content off-screen with no mobile navigation at all. The sidebar is now a
  drawer, dvh replaces vh, safe-area insets are honoured, inputs are 16px so
  iOS stops zooming on focus, and form grids stack.
- Org chart nodes redesigned: per-kind accent stripes and icons, vacant
  roles called out, expand control moved to the bottom edge carrying the
  child count.
- Pagination is windowed; it previously rendered one link per page (54 for
  the employee list, unbounded for the audit log).
- Positions page reduced to open positions with a single "Besetzen" action.
- The employee Organisation tab links into the org chart focused on that
  person, reusing the chart's existing search-match highlighting.

Also included, uncommitted until now
- Dependants, HR notes, academic titles, split address fields, position
  validity and role/employment fields, with their migrations and UI.
- Docker/compose deployment setup, data-model and security-review docs.
2026-07-24 23:38:10 +02:00