Files
Laufzettel/README.md
T
Florian WildeandClaude Opus 5 a417426f43
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(meldungen): Benachrichtigungen per Mail und Webhook (#54)
Ein zugewiesener Lauf fiel bisher nur auf, wenn jemand die Uebersicht
oeffnete; ein ueberfaelliger ueberhaupt nicht. Vier Anlaesse gehen jetzt
hinaus — zugewiesen, ueberfaellig, abgeschlossen und Verzeichnisabgleich
fehlgeschlagen —, je Person abschaltbar. Niemand bekommt Post ueber die
eigene Tat: das ist der haeufigste Grund, Benachrichtigungen ganz
abzuschalten, und deshalb faellt der Handelnde immer aus der
Empfaengerliste.

Eingereiht wird im Anfrage-Pfad, gesendet wird es nicht. Ein haengender
Mailserver darf kein Abhaken blockieren, also schreibt der Dienst eine
Zeile in `Zustellung` und ein eigener Zeitplan schickt sie. Betreff und
Rumpf stehen fertig darin: eine Meldung sagt, was *damals* war.

In keiner Nachricht steht ein Schrittinhalt, und damit auch kein
erzeugtes Passwort. Darueber wacht src/lib/meldungen.ts — die Funktionen
dort nehmen nur Kopfdaten, der Typ laesst nichts anderes zu. Geprueft
wird es zusaetzlich ueber alle Rumpftexte, die wirklich verschickt
wurden, so wie #012 es fuer die Druckansicht tat.

Zwei Dinge haetten ohne Datenbank nicht gehalten. Die Einmaligkeit der
Ueberfaelligkeitsmeldung liegt in einem partiellen eindeutigen Index:
der Zeitplan sieht jede Minute nach, und ohne ihn bekaeme der
Zustaendige fuer einen Lauf, der ueber das Wochenende steht, 2880
Mails — jede fuer sich korrekt, kein Test rot. Und die Sperre der
Warteschlange liegt in derselben Tabelle wie die des Abgleichs, denn
hinter einem Lastverteiler laufen zwei Instanzen.

Eine stille Warteschlange ist schlimmer als keine: Verwaltung →
Zustellungen zeigt mit Grund, was hinausging und was nicht.

Geprueft gegen einen echten SMTP-Faenger (mailpit) statt gegen eine
abgefangene Funktion — und `mailpit` gehoert damit ins Tor vor dem
Commit, sonst ueberspringt die Suite 3 Tests und sieht gruen aus.

Closes #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 22:52:36 +02:00

229 lines
12 KiB
Markdown

# laufzettel
Checklisten für wiederkehrende IT-Arbeiten. Versionierte Vorlagen, daraus gestartete Läufe, im
Team abgearbeitet, abgeschlossen und unveränderlich archiviert — mit Historie je Gerät und je
Mitarbeiter. Einstieg über QR-Aufkleber: Code scannen, Geräteseite öffnen, loslegen.
> **Stand:** in Entwicklung auf `v0.1.0` (MVP). Der Fortschritt steht in
> [`docs/issues/README.md`](docs/issues/README.md), Entscheidungen im
> [Entwicklungstagebuch](docs/devlog.md).
## Zwei Betriebsmodi
| Modus | Für wen | Kunden-Ebene | Verzeichnis |
|---|---|---|---|
| `INTERN` (Standard) | eine Firma, eigene Mitarbeiter | unsichtbar | eigenes AD per LDAP |
| `DIENSTLEISTUNG` | IT-Dienstleister mit mehreren Kunden | sichtbar | manuell oder per CSV je Kunde |
Der Modus liegt in der Datenbank und ist vom Admin ohne Neustart umschaltbar. Jede kundenbezogene
Zeile trägt immer eine Kunden-Zuordnung, im Modus `INTERN` die eigene Organisation — deshalb ist
der Wechsel eine Datenänderung und keine Migration.
## Voraussetzungen
- Node 24 (`.nvmrc`) und pnpm 12 über corepack, **oder** nur Docker mit Compose-Plugin
- PostgreSQL 17 (im Compose-Stand enthalten)
## Entwicklung
```bash
corepack enable pnpm
pnpm install
cp .env.example .env # APP_SECRET und Passwörter ausfüllen
pnpm db:up # PostgreSQL auf 127.0.0.1:5434
pnpm db:migrate
pnpm db:seed
pnpm dev # http://localhost:3000
```
`APP_SECRET` erzeugen:
```bash
openssl rand -base64 48
```
### Qualitätsschranken
```bash
docker compose -f docker-compose.test.yml up -d --wait ldap mailpit # sonst fehlen 25 Tests
pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test && pnpm pruefe:doku
```
Die beiden Container sind keine Zierde: ohne `ldap` überspringt die Suite den ganzen
Verzeichnis-Abgleich (22 Tests), ohne `mailpit` den echten Mailversand (3 Tests) — sie sagt das auf
stderr, aber der Lauf sieht grün aus. In CI bricht sie stattdessen ab.
Ein Pre-Commit-Hook erzwingt Lint und Format, ein Commit-Msg-Hook Conventional Commits.
Ende-zu-Ende-Tests: `pnpm test:e2e` (verlangt einen gebauten, laufenden Stand — siehe
`docs/anleitung/`).
## Betrieb mit Docker Compose
```bash
cp .env.example .env # APP_URL, APP_SECRET, POSTGRES_PASSWORD sind Pflicht
docker compose up -d --build
```
Die Migrationen laufen beim Start automatisch in einem eigenen Einmal-Dienst. Der **Seed läuft
absichtlich nicht automatisch** — ihn startest du einmal von Hand:
```bash
docker compose run --rm migrate pnpm db:seed
```
### Was der Seed anlegt
| | Inhalt |
|---|---|
| Grundbestand | Betriebsmodus, eigene Organisation, Administrator aus `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORD` |
| Vorlagen | die sieben Abläufe des Hauses als **veröffentlichte Version 1** — PC Neuinstallation, Onboarding, Offboarding, Server-Inbetriebnahme, Firewall-Rollout, Patchday, Restore-Test |
| Beispielbestand | 3 Abteilungen, 5 Personen, `PC-001` und `NB-001` mit Tag-Code, 5 vorgedruckte Tags, Schnellstart-Vorgaben je Gerätetyp |
| Verzeichnis | eine **deaktivierte** Beispielquelle zum Abschauen — Adresse und Bind-Passwort ersetzen, dann einschalten |
Mit `SEED_MODE=DIENSTLEISTUNG` (Alias `SEED_MODE=SERVICE`) kommen zwei Demo-Kunden dazu,
„Musterfirma GmbH" (`MUS`) und „Bäckerei Reiter" (`REI`), mit je einer Abteilung, einer Person und
einem Gerät nach der Hostname-Regel (`MUS-PC-001`).
**Der Seed richtet ein, er räumt nicht auf.** Was schon da ist, bleibt: eine bearbeitete Vorlage,
eine geänderte Ziel-OU oder ein umgeschalteter Betriebsmodus überstehen jeden weiteren Lauf. Zweimal
hintereinander aufgerufen ändert der zweite Lauf nichts.
Die sieben Vorlagen liegen als JSON im [Austauschformat](docs/vorlagen-format.md) unter
`prisma/seed-daten/vorlagen/`. Wer eine anpassen will, ändert die Datei — oder besser: die Vorlage
in der Oberfläche, denn eine veröffentlichte Version wird nie verändert, und der Seed fasst eine
vorhandene Vorlage nicht mehr an.
### Die Datenbank ist von außen nicht erreichbar
Das ist bewusst so gebaut und in `tests/betriebsstand.test.ts` festgehalten:
- `postgres` hat **kein** `ports:`-Mapping, nur `expose`
- `postgres` hängt ausschließlich im Netz `intern`, das `internal: true` ist — ein Netz ohne
Gateway, also weder vom Host noch aus dem LAN erreichbar
- nur `web` hängt zusätzlich im Netz `extern` (Reverse Proxy, OIDC, LDAP)
- `web` bindet standardmäßig auf `127.0.0.1:3000`; davor gehört ein Reverse Proxy
Prüfen:
```bash
nc -z localhost 5432 && echo "FEHLER: offen" || echo "dicht"
docker compose exec web nc -z postgres 5432 && echo "von innen erreichbar"
```
Für die Entwicklung gibt es `docker-compose.dev.yml`, das PostgreSQL auf `5434` öffnet — umstellbar
über `DEV_DB_PORT`. 5432 ist oft lokal belegt und 5433 von einem anderen Projekt, daher dieser Port.
Diese Datei ist nicht für den Betrieb gedacht.
### Reverse Proxy
Die App läuft hinter einem Reverse Proxy und hat keine fest verdrahteten Adressen. Maßgeblich ist
`APP_URL` — sie steckt in den QR-Codes und in der OIDC-Rückruf-Adresse. Der Proxy muss
`X-Forwarded-Proto` und `X-Forwarded-Host` setzen.
### Sicherung
Der geschlossene Port ändert nichts an der Sicherung, sie läuft über den Dienstnamen:
```bash
docker compose exec -T postgres pg_dump -U laufzettel laufzettel | gzip > sicherung-$(date +%F).sql.gz
```
Zurückspielen:
```bash
gunzip -c sicherung-2026-09-24.sql.gz | docker compose exec -T postgres psql -U laufzettel laufzettel
```
Gesichert werden müssen **zwei** Dinge: die Datenbank (das Volume heißt `laufzettel_datenbank`; die
App selbst hält keinen veränderlichen Zustand auf der Platte) **und die Geheimnisse aus der `.env`**.
Ohne das alte `APP_SECRET` sind nach dem Zurückspielen alle gespeicherten LDAP-Bind-Passwörter
unlesbar — und das merkt man erst beim nächsten Abgleich. Einzelheiten in
[`docs/betrieb/sicherung.md`](docs/betrieb/sicherung.md).
## Anmeldung
Zwei Wege, beide gleichzeitig möglich:
- **OIDC** (Ziel: authentik) für den normalen Betrieb
- **lokal mit E-Mail und Passwort**, nur wenn `AUTH_LOCAL_ENABLED=true` — für die Entwicklung und
als Notfallzugang, wenn der Identitätsanbieter ausfällt
Die Rolle kommt bei jedem OIDC-Login aus dem `groups`-Claim und wird über
`OIDC_GROUP_ADMIN`, `OIDC_GROUP_EDITOR`, `OIDC_GROUP_TECHNICIAN` und `OIDC_GROUP_VIEWER`
zugeordnet. Unbekannte Benutzer werden beim ersten Login angelegt.
| Rolle | Darf |
|---|---|
| `ADMIN` | alles, inkl. Benutzer, Verzeichnis, Abteilungen, Archiv-Verwaltung |
| `REDAKTEUR` | Vorlagen anlegen, bearbeiten, veröffentlichen; alles was `TECHNIKER` darf |
| `TECHNIKER` | Läufe starten, abarbeiten, abschließen, archivieren; Geräte pflegen |
| `LESER` | nur lesen |
Die Anbindung an authentik ist in [`docs/betrieb/authentik.md`](docs/betrieb/authentik.md)
Schritt für Schritt beschrieben.
## Verzeichnis-Abgleich (nur Modus `INTERN`)
Unter **Admin → Verzeichnis** wird eine LDAP-Quelle angelegt: Adresse (`ldaps://` empfohlen,
`ldap+starttls://` und `ldap://` gehen auch), ein Dienstkonto mit **Leserecht**, der Basis-DN und
optional ein eigener Filter. Ein Dienstkonto genügt — Laufzettel schreibt nie ins Verzeichnis.
- **„Verbindung testen“** liest die ersten Einträge und zeigt sie **mit den gemappten Feldern**,
ohne etwas zu speichern. Erst daran sieht man, ob die Zuordnung stimmt.
- **Die Attributzuordnung** ist einklappbar im Formular und trägt die AD-Vorgaben; ein OpenLDAP
bekommt hier etwa `entryUUID` als Kennung.
- **Der Zeitplan** läuft im selben Prozess wie die Anwendung und sieht jede Minute nach, welche
Quelle fällig ist; das Intervall steht je Quelle. `ZEITPLAN_AKTIV=false` schaltet ihn ab — für
Einmal-Container, die nur Migrationen fahren.
- **Gelöscht wird nie.** Wer im Verzeichnis verschwindet oder deaktiviert wird, wird hier inaktiv.
Lässt sich **kein einziger** Eintrag zuordnen, deaktiviert der Abgleich niemanden und meldet
stattdessen einen Fehler — ein falsch gesetztes Kennungsattribut soll nicht die halbe Firma
abschalten.
- **Das Bind-Passwort** wird verschlüsselt gespeichert und nie wieder angezeigt; ein leeres Feld
beim Bearbeiten heißt „unverändert“.
Ohne Verzeichnis geht es auch: Personen lassen sich von Hand anlegen oder aus einer CSV-Datei
einlesen.
## Umgebungsvariablen
Jede Variable steht mit Erklärung in [`.env.example`](.env.example) — hier die Übersicht. **Pflicht**
sind `APP_URL`, `APP_SECRET`, `POSTGRES_PASSWORD` und `DATABASE_URL`; fehlt eine davon, bricht der
Compose-Stand ab, statt mit Standardwerten zu starten.
| Variable | Bedeutung |
|---|---|
| `APP_URL` | Die **öffentliche** Adresse, unter der die App erreichbar ist. Steht sie hinter einem Reverse Proxy, ist es dessen Adresse — nicht die interne. Aus ihr entstehen die QR-Adressen auf den Aufklebern und die OIDC-Rücksprungadresse. |
| `APP_SECRET` | Schlüssel für Sitzungs-Cookies und die Verschlüsselung des LDAP-Bind-Passworts. Mindestens 32 Zeichen, erzeugen mit `openssl rand -base64 48`. **Ändert man ihn, sind alle Sitzungen ungültig und gespeicherte Bind-Passwörter unlesbar.** |
| `DATABASE_URL` | Verbindung zur PostgreSQL-Datenbank. |
| `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | Zugangsdaten des Datenbank-Containers im Compose-Stand. |
| `AUTH_LOCAL_ENABLED` | `true` schaltet die lokale Anmeldung mit E-Mail und Passwort frei — Notfallzugang und Entwicklung. Auf `false` verschwindet das Formular **und** der Endpunkt lehnt ab. |
| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` | Der Identitätsanbieter. Discovery über `<issuer>/.well-known/openid-configuration`. |
| `OIDC_NAME` | Beschriftung des Anmeldeknopfs, etwa „Firmenanmeldung“. |
| `OIDC_SCOPE` | Angefragte Scopes; muss einen enthalten, der `groups` liefert. |
| `OIDC_GROUP_ADMIN`, `OIDC_GROUP_EDITOR`, `OIDC_GROUP_TECHNICIAN`, `OIDC_GROUP_VIEWER` | Welche Gruppe welche Rolle bekommt. Die Rolle wird bei **jeder** Anmeldung neu gesetzt. |
| `OIDC_CUSTOMER_GROUP_PREFIX` | Nur im Modus `DIENSTLEISTUNG`: Gruppen mit diesem Präfix weisen einen Benutzer einem Kunden zu. |
| `BIND_ADDR`, `APP_PORT` | Wohin der Compose-Stand die App bindet. Standard `127.0.0.1:3000` — davor gehört ein Reverse Proxy. |
| `APP_VERSION`, `GIT_SHA` | Erscheinen unten links in der Oberfläche. Im Compose-Stand Build-Argumente. |
| `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD` | Der Administrator, den `pnpm db:seed` anlegt. Das Passwort wird nur als argon2-Hash gespeichert. Fehlt eines, überspringt der Seed ihn. |
| `SEED_MODE` | `INTERN` (Standard) oder `DIENSTLEISTUNG` — der Alias `SERVICE` tut dasselbe. Betrifft nur den **ersten** Seed-Lauf; danach steht der Modus in der Datenbank. |
| `SEED_ORG_NAME`, `SEED_ORG_KUERZEL` | Name und Kürzel der eigenen Organisation. Das Kürzel steht auf Etiketten und in der Hostname-Regel. |
| `ZEITPLAN_AKTIV` | `false` schaltet den Verzeichnis-Abgleich nach Zeitplan ab. |
| `TEST_DATABASE_URL` | Datenbank für die Tests. Ohne sie überspringen sich alle Datenbanktests. |
## Dokumentation
- [Anleitung für Technik und Verwaltung](docs/anleitung/) — acht Kapitel mit Bildern. `pnpm docs:pdf`
macht daraus ein PDF; jedem Release liegt es bei
- [Architektur und Entscheidungen](docs/architektur.md)
- [Austauschformat für Vorlagen](docs/vorlagen-format.md) — wie eine Vorlage als JSON aussieht
- [Betrieb](docs/betrieb/) — [Inbetriebnahme mit Compose](docs/betrieb/inbetriebnahme.md),
[nativ im Proxmox-LXC](docs/betrieb/proxmox.md), [Reverse Proxy](docs/betrieb/reverse-proxy.md),
[Anbindung an authentik](docs/betrieb/authentik.md), [Sichern und Zurückspielen](docs/betrieb/sicherung.md)
- [Konventionen](CLAUDE.md) — Sprache, Schichtung, feste Regeln
- [Änderungen](CHANGELOG.md) · [Entwicklungstagebuch](docs/devlog.md)
## Lizenz
Noch nicht festgelegt.