employees.email ist die private Adresse (20260811140000). Sie taugt nicht
als Dienstadresse und darf auch nicht als solche benutzt werden: der
Honestly-Export geht an einen fremden Anbieter, der damit im Namen des
Arbeitgebers einlaedt. Die Spalte "Email" stand dort deshalb seit jeher
leer, mit einem Vermerk in lib/honestly.ts, dass die Firmenadresse im
Datenmodell fehlt. Jetzt gibt es sie, und die Spalte fuellt sich.
Eindeutig, aber freiwillig -- mehrere Personen ohne Adresse stoeren den
Index nicht, weil null nie gleich null ist. Geschrieben wird ueber
`case when ? then nullif` statt `coalesce`: eine Dienstadresse muss sich
auch wieder entfernen lassen.
Vier SQL-Funktionen mussten mit, weil `create or replace` die ganze
Fassung ersetzt und ein ausgelassenes Feld dort still verschwindet:
hire_employee und rehire_employee (beide teilen sich den Schritt
"Person" -- das Formular haette das Feld gezeigt und den Wert
weggeworfen), change_employee_data (sonst nicht aenderbar),
apply_due_pending_changes (sonst verfiele eine auf spaeter datierte
Aenderung) und die Feldkarte (sonst waere der Eintrag in der Historie
nicht korrigierbar). Die Selbstpruefung am Ende prueft jede einzeln.
A participant list for the employee survey on honestly.de, offered as
CSV and Excel right under the full data export. Columns: Personalnummer,
Email, Firstname, Last Name, Language, Location, OU, OU+1, ... , Role.
Language is always "de", Role always "Respondee".
The org columns run bottom-up: OU is the person's own unit, OU+1 the one
above, up to the top node. Their number follows the deepest chain in
the file, shorter chains are padded with empty cells, and there is
always at least one OU column so the file keeps its shape.
It exports exactly the people the full export would, with the same
filters. To guarantee that, the selection moved out of the full export's
route into lib/export-auswahl.ts and both routes use it; the full
export's columns are untouched.
Email is employees.email, which is the private address and optional --
there is no work address in the schema. Missing addresses stay empty
rather than getting a placeholder that would receive an invitation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Aus dem Gespraech vom 17.09.2026. Migrationen 20260917110000 (Enum-Wert)
und 20260917120000 (Logik) — getrennt, weil ein in derselben Transaktion
angelegter Enum-Wert dort noch nicht benutzt werden darf.
Die Besetzungsart (extern/intern) wurde bisher nur bei der Neueinstellung
gesetzt und war danach unerreichbar. Eine Angabe, die mit dem ersten Tag
erstarrt, obwohl gerade ihr Wechsel der Vorgang ist, um den es geht. Sie
steht jetzt in "Daten aendern".
Wechselt sie von Extern auf Intern, entsteht das Ereignis "Uebernahme" — in
der Personalakte und im Protokoll, und im Berichtemanager als Ereignistyp
auswertbar. Der umgekehrte Weg bekommt ausdruecklich keines: eine Uebernahme
zurueckzunehmen gibt es fachlich nicht.
Zwei Eintraege und nicht einer: die Vertragsaenderung haelt fest, dass ein
Feld sich geaendert hat und worauf (und laesst sich darueber zuruecknehmen),
das Ereignis, dass dieser Wechsel eine Uebernahme war. Nur das zweite laesst
sich zaehlen.
Eine auf spaeter datierte Uebernahme erzeugt das Ereignis sofort mit
pending_id, wie die Vertragsaenderung daneben; der Nachtlauf schreibt am
Stichtag nur noch die Spalte. Die Selbstpruefung haelt fest, dass er kein
zweites Ereignis schreibt — sonst staende die Uebernahme doppelt in der Akte
und jede Auswertung zaehlte sie zweimal.
Im Assistenten erscheint das Feld nicht: dort steht die Besetzungsart im
Schritt "Position", und zweimal danach zu fragen waere eine Einladung, zwei
verschiedene Antworten zu geben.
Migration 20260917100000. Ruecknahme einer eigenen Entscheidung, nach der
Erklaerung des Kunden am 17.09.2026.
Wir hatten den Anstoss aus der Beendigungsart abgeleitet — Kuendigung AN
gilt als freiwillig, Kuendigung AG als unfreiwillig. Das schien sauberer,
weil es zwei Felder ausschliesst, die einander widersprechen koennen.
In Oesterreich stimmt es nicht. Die einvernehmliche Aufloesung ist hier der
Regelfall und sagt ueber den Anstoss nichts aus: sie kann von der Person
ausgehen ("ich moechte kuendigen", worauf einvernehmlich aufgeloest wird,
damit das AMS zahlt) oder vom Dienstgeber ("ich will die Trennung, dafuer
gibt es eine Abfindung"). Dieselbe Beendigungsart, zwei gegensaetzliche
Antworten — und das ist genau die Unterscheidung, auf die es bei einer
Fluktuationsanalyse ankommt. Die Ableitung haette die Haelfte der Faelle
still falsch einsortiert.
Im Formular steht jetzt die Beendigungsart oben mit allen Werten, darunter
"Freiwillig oder unfreiwillig". Keine der beiden schraenkt die andere ein.
Der abgeleitete Hinweis unter der Beendigungsart ist weg — er erschien von
selbst und sah aus wie ein Fehler des Formulars.
Die Angabe ist freiwillig: der Bestand traegt sie nicht, und ein
Befristungsablauf geschieht auf niemandes Betreiben. Ob sie fuer gewoehnliche
Austritte Pflicht werden soll, ist eine Frage an den Kunden.
rehire_employee raeumt sie mit dem Austritt weg. Die Funktion ist dabei
ausgeschrieben worden; die Selbstpruefung haelt fest, dass die beiden
Umstellungen, die sie schon hinter sich hatte (app_current_user_id statt
auth.uid, fester search_path), dabei nicht verlorengehen — genau das ist der
Fehler, den ein create-or-replace aus einer alten Vorlage leise macht.
Die neue Kachel "Aktives Dienstverhaeltnis" verlinkte auf
?status=Aktiv&status=Karenz. Die Mitarbeiterliste liest den Parameter aber
als *eine* Zeichenkette und trennt selbst an Kommas — zweimal uebergeben
macht Next daraus ein Array, und `.split(",")` lief dagegen. Sichtbar war
nur "Diese Ansicht konnte nicht geladen werden".
Die Kachel schreibt jetzt status=Aktiv,Karenz. Dazu glaettet die Seite alle
ihre Parameter: eine Adresse kommt nicht nur aus der eigenen Anwendung, sie
steht in Lesezeichen und in E-Mails, und ?q=a&q=b haette sie genauso
gefaellt.
Zwei Anmerkungen von Max:
* Die Reihenfolge der Wochentage wurde beim Speichern mitgenommen — "Mo,
Di" und "Di, Mo" waren zwei Werte fuer dieselbe Aussage. Da
change_employee_data die Arbeitstage als zusammengefuegte Zeichenkette
vergleicht, erzeugte jedes Nachsehen und Wiederherstellen eine
Vertragsaenderung in der Akte und einen Protokolleintrag — ueber nichts.
Jetzt sortiert gespeichert (lib/wochentage.ts, an einer Stelle statt in
vier Kopien), auch im Massenimport. Der Bestand richtet sich beim
naechsten Speichern von selbst.
* "Beguenstigt behindert" steht jetzt als eingerueckter Unterpunkt des
Kuendigungsschutzes statt als eigener Block daneben. In der Datenbank
bleiben es getrennte Felder, und das mit Absicht: eine Kopplung liesse
jede Korrektur am Personenkreis scheitern, solange der Grad noch
dransteht.
Anforderung 9 aus dem Workshop. Migration 20260915140000.
Im Dokument standen die beiden untereinander:
Arbeiter, Angestellte, Lehrlinge
Standard / Praktikant / Geringfuegige Beschaeftigung / Altersteilzeit
Die erste Zeile gibt es schon als worker_type, Lehrling seit 20260910140000.
Die zweite ist eine andere Frage: nicht *als was* jemand angestellt ist,
sondern *in welcher Form*. Beide gelten gleichzeitig — ein Praktikant ist
Arbeiter oder Angestellter, nicht statt dessen. In eine Liste gepresst
muesste man sich fuer eine der Antworten entscheiden und verloere die andere.
NOT NULL mit Vorgabe "Standard": jede Person ist in irgendeiner Form
beschaeftigt, und "nicht erfasst" waere keine Aussage, sondern eine Luecke,
die sich durch jede Auswertung zieht. Der Bestand bekommt damit einen
sichtbaren, korrigierbaren Ausgangswert statt eines leeren Feldes.
Die vier Funktionen wurden vollstaendig neu geschrieben. Die Selbstpruefung
haelt deshalb auch die Felder der vorigen Migration fest: eines beim
Uebertragen zu verlieren waere ein Fehler, der nirgends auffiele — die
Oberflaeche schickte den Wert weiter, und die Funktion ignorierte ihn.
Anforderungen 5 und 5a aus dem Workshop. Migration 20260915120000.
Bisher gab es ein Kennzeichen und ein Enddatum. Das beantwortet "darf hier
ohne Weiteres beendet werden?" — nicht aber, *warum* jemand geschuetzt ist,
und davon haengt ab, wer zustimmen muss. Nachzusehen war das nur im
Papierakt, also dort, wo unter Zeitdruck niemand nachsieht.
Zwoelf Personenkreise als CHECK auf text und nicht als Aufzaehlungstyp: die
Liste ist Rechtslage und aendert sich mit dem Gesetz, ein Typ liesse einen
zurueckgenommenen Wert fuer immer stehen. Die Oberflaeche liest dieselbe
Liste aus lib/kuendigungsschutz.ts; ein Test liest die Migration und haelt
beide gegeneinander, damit die begruendete Doppelung keine stille wird.
Die begueenstigte Behinderung bekommt Kennzeichen, Grad, Beginn und Ende —
vier Spalten und nicht eine zusammengesetzte, weil in Excel danach
gefiltert und summiert wird. Datenbankseitig sind sie *nicht* an den
Personenkreis gekettet: eine solche Bedingung scheiterte genau dann, wenn
jemand den Kreis korrigiert und der Grad noch dransteht. Die Oberflaeche
stellt den Zusammenhang her.
Dazu zwei Dinge, die auf dem Weg auffielen:
* Der Nachtlauf wendete bei einer auf spaeter datierten Aenderung nur
Person und Vertrag an — die ganze Gruppe "role" fiel weg. Betriebsrat,
Dienstwagen, Kollektivvertrag, Arbeitstage, Teilzeit und
Kuendigungsschutz wurden erfasst, in der Historie vermerkt, protokolliert
und am Stichtag nicht geschrieben. Sichtbar wurde das nie. Die neuen
Felder haetten den Fehler geerbt; er ist jetzt fuer alle behoben.
* Der Mitarbeiter-Export filterte ohne Stichtag ueber die Spalte `status`,
mit Stichtag ueber die Ableitung. Der Export nach "Ausgetreten" liess
damit genau die Leute aus, die gerade ausgetreten sind.
Anforderung 1 — Freiwilliger vs unfreiwilliger Austritt. Die Liste der
Beendigungsarten zieht aus TerminatePanel.tsx nach lib/beendigung.ts um: der
Berichtemanager braucht sie ebenso, und zwei Listen liefen auseinander. Zwei
neue Arten (Beendigung in der Probezeit, je Seite). Auf wessen Betreiben
beendet wurde, wird aus der Art **abgeleitet** und nicht daneben gespeichert
— als zweites freies Feld liesse sich "Entlassung, freiwillig" erfassen. Das
Dropdown im Formular schraenkt die Auswahl darunter ein.
Drei Gruppen statt zwei: Befristungsablauf geschieht auf niemandes
Betreiben, ein Nichtantritt ist kein Austritt. Beide einer Seite
zuzuschlagen wuerde jede Fluktuationsquote verfaelschen.
Anforderung 2 — Namensfilter in "Anstehend", ab neun Eintraegen.
Anforderung 3 — die zwei Unterschriftenfelder im gedruckten Blatt sind weg;
"Firmenfahrzeug" steht in beiden Checklisten. has_dienstwagen sagt, ob eines
zusteht, nicht ob es uebergeben wurde.
Anforderung 4 — "+794 weitere" ist ein Knopf geworden; die Namen waren
vorher nur ueber den Export erreichbar. Stammdatenaenderung und
Gehaltsanpassung stehen nicht mehr zur Auswahl: die eine entsteht bei jeder
geaenderten Telefonnummer, die andere ist ein totes Ereignis, seit das
Gehalt in Loga liegt. Neu ist der Untertyp — Beendigungsart beim Austritt,
Art der Abwesenheit bei der Langzeitabwesenheit, im Bericht und im Export.
Anforderung 10 — zwei Kacheln. "Aktives Dienstverhaeltnis" ist nicht
dasselbe wie "Aktive Mitarbeiter:innen": dort steht, wer heute arbeitet,
hier, mit wem ein Vertrag laeuft. Sichtbar waren 806 und 10, addieren musste
man selbst.
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>
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>
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>
Employees from outside the EU, the EEA and Switzerland need a residence
permit, and HR needs to know when it expires — about eighty people in
the current data, across Türkei, Serbien and Bosnien. Two columns, the
same shape as the dismissal protection: a flag and a date that only
means anything with it. The date is optional, because an open-ended
permit has none and a mandatory field would force an invented one.
The nationality coupling deliberately stays out of the database. Putting
it there would mean keeping the country list in two places — SQL and
lib/countries.ts, where the picker needs it anyway — so an EU accession
would become a migration instead of a line in a list. Worse, correcting
somebody's nationality would fail the constraint while the old permit
was still attached, which is exactly the moment someone is fixing a
mistake. The UI decides whether the fields appear, and clears them when
the nationality moves into the free-movement area.
So the list is the load-bearing part, and it is tested: 31 entries, all
of them values the picker can actually produce, no duplicates, no third
countries. A missing nationality reads as "no permit required" — an
unanswered question is a reason to record it, not to demand papers.
The permit shows on the Stammdaten tab only for the nationalities it
applies to. A line reading "Aufenthaltstitel: Nein" under an Austrian
citizenship would look like information rather than a question that does
not arise.
Filter by it and by when it expires — the question behind that being
"whose permit runs out next quarter" — plus columns in the export.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Last step moved the four part-time arrangements out of the absence list
and recorded the reason in the history text. That answered "what
happened" but not "who is in one right now", and the profile showed
nothing at all. So it becomes a real field: teilzeit_art, with an
optional end date.
The objection I raised then still holds — a state goes stale, because
nobody goes back to note when a Bildungsteilzeit ended. teilzeit_bis is
the answer to it: with an end date a report decides for itself what is
still running instead of trusting that someone maintained the row. Left
empty it means "open end", which is an honest thing to say.
It runs through the ordinary change machinery rather than beside it. It
sits in app_feld_karte, so it shows up in the history as a field with
before and after, and can be corrected there like any other. The
description suffix from last step is gone — writing the same thing twice
is how two versions start disagreeing.
Reporting: filter by variant, by "in one at all", and by when it ends;
group headcount by variant, where the absence of one reads "Keine"
rather than a dash, because in a report that is an answer and not a gap.
Plus columns in the export and the import.
One gap found while rehearsing, and only because the probe happened to
pick a return date in the future: a scheduled return carries its payload
through pending_org_changes, and that payload did not include the
variant. Someone would have come back on reduced hours in April with the
reason gone. The daily run now carries it too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Works council members, expectant mothers, parents on leave, registered
disabled employees, apprentices — each has its own rules that come
before a dismissal. The tool does not judge whether one is lawful, but
it must not stay quiet about it either, and looking it up on the
contract tab is exactly the step that gets skipped under time pressure.
So: a checkbox, an optional end date, and a red warning at the top of
the termination panel naming the date — or saying plainly that no end
was recorded. It shows for a no-show too; the protection runs from the
start of the contract, not the first day worked.
The date is optional on purpose. A works council mandate has a known
end, a pregnancy does not, and a mandatory field would force an invented
number. A constraint says only what cannot be: an end date without the
flag, which would be a leftover nobody could interpret.
The field goes the whole way through — hire, data change, contract
sheet, export, report criteria (as a yes/no and as a date range), and
the import. A field that exists in one screen and not the next is how
people stop trusting the numbers.
Terminating is now offered for planned entries as well, labelled "Nicht
angetreten", with No Show preselected. Without it a person who never
turned up stayed a planned entry forever, since nothing else can end
one.
One finding worth recording: tsc has been reporting success on a broken
program. A generated file under .next got corrupted when a build ran
against a live dev server, and its syntax errors suppressed semantic
checking everywhere else — two genuine type errors in this change went
unreported until I typechecked with .next excluded. The file is removed
and the ordinary typecheck is meaningful again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Employee names now read "Winkler, Hannah" wherever a person appears in a
list, a table, a heading or a tree node. That is the order a personnel
list is kept in, it is the order people are looked up in, and it finally
matches the sorting — the employee list has always been ordered by
surname, which made an alphabetical page look unsorted.
The name was being assembled inline in about twenty places. A rename
that catches half of them is worse than none, so it now goes through
fmtName in lib/format.ts and every display site calls it.
Sentences keep the natural order: "Hannah Winkler wurde versetzt" reads
like German, "Winkler, Hannah wurde versetzt" reads like a form. So the
toasts are unchanged and only labels moved.
Two things the change would have quietly broken:
The org chart's own filter matched against "first last". It now matches
either order, with or without the comma, so typing what you see works
and so does typing what you remember.
The print model sorted by the last word of the composed name, which
happened to be the surname and is now the first name — every printed
unit would have come out sorted by first name. It sorts on the surname
field itself now, which is what it meant all along.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The export offered four criteria — unit, location, status, employment
type — while the employee record carries around twenty selectable
attributes. Anything else had to be filtered by hand in Excel afterwards,
which is how a payroll hand-off stops matching the application it came
from.
All of them are now filters: contract type, blue/white collar,
collective agreement, paygrade, internal/external, gender, company car
and its drivetrain, works council, lateral leadership, C-level, type of
long-term absence, weekday worked, dependents on file, and open ranges
for entry, exit, birth date and weekly hours. The unit filter covers
every level rather than only divisions, so a single department can be
selected without going the long way round.
They live in one table in lib/report-criteria.ts, which the filter panel
builds itself from, the parser validates against, and the query turns
into conditions. A new criterion is one entry there and nothing else —
and it cannot end up working in the report while being silently ignored
by the export.
The two export links and the saved-report config now carry the query
string through as it stands instead of listing the parameters they know
about. That enumeration was the actual defect: adding a filter meant
remembering three separate places, and forgetting one produced an export
that quietly disagreed with the figure on screen.
Validation is not housekeeping here. These values reach SQL comparisons
and the download filename, i.e. a Content-Disposition header; what is not
in the list does not get through.
The company car dropdown leaves the employee list. It is one of twenty
equals under Berichte now, where the selection can also be exported —
which was the point of asking in the first place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A round of interface corrections from use, plus one schema change behind
them.
The private email address is now optional. It was NOT NULL — the wrong
default for a private detail: someone without one had to invent one, and
invented data in a personnel file is worse than missing data. Both fields
are relabelled to say whose they are, "Private E-Mail" and "Private
Telefonnummer", because the company address does not exist until the person
starts. Uniqueness stays; several NULLs coexist in a Postgres unique index,
which is exactly what is wanted.
The summary step still promised that "Personalnummer und
Firmen-E-Mail-Adresse werden automatisch vergeben". Neither is true any
more. Removed rather than reworded — the step lists what was entered, and a
banner claiming otherwise is worse than no banner.
Dependents move into the wizard as step three, optional. They can only be
attached after the hire, because add_employee_dependent needs an id that
does not exist while the form is open, so they are collected in the draft
and written afterwards. That puts them outside the transaction the person is
created in: if one fails the person still exists, so the message names who
is missing instead of failing silently, and the SV number is checked in the
step rather than after.
The emergency contact gets its own step, second to last, and its
relationship is a dropdown of the common ones rather than free text —
otherwise "Gattin", "Ehefrau" and "Frau" end up side by side and nothing can
be counted. "Sonstige" is there because a closed list would otherwise be
presumptuous.
On the master-data tab it now sits below the dependents rather than above:
both are people around the employee, and this is the one you reach for in a
hurry.
Returning from a long absence: the choice read "unverändert", which made you
open the file to find out what you were agreeing to. It now reads "Wie vor
Abwesenheit (38,5 h)" with the hours actually worked, and the alternative is
"Reduziert" — whose hours field starts empty on purpose. A number already
filled in gets confirmed rather than read off the agreement it comes from.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three requests from use, one of which changes the schema's mind about
something.
The personnel number is no longer issued. It was GENERATED ALWAYS AS
IDENTITY, which refuses a supplied value outright — but it has to match Loga
and Interflex, and a number this application invents is unknown there, so the
same person ends up with two. Identity dropped, entered everywhere instead:
in the wizard, in the import, and validated against a duplicate with a
message that names the number.
Worth stating plainly: the column had no unique constraint. The identity
prevented collisions as a side effect, and once the value comes from outside
that side effect is gone. The constraint is the point now, and it was
missing.
Company cars distinguish Verbrenner from Elektro, tied to has_dienstwagen by
a CHECK so "E-KFZ" cannot appear against someone without a car. The list
filters on it — with, without, only electric, only combustion — which is the
question the report was really about; it was answerable before only through
an export and manual work.
Emergency contact is name, phone and relationship. Relationship stays free
text: the examples given — Gattin/Gatte, Schwester/Bruder, Freund — are not
a list that closes without telling someone their arrangement does not count.
Name and phone are all-or-nothing, in the database and in both forms: a name
without a number helps nobody, a number without a name does not say who
answers.
Two mistakes of mine on the way, both caught by checks I had written into
the migrations rather than by me:
- The first CHECK on the car type would have permitted exactly the case it
was written against. `art in (…)` yields NULL rather than false when the
column is null, and a CHECK counts NULL as satisfied. It needs an
explicit `is not null` in front.
- The constraint was added before the backfill, so it rejected every
existing row with a car.
Existing cars are recorded as Verbrenner, which is an assumption — but a
visible one: "Elektro" appears nowhere nobody confirmed it.
hire_employee and change_employee_data both had to learn the new columns.
They name their columns one by one, and what is missing there is dropped in
silence — the interface would have collected the fields and thrown them
away, which is what happened to the email address this morning.
Verified against the live database, all rolled back: a hire without a number
is refused, a duplicate is refused naming it, a freely chosen one goes
through; E-KFZ plus contact arrive intact; a contact without a phone is
refused. A change records both, with before and after in the audit detail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reverts 61ccce5, which reverted ecbda3f. The decision came back to Vercel,
so the two platform accommodations return: output: "standalone" is
conditional on VERCEL again, and /api/import goes back to 60 seconds, the
free tier's ceiling.
The Docker path is unaffected and stays documented — including the internal
network notes and deploy/Caddyfile written in between, which remain correct
for anyone taking that road. DEPLOYMENT.md conflicted at the top and now
carries both introductions instead of one replacing the other.
Verified with VERCEL=1: builds clean and emits no standalone directory.
Stated once and recorded here rather than repeated: Vercel's Hobby plan
excludes commercial use, and this is a company's HR system. Defensible while
the database holds nothing but the 852 invented people from the seed;
Pro at $20/month is the licensed path once real personnel data is in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This reverts commit ecbda3f. The deployment goes to a Linux server instead,
so the two accommodations no longer earn their place: output: "standalone"
returns to unconditional, which is what the Dockerfile wants, and
/api/import goes back to 120 seconds — the free-tier ceiling that forced 60
does not apply outside a serverless platform, and a large import benefits
from the headroom.
The Vercel section in DEPLOYMENT.md goes with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two settings were wrong for a platform build.
output: "standalone" tells Next.js to emit a self-contained server, which is
what the Dockerfile copies in — and what Vercel neither needs nor expects,
since it builds and packages the app itself. It is now conditional on the
VERCEL variable, which every build there sets, so each path gets what it
wants. Verified both ways: with VERCEL=1 no standalone directory appears,
without it one does.
/api/import declared maxDuration = 120. The free tier caps at 60 and refuses
anything higher, so the deployment would have failed on a value chosen for a
self-hosted server. Lowered, with the reason and the Pro ceiling written
next to it.
DEPLOYMENT.md now covers both paths, and says plainly that the repository
cannot be connected: git.elycon.solutions is self-hosted, and Vercel's git
integration only speaks GitHub, GitLab and Bitbucket. Deploying from the
workstation with the CLI works with any repository and is the shorter road;
mirroring to GitHub is written down as the alternative, with its cost — two
remotes to keep in step.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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.
Die Datenbank stand seit dem Cut-over auf org_units/om_positions/
position_assignments, die Anwendung fragte weiter nach employees.division_id,
team_id und manager_id — Spalten, die es nicht mehr gab. Die Oberfläche war
deshalb leer, obwohl die Daten vollständig da waren. Das ist jetzt behoben,
und zwar nicht durch Nachbau der alten Begriffe, sondern indem sie verschwinden.
Neu ist eine dünne Schicht, die die Verkettung Person → Besetzung →
Planstelle → Einheit einmal auflöst (lib/placement.ts) und der Baum als reine
Funktionen darauf (lib/org.ts): Vorfahrenkette, Teilbaum, Brotkrume. Alles
Weitere hängt daran.
Was sich dadurch von selbst erledigt hat:
- Das Organigramm musste drei Quellen versöhnen, weil keine den ganzen
Zeitstrahl abdeckte. position_assignments ist zeitabhängig, also
beantwortet eine Abfrage "wer besetzte am Stichtag welche Planstelle" —
für Vergangenheit und Zukunft gleichermassen. Wer keine Planstelle hatte,
war nicht da; eine zweite Zugehörigkeitsregel braucht es nicht mehr.
- Die Struktursicht war auf genau vier Ebenen verdrahtet und rendert jetzt
rekursiv über parent_id. Liste und Grafik entstehen aus *einem* Baum;
vorher lag dieselbe Hierarchie zweimal vor und konnte auseinanderlaufen.
- Eine offene Stelle ist keine eigene Tabelle mehr, sondern eine Planstelle
ohne laufende Besetzung — das Komplement kann nicht aus dem Tritt geraten.
- Eine Versetzung ist der Wechsel auf eine Zielplanstelle statt Zielteam
plus frei getipptem Titel. Sie kann damit nicht mehr dort landen, wo es
keine Stelle gibt, und die Tätigkeit kommt aus dem Job-Katalog.
- Beim Anlegen einer Planstelle entfällt die Suche nach der vorgesetzten
Person: sie ergibt sich aus der Einheit, die Frage kann nicht mehr falsch
beantwortet werden.
Zwei Auswertungen werden dabei richtiger, nicht nur anders. Ein
Stichtagsbericht gruppierte bisher nach der *heutigen* Zuordnung, weil es
keine Historie gab; er löst sie jetzt zum Stichtag auf. Und ein Ereignis
trägt die Einheit, in der die Person am Tag des Ereignisses sass — vorher
stand ein Austritt von vor zwei Jahren unter einem Team, in das sie nie
versetzt worden war. Der Bereichsfilter greift überall auf den ganzen
Teilbaum; auf den Bereich allein angewandt lieferte er nur die
Bereichsleitung.
Gelöscht: die Reorganisations-Werkbank samt Szenarien und Zügen (sie
verschob Teams und Abteilungen zwischen Bereichen — Objekte, die es nicht
mehr gibt; im OM-Modell ist das ein Umhängen von parent_id), die
Mitarbeiter- und Vorgesetztensuche, die nur sie und die Ausschreibung
brauchten, und aus lib/supabase/types.ts die Tabellen divisions,
departments, teams, positions und employee_assignments.
Die beiliegende Migration räumt die Datenbank entsprechend auf. Sie entfernt
auch Funktionen, die der Cut-over verfehlt hat: create_position,
delete_position und undo_reorg existierten zusätzlich in einer
jsonb-Variante und tauchen deshalb weiter in der PostgREST-Schnittstelle auf,
obwohl ihre Tabellen weg sind — ein Aufruf wäre erst zur Laufzeit
gescheitert. An ihre Stelle treten create_position und delete_position im
OM-Sinn; letzteres schliesst eine früher besetzte Planstelle, statt sie zu
löschen, sonst verschwände mit ihr die Besetzungshistorie.
Typecheck, Lint, Build und 182 Tests sind grün. Die Integrationstests sind
mitgezogen, aber weiterhin ungelaufen — dafür braucht es eine laufende
lokale Datenbank.
Karenz was doing duty as the name for every kind of extended absence, but
the cases behave differently in payroll and reporting — Wochenhilfe, a
Präsenzdienst, a long sick leave and a sabbatical are not the same thing.
The concept is now called Langzeitabwesenheit and carries which kind it is.
- employees.absence_type, constrained to the thirteen kinds. start_karenz
stores it on both paths (written straight away, or parked in the
pending_org_changes payload when the absence starts later);
record_karenz_return and the karenz_return branch of
apply_due_pending_changes clear it, so a returned employee does not keep
looking like they are still away. It also reaches employee_history, the
audit log and the employee export.
- The status enum value stays 'Karenz'. Postgres can rename an enum value in
place, but every stored function body that spells it would then reference
a value that no longer exists — a dozen functions across fifteen
migrations, rewritten for a label. The mapping lives in lib/absence.ts
instead, which is the single place the UI reads the display name from.
- Where a kind is recorded the chip shows it — "Bildungskarenz" says more
than "Langzeitabwesenheit". Absences predating the field have none and
fall back to the generic name rather than to a guess, and a value outside
the list is dropped rather than echoed into the UI.
- The export prints the display name, not the raw enum: a payroll hand-off
reading "Karenz" for what the app calls Langzeitabwesenheit only causes
questions. Audit filter options keep their stored values and change only
their labels.
- The seed spreads the twelve absences across the kinds; all of them being
Karenz would leave any breakdown by kind invisible.
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.
Adds the Berichte export pipeline (/api/export/{report,events,employees})
with shared CSV/XLSX writers in lib/export.ts and lib/reports-data.ts.
Security pass alongside it: sanitize .or() search terms against PostgREST
filter injection, sanitize spreadsheet cells against CSV/Excel formula
injection, stop leaking raw DB error messages to clients, harden the
service-role client with server-only, add baseline security headers, and
bump the vulnerable nested postcss via an override.
Reworks the app from a two-role (hr_admin/manager) model to a single
HR-only role gated by profiles.is_active, fixes transfer/promote/karenz/
reorg RPCs to actually defer future-dated changes via a new
pending_org_changes table instead of writing them immediately (applied
by a daily Vercel Cron route), makes reorg undo append-only instead of
deleting history, adds Karenz-return and history-date integrity guards,
deprecates the salary column, and adds explicit schema grants + perf
indexes needed to run against a fresh (non-hosted) Postgres instance.
Adds vitest unit + integration test suites (the latter against a real
local Supabase instance) covering all of the above, plus lint/typecheck/
build wiring (`npm run check`).