C.08 -- die Suche mit Bindestrich fand nichts. Das Feld wird per translate an
"-/:.," in Leerzeichen zerlegt, die Eingabe aber nicht: "Mueller-Weiss" erzeugte
das Muster mueller-weiss%, waehrend im Heuhaufen "mueller weiss" stand. Ohne
Bindestrich fand man dieselbe Person. Die Trennzeichen stehen jetzt einmal in
lib/employee-search.ts und werden von beiden Seiten des Vergleichs benutzt.
C.09 -- der Einheitenfilter verlor jede Person mit vorgemerktem Austritt.
"Laufend" war valid_to is null, aber terminate_employee setzt das Ende schon
beim Erfassen, Monate vor dem Tag. Jetzt zaehlt auch, was noch laeuft
(valid_to > heute). Bewusst ohne valid_from <= heute: ein geplanter Eintritt
gehoert in die Liste, sonst fiele er aus dem Filter, obwohl der Status
"Geplant" ihn ausdruecklich fuehrt.
E.07 -- nur die Kostenstelle zu aendern war unmoeglich. update_position weist
einen Aufruf ohne Aenderung ab, und die Umkontierung lief erst bei dessen
Erfolg. Sie wird jetzt uebersprungen, wenn sich an den Stammangaben nichts
geaendert hat.
H.06 -- jedes Speichern erzeugte zusaetzlich "Wochenstunden 30.0 -> 30". Der
Vergleich laeuft ueber Text, die Spalte ist numeric(4,1), und das Formular
schickt 30. Der Kommentar an der Zeile nannte die Absicht richtig, nur reicht
::numeric dafuer nicht -- es muss auf die Genauigkeit der Spalte gehen.
Zwei Fehler aus dem ersten Lauf des Rauchtests, beide von mir.
Der Anker fuer transfer_employee stammte aus 20260727120200 und fand nichts:
die Funktion ist seither dynamisch gepatcht worden, die Belegungsabfrage ist
zweizeilig und beruecksichtigt den Stichtag. Dieselbe Falle wie immer, nur
diesmal nicht im Rumpf einer Funktion, sondern im Anker auf sie -- fuer
hire_employee und rehire_employee stimmten die Anker, weil sie aus der zuletzt
erzeugten Fassung kamen. Die Migration war nie angewendet (sie bricht als
Ganzes ab), deshalb die Datei korrigiert statt eine neue geschrieben.
Und der Rauchtest hat sich an einer Stelle selbst durchgewunken: das `raise`
fuer den Fehlschlag stand innerhalb des Blocks mit exception-Zweig, wurde also
vom eigenen Handler gefangen -- und weil seine Meldung das Wort "vorgemerkte"
enthielt, bestand die Pruefung auf die erwartete Fehlermeldung. Der Schritt
galt als bestanden, obwohl die Migration gar nicht angewendet war. Jetzt merkt
sich der Block nur, ob es ging, und ausgewertet wird danach.
rehire_employee war seit dem 17.09. bei jedem Aufruf kaputt, und keine Pruefung
konnte das bemerken: die Selbstpruefungen der Migrationen lesen den Text der
Funktionen, die Unit-Tests laufen ohne Datenbank, und die Integrationstests
haben keinen Bestand, gegen den sie liefen. db/tests/rauchtest.sql schliesst
die Luecke -- es ruft die Funktionen auf, gegen die echte Datenbank, in der
Reihenfolge des Lebenszyklus: anlegen, versetzen, befoerdern, vormerken,
austreten, wiedereintreten.
Zwei Eigenschaften, ohne die es gefaehrlich waere. Es laeuft als
Anwendungsrolle statt als Superuser -- sonst bewiese es nur, dass die
Funktionen fuer niemanden gehen, der sie benutzt. Und es rollt am Ende zurueck,
mit einer Zaehlung danach, die das belegt: beim Test vom 29.09. sind reale
Personen auf Testplanstellen umgezogen und dort geblieben.
Beim Schreiben fiel die fuenfte Stelle mit dem Paar aus Schliessen und
Einfuegen auf: terminate_employee. Wer heute eingestellt und heute wieder
ausgetragen wird -- ohne den Grund "No Show" --, lief in chk_assignment_range.
Die No-Show-Haelfte derselben Funktion macht es laengst richtig und erklaert
auch, warum; es galt nur fuer genau einen Austrittsgrund.
F.24 -- eine Planstelle, auf die eine Versetzung vorgemerkt ist, galt als frei.
Die Vormerkung steht in pending_org_changes, "besetzt?" wurde allein an
position_assignments gefragt. Auffallen wuerde es erst in der Nacht des
Wirksamkeitstages: dort legt der Nachtlauf die zweite Besetzung an und laeuft in
den Teilindex, der genau eine laufende Besetzung je Planstelle zulaesst. Er
arbeitet in einer Transaktion ueber alle faelligen Vorgaenge -- eine einzige
solche Buchung haette ihn vollstaendig zum Stehen gebracht, auch fuer alle
anderen. Neue Auskunft planstelle_vorgemerkt, abgefragt in hire_employee,
rehire_employee, transfer_employee und promote_employee, mit Datum in der
Meldung. Die eigene Vormerkung zaehlt nicht als Hindernis.
D.06 -- die Struktursicht zeigte Einheiten, die es am gewaehlten Stichtag noch
nicht gab: org_units wurde ungefiltert gelesen, waehrend Planstellen und
Besetzungen daneben auf den Stichtag eingeschraenkt waren. Die Druckansicht
filterte schon immer richtig; jetzt tun es alle drei Stellen.
H.09 -- der Kuendigungsschutz stand in der Akte nur als "bis TT.MM.JJJJ",
obwohl Personenkreis, Beginn, Ende und die ganze Begunstigung erfasst werden.
Der Personenkreis ist die eigentliche Auskunft. Die Behinderung bekommt eine
eigene Zeile: sie fuehrt oft zu Kuendigungsschutz, ist aber ein eigener
Bescheid.
Elternteilzeit und Wiedereingliederungsteilzeit sind jetzt auch bei der
Stundenaenderung waehlbar. Sie standen nur bei der Rueckkehr aus einer
Abwesenheit, mit der Begruendung, dass sie typischerweise dann beginnen --
typischerweise ist aber nicht immer. Die Ueberschneidung der beiden Listen ist
damit gewollt; der Test prueft nicht mehr auf Partition, sondern darauf, dass
keine Teilzeit an keinem der beiden Wege haengt.
Wer heute schon einmal versetzt wurde, liess sich heute nicht noch einmal
versetzen oder befoerdern: die laufende Besetzung beginnt dann heute, und der
Code schliesst sie auf denselben Tag. chk_assignment_range verlangt aber
valid_to > valid_from -- ein Intervall von null Tagen ist verboten, und der
Vorgang brach mit der rohen Datenbankmeldung ab.
Das Verbot ist richtig, und der Fall ist im Haus schon einmal entschieden
worden: terminate_employee loescht die Besetzung beim Grund "No Show", statt
sie auf [Eintritt, Eintritt) zu schliessen -- niemand hatte sie je inne.
Dieselbe Begruendung gilt hier. Die zweite Versetzung am selben Tag
abzuweisen waere genau der haeufigste Fall gewesen: jemand hat die falsche
Planstelle erwischt und will es sofort richtigstellen. Beide Ereignisse
bleiben in der Historie stehen.
Vier Stellen mit demselben Paar aus Schliessen und Einfuegen: transfer_employee,
promote_employee und im Nachtlauf die Zweige transfer und promotion. Alle vier
aus der laufenden Definition gelesen und an einem Anker ergaenzt; die
Selbstpruefung zaehlt die beiden Stellen im Nachtlauf, statt nur ihr
Vorhandensein zu pruefen -- der Zweig transfer ist dort schon einmal spurlos
verschwunden.
Zwei Befunde aus dem Test vom 29.09., beide Klasse A.
K.01 -- rehire_employee scheitert bei jedem Aufruf: der case-Ausdruck fuer den
Status liefert text, die Spalte ist ein Aufzaehlungstyp. Genau das wurde am
10.08. schon einmal behoben. Vier spaetere Migrationen haben die Funktion neu
erzeugt und den Zusatz nicht mitgenommen -- zwei davon aus einer aelteren
Datei, zwei aus der laufenden Definition. Daraus die Lehre, die vorher nicht
dastand: aus dem laufenden Stand zu erzeugen schuetzt davor, Verhalten zu
verlieren, nicht davor, einen bereits vorhandenen Fehler mitzunehmen. Die
Selbstpruefung benennt deshalb jetzt das Erwartete und nicht nur das Neue.
H.05 -- "Daten aendern" schrieb veraltete Werte zurueck. Die Panels bleiben
eingebunden, damit ihr Ein- und Ausfahren laufen kann, belegen ihre Felder aber
mit useState(employee.…) vor -- und das laeuft nur beim ersten Aufbau. Nach
einer Befoerderung brachte router.refresh() die frische Akte herein, der
Zustand im Panel blieb der von vorhin, und beim naechsten Speichern ging er als
Ganzes an change_employee_data: der eben gesetzte Hay-Grade stand wieder auf
dem alten Wert. Jedes Panel bekommt jetzt einen key aus employees.updated_at,
der sich bei jeder Aenderung an der Zeile bewegt und sonst nie. Der
Wiedereintritts-Assistent daneben macht es seit jeher so.
Der Papierkorb neben dem Plus, im Organigramm wie in der Liste, und nur dort,
wo nichts darunter haengt. Ein Knopf, der beim Klick eine Absage erteilt, ist
schlechter als kein Knopf -- die Datenbank prueft es trotzdem, denn sie sieht
auch geschlossene Planstellen, die im Baum gar nicht gezeichnet sind.
Abgewiesen wird mit Grund: untergeordnete Einheiten, Planstellen (geschlossene
zaehlen mit), Kostenstellen, die darauf verweisen, und die Wurzel selbst.
Geloescht und nicht geschlossen, und das ist eine bewusste Einschraenkung.
delete_position macht seit dem OM-Umbau den Unterschied vor: besetzt gewesen →
valid_to, nie besetzt gewesen → weg. Fuer Einheiten laesst sich davon heute nur
die zweite Haelfte umsetzen, weil ein valid_to an einer Einheit zwar
eingetragen, aber nirgends gelesen wuerde -- weder Organigramm noch
orgMapsAbfragen, Berichte, Druck oder die Auswahl beim Anlegen einer Planstelle
schraenken org_units auf den Stichtag ein. Die geschlossene Einheit staende
ueberall weiter da, nur mit einem Datum, das niemand sieht. Das Schliessen zum
Stichtag kommt, wenn org_units gegen den Stichtag gelesen wird -- dieselbe
Arbeit, die auch das Verschieben braucht.
Bisher gab es dafuer nur den Import. Jetzt sitzt auf jeder Einheit im
Organigramm -- in der Grafik wie in der Liste -- ein Plus, das eine
untergeordnete Einheit anlegt, wahlweise gleich mit Leitungsplanstelle.
Bewusst nur dieser eine Fall. Umbenennen, verschieben und schliessen fehlen
nicht aus Zeitmangel: org_units.parent_id traegt kein Datum, ein Verschieben
aenderte damit auch jede Auswertung auf einen vergangenen Stichtag, und der
fruehere Stand waere danach nirgends mehr ablesbar. Anlegen stellt diese Frage
nicht, weil vorher nichts da war -- und es kann dabei auch kein Kreis
entstehen, was hier mehr wiegt als es klingt: die rekursive Abfrage in
om_reporting_lines hat weder Tiefenbegrenzung noch Kreiserkennung.
Die Leitungsplanstelle entsteht ueber create_position statt durch eine zweite
Fassung derselben Logik -- dort haengen Jobkatalog, Nummernvergabe und die
Pruefung "je Einheit genau eine Leitung".
Die Orgnummer wird eingetragen, nicht vergeben. Vorgeschlagen wird die naechste
freie Nummer der bestehenden Reihe, und nur dann, wenn sich im Bestand genau
eine Systematik ablesen laesst -- eine plausibel aussehende, aber erfundene
Nummer prueft niemand nach.
Das Datum kommt nicht aus dem Stichtag der Ansicht. Wer sich die Struktur zum
letzten Jahresende ansieht und auf Plus drueckt, will in aller Regel eine
Einheit von heute anlegen.
Die Befoerderung konnte ihn schon setzen, "Daten aendern" nicht -- weder das
Formular noch change_employee_data kannten das Feld. Damit war eine Einstufung
nur ueber den Weg "Befoerderung" zu aendern, und eine Richtigstellung ("da
stand von Anfang an die falsche Stufe") ist keine Befoerderung: sie soll keine
Planstelle wechseln und kein Ereignis in der Historie hinterlassen.
Das Feld gehoert zur Gruppe contract und ist damit datiert -- eine Umstufung
gilt ab einem Tag. Es kann also in pending_org_changes landen, und deshalb
steht es auch im Nachtlauf. Genau diese zweite Stelle ist hier schon einmal
vergessen worden: die Gruppe role fehlte dort monatelang, und eine datierte
Aenderung wurde als applied vermerkt, ohne etwas zu tun. Drittens
app_feld_karte, sonst waere der Eintrag in der Historie nicht richtigstellbar.
Alle drei Funktionen werden aus der laufenden Definition gelesen und an genau
einem Anker ergaenzt, nicht aus einer Datei kopiert.
Der Test zur Feldkarte liest jetzt alle Migrationen statt einer bestimmten. Der
feste Dateiname darin trug den Vermerk "die zuletzt gueltige Fassung" und war
schon zwei Migrationen spaeter falsch -- und ein Teil der Feldkarte kommt
inzwischen ohnehin aus einer Punktaenderung statt aus einer vollstaendigen
Fassung.
A--F stammte aus der Spezifikation, nicht vom Kunden: sechs erfundene Stufen
mit erfundenen Beschreibungen. Der Kunde bewertet nach Hay und hat die Liste
geschickt -- 13 Stufen plus einen Generic Grade.
Dieselbe Spalte fuellt im Cornerstone-Extrakt die Grade ID. Solange dort A--F
steht, ist die Datei in jeder Zeile falsch, ohne dass es beim Erzeugen
auffaellt: das Zielsystem kennt diese Kennungen nicht.
Der Typ heisst weiter paygrade_type, ist aber jetzt eine Domain ueber text mit
CHECK statt eines Aufzaehlungstyps. Ein Enum laesst sich nicht umschreiben --
Werte entfernen geht gar nicht, und add value darf im selben Vorgang, der den
neuen Wert schreibt, nicht benutzt werden. Wichtiger: die rund zehn
SQL-Funktionen, die den Wert nach paygrade_type umwandeln, bleiben unveraendert
gueltig. Jede von ihnen neu zu erzeugen hiesse, zehnmal die Gelegenheit zu
haben, aus einer veralteten Vorlage zu kopieren.
Zwei Funktionen muessen doch angefasst werden, beide per Punktaenderung an der
laufenden Definition statt per Kopie aus einer Datei: der Vorgabewert B in
hire_employee und die Beschriftung im Protokoll von promote_employee.
Der Bestand bekommt den Generic Grade. Aus A--F liesse sich kein Hay-Grade
ableiten: andere Einteilung, andere Anzahl. Geraten saehe im Extrakt genauso
aus wie erhoben.
Cornerstone fuehrt jede Person unter einer eigenen Kennung. Der Export
dorthin trug in "User ID" und "Username" bisher die Alpenwerk-UUID --
richtig, solange es nichts Besseres gab, aber nicht die Kennung, unter
der Cornerstone die Person kennt. Jetzt steht dort diese Spalte.
Freiwillig, weil die Zuordnungstabelle des Kunden fuer 434 der 784
Personen keine Kennung liefert. Eindeutig, weil eine Kennung genau einer
Person gehoert. Als Text, weil fuehrende Nullen in einer numerischen
Spalte verlorengingen.
Ohne hinterlegte Kennung bleiben User ID und Username **leer**. Ein
Rueckfall auf die UUID braechte zwei Kennungsarten in eine Datei, ohne
dass es auffiele, und legte in Cornerstone eine zweite Person neben der
bestehenden an. Eine fehlende Angabe soll fehlen; dafuer gibt es einen
eigenen Test.
Fuenf Funktionen mussten mit -- dieselbe Liste und derselbe Grund wie bei
der Firmen-E-Mail: hire_employee und rehire_employee teilen sich den
Schritt "Person", change_employee_data macht das Feld aenderbar,
apply_due_pending_changes sorgt dafuer, dass eine datierte Aenderung
nicht verfaellt, app_feld_karte haelt den Eintrag in der Historie
richtigstellbar. Die Migration ist wieder erzeugt, nicht abgeschrieben,
und prueft jede der fuenf einzeln.
Erfasst wird das Feld in der Akte, in "Daten aendern", bei Einstellung
und Wiedereintritt sowie ueber den Massenimport; es steht im
Mitarbeiterexport und fuellt im Cornerstone-Export User ID und Username.
Die Migration ist an ihrer eigenen Pruefung gescheitert:
FEHLGESCHLAGEN 20260924220000_reporting_lines_ohne_zeilenschutz.sql
Nicht berechtigt: nur aktive HR-Benutzer:innen duerfen diese Aktion
ausfuehren.
Die Pruefung verglich die Zeilenzahl der neuen Funktion mit der Zahl der
laufenden Besetzungen -- und rief sie dafuer auf. Die Funktion verlangt
seit dieser Migration HR-Rechte, die Migration selbst laeuft aber als
Administrator ohne app.user_id. require_hr_admin() hat sie also zu Recht
abgewiesen.
Nichts wurde eingespielt, die Transaktion ist vollstaendig
zurueckgerollt.
Jetzt wird der Koerper gelesen statt ausgefuehrt: geprueft wird, dass
require_hr_admin, security definer und der search_path stehen und dass
die Bestandteile der Abfrage vollzaehlig uebertragen sind. Eine
Selbstpruefung darf nichts aufrufen, was einen angemeldeten
Anwendungsbenutzer voraussetzt.
Die Akte brauchte 1,3 bis 1,5 s, davon ueber 1,1 s in dieser Funktion.
Als postgres -- am Zeilenschutz vorbei -- kostet dieselbe Funktion 60 ms.
als postgres 60 ms
als alpenwerk_app, vor 20260924200000 1457 ms
als alpenwerk_app, danach 1153 ms
Die vorige Migration fasste die Policies so, dass is_hr_user() je Abfrage
einmal laeuft. Das half um ein Fuenftel -- die Pruefung war also nicht der
Hauptposten.
Die Teile der Funktion sind einzeln billig, unter Zeilenschutz gemessen:
Besetzungen 12 ms, Vorfahrenkette 7 ms, Leitungen 9 ms. Zusammen 28 ms,
die Funktion daraus 1153. Die Kosten entstehen beim Zusammensetzen: unter
Zeilenschutz wird aus jeder Tabellenreferenz eine Unterabfrage mit
Sicherheitsschranke, und der Planer verliert die Freiheit, die
Zwischenmengen einmal zu berechnen -- die beiden Unterabfragen am Ende
laufen dann fuer jede der 784 Zeilen erneut.
Jetzt security definer mit require_hr_admin() in der ersten Zeile. Das ist
dieselbe Bedingung, die in den Policies aller vier gelesenen Tabellen
steht -- require_hr_admin() ist woertlich "if not is_hr_user() then
raise". Sie wird einmal gestellt statt hunderttausendfach. Wer Daten
bekommt, aendert sich nicht; eine unberechtigte Anfrage bekommt jetzt eine
Meldung statt einer leeren Antwort.
Der Preis: diese Funktion liest die vier Tabellen am Zeilenschutz vorbei.
Kaeme je eine feinere Rechteordnung, muesste sie eigens nachgezogen
werden. Mit dem Eigentuemer besprochen -- solche Rollen sind nicht
vorgesehen.
Der Koerper ist unveraendert aus 20260727120100, der einzigen textlichen
Fassung. Geaendert sind Sprache, security definer, search_path und die
erste Zeile.
Die Personalakte brauchte beim Oeffnen anderthalb Sekunden. Im Container
gemessen: person 4ms, hauptabfrage 1554ms, nachschlag 11ms. In der
Hauptabfrage steckt om_reporting_lines -- als postgres 60 ms, als
alpenwerk_app 1457 ms. Derselbe Aufschlag liegt auf allem: ein blosses
count(*) kostet unter RLS 44 bis 55 ms statt 7.
Ursache: ein Policy-Ausdruck wird je Zeile ausgewertet. `is_hr_user()` ist
zwar stable, aber das erlaubt Postgres nur, den Wert innerhalb einer
Anweisung als unveraenderlich anzusehen -- nicht, ihn aus dem Zeilenfilter
zu ziehen. Bei jeder Zeile laufen zwei Unterabfragen auf profiles und
app_passwoerter.
In einen Unterausdruck gefasst wird daraus ein InitPlan, einmal
ausgewertet. Ohne RLS gemessen, damit nur dieser Unterschied sichtbar ist:
om_positions where is_hr_user() 14,8 ms
om_positions where (select is_hr_user()) 0,5 ms
employees where is_hr_user() 4,1 ms
employees where (select is_hr_user()) 0,5 ms
Die Zugriffsentscheidung aendert sich nicht: dieselbe Funktion, dasselbe
Ergebnis, nur nicht mehr achthundertmal gefragt. Keine Tabelle verliert
ihre Policy, keine Rolle bekommt Rechte dazu, alpenwerk_app bleibt ohne
BYPASSRLS.
Der andere Weg -- om_reporting_lines auf security definer umstellen --
waere genau das nicht: er verschoebe die Grenze, um schneller zu werden,
und haelfe nur dieser einen Funktion.
Rund zwanzig Policies von Hand abzuschreiben hiesse, eine davon falsch zu
treffen und eine Tabelle zu oeffnen oder zu schliessen, ohne dass es
auffaellt. Deshalb werden die bestehenden Definitionen aus pg_policies
gelesen und unveraendert wieder angelegt; ersetzt wird allein der
Funktionsaufruf. Die Selbstpruefung zaehlt nach, dass keine offen blieb
und keine verlorenging.
change_employee_data schreibt bei einem Stichtag in der Zukunft nicht in
die Zeile, sondern legt einen Eintrag in pending_org_changes an; am
Stichtag traegt ihn apply_due_pending_changes nach. Dessen Zweig
contract_change kannte fuenf Spalten nicht, die der sofortige Weg
schreibt: die drei Felder des Notfallkontakts und beide Titelfelder.
Wer also den Notfallkontakt oder einen akademischen Titel zu einem
kuenftigen Datum erfasste, sah den Vorgang in der Personalakte, und am
Stichtag geschah nichts. Der Eintrag wurde auf "applied" gesetzt, die
Zeile blieb unveraendert, eine Meldung gab es nicht.
Gefunden durch einen Abgleich beider Wege: 44 Spalten schreibt
change_employee_data sofort, 39 der Nachtlauf. Jetzt 44 zu 44. Dieselbe
Luecke wie bei der Gruppe `role` (20260915120000) und bei der Besetzung im
Zweig `transfer` (20260924100000) -- und derselbe Weg, sie zu finden.
Die Selbstpruefung nennt die fuenf Felder ausdruecklich und prueft
zusaetzlich weiter, was hier schon einmal verlorenging.
Angehoerige liessen sich bisher nur anlegen und entfernen. Ein Tippfehler
im Namen war nur zu beheben, indem man die Person loeschte und neu
anlegte -- zwei Eintraege in der Akte fuer eine Korrektur, und der erste
sagte "entfernt", was nicht stimmte. Jetzt steht in jeder Zeile ein Stift,
wie beim Notfallkontakt.
update_employee_dependent kommt bewusst **ohne** Stichtag. Hinzufuegen und
Entfernen tragen einen, dort passiert etwas zu einem Zeitpunkt. Eine
Berichtigung nicht: der Wert war schon vorher falsch, und ein "wirksam ab"
hiesse, die Person habe bis dahin anders geheissen. Der Eintrag in der
Akte nennt deshalb Vorher und Nachher statt eines Datums.
In "Daten aendern" standen fuer den Notfallkontakt drei Eingabefelder,
waehrend die Angehoerigen darueber als Tabelle mit Knoepfen erscheinen --
zwei Bauformen fuer denselben Zweck, direkt untereinander. Dort steht
jetzt derselbe Abschnitt wie im Reiter "Stammdaten"; beide laufen ueber
das "Wirksam ab" des Formulars, wie die Angehoerigen es schon taten.
Damit schreibt auch nur noch eine Stelle diese drei Spalten. Vorher
schickte das Formular sie zusaetzlich im eigenen Aufruf mit.
Die Annahme war, acht Funktionen riefen weiterhin auth.uid() und
scheiterten deshalb an den Rechten der Anwendungsrolle. In der Datenbank
ist das nicht so:
select proname, prosecdef, pg_get_functiondef(oid) like '%auth.uid()%'
-- alle acht: ruft_auth_uid = f
Der Grund: 20260805110000_replace_auth_uid_in_functions.sql liest die
Definition jeder Funktion, ersetzt den Text und legt sie neu an. Es gibt
rund ein Dutzend solcher Migrationen. Wer die *textliche* letzte Fassung
in den Migrationsdateien sucht, sieht diese Aenderungen nicht -- genau das
hatte ich getan.
Damit war die Migration nicht nur ueberfluessig, sondern schaedlich: sie
haette die acht Funktionen mit Koerpern aus alten Dateien ueberschrieben
und jeden danach aufgetragenen Patch stillschweigend entfernt. Also genau
der Fehler, gegen den sie sich richtete.
Sie wurde nie eingespielt. Die Lehre steht in CLAUDE.md: massgeblich ist
der Katalog der laufenden Datenbank, nicht die Summe der Dateien.
Die Attrappe des Supabase-Schemas gibt der Anwendungsrolle mit Absicht
kein usage auf `auth`: "fiele je wieder ein Bezug auf dieses Schema in
den laufenden Betrieb, soll er scheitern und nicht stillschweigend
funktionieren." Er ist gefallen, und sie scheitern.
Auf dem Server nachgemessen, nicht vermutet:
select has_schema_privilege('alpenwerk_app', 'auth', 'usage'); -- f
set local role alpenwerk_app; select auth.uid();
-- ERROR: permission denied for schema auth
Keine der acht ist security definer, sie laufen also mit den Rechten der
Anwendungsrolle. Betroffen war je eine vollstaendige Operation:
Versetzung, Planstelle anlegen, Planstelle loeschen, Angehoerige
hinzufuegen und entfernen, HR-Notiz anlegen und erledigen, Rueckkehrdatum
verschieben.
Der Umstieg auf app_current_user_id() geschah im August und danach
Funktion fuer Funktion, sobald eine ohnehin angefasst wurde. Diese acht
wurden seither nicht mehr angefasst, und nichts prueft den Rest: die
Migrationen laufen durch, die Tests kennen keine Datenbank, und wer die
Oberflaeche bedient, bekommt die Meldung erst beim Speichern.
Geaendert ist nur zweierlei je Funktion -- auth.uid() wird
app_current_user_id(), und der search_path steht fest. Die Koerper sind
unveraendert aus ihren letzten gueltigen Fassungen uebernommen.
Die Selbstpruefung geht das ganze Schema durch statt nur die acht: sie
schlaegt an, sobald irgendeine Funktion in public das Schema auth wieder
anfasst, und prueft zusaetzlich, dass das Recht entzogen bleibt.
Bei "Befoerdern" steht jetzt die Wahl: selbe Planstelle oder eine neue.
Bei "neu" wird sie aus den freien gewaehlt, dieselbe Auswahl wie die
Zielplanstelle bei der Versetzung, und die Stelle schlaegt ihre
Taetigkeit als neue Bezeichnung vor -- nur als Vorschlag, denn
job_title darf von der Planstelle abweichen.
Die Zielstelle muss frei sein, mit derselben Meldung wie bei der
Versetzung. Ausgenommen ist die eigene: darauf sitzt die Person schon,
und "bereits besetzt" waere eine Falschaussage ueber sie selbst.
Dabei ist ein aelterer Fehler aufgefallen. transfer_employee legt fuer
ein kuenftiges Datum einen Eintrag in pending_org_changes mit
target_position_id an. Der Nachtlauf hat die Besetzung dort zur
Umstellung auf Planstellen (20260727120200) umgehaengt; zuletzt stand in
diesem Zweig nur noch
update employees set job_title = coalesce(payload->>'new_title', ...)
also die Fassung von *vor* jener Umstellung -- und new_title kommt in
diesem payload gar nicht vor. Eine auf spaeter datierte Versetzung wurde
am Stichtag auf "applied" gesetzt und bewegte niemanden: die Person blieb
auf ihrer alten Planstelle, im Organigramm unveraendert, ohne Meldung.
Eine der Migrationen dazwischen hat die Funktion aus einer alten Vorlage
neu geschrieben -- dieselbe Falle wie bei der Gruppe `role` und bei der
Anmerkung zum Austritt.
Beide Zweige haengen die Besetzung jetzt um, und die Selbstpruefung
zaehlt nach, dass es zwei sind. Nebenbei bekommt promote_employee den
festen search_path und app_current_user_id() statt auth.uid().
Kleinigkeiten zuerst: "Gelaufen" heisst jetzt "Vergangen", die Kachel
"Langzeitabwesend" heisst "Langzeitabwesende", "Eintritte/Austritte
(Jahr)" heissen "(YTD)" -- gezaehlt wurde ohnehin seit Jahresbeginn.
"Personenkreis" heisst "Grund". Der Hinweis unter dem Grad der
Behinderung und der Erklaertext ueber dem Honestly-Report sind weg.
"Gehaltsanpassung" steht nicht mehr im Filter des Protokolls: das Gehalt
ist aus dem Funktionsumfang, keine Funktion schreibt die Art mehr, und
ein Filter, der immer leer ausgeht, sieht aus wie ein Fehler.
Im Fenster fuer den Notfallkontakt faellt "Wirksam ab" weg -- eine
Telefonnummer fuer den Ernstfall gilt ab sofort. In "Daten aendern"
wandert der Block unter die Angehoerigen, dieselbe Reihenfolge wie im
Reiter "Stammdaten". Auf der Uebersicht fuehren die Namen unter "Letzte
Aktivitaeten" in die Akte; vorher war die Karte eine Sackgasse.
"Eintrag berichtigen" bot fuer jedes Feld ein Textfeld an, auch fuer
"Notfallkontakt Verhaeltnis", wo die Erfassung sonst eine Liste fuehrt.
Wer dort "Gattin" statt "Gattin/Gatte" tippte, erzeugte einen Wert, den
keine Auswertung mehr findet -- die Berichtigung schreibt in dieselbe
Spalte wie das Formular, nur ohne dessen Pruefung. Welche Felder eine
Liste bekommen, steht in lib/historie-felder.ts; ein Test prueft jeden
Schluessel gegen app_feld_karte(), damit ein Tippfehler dort nicht still
auf ein Textfeld zurueckfaellt. Felder mit Aufzaehlungstyp bleiben
bewusst aussen vor: dort ist der gespeicherte Wert nicht die Anzeige.
Und die Antwort auf "wo sieht man die Anmerkung beim Austritt?": nirgends.
Das Formular sammelte sie ein, terminate_employee liess sie fallen. Bis
20260814100000 stand sie in der Beschreibung des Ereignisses; beim
Umschreiben fuer den Nichtantritt ging sie verloren, und die Migration
fuer die Austrittsart reichte die verkuerzte Fassung weiter. Sie steht
jetzt wieder in der Personalakte und im Protokoll, und die Selbstpruefung
faengt den naechsten Verlust ab.
Beim Speichern in "Daten aendern" brach es ab, sobald bei einer zweiten
Person das Feld "E-Mail (privat)" leer blieb:
duplicate key value violates unique constraint "employees_email_key".
email war bis 20260811140000 NOT NULL -- jede Person hatte eine, das Feld
war nie leer. Seither ist die Angabe freiwillig, aber
change_employee_data schrieb weiter `coalesce(v_person->>'email', email)`.
Ein leeres Formularfeld kommt als "" an, und "" ist nicht null: es landete
als leere Zeichenkette in der Spalte. Beim ersten Mal ging das gut, beim
zweiten schlug der eindeutige Index zu -- "" ist gleich "", waehrend null
nie gleich null ist.
Aufgefallen ist es erst jetzt, weil die Spieldaten durchweg Adressen
trugen. Die 784 uebernommenen Personen tragen keine.
Alle freiwilligen Textfelder bekommen deshalb dieselbe Form wie der
Notfallkontakt und die Firmen-E-Mail: `case when ? then nullif(…, '')`.
Fehlt der Schluessel, bleibt der alte Wert; steht er leer da, wird das
Feld geleert -- die Absicht, die jemand ausdrueckt, wenn er eine Angabe
herausloescht. first_name, last_name, gender, birth_date und nationality
bleiben ausgenommen, sie sind NOT NULL.
Der Nachtlauf bekommt dieselbe Behandlung, sonst liefe eine auf spaeter
datierte Aenderung in denselben Index -- um drei Uhr frueh und ohne
jemanden, dem die Meldung angezeigt wuerde. Was bereits als "" in der
Datenbank steht, raeumt die Migration auf; sonst blockierte diese eine
Zeile weiterhin jede weitere Person ohne Adresse.
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.
Aus dem Gespraech vom 17.09.2026. Migration 20260917130000.
Der kleine Dialog fragte Datum und Planstelle und liess alles andere stehen,
wie es beim Austritt war. Nach zwei Jahren Abwesenheit ist das selten noch
richtig — Anschrift, Wochenstunden, Kollektivvertrag, oft auch der Name. Wer
es bemerkte, musste erst wiedereinstellen und danach "Daten aendern"
oeffnen: zwei Vorgaenge fuer einen, und in der Akte stand dann eine
Vertragsaenderung am Tag des Wiedereintritts, die niemand vorgenommen hat.
rehire_employee nimmt jetzt den ganzen Satz entgegen und schreibt ihn in
einer Transaktion. Erst einstellen und dann aendern waeren zwei
Transaktionen, und scheitert die zweite, steht die Person wieder im Dienst —
mit den Daten von damals und ohne dass es jemand merkt.
Jedes Feld mit coalesce: fehlt ein Schluessel, bleibt der bestehende Wert.
Das haelt den schlanken Aufruf am Leben und ist zugleich die Bedingung
dafuer, dass der Assistent nur schickt, was er auch zeigt — Anschrift,
Staatsbuergerschaft und Aufenthaltstitel fragt er naemlich nicht, so wenig
wie bei einer Neueinstellung.
Die Planstelle und das Eintrittsdatum sind bewusst leer: die alte Stelle
kann besetzt oder entfallen sein, und ein vorbelegter Platz, den es so nicht
mehr gibt, waere schlimmer als ein leeres Feld — er sieht nach einer Antwort
aus. Dazu die Pruefungen der Neueinstellung, die hier fehlten: existiert die
Planstelle, gilt sie zum Datum, ist sie frei.
Die Personalnummer steht fest und wird nur gezeigt. Sie zu pruefen faende
zwangslaeufig einen Treffer — die Person selbst — und sperrte das Formular
mit einer Meldung, die stimmt und trotzdem in die Irre fuehrt.
Ein eigenes Bauteil statt eines Schalters im HireWizard: kein Entwurf zu
speichern, keine Angehoerigen anzulegen (die stehen schon in der Akte),
keine Nummer zu pruefen, andere Funktion am Ende. Geteilt werden die
Schritte, und das ist der Teil, der wirklich geteilt gehoert. RehirePanel
ist damit weg.
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.
Anforderung 11 aus dem Workshop. Migration 20260915160000.
In der Fertigung sind Planstellen reihenweise gleich: zwoelf
"Maschinenbediener:in" in derselben Abteilung auf derselben Kostenstelle.
Von Hand angelegt sind das zwoelf Gelegenheiten, die Taetigkeit
unterschiedlich zu schreiben — und ab der zweiten Schreibweise steht sie
zweimal im Katalog und jede Auswertung nach Taetigkeit ist falsch.
Der Klon nimmt Einheit, Taetigkeit (denselben Katalogeintrag) und die zum
Stichtag geltende Kontierung. Nicht mit kommt die Besetzung: eine
Planstelle ist ein Platz, keine Person, der Klon ist frei.
Leitungsplanstellen sind ausgenommen, und zwar mit einer eigenen Meldung.
Je Einheit gibt es genau eine, und ein Unique-Index sichert das ab — ohne
die Pruefung waere ein Klonversuch entweder "duplicate key value violates
unique constraint" oder, mit stillschweigend fallengelassenem is_chief,
eine Planstelle, die anders ist als ihre Vorlage, ohne dass es jemand
angefordert hat. In der Liste fehlt der Knopf dort; das ist Bequemlichkeit,
die Regel steht in der Funktion.
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.
Seeing a colleague's draft turned out to be half a feature: the point of
sharing it is to finish it while they are away. So writing is allowed
now -- but never by two people at once.
A draft is a single JSONB field. Whoever saves writes the whole state,
not the changed field, so two open wizards overwrite each other
completely and the second person sees nothing wrong: their own state is
right there on screen. That is why writing stayed with the owner until
now, and a lock is what makes giving that up safe.
The lock lives in the row (locked_by, locked_at) and is enforced by the
update and delete policies, not by the application. It expires, and that
is the important half: releasing happens when the wizard closes, and a
closed laptop never closes a wizard. Without expiry one crashed tab
would take a draft away for good -- worse than the problem being solved.
The wizard refreshes its lock while open so a long form does not lose it
mid-way.
Delete had to widen too, which reads like more than was asked for: the
wizard deletes the draft once the person is hired. Without it the hire
would go through and the draft would sit there forever. The card still
only offers delete on your own drafts.
Four of five mutations against the lock go red. The fifth -- dropping
`!open` from the refresh guard -- does not, because freigeben() already
nulls the ref the interval checks. The condition stays as the readable
statement of intent, now with a comment saying so.
Not run against a live database here; the CI migration job is the first
real execution.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 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>
Yesterday's version had it the other way — everyone visible, untick to
hide. The decision from the business side is the opposite: you see your
own notes, and you tick the colleagues you also want. So note_mutes
becomes note_subscriptions and the predicate flips from `not exists` to
`exists`.
The existing rows are not carried over. The meaning inverts rather than
the sign: converting faithfully ("everyone except the muted") would write
almost the whole roster into the new table and reproduce exactly the state
the change is meant to end. Anyone opening the setting tomorrow would
think it had not taken effect. The table is a day old; what is lost is a
few ticks from trying it out.
What this costs is worth saying plainly: the silent case that could not
happen under exceptions can happen now. Do not tick a colleague and you
will not see her follow-ups — not while she is on holiday either. That is
the flip side of the decision, and it is written down in the migration
rather than discovered later.
Each note now says who wrote it. Own notes read "von mir" rather than
repeating your own name, which would sit on every second line and tell
nobody anything. The flag is computed on the server: the user id is
already there, and threading it through four components for one word is a
poor trade. The counter on the button follows the same turn — "+2" for
what you added, nothing when you added nothing.
Verified: 21 tests, five mutation-checked (restoring `not exists`,
dropping the own-notes clause, inverting the default, hiding the author,
and printing your own name instead of "von mir" each turn them red). 489
tests, typecheck, lint, schema drift and build clean. The migration is
reviewed but not run — no reachable database here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bell is a shared pile: every active HR person sees every open note,
regardless of who wrote it. That was agreed and it stays the default —
this narrows it, it never widens it. You can now untick colleagues whose
notes you do not want to see.
What gets stored is the *exceptions*, not the selection. The difference
shows the day someone new joins HR: had the selection been stored, she
would be invisible to everyone until each person ticked her, and nobody
would notice her follow-ups piling up. This way she is visible from day
one and hiding her is a deliberate act. Same reasoning that made notes a
shared inbox in the first place — the silent gap is worse than a row too
many.
Own notes always come through: `note_mutes` rejects a self-reference, and
the predicate says so again rather than depending on a check constraint
staying put. Notes with no author come through too — hiding one because
nobody knows who wrote it is exactly the loss this list exists to prevent.
The rule lives in lib/notes.ts as one SQL expression because two places
need it: the bell in the header and the "Anstehend" card on the dashboard.
Two copies drift, and then the card counts something the bell does not
show.
No SQL function and no audit row, unlike anything that touches employee
data — this is a personal display preference, and an audit trail recording
every tick would make finding real changes harder. Same pattern as saved
reports and hire drafts, and the owner policy on note_mutes means a row
for someone else cannot be written even with invented values.
The checkbox flips immediately and flips back if saving fails; the list
gets clicked through several at a time and a round trip per tick feels
like hesitation.
Verified: 19 tests, five mutation-checked (or→and, dropping the own-notes
clause, inverting `not exists`, inverting the default, and losing the
email fallback each turn them red). Typecheck, lint, schema drift, 477
tests and the build are clean. Not seen in a browser: login goes through
the company account and the database is unreachable — the migration is
reviewed but has not been run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Beim Neuaufbau der Datenbank aus den Migrationen am 26.08. blieben
cost_centers, position_cost_centers, onboarding_tasks und
offboarding_tasks ohne Row Level Security: sie verlassen sich auf
den Ereignis-Trigger ensure_rls, den eine spaetere Migration erst
anlegt. Ein Ereignis-Trigger wirkt nur nach vorne.
Die Policies auf diesen Tabellen existieren und wurden nie
ausgewertet — PostgreSQL befragt sie nur bei eingeschaltetem RLS.
In jeder Aufstellung der Policies sah es richtig aus.
Der CI-Job prueft ab jetzt den Zustand nach dem Lauf, nicht nur
dass die Dateien durchlaufen. Genau in diesem Zwischenraum ist der
Fehler durchgekommen.
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>