#038: Die E2E-Ablaeufe laufen ab jetzt vor jedem Merge nach main, nicht vor jedem Commit. Gemessen: Bau 14 s, Start 7 s, Ablaeufe 256 s — zu den vier Minuten des bestehenden Tores. Neun Minuten vor jedem Commit ist ein Tor, das umgangen wird. Vor jedem Merge waeren es in Phase 2 acht Laeufe gewesen, und jeder der vier Befunde aus #027 waere am Tag seiner Entstehung aufgefallen. Geprueft wurde jeder der 16 Ablaeufe gegen die Frage, ob es ein Vitest-Gegenstueck gibt. Neun Zusagen haengen ausschliesslich dort, und keine laesst sich holen: alle brauchen einen echten Browser. Die Liste steht in CLAUDE.md. Der eigentliche Fund dabei war keine Testluecke, sondern eine Konvention ohne Pruefer: "jede Seite prueft selbst" steht seit #007 in CLAUDE.md, und geprueft hat es bis hierher nur ein Mensch, der nachsieht. Der Review von Phase 2 hat alle 27 Seiten durchgesehen — ein Ergebnis, das fuer genau einen Tag gilt. tests/zugang.test.ts liest sie jetzt, samt jedem Route Handler; die Ausnahmen (Auth, Healthcheck) stehen namentlich mit Begruendung, und ein Test prueft auch die Gegenrichtung. #037: Das Lesemodell der Anhaenge liegt in queries/ und engt selbst ein — vorher hing das daran, dass beide Aufrufer den Lauf vorher pruefen. /meine-geraete wird bei 390 px gemessen, mit einem echten MITARBEITER-Konto aus dem E2E-Vorlauf, dazu ein Test, dass dort kein Link in die Verwaltung angeboten wird. Dabei selbst einen Fehler gemacht, der die neue Regel begruendet: fuer das zweite Konto brauchte `anmelden` eine Variante, die nicht auf / wartet, und ich habe das Warten fuer alle gelockert. Das machte zwei unverwandte Ablaeufe unzuverlaessig — sichtbar nur im vollen Durchgang, einzeln waren beide gruen. Ein gemeinsamer Helfer in einer Testsuite ist geteilter Zustand. Ohne vollen Durchgang haette ich zwei rote Ablaeufe gemergt. 1457 Vitest-Tests (vorher 1454), 115 E2E-Ablaeufe (vorher 113). Offen bleibt nur noch #024, und das verlangt einen echten Proxmox-Host. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
534 lines
32 KiB
Markdown
534 lines
32 KiB
Markdown
# Architektur und Entscheidungen
|
||
|
||
Ergänzt [`CLAUDE.md`](../CLAUDE.md), das die verbindlichen Konventionen enthält. Hier steht das
|
||
Warum.
|
||
|
||
## 1. Zwei Betriebsmodi, ein Codepfad
|
||
|
||
`Einstellung.modus` ist `INTERN` oder `DIENSTLEISTUNG` und liegt in der Datenbank, nicht in der
|
||
Umgebung — der Admin schaltet ohne Neustart um.
|
||
|
||
Der Kern der Entscheidung: **jede kundenbezogene Zeile trägt immer eine `kundeId`**, auch im
|
||
Modus `INTERN`. Dort ist es die eine Organisation mit `isteigeneOrganisation = true`, die in
|
||
keiner Liste, keinem Filter, keinem Formular und auf keinem Etikett erscheint. Dadurch ist der
|
||
Wechsel nach `DIENSTLEISTUNG` eine Datenänderung (die eigene Organisation wird zum ersten Kunden)
|
||
und keine Migration.
|
||
|
||
Es gibt daher **nicht zwei Codepfade** für Geräte oder Läufe, sondern ein- und ausgeblendete
|
||
Felder und Filter. Zwei Funktionen tragen das:
|
||
|
||
- `betriebsmodus()` — gecachter Zugriff, den alle Listen und Formulare fragen
|
||
- `kundeErmitteln(eingabe)` — liefert im Modus `INTERN` immer die eigene Organisation und
|
||
**verwirft** eine mitgeschickte `kundeId`. Das ist eine Sicherheitsgrenze, kein Komfort.
|
||
|
||
## 2. Schichtung
|
||
|
||
```
|
||
Server Component ──► queries/* ──► prisma
|
||
Server Action ──► services/* ──► prisma
|
||
└─ rechtErforderlich(), Zod, Regeln, Protokoll
|
||
```
|
||
|
||
`services/*` hält die Regeln und wirft eigene `…Fehler`-Klassen. `queries/*` hält Lesemodelle und
|
||
wendet Rollen- und Mandantensicht selbst an, damit kein Aufrufer sie vergessen kann.
|
||
`actions/*` sind Dreizeiler: Recht prüfen, Schema anwenden, Service rufen.
|
||
|
||
Der Grund für die Trennung von Action und Service: die Regeln sollen **ohne Next.js testbar**
|
||
sein. Vitest ruft den Service direkt.
|
||
|
||
## 3. Anmeldung: kein Auth.js
|
||
|
||
Der Auftrag nannte Auth.js. Beim Prüfen der Lage am 2026-09-24:
|
||
|
||
- `next-auth` v5 steht auf `5.0.0-beta.32`, seit Jahren ohne GA
|
||
- authjs.dev führt das Projekt im Wartungsmodus („The Auth.js project is now part of Better
|
||
Auth"), die Maintainer schreiben „no immediate plans for v5“
|
||
- `@auth/prisma-adapter` deklariert `@prisma/client` nur bis `>=6` — wir fahren Prisma 7
|
||
|
||
Entschieden wurde deshalb der Eigenbau mit stabilen Bausteinen, wie in ProxLedger:
|
||
|
||
- **`openid-client` 6.x** für generisches OIDC mit Discovery (Ziel: authentik)
|
||
- **`jose`** für ein signiertes Sitzungs-Cookie (HS256 aus `APP_SECRET`)
|
||
- **`@node-rs/argon2`** für den lokalen Notfallzugang
|
||
|
||
Rolle und Aktiv-Status werden **pro Anfrage aus der Datenbank** gelesen, nicht aus dem Token.
|
||
Eine Rollenänderung greift damit sofort, und ein deaktivierter Benutzer wird auch mit gültigem
|
||
Cookie abgewiesen.
|
||
|
||
## 4. Versionierung und Archiv
|
||
|
||
Zwei Regeln tragen die Nachvollziehbarkeit:
|
||
|
||
1. **Eine veröffentlichte `VorlagenVersion` wird nie verändert.** „Bearbeiten“ erzeugt eine tiefe
|
||
Kopie als Entwurf. Ein Lauf auf Version 1 zeigt für immer die Schritte aus Version 1, auch
|
||
wenn längst Version 5 veröffentlicht ist.
|
||
2. **Ein archivierter Lauf ist schreibgeschützt.** Die Sperre sitzt in genau einer Funktion,
|
||
`laufMussVeraenderbarSein(lauf)`, die vor jeder Änderung an Kopfdaten, `Schrittergebnis` und
|
||
`Kommentar` gerufen wird. Nur `ADMIN` holt einen Lauf zurück, mit `Protokoll`-Eintrag.
|
||
|
||
Beides ist durch Tests abgedeckt, nicht durch Disziplin.
|
||
|
||
**Abgeschlossen ist nicht archiviert**, und der Unterschied ist Absicht. „Abgeschlossen“ ist ein
|
||
Arbeitszustand: fertig für jetzt. Eine nachgetragene Notiz, ein Kommentar mit dem, was beim
|
||
Nachfassen herauskam, eine korrigierte Seriennummer — das passiert danach, und zwar oft. Würde
|
||
schon der Abschluss sperren, bräuchte jede Kleinigkeit einen Administrator, und die Technikerin
|
||
würde Läufe schlicht nicht mehr abschließen; das Archiv wäre danach nicht genauer, sondern leerer.
|
||
Unveränderlich wird ein Lauf durch das **Archivieren**, eine ausdrückliche zweite Handlung. Damit
|
||
„abgeschlossen“ trotzdem nicht lügt, nimmt der Abschluss sich selbst zurück, sobald eine Änderung
|
||
einen Pflichtschritt wieder offen lässt.
|
||
|
||
Der Aufbau einer Version wird **als Ganzes** gespeichert, nicht Schritt für Schritt: der Editor
|
||
hält den Baum im Speicher und sortiert per Drag & Drop, ein Teil-Update wäre dort ständig mit dem
|
||
Sortieren im Streit. Dasselbe Zod-Schema (`src/lib/vorlagen-format.ts`) beschreibt den Entwurf im
|
||
Editor, den JSON-Export und den Import — drei getrennte Schemata würden auseinanderlaufen, und der
|
||
Import wäre der Ort, an dem das erst auffiele. Das Format ist in
|
||
[`docs/vorlagen-format.md`](vorlagen-format.md) beschrieben.
|
||
|
||
Die **eingebauten Platzhalter** stehen als Liste in [`src/lib/platzhalter.ts`](../src/lib/platzhalter.ts)
|
||
— Daten, kein Code. Der Editor zeigt sie als Hilfe, die Warnung vor unbekannten Platzhaltern
|
||
vergleicht dagegen, und die Auflösung in #012 baut darauf auf. Eine zweite Aufzählung an anderer
|
||
Stelle würde unweigerlich auseinanderlaufen: ein Platzhalter, der in der Hilfe steht, aber nicht
|
||
aufgelöst wird, bliebe im fertigen PowerShell-Befehl als `{{…}}` stehen.
|
||
|
||
Die Warnung ist ausdrücklich **keine Ablehnung**. Ein Tippfehler soll im Editor auffallen und nicht
|
||
erst dem Techniker im Lauf; es kann aber gute Gründe geben, einen Platzhalter stehen zu lassen —
|
||
etwa weil die Variable gleich noch kommt. Speichern und Veröffentlichen bleiben deshalb erlaubt.
|
||
|
||
## 5. Der Weg zu Phase 4 (Aktionen)
|
||
|
||
Die in den Vorlagen vorbereiteten PowerShell-Befehle sind bewusst die **Vorstufe ausführbarer
|
||
Schritte**. Damit der Schritt dorthin keine Migration braucht:
|
||
|
||
- `VorlagenSchritt.typ` ist ein Enum, dem `AKTION` hinzugefügt wird; `konfig` (Json) trägt dann
|
||
Connector und Parameter
|
||
- `Schrittergebnis.wert` ist ein Json, das ein Aktionsergebnis mit Log, IDs und Zeitstempeln
|
||
aufnimmt
|
||
- kein Codepfad nimmt an, dass ein Schritt nur von einem Menschen erledigt wird
|
||
- das Krypto-Modul für das LDAP-Bind-Passwort ist schon der Platz für Connector-Geheimnisse
|
||
|
||
## 5a. Was in der Datenbank steht und warum
|
||
|
||
Die Service-Schicht ist der richtige Ort für Regeln mit verständlichen Meldungen. Sie ist aber
|
||
nicht der einzige Weg, auf dem Daten entstehen: Migrationen, `psql`, Skripte und ab Phase 4 die
|
||
Connectoren schreiben an ihr vorbei. Was stillschweigend falsch werden könnte, steht deshalb
|
||
zusätzlich in der Datenbank:
|
||
|
||
| Regel | Form |
|
||
|---|---|
|
||
| Höchstens eine eigene Organisation | partieller Eindeutigkeitsindex |
|
||
| Höchstens ein Entwurf je Vorlage | partieller Eindeutigkeitsindex |
|
||
| Kundenkürzel, `tagCode`-Form, verschlüsseltes Bind-Passwort | CHECK auf die Form |
|
||
| Registriertes Gerät braucht Hostname oder Bezeichnung | CHECK, greift erst ab `registriertAm` |
|
||
| Mitarbeiter aus dem Verzeichnis **oder** Freitextname | CHECK |
|
||
| Status und Zeitstempel laufen nicht auseinander | vier CHECKs |
|
||
| `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` brauchen eine Notiz | CHECK |
|
||
| Einstellung ist eine einzelne Zeile | CHECK auf die feste Kennung |
|
||
| **Mandantengrenze** | vier zusammengesetzte Fremdschlüssel |
|
||
|
||
Die Mandantengrenze verdient die Erklärung: `Lauf` verweist mit `("geraetId", "kundeId")` auf
|
||
`Geraet("id", "kundeId")`, analog für Person und Abteilung, und `Geraet` ebenso auf den Besitzer.
|
||
Damit ist ein Lauf, der ein Gerät eines fremden Kunden zieht, nicht mehr bloß ein Fehler im
|
||
Service, sondern strukturell unmöglich. `MATCH SIMPLE` sorgt dafür, dass ein Lauf ohne Gerät
|
||
weiterhin erlaubt ist.
|
||
|
||
Zwei Dinge sind bewusst **nicht** kaskadierend: ein `Lauf` nimmt seine Schrittergebnisse und
|
||
Kommentare nicht mit, und ein `Protokoll`-Eintrag verliert seinen Urheber nicht. Beides stünde
|
||
gegen den Zweck eines Archivs. Der `tagCode` ist zusätzlich in `TagCodeVergabe` festgehalten,
|
||
aus dem nie gelöscht wird — sonst würde ein Code nach dem Löschen eines Geräts wieder frei und
|
||
ein alter Aufkleber zeigte auf ein fremdes Objekt.
|
||
|
||
## 5b. Warum ein Screenshot nichts über Bedienbarkeit sagt
|
||
|
||
In dieser Umgebung hydriert `next dev` nicht: die Seiten werden serverseitig gerendert und sehen
|
||
vollständig richtig aus, aber React hängt sich nie an — kein Knopf, kein Formular, kein Sortieren
|
||
tut etwas. Nachgewiesen in #010, und zwar auch ohne `src/proxy.ts` und ohne `output: "standalone"`;
|
||
der HMR-WebSocket scheitert mit `ERR_INVALID_HTTP_RESPONSE`. Es liegt am Werkzeug, nicht an diesem
|
||
Code — im gebauten Stand tragen 1281 von 1296 Elementen React-Knoten, im Entwicklungsmodus zwei.
|
||
|
||
Zwei Folgen, beide verbindlich:
|
||
|
||
1. **Oberflächen werden gegen `pnpm build && pnpm start` abgenommen**, nicht gegen `pnpm dev` —
|
||
ebenso die Screenshots. Steht in `CLAUDE.md`.
|
||
2. **Was nur mit angehängtem React gelingt, gehört in einen Playwright-Test.** `pnpm test:e2e`
|
||
klickt den Editor durch: anlegen, tippen, sortieren mit dem Finger bei 390 px, speichern, neu
|
||
laden und nachsehen, ob es wirklich in der Datenbank steht.
|
||
|
||
Daraus folgt eine dritte, die erst #027 gelehrt hat: **diese Abläufe müssen regelmäßig laufen, weil
|
||
sie die einzigen Prüfer ihrer Zusagen sind.** Sie stehen nicht im Commit-Tor — gemessen 256 s für
|
||
die Abläufe, dazu Bau und Start, zu den vier Minuten des Tores; neun Minuten vor jedem Commit ist
|
||
ein Tor, das umgangen wird. Verbindlich ist deshalb **vor jedem Merge nach `main`**, und `CLAUDE.md`
|
||
führt auf, welche Zusagen ausschließlich dort hängen.
|
||
|
||
Beim ersten vollständigen Durchgang nach Monaten waren sieben Abläufe rot, vier Ursachen, **keine
|
||
ein Produktfehler** — ein veralteter Feldvergleich, ein seit #020 mehrdeutiger Selektor, eine feste
|
||
Pixelzahl bei gewachsenem Layout und abweichende Entwicklungsdaten. Dass keiner ein Produktfehler
|
||
war, ist kein Trost: es heißt, dass die Abläufe in dieser Zeit **nichts** geprüft haben.
|
||
|
||
Die Content-Security-Policy in `src/proxy.ts` ist deshalb zweigeteilt: im Entwicklungsmodus mit
|
||
`unsafe-eval` und WebSocket, im ausgelieferten Stand ohne beides. Die Entscheidung fällt an
|
||
`process.env.NODE_ENV`, und `tests/sicherheitsrichtlinie.test.ts` prüft sie am Proxy selbst — eine
|
||
Prüfung der Funktion mit selbst gesetztem Argument hätte genau den Fehler durchgelassen, vor dem
|
||
der Kommentar darüber warnt.
|
||
|
||
## 5c. Warum die Maskierung dort sitzt, wo sie sitzt
|
||
|
||
Aus einer Vorlage entsteht ein PowerShell-Befehl, den ein Techniker kopiert und auf einem
|
||
Domänencontroller ausführt. Drei Entscheidungen tragen das (#012):
|
||
|
||
1. **Maskiert wird der eingesetzte Wert, nicht der fertige Befehl.** Sonst maskierte man die
|
||
Anführungszeichen des Befehls gleich mit. `platzhalterErsetzen` bekommt die Maskierung deshalb
|
||
als Funktion und wendet sie auf jeden Wert einzeln an.
|
||
2. **Die Maskierung setzt keine Anführungszeichen dazu** — die stehen in der Vorlage. Das ist eine
|
||
Annahme, und sie wird geprüft: steht ein `{{platzhalter}}` in einem `BEFEHL`-Schritt nicht
|
||
zwischen den erwarteten Anführungszeichen, wird der Schritt abgewiesen statt gefüllt. Ein halb
|
||
maskierter Befehl zum Kopieren wäre die gefährlichste aller Antworten.
|
||
3. **Was sich nicht sicher maskieren lässt, wird abgelehnt.** Für Cmd gibt es keine Regel, die ein
|
||
`"` in jedem Fall richtig behandelt — ein solcher Wert bekommt eine Fehlermeldung, keinen
|
||
Befehl. PowerShell liest auch `“`, `”` und `„` als Begrenzer; die werden auf ihre ASCII-Form
|
||
gebracht und dann maskiert, weil sie sich als Text ohnehin nicht durchreichen lassen.
|
||
|
||
Geprüft wird das **ausgeführt**, nicht gelesen: `tests/shell.test.ts` schickt bösartige Werte durch
|
||
ein echtes `bash -c` und durch ein echtes PowerShell — und wenn auf der Maschine kein `pwsh` liegt,
|
||
nimmt der Test das Container-Abbild. In CI bricht er ab, statt sich zu überspringen; ein stilles
|
||
Überspringen sieht genauso grün aus wie ein bestandener Lauf.
|
||
|
||
Das **generierte Passwort** entsteht erst beim Ersetzen und wird nirgends zurückgegeben — es gibt
|
||
keinen Weg, es außerhalb des fertigen Texts zu bekommen. Gäbe es ein Feld dafür, wäre die
|
||
Versuchung groß, es „nur fürs Protokoll“ mitzuschreiben.
|
||
|
||
## 5d. Wie die Aktualisierung funktioniert – und warum sie keine Befehle mitliefert
|
||
|
||
Die Abarbeitungsansicht hält `GET /api/laeufe/[id]/zustand` als **Ereignisstrom** offen — zwei
|
||
Techniker teilen sich ein Onboarding und sollen die Arbeit des anderen sehen, ohne die Seite neu zu
|
||
laden. Diese Nutzlast trägt **nur** Status, Schrittzustände und die Zahl der Kommentare und
|
||
Anhänge, ausdrücklich keine fertigen Befehle.
|
||
|
||
Der Grund ist `{{generiertes_passwort}}`: es entsteht bei jedem Auflösen neu. Lieferte die
|
||
Aktualisierung Befehle mit, stünde nach jeder Änderung ein anderes Passwort auf dem Bildschirm als
|
||
das, das der Techniker gerade in die Konsole geklebt hat. Die Befehle entstehen deshalb genau einmal
|
||
je Seitenaufbau, und der Client lädt die Seite erst neu, wenn sich am Zustand wirklich etwas
|
||
geändert hat. Bei verstecktem Tab wird der Strom geschlossen.
|
||
|
||
Der Route Handler prüft sein Recht selbst und gibt einen fremden Lauf als „nicht gefunden“ zurück —
|
||
auch und gerade für den Strom, **bevor** das erste Ereignis fließt.
|
||
|
||
### Warum die Datenbank das Ereignis schickt, nicht der Dienst
|
||
|
||
Bis #033 fragte die Seite alle zehn Sekunden. Das war richtig, aber zehn Sekunden sind lang, wenn
|
||
zwei Leute dieselbe Liste abhaken, und zwanzig offene Laschen bedeuteten zwanzig Abfragen je zehn
|
||
Sekunden. Seit #033 melden vier Trigger `NOTIFY lauf_geaendert, '<laufId>'`, eine LISTEN-Verbindung
|
||
je Prozess hört zu, und der Route Handler schiebt weiter.
|
||
|
||
**In der Datenbank und nicht im Service**, aus demselben Grund, aus dem auch die Regeln dort stehen:
|
||
ein Ereignis, das der Service verschickt, entsteht nur auf dem Weg durch den Service. Ein Trigger
|
||
sieht auch den Seed, eine Migration und die Reparatur per `psql`. Und `NOTIFY` geht an **jede**
|
||
Verbindung mit `LISTEN` — also an jede Instanz der Anwendung, ohne einen zweiten Dienst.
|
||
|
||
**Welche Tabellen melden, hängt an dem, was das Lesemodell liefert.** Das Polling verglich den
|
||
ganzen Stand und sah damit auch, was kein Trigger meldet; ein Strom sieht nur, was gemeldet wird.
|
||
Fehlte `Kommentar` oder `Anhang`, wäre die Live-Aktualisierung gegenüber dem Polling ein
|
||
**Rückschritt** — der Kommentar des Kollegen käme nie an. `tests/datenbankregeln.test.ts` zählt die
|
||
vier Trigger namentlich auf, `tests/zustand-route.test.ts` die Schlüssel des Zustands.
|
||
|
||
**Das Polling bleibt, als Rückfall an derselben Adresse.** Wer kein `EventSource` hat oder hinter
|
||
einem Proxy sitzt, der Ströme puffert, bekommt den alten Weg — dieselbe Rechteprüfung, dasselbe
|
||
Lesemodell, dieselbe Nutzlast. Ein zweiter Endpunkt wäre eine zweite Stelle, an der jemand das Recht
|
||
vergessen kann. Unterschieden wird allein am `Accept`-Kopf, den `EventSource` von sich aus schickt.
|
||
Dieser Rückfall wird gefahren, nicht behauptet: `tests/e2e/live.spec.ts` nimmt dem Browser
|
||
`EventSource` weg.
|
||
|
||
Eine Obergrenze (`SSE_MAX_STROEME`, Vorgabe 200) begrenzt die offenen Ströme je Instanz. Ist sie
|
||
erreicht, antwortet der Handler gewöhnlich mit JSON; `EventSource` meldet darauf einen Fehler und
|
||
versucht es nicht wieder, und der Client fällt von sich aus auf Polling zurück. Das braucht keinen
|
||
Sonderfall und keinen eigenen Fehlercode.
|
||
Die Hülle unter `src/app/(app)/layout.tsx` deckt `src/app/api/` nicht ab.
|
||
|
||
### Warum die Benachrichtigungen in einer Warteschlange liegen
|
||
|
||
Vier Anlässe gehen seit #034 hinaus: zugewiesen, überfällig, abgeschlossen, Abgleich fehlgeschlagen.
|
||
Drei Entscheidungen prägen das.
|
||
|
||
**Eingereiht wird im Anfrage-Pfad, gesendet wird es nicht.** Ein hängender Mailserver darf kein
|
||
Abhaken blockieren. Der Dienst schreibt eine Zeile in `Zustellung` — eine Einfügung in dieselbe
|
||
Datenbank, die der Vorgang ohnehin anfasst —, und ein Zeitplan im selben Prozess schickt sie. Das
|
||
Einreihen passiert **nach** der Transaktion und mit abgefangenem Fehler: ein Abhaken darf nicht
|
||
daran scheitern, dass eine Benachrichtigung nicht eingereiht werden konnte.
|
||
|
||
**Betreff und Rumpf stehen fertig in der Zeile.** Das kostet Platz und ist es wert: eine Meldung
|
||
sagt, was *damals* war. Baute der Zeitplan den Text erst beim Senden, stünde in einer Mail von heute
|
||
der Stand von jetzt — und in einer aufgegebenen Zustellung von nächster Woche etwas völlig anderes.
|
||
|
||
**In keiner Meldung steht ein Schrittinhalt.** Darüber wacht `src/lib/meldungen.ts`: die Funktionen
|
||
dort nehmen **nur** Kopfdaten, der Typ lässt nichts anderes zu. Eine Mail mit dem Lauf-Inhalt wäre
|
||
genau die Lücke, die dieses Projekt an jeder anderen Stelle geschlossen hat — das erzeugte Passwort
|
||
wird nirgends gespeichert, aber eine Mail liegt für immer in einem Postfach, oft auf einem Server,
|
||
der nicht im Haus steht. `tests/benachrichtigungen.test.ts` prüft es zusätzlich über **alle**
|
||
Rumpftexte, die wirklich verschickt wurden, so wie #012 es für die Druckansicht tat.
|
||
|
||
Zwei Dinge, die ohne Datenbank nicht gehalten hätten. Die **Einmaligkeit der
|
||
Überfälligkeitsmeldung** liegt in einem partiellen eindeutigen Index: der Zeitplan sieht jede Minute
|
||
nach, und ohne ihn bekäme der Zuständige für einen Lauf, der über das Wochenende steht, 2880 Mails —
|
||
jede davon für sich korrekt, kein Test rot. Und die **Sperre** der Warteschlange liegt in derselben
|
||
Tabelle wie die des Abgleichs: hinter einem Lastverteiler laufen zwei Instanzen, und ein `let laeuft
|
||
= false` im Modul wüsste nichts von der zweiten.
|
||
|
||
**Das Polling-Prinzip gilt auch hier nicht umgekehrt:** ein Kommentar und ein angehängtes Foto lösen
|
||
*keine* Mail aus. Beides sieht die Live-Aktualisierung sofort (Abschnitt 5d); eine Mail dafür wäre
|
||
Lärm. Mehr Anlässe wären ein Benachrichtigungsbaukasten, und der ist eine Phase für sich.
|
||
|
||
### Warum ein Mitarbeiter keine Akteneinsicht bekommt
|
||
|
||
Ein `Mitarbeiter` ist kein `Benutzer`. Die vier Rollen bis #035 waren für Leute, die **arbeiten**;
|
||
ein Mitarbeiter ist Gegenstand der Arbeit, nicht ihr Träger. Mit der Selbstauskunft kommt die fünfte
|
||
Rolle `MITARBEITER` dazu, und sie ist die einzige, die **nicht** `lesen` darf.
|
||
|
||
**`selbstauskunft` ist ein Ersatz für `lesen`, kein Zusatz.** Darum hat auch ein `ADMIN` dieses
|
||
Recht nicht: er sieht über `lesen` ohnehin alles, und ein zweiter Weg zu denselben Daten wäre eine
|
||
zweite Stelle, an der jemand die Einschränkung auf die eigene Person vergisst. Wer beides braucht,
|
||
braucht zwei Konten — und genau das hält die Datenbank fest: der CHECK
|
||
`Benutzer_mitarbeiter_nur_bei_rolle` verlangt, dass Rolle und Zuordnung zusammengehören, in beide
|
||
Richtungen.
|
||
|
||
**Was zurückkommt, entscheidet das Lesemodell und nicht die Seite.**
|
||
`src/server/queries/selbstauskunft.ts` liefert Titel, Status, Zeitraum und Gerät — keine Schritte,
|
||
keine Notizen, keine Kommentare, keine Anhänge. In einem Lauf stehen Arbeitsnotizen über Menschen
|
||
(„Nutzerin war unkooperativ“) und Befehle mit Platzhaltern für Zugangsdaten; beides ist nicht dafür
|
||
gedacht, gelesen zu werden. Eine Seite, die filtert, wäre eine Seite, die es vergessen kann.
|
||
|
||
Geprüft wird das an der **Antwort** und nicht an der Seite: `tests/selbstauskunft.test.ts` hängt an
|
||
einen Lauf eine Notiz, einen Kommentar, einen Anhang und einen Befehlsschritt mit
|
||
`{{generiertes_passwort}}` und prüft, dass nichts davon durchkommt. Dazu ein struktureller Test, der
|
||
das `select` selbst liest — denn eine Gegenprobe zeigte, dass der erste Test grün blieb, als das
|
||
`select` versuchsweise Notizen mitholte: gerettet hatte ihn die Abbildungsfunktion, nicht die
|
||
Abfrage.
|
||
|
||
**Zugeordnet wird über den UPN, und mehrdeutig ist ein Fehler.** Zwei Kunden können dieselbe Person
|
||
führen; ein `findFirst` entschiede dann nach Sortierung darüber, wessen Geräte jemand sieht. Lieber
|
||
kein Zugang als der falsche — die Anmeldung wird abgewiesen und der Fall protokolliert.
|
||
|
||
**Die Weiche greift nur mit eingerichteter Gruppenzuordnung.** Ohne abgebildete Gruppen kann die
|
||
Anwendung nicht unterscheiden, ob jemand Techniker oder Mitarbeiter ist; dort bleibt alles wie vor
|
||
#035. Das ist keine Vorsicht, sondern Notwendigkeit: ein Update, das jeden aussperrt, dem die
|
||
Gruppen fehlen, wäre ein kaputtes Update.
|
||
|
||
## 5e. Der Aufkleber ist die Schnittstelle
|
||
|
||
Jedes Objekt traegt einen QR-Aufkleber mit einem `tagCode`, der **nie geaendert
|
||
und nie wiederverwendet** wird — auch nicht, nachdem das Geraet geloescht wurde
|
||
(`TagCodeVergabe`, #006). Der Aufkleber ist damit die einzige Angabe am Geraet,
|
||
die Jahre haelt: Hostnamen aendern sich, Besitzer wechseln, Seriennummern werden
|
||
abgeschrieben.
|
||
|
||
**Im QR steht ausschliesslich `{APP_URL}/t/{tagCode}`.** Kein Name, keine
|
||
Seriennummer, kein Besitzer. Ein Aufkleber haengt sichtbar am Geraet und wird von
|
||
jedem Telefon gelesen, auch von einem fremden; was darauf steht, ist
|
||
veroeffentlicht. Die Adresse selbst verraet nichts — dahinter verlangt
|
||
`/t/[tagCode]` eine Anmeldung und zeigt einem fremden Kunden dieselbe Antwort wie
|
||
bei einem Code, den es nicht gibt.
|
||
|
||
Drei Dinge daran sind nicht mehr zu korrigieren, sobald gedruckt ist, und stehen
|
||
deshalb fest:
|
||
|
||
1. **Die Basisadresse kommt aus `APP_URL`, mit ihrem Pfad.** Liegt die Anwendung
|
||
unter `https://firma.de/laufzettel`, muss genau das im QR stehen;
|
||
`basisAdresse()` in `src/lib/adressen.ts` haelt den Pfad fest, `tagAdresse()`
|
||
haengt relativ an. Ohne gesetzte `APP_URL` warnt die Druckseite, statt still
|
||
`localhost` zu drucken.
|
||
2. **Der QR entsteht lokal** (`qrcode`, Fehlerkorrektur M, Ruhezone 4). Eine
|
||
externe API bekaeme mit jedem Aufruf den Bestand an Tag-Codes geliefert.
|
||
3. **Die Masse sind geprueft, nicht behauptet.** `tests/e2e/etiketten.spec.ts`
|
||
misst die Seitengroesse an einem echten PDF (62 × 29 mm bzw. A4) und liest
|
||
ein bei 300 dpi gerastertes Etikett mit demselben Decoder zurueck, den auch
|
||
der Scanner benutzt.
|
||
|
||
Der Scanner laedt `barcode-detector/polyfill`: wo der Browser eine
|
||
`BarcodeDetector`-API mitbringt, laeuft sie, sonst springt WebAssembly ein. Dessen
|
||
`.wasm` kommt aus dem eigenen Haus (`scripts/wasm-bereitstellen.mjs` legt es unter
|
||
`public/zxing/`), weil die Content-Security-Policy nur `connect-src 'self'`
|
||
erlaubt und die Anwendung ohne Internet laufen soll; dafuer traegt `script-src`
|
||
ein `'wasm-unsafe-eval'` — die schmale Erlaubnis, WebAssembly zu uebersetzen, und
|
||
**nicht** `unsafe-eval`. Laeuft die Kamera nicht oder faellt der Decoder aus, steht
|
||
an derselben Stelle ein Feld fuer den abgetippten Code: der steht lesbar unter
|
||
jedem QR, und ein Scanner, der nicht startet, darf keine Sackgasse sein.
|
||
|
||
## 5f. Archiv und Ausdruck: das Gedaechtnis
|
||
|
||
Die **Archivsuche** geht ueber drei Tabellen — `Lauf.titel`, `Schrittergebnis.notiz`,
|
||
`Schrittergebnis.wert` und `Kommentar.rumpf` — und steht deshalb als **eine**
|
||
SQL-Anweisung in `src/server/queries/archiv.ts` statt als vier Prisma-Abfragen.
|
||
Zwei Gruende, beide handfest:
|
||
|
||
1. `Schrittergebnis.wert` ist ein Json. Nur `("wert")::text` trifft den
|
||
`pg_trgm`-GIN-Index aus #006; eine ganze Json-Spalte als Text zu durchsuchen
|
||
kann Prisma nicht.
|
||
2. Die Fundstelle gehoert zum Treffer. Wer im Archiv sucht, will nicht wissen
|
||
**dass** etwas passt, sondern **wo** — Titel, Notiz, Wert oder Kommentar, mit
|
||
dem Ausschnitt drumherum. Vier getrennte Abfragen muessten das hinterher
|
||
wieder zusammenlegen.
|
||
|
||
Jeder Wert geht als Parameter in die Anweisung (`Prisma.sql`), und `%`, `_` und
|
||
`\` werden fuer `ILIKE` maskiert: die Suche nach „50 %“ trifft sonst „5099“.
|
||
`tsvector` bleibt die spaetere Optimierung, so wie in §5a festgehalten.
|
||
|
||
**Abgelegt heisst `COALESCE(archiviertAm, abgeschlossenAm, erstelltAm)`** — derselbe
|
||
Ausdruck fuer die Sortierung **und** den Zeitraumfilter. Zwei verschiedene
|
||
Ausdruecke hiessen: ein Lauf steht ausserhalb des Zeitraums, den die Liste an
|
||
seiner Position anzeigt.
|
||
|
||
Die **Druckansicht** (`/drucken/lauf/<id>`) ist ein eigenes Lesemodell und kein
|
||
Schalter an `laufZumAbarbeiten`. Dort entsteht beim Fuellen der Befehle ein
|
||
`{{generiertes_passwort}}`; auf einem Blatt, das jemand abheftet, darf keines
|
||
stehen — dort erscheint `[Passwort – nicht gespeichert]`, und erzeugt wird
|
||
keines. Der Beweis dafuer ist ein Vergleich: zwei Ausdrucke desselben Laufs sind
|
||
Zeichen fuer Zeichen gleich, ein erzeugtes Passwort waere bei jedem Aufbau ein
|
||
anderes.
|
||
|
||
Vom Mitarbeiter holt dieses Lesemodell **nur Name, UPN und Abteilung** — nicht
|
||
alles und dann gefiltert. Ein Ausdruck liegt herum, wird kopiert und weitergegeben;
|
||
was darauf steht, ist aus der Hand gegeben.
|
||
|
||
## 5g. Der Verzeichnis-Abgleich: vier Schichten und eine Notbremse
|
||
|
||
Eine Verzeichnisquelle hat seit #028 eine **Art** — `LDAP`, `GRAPH` oder `SCIM` —,
|
||
und die erste Frage ist nicht, wie gelesen wird, sondern **in welche Richtung**:
|
||
|
||
| Art | Richtung | Wo der Code liegt |
|
||
|---|---|---|
|
||
| `LDAP` | Laufzettel holt | `src/server/ldap/*` |
|
||
| `GRAPH` | Laufzettel holt | `src/server/graph/*` |
|
||
| `SCIM` | der Anbieter schickt | `src/app/api/scim/*`, `src/server/scim/*`, `src/server/services/scim.ts` |
|
||
|
||
Die ersten zwei laufen durch **denselben** Abgleich; die eine Stelle, die die Art
|
||
kennt, ist `eintraegeDerQuelleStroemen` in
|
||
`src/server/verzeichnis/quellenleser.ts`. Eine zweite Verzweigung waere eine
|
||
zweite Stelle, die sich an eine neue Quellenart erinnern muss, und die vergisst
|
||
man.
|
||
|
||
**Bei `SCIM` gibt es keinen Abgleich**, und das ist an zwei Stellen gehalten:
|
||
`faelligeQuellen` nimmt solche Quellen nicht auf, und die Weiche wirft. Der Grund
|
||
ist nicht Ordnungsliebe — ein Abgleich, der dort nichts liest und `ERFOLG` meldet,
|
||
haette ueber die Abschaltregel die ganze Belegschaft deaktiviert. Dasselbe Muster
|
||
wie der Reviewbefund aus #016, nur mit einer anderen Ursache.
|
||
|
||
Was beim Schreiben danach gilt, gilt fuer alle drei gleich: anlegen, uebernehmen,
|
||
aktualisieren, deaktivieren, **nie loeschen** — auch ein `DELETE` eines
|
||
SCIM-Anbieters setzt nur `aktiv = false`. Das **Token** einer SCIM-Quelle ist der
|
||
einzige Mandantenbeweis, den eine Zustellung mitbringt; es steht als argon2-Hash
|
||
in der Datenbank, wird einmal angezeigt und kommt in keiner Sicht und keinem
|
||
Protokolleintrag vor.
|
||
|
||
Das LDAP-Modul liegt unter `src/server/ldap/` und ist absichtlich in vier Dateien
|
||
geteilt, von denen nur eine das Netz anfasst:
|
||
|
||
| Datei | Was darin steht |
|
||
|---|---|
|
||
| `fehler.ts` | `VerzeichnisFehler` — die Basis, unterhalb der Dienstschicht, damit `verbindung.ts` von ihr erben kann, ohne die Schichtung umzudrehen |
|
||
| `verbindung.ts` | Adressformen (`ldaps://`, `ldap+starttls://`, `ldap://`), TLS-Optionen, `mitVerbindung()` und das Saeubern fremder Fehlermeldungen |
|
||
| `zuordnung.ts` | reine Funktionen: aus einem `Entry` wird ein Datensatz in unseren Feldern, DNs werden vergleichbar, Vorgesetzte aufgeloest |
|
||
| `leser.ts` | `searchPaginated` in 1000er-Seiten, Eintrag fuer Eintrag weitergegeben |
|
||
|
||
Die **Attributzuordnung** und die Grenzen des Intervalls stehen in
|
||
`src/lib/verzeichnis.ts` und nicht bei den LDAP-Modulen: das Formular braucht
|
||
dieselbe Liste und dieselben Zahlen, und eine Client-Komponente importiert nichts
|
||
aus `src/server/*`. Als beides zweimal geschrieben war, lief es auseinander — die
|
||
Oberflaeche liess hoechstens 1440 Minuten zu, der Dienst 10080.
|
||
|
||
**Der Abgleich laeuft nicht in einer Transaktion.** Das ist eine Entscheidung mit
|
||
Folgen: bricht er in der Mitte ab, bleiben die bis dahin geschriebenen Personen
|
||
stehen. Eine Transaktion ueber das gesamte Verzeichnis haette die Tabelle so lange
|
||
gesperrt, wie das Lesen dauert. **Deaktiviert wird dagegen nur nach einem
|
||
vollstaendigen Durchlauf** — sonst haette ein Netzwerkfehler nach der halben Liste
|
||
die andere Haelfte der Firma abgeschaltet.
|
||
|
||
Darum herum liegen drei Regeln, die alle denselben Fehler verhindern — dass eine
|
||
kaputte Eingabe wie „alle sind gegangen“ aussieht:
|
||
|
||
1. **Kein einziger Eintrag zuordenbar ⇒ Fehler, kein Abschalten.** Steht in der
|
||
Zuordnung ein Kennungsattribut, das es am Server nicht gibt, wird jeder Eintrag
|
||
uebersprungen: das Lesen hat geklappt, niemand wurde wiedergesehen, und die
|
||
Abschaltung traefe die gesamte Belegschaft — mit Status `ERFOLG`. Danach sucht
|
||
niemand mehr nach dem Grund.
|
||
2. **Uebersprungene Eintraege sind nicht verschwunden.** Sie werden am `dn` erkannt
|
||
und aus der Abschaltung herausgenommen.
|
||
3. **Von Hand angelegte Personen werden uebernommen, nicht verdoppelt.** Beim
|
||
Onboarding entsteht die Person hier und das AD-Konto danach; der Abgleich findet
|
||
sie ueber `upn` oder `sam` (ohne Rucksicht auf Gross- und Kleinschreibung) und
|
||
haengt Quelle und Kennung an dieselbe Zeile. Eine Person, die schon eine andere
|
||
Quelle fuehrt, wird nie uebernommen.
|
||
|
||
Uebersprungene Eintraege kommen als `hinweis` zurueck und **nicht** als `fehler`:
|
||
`Verzeichnisquelle.letzterAbgleichFehler` soll heissen, dass etwas schiefging, und
|
||
nicht „ein Konto war krumm“.
|
||
|
||
Der **Zeitplan** ist `node-cron` im selben Prozess wie die Anwendung, gestartet aus
|
||
`src/instrumentation.ts` — nur in der Node-Laufzeit und nur, wenn `ZEITPLAN_AKTIV`
|
||
nicht `false` ist. Zwei Gruende fuer „im selben Prozess“: der nativ ausgerollte LXC
|
||
braucht keine zweite systemd-Unit, und der Abgleich benutzt denselben Dienst wie
|
||
„Jetzt abgleichen“, also gibt es keinen zweiten Weg mit eigenen Regeln. Die Sperre
|
||
liegt in der Datenbank (`AbgleichSperre`) und **nicht** im Prozess: zwei Instanzen
|
||
hinter einem Reverse Proxy kommen sich sonst in die Quere. Der Zeitplan nimmt
|
||
ausdruecklich **keine** eigene Sperre — sie traege einen anderen Schluessel und
|
||
liefe an der des Dienstes vorbei.
|
||
|
||
## 5h. Der Seed: einrichten, nicht aufraeumen
|
||
|
||
Die sieben Vorlagen stehen als **JSON im Austauschformat** unter
|
||
`prisma/seed-daten/vorlagen/` und nicht als TypeScript-Literale. Damit prueft sie
|
||
dasselbe Zod-Schema, das auch Import, Export und Editor benutzen: eine
|
||
Seed-Vorlage ist genau das, was ein Redakteur einspielen koennte, und ein Fehler
|
||
faellt beim Einrichten auf statt im ersten Lauf.
|
||
|
||
Angelegt werden sie **nicht** ueber `vorlageImportieren`. Der Import erzeugt
|
||
absichtlich einen Entwurf und schreibt einen Protokolleintrag mit dem handelnden
|
||
Menschen; der Seed hat keinen Menschen und braucht veroeffentlichte Versionen,
|
||
weil sich sonst kein Lauf starten liesse. Er geht trotzdem denselben Weg wie ein
|
||
Redakteur — **erst Entwurf, dann veroeffentlichen**: die Trigger aus #010
|
||
verbieten jedes Schreiben an Abschnitten, Variablen und Schritten einer
|
||
veroeffentlichten Version. Eine Version gleich als `VEROEFFENTLICHT` anzulegen und
|
||
danach zu fuellen scheitert an der Datenbank, und das ist richtig so.
|
||
|
||
**Der Seed richtet ein, er raeumt nicht auf.** Jede Zeile hat einen sprechenden
|
||
Schluessel — die Vorlage ihren Namen, die Abteilung `(kundeId, name)`, die Person
|
||
ihren UPN, das Geraet seinen `tagCode` —, und was es schon gibt, bleibt
|
||
unberuehrt. Kein `upsert` mit gefuelltem `update`: eine im Betrieb geaenderte
|
||
Ziel-OU, eine bearbeitete Vorlage und ein umgeschalteter Betriebsmodus ueberstehen
|
||
jeden weiteren Lauf. Zweimal hintereinander aufgerufen aendert der zweite Lauf
|
||
nichts, und `tests/seed.test.ts` haelt das fest.
|
||
|
||
Im Modus DIENSTLEISTUNG haengt der Beispielbestand an der **Musterfirma** und nicht
|
||
an der eigenen Organisation: der Auftrag verlangt, dass `LZ-7K3F9Q` dort auf
|
||
`MUS-PC-001` fuehrt. Ein Tag-Code gehoert genau einem Geraet, beides zugleich
|
||
ginge also ohnehin nicht — „erzeugt **stattdessen**“.
|
||
|
||
Die Tag-Codes der Beispielgeraete sind **fest** und nicht gezogen. Ein Seed, der
|
||
jedes Mal fuenf neue Codes zieht, waere nicht idempotent: beim dritten Lauf laegen
|
||
fuenfzehn Aufkleber im Stapel. Weil ein `tagCode` global eindeutig ist und nie
|
||
wiederverwendet wird, bekommt jeder Demo-Kunde seinen eigenen Satz.
|
||
|
||
## 6. Betrieb: zwei Wege, eine Regel
|
||
|
||
Die Datenbank ist auf beiden Wegen von außen nicht erreichbar:
|
||
|
||
| Weg | Wie |
|
||
|---|---|
|
||
| Docker Compose | `postgres` ohne `ports:`, nur im Netz `intern` mit `internal: true` (kein Gateway) |
|
||
| Nativ im LXC | PostgreSQL mit `listen_addresses = ''` — nur Unix-Socket |
|
||
|
||
Festgehalten in `tests/betriebsstand.test.ts`, damit ein `ports:`-Mapping nicht versehentlich
|
||
zurückkommt.
|
||
|
||
## 7. Werkzeug-Obergrenzen
|
||
|
||
Zwei Abhängigkeiten dürfen **nicht** angehoben werden, beides empirisch festgestellt:
|
||
|
||
- **`eslint` bleibt auf 9.x.** `eslint-config-next` bringt `eslint-plugin-react@7.37.5` mit, das
|
||
`eslint` nur bis `^9.7` unterstützt. Mit eslint 10 bricht das Regelwerk beim Laden ab
|
||
(`contextOrFilename.getFilename is not a function`). Eine neuere Fassung des Plugins gibt es
|
||
nicht — die letzte stammt von April 2025.
|
||
- **`typescript` bleibt auf `~6.0.3`.** `typescript-eslint@8` erlaubt nur `>=4.8.4 <6.1.0`.
|
||
TypeScript 7.0.2 ist zwar `latest`, aber noch nicht unterstützt.
|
||
|
||
Dazu ein Pinning: **`prisma` und `@prisma/client` exakt auf `7.10.0`**. Der `latest`-Tag von
|
||
`prisma` zeigt auf einen 8.0-RC, der von `@prisma/client` nicht — ein unbedachtes
|
||
`pnpm add prisma` würde beide auseinanderlaufen lassen.
|