Commit Graph
11 Commits
Author SHA1 Message Date
Harlekin eef145c97b Suche fängt bei „Alle" an, Bereiche in der Kopfzeile sind Knöpfe
**Suche.** Sie startete auf den Fallakten, und man musste die Art vorher
wählen — die falsche Reihenfolge: wer einen Namen eintippt, weiss noch nicht,
ob er eine Fallakte, ein Dokument oder eine Personalakte sucht. Er will wissen,
wo der Name vorkommt.

„Alle" ist jetzt der Auslieferungszustand und zeigt je Art fünf Treffer als
eigenen Block, mit dem Weg in die volle Liste daneben. Getrennte Blöcke statt
einer gemischten Liste nach Rang: ein Aktenzeichen neben einem Namen neben
einer Dokumentnummer hat keine erkennbare Ordnung. Die einzelnen Arten bleiben
als Filter, und nur dort gibt es Facetten und Blättern — beides bezieht sich
auf eine Grundmenge, ein Statusfilter über Fall- und Personalakten zugleich
wäre bei der einen ein Filter und bei der anderen ein Ausschluss.

**Kopfzeile.** Die Bereiche waren Textlinks: von einem Fliesstextlink nicht zu
unterscheiden und ohne Hinweis darauf, wo man gerade ist. Jetzt dieselbe
Knopfform wie alles Bedienbare, der aktive Bereich gefüllt.

Der gefüllte Zustand trägt hellen Text statt der Akzentfarbe: auf der getönten
Fläche wäre Akzent auf Akzent und der Kontrast fiele unter die Untergrenze.
Dazu etwas grosszügigere Polsterung an beiden Knopfgrössen, .btn-count für
Zahlen in Knöpfen, und kein Doppelring mehr am Profilbild.

Nachgesehen mit dem gebauten CSS im Browser, hell und dunkel.
2026-09-03 21:59:06 +02:00
Harlekin b8f42b06e3 Abteilungen als Ebene über der Personalakte
Punkt 3 der Wünsche.

Die Abteilung stand als Freitext in der Personalakte. Damit liess sich nichts
fragen: „SHD", „Shd" und „Sicherheitsdienst" waren drei Abteilungen. Sie ist
jetzt ein eigenes Modell, im Adminbereich verwaltbar, und die drei vorhandenen
Freitexte sind in der Migration zu Abteilungen geworden.

**Eine Fallakte trägt keine Abteilung.** Sie gehört zu einer, wenn eine
beteiligte Person ihr angehört — abgeleitet über CaseParticipant und die
Personalakte. Eine eingetragene Zuordnung wäre nach zwei Wochen falsch: Leute
wechseln, Beteiligte kommen dazu, und niemand pflegt das nach. Dasselbe gilt
für die Dokumente: ein Dokument unmittelbar an der Abteilung wäre halb
verdrahtet, weil die Vorlagen über TemplateUse nur Fall- und Personalakte
kennen.

Die Abteilungsseite zeigt Mitglieder, abgeleitete Fälle und Dokumente,
Vermerke, Chat und ein Kreisdiagramm der Fallarten; die Übersicht eines über
alle Abteilungen. Das Diagramm ist serverseitig gerendertes SVG — eine
Bibliothek wäre das Teuerste auf der Seite gewesen. Die Segmente sind
Abstufungen von --steel und nicht die Stufenfarben: ein Tortenstück in Oxidrot
würde als „streng geheim" gelesen.

Die Zusammenfassung ist die gefährliche Stelle. Eine Abteilungsseite fasst
zusammen, was einzeln längst geschützt ist, und eine Zusammenfassung, die nicht
filtert, verrät mehr als jede Einzelansicht. Deshalb läuft hier jede Liste und
jede Zahl durch dieselben Fragmente wie überall; ein Test prüft, dass die Summe
der Diagrammanteile mit der Länge der Liste übereinstimmt. Wo jemand
Personalakten gar nicht sehen darf, steht null und keine Null.

Vermerke erben Regel 2 unverändert: kein DELETE, Version bei jeder Änderung,
Grabstein bei jeder Zurücknahme, als Regel und Trigger in der Datenbank. Ohne
Korrekturkette, Anhänge und Karenzzeit — was Beweiswert hat, gehört in eine
Fallakte.

Nebenbei: das Zugriffsprotokoll hängt jetzt am dafür vorgesehenen Recht
audit.read statt an admin.
2026-09-03 19:57:28 +02:00
Harlekin 54176f17ba Runde Bedienelemente, Admin zusammengelegt, Kanäle entfallen
Vier der acht Änderungswünsche.

**Knöpfe.** Rund vierzig der siebzig Knöpfe waren blosser Text in Akzentfarbe,
von einem Link nicht zu unterscheiden. Jeder trägt jetzt Rahmen und
Grundfläche, über die Klasse .btn an einer Stelle definiert statt siebzigmal
als Klassenkette. Beim Drücken zieht der Knopf sich zusammen und bekommt einen
inneren Ring — er wirkt kurz dicker, ohne dass die Rahmenbreite sich ändert und
das Umfeld springt; die Rückmeldung setzt ohne Verzögerung ein und klingt
langsam ab, sonst bliebe ein kurzer Klick unsichtbar.

Dazu die neue Regel: Flächen bleiben eckig, alles Bedienbare ist rund. Damit
ist die Rundung eine Auskunft und keine Verzierung.

**Verwaltung.** Rollen, Vorlagen und Änderungsrichtlinie waren drei Einträge in
der Kopfzeile für etwas, das man selten und dann zusammenhängend braucht. Jetzt
ein Eintrag /admin mit Reiterleiste, dazu eine gemeinsame Tabs-Komponente, die
auch der Fallakte zeigt, welcher Reiter offen ist.

**Kanäle.** Ersatzlos entfernt, mitsamt Tabelle. Der Chat gehört dorthin, wo
der Vorgang steht; ein eigener Reiter mit freien Räumen war der Rückweg nach
Discord. Die Personalakte hat jetzt denselben Chat wie die Fallakte — ohne
„als Vermerk übernehmen", denn dafür braucht es einen Verlauf, und Meldungen
gehen nur an Erwähnte, nie in den allgemeinen Kanal: dass es zu jemandem eine
Personalakte gibt, ist selbst eine Auskunft. Ein CHECK erzwingt jetzt, dass
eine Nachricht zu genau einem Ort gehört.

**Kopfzeile.** Suchbalken statt Suchlink, als gewöhnliches Formular ohne ein
Byte im Bündel. Thema, Benachrichtigungen und Abmelden sitzen hinter dem
Profilbild, das von Discord kommt; wer keins hat, bekommt seine Initialen.

Nebenbei: Aktenzeichen in Tests waren nur auf vier Stellen eindeutig und
kollidierten zwischen Läufen, weil Akten nicht löschbar sind.
2026-09-03 15:07:43 +02:00
Harlekin b911c1bd9a Phase 9 abgeschlossen: Übersicht, Beobachtungsliste, helles Thema, Messung
Die Übersicht beantwortet vier Fragen, die man morgens hat: woran arbeite ich,
was ist fällig, wo hat jemand geschrieben, was hat sich getan. Jede Liste
läuft durch visibleCaseWhere — das ist der Ort, an dem eine unachtsame
Abfrage am ehesten eine Akte verrät. Ein Test prüft jede einzeln gegen eine zu
hoch eingestufte Akte. Der bisherige Inhalt ("Dein Zugang") steht jetzt unter
/zugang.

Beobachten ist eine Merkliste, kein Recht: nur was sichtbar ist, lässt sich
merken, und steigt eine Akte später über die eigene Stufe, verschwindet sie
aus der Liste, ohne dass jemand aufräumt.

Das helle Thema tauscht nur die semantische Ebene; die Grundpalette bleibt.
Die Klassifizierungsfarben sind für hellen Grund eine Spur dunkler, im Farbton
aber dieselben — eine Stufe muss in beiden Themen wiedererkennbar sein. Drei
Zustände, "wie das System" ist der Auslieferungszustand, und ein Skript im
Kopf setzt die Wahl, bevor das erste Mal gemalt wird.

Die Messung misst nicht den Nutzer. web-vitals schickt Zahlen und das
Routenmuster, keine Kennung und keinen konkreten Pfad: wer wann welche Akte
geöffnet hat, steht im Zugriffsprotokoll, und eine zweite Spur daneben wäre
eine Überwachungsfunktion ohne Auftrag. Ausgewertet wird Median und 75.
Perzentil, nicht der Mittelwert.

Mobil: die breiten Tabellen scrollen in sich, statt die Seite breiter zu
machen. Ein e2e-Test hält fest, dass keine Seite waagerecht überläuft.

scripts/restore-test.sh spielt eine Sicherung in eine Wegwerf-Datenbank und
prüft ausdrücklich, dass Regeln und Trigger mitkommen: eine Wiederherstellung
ohne entry_no_delete wäre eine Datenbank ohne die Zusicherung, um derentwillen
das System gebaut wird. Einmal durchgespielt, alles angekommen.

186 Tests im Web-Paket, 17 in der Datenbank, 7 im Worker, 54 e2e.
Grösste Route 116,2 von 120 kB.
2026-09-03 10:28:50 +02:00
Harlekin a49f4b5956 Phase 7 abgeschlossen: Suche mit gestuftem Index, Facetten, Palette
Der bisherige Suchvektor enthielt nur, was jeder sehen darf. Das war
leck-sicher und unbrauchbar zugleich: ab Stufe 1 fand die Suche nichts von
dem, was einen Fall ausmacht.

SearchIndex hält jetzt sechs Zeilen je Ziel, eine pro Freigabestufe. Die Zeile
zur Stufe N enthält alles, was ein Leser dieser Stufe sehen darf. Eine Suche
nimmt genau diese eine Zeile und kann deshalb nichts finden, was sie nicht
lesen darf — auch keinen höher eingestuften Eintrag in einer sichtbaren Akte.

Schwärzungen werden dabei stufenweise aufgelöst: strip_redactions_above
entfernt nur, was über der Stufe liegt. Sonst fände ein Leser der Stufe 4
einen Namen nicht, den er auf der Seite im Klartext sieht, und ein Leser der
Stufe 1 einen, den er dort nur als Balken sieht.

Die Suchbedingung ist nicht die Zugriffsprüfung: der Volltext liefert
Kandidaten, die Sichtbarkeit kommt aus visibleCaseWhere — derselben Funktion
wie in der Fallliste, und Treffer, Trefferzahl und Facettenzähler entstehen
gemeinsam in einer Abfrage. Eine in SQL nachgebaute Sichtbarkeitsregel wäre
die Sorte Duplikat, die auseinanderläuft.

Filter stehen in der Adresse. Damit ist jeder Zustand teilbar, der
Zurück-Knopf tut das Richtige, und eine gespeicherte Ansicht ist ein
Lesezeichen. Die mitgelieferten Ansichten stehen im Programm, weil sie von der
Gegenwart abhängen.

Strg+K öffnet die Kommandopalette. Im Grundgerüst steckt nur das
Tastaturereignis, die Palette selbst wird beim Öffnen geholt — das kostet die
anderen Seiten rund hundert Byte.

Offen geblieben: "Zuletzt geöffnet" als Ansicht. Dafür müsste jeder Aufruf
jeder Akte mitgeschrieben werden; das wäre eine Überwachungsfunktion, die
niemand bestellt hat. In TASKS vermerkt.

165 Tests im Web-Paket, 17 in der Datenbank, 7 im Worker, 34 e2e.
Grösste Route bei 119,7 kB gegen 120 erlaubte.
2026-09-02 21:23:01 +02:00
Harlekin cf4348b8b1 Phase 6 abgeschlossen: Aktenchat, Kanäle, Echtzeit, Benachrichtigungen
Ein Ereignisstrom mit Themen (/api/stream), in dem der Dateistrom aus Phase 5
aufgegangen ist. Zwei Prüfungen: beim Verbinden, welche Themen der Nutzer
abonnieren darf, und je Ereignis wird der Inhalt mit seinen Rechten geladen.
Das zweite ist nicht nur Vorsicht — eine Umklassifizierung wirkt dadurch in
einer offenen Verbindung sofort. In der pg_notify-Nutzlast steht nie ein
Inhalt, nur Thema, Art und Kennung.

Der Aktenchat sendet optimistisch mit clientseitiger UUIDv7, die zugleich
Idempotenzschlüssel ist. Antworten, vier feste Reaktionen, Bearbeiten mit
sichtbarem editedAt, Zurückziehen mit Platzhalter — Löschen gibt es nicht, und
eine Regel in der Datenbank hält das fest. Eine zurückgezogene Nachricht kommt
ohne Text an, nicht ausgeblendet.

Aktenzeichen im Text werden zu Vorschaukarten, serverseitig aufgelöst. Eine
Akte, die der Leser nicht sehen darf, sieht darin genauso aus wie eine, die es
gar nicht gibt.

"Als Vermerk übernehmen" steht sichtbar an jeder Nachricht. Der Vermerk gehört
dem Verfasser, nicht dem Übernehmenden; ein Systemeintrag nennt den Ursprung,
damit der Wortlaut später nicht für die Akte geschrieben wirkt.

Bei den Discord-Benachrichtigungen entscheidet die Web-App, wer was erfährt,
und der Worker liefert nur aus. Die Alternative hätte geheissen, die
Sichtbarkeitsprüfung im Worker ein zweites Mal zu bauen. In meta steht ein
Titel nur, wenn er hinaus darf — ab Stufe 3 keiner. Postausgang als Tabelle,
angestossen über LISTEN, mit Takt als Sicherheitsnetz.

Nebenbei riss das Budget der Fallakte erneut (122 kB). Drei Schritte zurück
auf 119,6: Chattexte in eine eigene i18n-Datei, ein gemeinsames AddPanel statt
viermal derselbe Aufklapp-Code, und eine eigene UUIDv7 statt des Pakets — die
Sortierbarkeit, auf der die Nachlieferung beruht, steht damit im eigenen Code
und hat Tests.

Unterwegs habe ich NotifyMode und User.notifyMode ein zweites Mal angelegt,
obwohl es beides seit dem Grundgerüst gibt. Die Migration schlug fehl und die
Datenbank musste von Hand aufgeräumt werden; die Lehre steht in docs/13.

155 Tests im Web-Paket, 15 in der Datenbank, 7 im Worker, 32 e2e.
2026-09-02 21:06:03 +02:00
Harlekin db438ac57e Phase 4 abgeschlossen: Personalakten, Dokumente, Beteiligte, Verknüpfungen
Die Dokumentebene aus docs/13 ist gebaut, additiv: Document, DocumentSection
und DocumentSectionVersion neben Case, CaseSection und CaseEntry. Ein Dokument
hängt an einer Fallakte, an einer Personalakte oder an nichts, höchstens an
einem — gesichert durch eine CHECK-Bedingung. Es gibt kein DELETE; eine
Zurücknahme lässt einen Grabstein stehen, der auch für den Admin zu ist.

Eine neue Dokumentart ist damit ein Datensatz, kein Deployment. Im Seed stehen
vier: vermerk, vorfallsbericht, sonderarbeitsvertrag (mehrseitiger Vertragstext
mit Platzhaltern und Unterschriftenzeilen) und bussgeld.

Zwei Umbenennungen, beide von Hand migriert, damit die vorhandenen Zeilen und
Zählerstände stehen bleiben:
- CaseTemplate -> DocumentTemplate, plus uses[] und layout
- CaseCounter -> NumberSeries, ein Nummernkreis je Präfix für Aktenzeichen
  und Dokumentnummern

Die Regeln für Abschnitte stehen jetzt einmal in lib/sections/render.ts und
gelten für Fallakten und Dokumente gleichermaßen. Die Frage aus docs/13, ob
CaseSection langfristig in die Dokumentebene wandert, ist damit verneint und
begründet.

Rückverweise auf der Personalakte laufen durch visibleCaseWhere, die Zahl
daneben entsteht aus dem gefilterten Ergebnis. Auch die Rollengruppe
verschwindet mit, sonst verriete "Betroffener" ohne Eintrag die Ermittlung.
Die eigene Personalakte ist keine Ausnahme von der Freigabestufe.

Vier neue Rechte: document.create, .edit, .finalize, .retract. Das letzte
steht bei den zurückgehaltenen, es greift in Geschriebenes ein.

91 Tests im Web-Paket, 15 in der Datenbank, 28 e2e. Größte Route 118 kB
gegen das Budget von 120 kB — die Luft wird knapp.
2026-09-02 14:15:16 +02:00
Harlekin 399988596f Phase 3 abgeschlossen: Vorlagen, Abschnitte, Schwärzung, Diff
Eine Vorlage ist eine geordnete Liste von Bausteinen: Eingabefelder und feste
Textbausteine mit Platzhaltern. Damit trägt dieselbe Struktur den
Vorfallsbericht und den Sonderarbeitsvertrag. Die Startvorlagen bilden jetzt
den echten Bericht der Abteilung ab, samt Berichtsart, Gefahreneinschätzung
und Foundation-Freigabe — letztere als gewöhnliches Feld ohne Wirkung auf den
Zugriff.

Abschnitte werden gerendert und bearbeitet, Entwürfe alle zwei Sekunden lokal
gesichert. Gespeichert wird auf Knopfdruck: ein Abschnitt ist der Sachstand
der Akte, kein Chatbeitrag. Die geladene Version geht mit; weicht sie ab, gibt
es kein stilles Überschreiben, sondern beide Fassungen nebeneinander mit
"Meine übernehmen" und "Fremde übernehmen".

Felder über der Stufe des Lesers werden serverseitig durch einen Platzhalter
ersetzt, Schwärzungen im Fliesstext kommen als Segmente. Beides so, dass der
echte Wert den Server nicht verlässt — nicht als Ausblenden im Client.

Der Wortdiff ist selbst geschrieben statt eingekauft: ein Dutzend Zeilen gegen
30 kB im Bündel für etwas, das nur serverseitig läuft.

Beim Bauen des Vorlagen-Editors riss das Bündel-Budget auf 127,9 kB, weil zod
über das Vorlagenmodul in den Client geriet. Typen und Validierung sind jetzt
getrennt: fields.ts ohne zod für Client und Server, schema.ts nur
serverseitig. Wieder bei 111,9 kB von 120.

85 Tests, 22 E2E.
2026-09-02 12:52:53 +02:00
Harlekin 2599a5e6f8 Phase 2 abgeschlossen: nachträgliche Änderungen, Grabstein, Richtlinie
Der Kern von harter Regel 2. Jede Änderung an einem Verlaufseintrag schreibt
den vorherigen Stand nach CaseEntryVersion und erhöht die Versionsnummer.
Autor und Entstehungszeit bleiben unangetastet, geprüft gegen die Datenbank.

Die Karenzzeit wird serverseitig aus AppSetting gelesen, nie aus dem Request.
Eine Änderung darin wird versioniert und protokolliert, aber nicht sichtbar
markiert — ein Tippfehler, zwei Minuten später korrigiert, soll die Akte nicht
mit "bearbeitet"-Marken zupflastern. Alles danach ist markiert und verlangt
eine Begründung, ebenso jede Änderung an einem fremden Eintrag.

Die Stelle, an der eine naive Umsetzung leckt, hat einen eigenen Test: wird
minClearance eines Eintrags angehoben, erben alle bestehenden Versionen den
neuen Wert. Sonst liest jemand mit niedrigerer Stufe den ursprünglichen Text
einfach in der Historie.

Zurückziehen setzt einen Grabstein mit Pflichtbegründung, der Text bleibt in
der Datenbank. Rückgängig machen geht und wird wiederum protokolliert.
Systemeinträge sind von jeder Änderung ausgenommen.

Dazu die Änderungsübersicht als Reiter an der Akte — sichtbar für alle, die
die Akte lesen dürfen, nicht nur für die Leitung — und die Adminseite für die
Änderungsrichtlinie.

Zwei Testprobleme dabei behoben: AppSetting ist eine einzige Zeile für die
ganze Anwendung, deshalb laufen die Web-Tests jetzt nacheinander, und die
Testdatenbank startet bei jedem Lauf im Auslieferungszustand. Vorher hing das
Ergebnis davon ab, was der vorige Lauf hinterlassen hatte.

63 Tests, zweimal hintereinander grün. Bündel bei 110,5 kB von 120.
2026-09-01 23:11:42 +02:00
Harlekin 651f239c68 Phase 2, erster Teil: Akten anlegen, lesen, Vermerke schreiben
Fachlogik in lib/cases/service.ts, Zugriffsprüfung in lib/access/. Jede
Funktion prüft selbst und schreibt ihren Protokolleintrag in derselben
Transaktion wie die Änderung — die Server Actions daneben sind dünn.

Schreibgrenzen nach docs/03: niemand legt eine Akte über der eigenen Stufe an,
niemand stuft über die eigene Stufe hoch, archiviert und versiegelt sind
schreibgeschützt auch für Admins. Eine Herabstufung verlangt eine Begründung
und landet als sichtbarer Systemeintrag im Verlauf, nicht nur im Protokoll.

Aktenzeichen über CaseCounter mit INSERT ... ON CONFLICT DO UPDATE, geprüft mit
acht gleichzeitigen Anlagen ohne Doppelung. Wer eine Akte anlegt, wird ihr
Ermittler, sonst verliert er sie aus dem Blick, sobald jemand hochstuft.

Fallliste cursorbasiert auf (updatedAt, id), Sichtbarkeit und Trefferzahl in
derselben Abfrage. Aktenansicht mit Kopfdaten sofort und Verlauf in eigener
Suspense-Grenze. Vermerke optimistisch mit clientseitiger UUIDv7, die zugleich
Idempotenzschlüssel ist — zweimal gesendet bleibt ein Eintrag.

Lesezugriffe ab Stufe 3 werden protokolliert, gepuffert und alle zwei Sekunden
gebündelt geschrieben, damit das Protokoll die Antwort nicht ausbremst.

Die vier Startvorlagen stehen jetzt im Seed statt in Phase 3: ohne Vorlage
lässt sich keine Akte anlegen.
2026-09-01 22:57:38 +02:00
Harlekin cb66839a05 Phase 1: Anmeldung, Rechte und Rollenverwaltung
Auth.js übernimmt nur den OAuth-Ablauf mit state und PKCE, Scope identify. Das
Zugangstoken des Nutzers wird danach verworfen: die Rollen holt der Bot mit
seinem eigenen Token. Die Sitzung steht in der Datenbank, im Cookie liegt nur
userId und sessionId. Ohne die Zeile in Session ist das Cookie wertlos, und nur
so lässt sich eine Sitzung zurückziehen.

Wer sich anmelden darf, steckt in lib/auth/signInPolicy.ts statt im Callback:
das ist die Regel, an der alles hängt, und sie gehört einzeln prüfbar. Kein
Guild-Mitglied, kein Zugang. Keine Rolle mit grantsMembership, kein Zugang.
Notfall-Admins umgehen die Rolle, aber nicht die Guild-Mitgliedschaft.

RoleMapping trägt jetzt grantsMembership, category und isPresent. Der Worker
spiegelt jede Rolle der Guild hierher, damit im Adminbereich nichts von Hand
eingetragen werden muss. Gelöschte Rollen werden nicht entfernt, sondern auf
isPresent = false gesetzt: beim Umzug auf den echten Server bleiben sie als
Vorlage stehen.

Der Worker gleicht über Gateway-Ereignisse und zusätzlich alle fünf Minuten
voll ab. Verliert jemand seine AfiS-Rolle, beendet er dessen Sitzungen sofort
und setzt isActive auf false — der Datensatz bleibt, damit Autorenangaben in
alten Akten gültig bleiben. Das passiert im Worker und nicht in der Web-App,
weil es dort erst beim nächsten Request des Betroffenen griffe.

Zwei Widersprüche der Dokumente aufgelöst, festgehalten in
docs/13-entscheidungen.md: der Ausschluss schlägt auch den Admin (das
Codebeispiel in 03-auth-und-rechte.md übergeht ihn, die Reihenfolge eine Zeile
darüber nicht), und versiegelte Akten fallen im Where-Fragment heraus statt in
einer Prüfung danach.

45 Tests, davon die Negativfälle aus docs/03 gegen eine echte Datenbank: Stufe 2
sieht die Stufe-4-Akte nicht und findet sie auch nicht in der Trefferzahl, ein
Ausgeschlossener mit Stufe 5 kommt nicht heran, ein Rollenentzug beendet die
Sitzungen, und ein Ausfall der Discord-API sieht nicht aus wie eine verweigerte
Mitgliedschaft.
2026-09-01 19:11:53 +02:00