feat: Projektgerüst, gehärteten Betriebsstand und Prozess aufsetzen
Legt die Grundlage, auf der alle weiteren Meilensteine nur noch Fachlichkeit ergänzen: Next 16 mit App Router und TypeScript strict, Tailwind 4, Prisma 7, Vitest 5, Playwright, ESLint, Prettier und die Hooks für Lint und Commits. Zwei Dinge sind bewusst so gebaut und durch Tests festgehalten: Die Datenbank ist im Betriebsstand von außen nicht erreichbar. Sie hat kein ports:-Mapping und hängt allein im Netz "intern", das ohne Gateway läuft; nur "web" kommt zusätzlich nach draußen. tests/betriebsstand.test.ts schlägt an, falls ein Port-Mapping zurückkehrt. Geprüft, nicht behauptet: docker compose up startet den Stack, 5432 ist vom Host aus zu, aus "web" offen. pnpm pruefe:doku hält Dokumentation und Code zusammen — Version im Changelog, Issue-Dateien gegen ihre Übersicht, keine Verweise auf fehlende Dateien, keine fehlenden Bilder. Das Skript wurde gegen vier eingebaute Fehler gegengeprüft. Drei Versions-Obergrenzen sind nötig und in CLAUDE.md begründet: eslint bleibt auf 9.x, weil eslint-plugin-react keinen Nachfolger für eslint 10 hat und das Regelwerk sonst beim Laden abbricht; typescript bleibt auf 6.0.x, weil typescript-eslint TypeScript 7 noch nicht unterstützt; prisma ist exakt auf 7.10.0 gepinnt, weil sein latest-Tag auf einen 8.0-RC zeigt, der von @prisma/client aber nicht. Auth.js entfällt entgegen der ursprünglichen Vorgabe: v5 steht dauerhaft auf Beta, das Projekt ist im Wartungsmodus und sein Prisma-Adapter deckt Prisma 7 nicht ab. Stattdessen openid-client und jose, wie in ProxLedger. Dieser erste Commit geht direkt auf main, weil es noch keinen Branch gibt, von dem abzuzweigen wäre. Ab #006 läuft jedes Issue über einen eigenen Branch. Closes #5 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
coverage
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
docs/screenshots
|
||||||
|
docs/anleitung/bilder
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Laufzettel – Umgebungsvariablen.
|
||||||
|
# Kopieren nach .env und ausfuellen. Die .env gehoert nie ins Repo.
|
||||||
|
|
||||||
|
# ---------- Pflicht ----------
|
||||||
|
|
||||||
|
# Oeffentliche Adresse der Anwendung. Steckt in den QR-Codes und in der
|
||||||
|
# OIDC-Rueckruf-Adresse, muss also stimmen, wenn ein Reverse Proxy davor haengt.
|
||||||
|
APP_URL=https://laufzettel.example.de
|
||||||
|
|
||||||
|
# Geheimnis fuer Sitzungs-Cookies und die Verschluesselung des LDAP-Bind-Passworts.
|
||||||
|
# Mindestens 32 Zeichen. Erzeugen mit: openssl rand -base64 48
|
||||||
|
APP_SECRET=
|
||||||
|
|
||||||
|
# Zugang zur Datenbank. Im Compose-Stand wird der Dienstname postgres verwendet
|
||||||
|
# und die Datenbank ist von aussen nicht erreichbar.
|
||||||
|
POSTGRES_USER=laufzettel
|
||||||
|
POSTGRES_PASSWORD=
|
||||||
|
POSTGRES_DB=laufzettel
|
||||||
|
DATABASE_URL=postgresql://laufzettel:@postgres:5432/laufzettel?schema=public
|
||||||
|
|
||||||
|
# ---------- Anmeldung ----------
|
||||||
|
|
||||||
|
# Lokaler Login mit E-Mail und Passwort. Fuer die Entwicklung und als Notfallzugang.
|
||||||
|
# Im Betrieb mit OIDC auf false setzen.
|
||||||
|
AUTH_LOCAL_ENABLED=false
|
||||||
|
|
||||||
|
# Generischer OIDC-Anbieter, Ziel ist authentik. Leer lassen, um OIDC abzuschalten.
|
||||||
|
OIDC_ISSUER=
|
||||||
|
OIDC_CLIENT_ID=
|
||||||
|
OIDC_CLIENT_SECRET=
|
||||||
|
OIDC_NAME=authentik
|
||||||
|
|
||||||
|
# Zuordnung der Rollen ueber den groups-Claim. Wird bei jedem Login neu ausgewertet.
|
||||||
|
OIDC_GROUP_ADMIN=lz-admin
|
||||||
|
OIDC_GROUP_EDITOR=lz-redakteur
|
||||||
|
OIDC_GROUP_TECHNICIAN=lz-techniker
|
||||||
|
OIDC_GROUP_VIEWER=lz-leser
|
||||||
|
|
||||||
|
# Optional, nur im Betriebsmodus Dienstleistung: Gruppen wie lz-kunde-MUS binden
|
||||||
|
# Benutzer an Kunden. TECHNIKER und LESER sehen dann nur ihre Kunden.
|
||||||
|
# Leer lassen heisst: keine Einschraenkung.
|
||||||
|
OIDC_CUSTOMER_GROUP_PREFIX=
|
||||||
|
|
||||||
|
# ---------- Betrieb ----------
|
||||||
|
|
||||||
|
# Adresse, auf die der Webdienst gebunden wird. Standard ist nur localhost,
|
||||||
|
# davor gehoert ein Reverse Proxy. Fuer direkten Zugriff im LAN: 0.0.0.0
|
||||||
|
BIND_ADDR=127.0.0.1
|
||||||
|
APP_PORT=3000
|
||||||
|
|
||||||
|
# Wird beim Bauen in die Anzeige unten links uebernommen.
|
||||||
|
APP_VERSION=0.1.0
|
||||||
|
GIT_SHA=
|
||||||
|
|
||||||
|
# ---------- Seed ----------
|
||||||
|
|
||||||
|
SEED_ADMIN_EMAIL=admin@firma.example
|
||||||
|
SEED_ADMIN_PASSWORD=
|
||||||
|
# INTERN (Standard) oder DIENSTLEISTUNG. SERVICE wird als Alias akzeptiert.
|
||||||
|
SEED_MODE=INTERN
|
||||||
|
|
||||||
|
# ---------- Tests ----------
|
||||||
|
|
||||||
|
# Datenbank fuer Vitest. Ohne diese Variable ueberspringen sich die Datenbanktests.
|
||||||
|
# Passend zu docker-compose.dev.yml (Port 5433):
|
||||||
|
TEST_DATABASE_URL=postgresql://laufzettel:laufzettel@localhost:5433/laufzettel_test?schema=public
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
name: Prüfung
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, "feat/**", "fix/**"]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pruefung:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: laufzettel
|
||||||
|
POSTGRES_PASSWORD: laufzettel
|
||||||
|
POSTGRES_DB: laufzettel_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U laufzettel"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://laufzettel:laufzettel@localhost:5432/laufzettel_test?schema=public
|
||||||
|
TEST_DATABASE_URL: postgresql://laufzettel:laufzettel@localhost:5432/laufzettel_test?schema=public
|
||||||
|
APP_SECRET: ci-geheimnis-mit-mindestens-32-zeichen-laenge
|
||||||
|
APP_URL: http://localhost:3000
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
|
||||||
|
- name: pnpm bereitstellen
|
||||||
|
run: corepack enable pnpm
|
||||||
|
env:
|
||||||
|
COREPACK_ENABLE_DOWNLOAD_PROMPT: 0
|
||||||
|
|
||||||
|
- name: Abhängigkeiten installieren
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Prisma-Client erzeugen
|
||||||
|
run: pnpm exec prisma generate
|
||||||
|
|
||||||
|
- name: Code-Stil prüfen
|
||||||
|
run: pnpm lint
|
||||||
|
|
||||||
|
- name: Typen prüfen
|
||||||
|
run: pnpm typecheck
|
||||||
|
|
||||||
|
- name: Tests
|
||||||
|
run: pnpm test
|
||||||
|
|
||||||
|
- name: Bauen
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Dokumentation prüfen
|
||||||
|
run: pnpm pruefe:doku
|
||||||
|
|
||||||
|
- name: Shell-Skripte prüfen
|
||||||
|
run: |
|
||||||
|
if compgen -G "deploy/*.sh" > /dev/null || compgen -G "deploy/proxmox/*.sh" > /dev/null; then
|
||||||
|
shellcheck --severity=warning deploy/*.sh deploy/proxmox/*.sh
|
||||||
|
else
|
||||||
|
echo "Noch keine Shell-Skripte vorhanden (kommen mit #019)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: pruefung
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: laufzettel
|
||||||
|
POSTGRES_PASSWORD: laufzettel
|
||||||
|
POSTGRES_DB: laufzettel_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U laufzettel"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://laufzettel:laufzettel@localhost:5432/laufzettel_test?schema=public
|
||||||
|
APP_SECRET: ci-geheimnis-mit-mindestens-32-zeichen-laenge
|
||||||
|
APP_URL: http://127.0.0.1:3000
|
||||||
|
AUTH_LOCAL_ENABLED: "true"
|
||||||
|
SEED_ADMIN_EMAIL: admin@firma.example
|
||||||
|
SEED_ADMIN_PASSWORD: ci-passwort-nur-fuer-tests
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
|
||||||
|
- name: pnpm bereitstellen
|
||||||
|
run: corepack enable pnpm
|
||||||
|
env:
|
||||||
|
COREPACK_ENABLE_DOWNLOAD_PROMPT: 0
|
||||||
|
|
||||||
|
- name: Abhängigkeiten installieren
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Browser holen
|
||||||
|
run: pnpm exec playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Schema und Seed
|
||||||
|
run: pnpm exec prisma migrate deploy && pnpm db:seed
|
||||||
|
|
||||||
|
- name: Bauen
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Ende-zu-Ende-Tests
|
||||||
|
run: pnpm exec playwright test
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: failure()
|
||||||
|
with:
|
||||||
|
name: playwright-bericht
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 7
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# Abhaengigkeiten
|
||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# Next
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Erzeugter Prisma-Client
|
||||||
|
src/generated/
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
coverage/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
blob-report/
|
||||||
|
.playwright/
|
||||||
|
|
||||||
|
# Umgebung – Geheimnisse gehoeren nie ins Repo
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Werkzeuge
|
||||||
|
*.tsbuildinfo
|
||||||
|
.DS_Store
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
pnpm exec commitlint --edit "$1"
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
pnpm lint-staged
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
coverage
|
||||||
|
docs
|
||||||
|
*.md
|
||||||
|
pnpm-lock.yaml
|
||||||
|
src/generated
|
||||||
|
prisma/migrations
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 110
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Alle nennenswerten Änderungen an Laufzettel werden in dieser Datei dokumentiert.
|
||||||
|
|
||||||
|
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
|
||||||
|
und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||||
|
|
||||||
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- Projektgerüst: Next 16 mit App Router und TypeScript strict, Tailwind 4, Vitest 5,
|
||||||
|
Playwright 1.63, ESLint flat config, Prettier, husky mit lint-staged und commitlint (#005)
|
||||||
|
- Gestaltung: Design-Token als CSS-Variablen, selbst gehostete Schriften, Wortmarke und
|
||||||
|
Bildmarke `[ ✓ ]`, Versionsanzeige unten links (#005)
|
||||||
|
- Betriebsstand mit Docker Compose: die Datenbank ist von außen nicht erreichbar — kein
|
||||||
|
veröffentlichter Port und ein Netz ohne Gateway; Migrationen laufen beim Start in einem
|
||||||
|
eigenen Einmal-Dienst, der Seed bleibt ein bewusster Handgriff (#005)
|
||||||
|
- Healthcheck `GET /api/gesundheit` und Sicherheitskopfzeilen samt CSP-Nonce je Anfrage (#005)
|
||||||
|
- shadcn/ui eingerichtet: sein Variablen-Vertrag wird aus den Laufzettel-Token gespeist, damit
|
||||||
|
kopierte Bauteile ohne Nacharbeit passen (#005)
|
||||||
|
- `pnpm pruefe:doku` hält Dokumentation und Code zusammen: Version im Changelog, Issue-Dateien
|
||||||
|
gegen ihre Übersicht, keine Verweise auf fehlende Dateien, keine fehlenden Bilder (#005)
|
||||||
|
- `pnpm docs:screenshots` erzeugt Screenshots reproduzierbar und prüft dabei gleich, dass am
|
||||||
|
Handy nicht horizontal gescrollt wird (#005)
|
||||||
|
- Issues, Labels und Meilensteine für alle vier Phasen im Gitea-Tracker (#001–#019)
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
|
||||||
|
- Der Compose-Stand bricht ab, wenn `APP_SECRET`, `POSTGRES_PASSWORD` oder `APP_URL` fehlen,
|
||||||
|
statt mit Standardwerten zu starten (#005)
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# Konventionen für Laufzettel
|
||||||
|
|
||||||
|
Diese Datei ist verbindlich. Wer hier etwas ändert, ändert es auch in `docs/architektur.md`.
|
||||||
|
|
||||||
|
## Sprache
|
||||||
|
|
||||||
|
**Deutsch, überall wo ein Mensch liest oder benennt** — auch Bezeichner. Funktions- und
|
||||||
|
Variablennamen, Prisma-Modelle und -Felder, Enum-Werte, Fehlerklassen, Dateinamen, Kommentare,
|
||||||
|
Testbeschreibungen, Commit-Betreffs und Dokumentation sind deutsch. **Umlaute werden
|
||||||
|
ASCII-gefaltet**: `Geraet`, `geaendertAm`, `laeufe`, `veroeffentlichtAm`.
|
||||||
|
|
||||||
|
Englisch bleiben nur:
|
||||||
|
|
||||||
|
- vom Framework vorgegebene Namen: `GET`, `POST`, `layout.tsx`, `route.ts`, `page.tsx`, `proxy.ts`,
|
||||||
|
`dependencies`, `default`
|
||||||
|
- Lehnwörter aus dem Fachjargon: `tagCode`, `Token`, `OIDC`, `LDAP`, `Hostname`, `Shell`,
|
||||||
|
`Markdown`, `Nonce`, `Healthcheck`
|
||||||
|
|
||||||
|
Kommentare erklären **warum**, nicht was, und tragen wo hilfreich die Issue-Nummer: `(#007)`.
|
||||||
|
|
||||||
|
## Namensabbildung zur Auftragsvorgabe
|
||||||
|
|
||||||
|
Der Auftrag nennt englische Modelle. Verbindlich gilt:
|
||||||
|
|
||||||
|
| Vorgabe | Modell | Vorgabe | Modell |
|
||||||
|
|---|---|---|---|
|
||||||
|
| User | `Benutzer` | Template | `Vorlage` |
|
||||||
|
| Customer | `Kunde` | TemplateVersion | `VorlagenVersion` |
|
||||||
|
| Setting | `Einstellung` | TemplateVariable | `VorlagenVariable` |
|
||||||
|
| DirectorySource | `Verzeichnisquelle` | TemplateSection | `VorlagenAbschnitt` |
|
||||||
|
| Employee | `Mitarbeiter` | TemplateStep | `VorlagenSchritt` |
|
||||||
|
| Department | `Abteilung` | ChecklistRun | `Lauf` |
|
||||||
|
| Device | `Geraet` | StepResult | `Schrittergebnis` |
|
||||||
|
| DeviceTemplateDefault | `GeraetetypVorlage` | Comment | `Kommentar` |
|
||||||
|
| — (ergänzt) | `AbgleichSperre` | AuditLog | `Protokoll` |
|
||||||
|
|
||||||
|
Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER}` · `Betriebsmodus {INTERN DIENSTLEISTUNG}` ·
|
||||||
|
`LaufStatus {OFFEN IN_ARBEIT ABGESCHLOSSEN ARCHIVIERT}` ·
|
||||||
|
`SchrittZustand {OFFEN ERLEDIGT NICHT_ZUTREFFEND FEHLGESCHLAGEN}` ·
|
||||||
|
`SchrittTyp {HAKEN TEXT ZAHL AUSWAHL DATUM BEFEHL}` (später `AKTION`) ·
|
||||||
|
`GeraeteTyp {PC NOTEBOOK SERVER FIREWALL SWITCH DRUCKER RAUM SONSTIGES}` ·
|
||||||
|
`VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}`.
|
||||||
|
|
||||||
|
`SEED_MODE` akzeptiert `DIENSTLEISTUNG` und `SERVICE` als Alias.
|
||||||
|
|
||||||
|
## Schichtung
|
||||||
|
|
||||||
|
```
|
||||||
|
src/server/services/* Regeln und Schreiboperationen. Werfen eigene …Fehler-Klassen.
|
||||||
|
src/server/queries/* Lesemodelle. Wenden Rollen- und Mandantensicht selbst an.
|
||||||
|
src/server/actions/* Dünne "use server"-Einstiege. Prüfen genau ein Recht, rufen den Service.
|
||||||
|
src/lib/* Framework-freie Helfer. Kein Next, kein React, gut testbar.
|
||||||
|
src/app/api/* Route Handler. Nur Polling, QR-SVG, CSV, Auth, Healthcheck. Minimal.
|
||||||
|
src/components/* Darstellung. Keine Geschäftslogik.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Keine Geschäftslogik in Komponenten oder Route Handlern.** Eine Server Action ist ein
|
||||||
|
Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen.
|
||||||
|
|
||||||
|
## Feste Regeln, die in Tests festgehalten sind
|
||||||
|
|
||||||
|
- **Versionierung:** Eine veröffentlichte `VorlagenVersion` wird nie verändert. „Bearbeiten"
|
||||||
|
erzeugt eine tiefe Kopie als Entwurf. Höchstens ein Entwurf je Vorlage. Läufe verweisen nur auf
|
||||||
|
veröffentlichte Versionen.
|
||||||
|
- **Archiv:** `laufMussVeraenderbarSein(lauf)` sitzt an genau einer Stelle und wird vor jeder
|
||||||
|
Änderung an Kopfdaten, `Schrittergebnis` und `Kommentar` gerufen. Zurückholen nur `ADMIN`.
|
||||||
|
- **Abschluss:** nur wenn alle Pflichtschritte `ERLEDIGT` oder `NICHT_ZUTREFFEND` sind.
|
||||||
|
`NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz.
|
||||||
|
- **Mandant:** Jede kundenbezogene Zeile trägt `kundeId`. `kundeErmitteln()` liefert im Modus
|
||||||
|
`INTERN` immer die eigene Organisation und **verwirft** eine mitgeschickte `kundeId`.
|
||||||
|
- **Rechte:** nur serverseitig, aus der `RECHTE`-Tabelle über `darf()` / `rechtErforderlich()`.
|
||||||
|
Rolle und Aktiv-Status werden pro Anfrage **aus der Datenbank** gelesen, nicht aus dem Token.
|
||||||
|
- **`tagCode`:** wird nie geändert und nie wiederverwendet, auch nicht nach Archivierung.
|
||||||
|
- **Löschen:** Geräte, Abteilungen und Vorlagen werden archiviert. Mitarbeiter werden nur
|
||||||
|
`aktiv = false`. Nichts wird gelöscht, woran ein Lauf hängt.
|
||||||
|
- **`{{generiertes_passwort}}`:** wird je Anzeige neu erzeugt und **nie** gespeichert — nicht in
|
||||||
|
`Schrittergebnis`, `Kommentar`, `Protokoll` oder der Druckansicht.
|
||||||
|
- **Erweiterbarkeit:** Kein Codepfad nimmt an, dass ein Schritt nur von einem Menschen erledigt
|
||||||
|
wird. `SchrittTyp` bekommt später `AKTION`, `konfig` und `wert` sind Json.
|
||||||
|
|
||||||
|
## Werkzeuge und Versionen
|
||||||
|
|
||||||
|
Node 24 (`.nvmrc`), pnpm 12 über corepack. Exakt gepinnt, weil dort etwas im Fluss ist:
|
||||||
|
`next@16.3.6`, `react@19.3.0`, `prisma`/`@prisma/client`/`@prisma/adapter-pg` auf **`7.10.0`**
|
||||||
|
(der `latest`-Tag von `prisma` zeigt auf einen 8.0-RC, `@prisma/client` nicht — das würde
|
||||||
|
auseinanderlaufen), `vitest@5.0.1`, `@playwright/test@1.63.0`.
|
||||||
|
|
||||||
|
Zwei Obergrenzen, die nicht angehoben werden dürfen:
|
||||||
|
|
||||||
|
- **`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. Eine
|
||||||
|
neuere Fassung des Plugins gibt es nicht.
|
||||||
|
- **`typescript` bleibt auf `~6.0.3`.** `typescript-eslint@8` erlaubt nur `>=4.8.4 <6.1.0`.
|
||||||
|
TypeScript 7 ist noch nicht unterstützt.
|
||||||
|
|
||||||
|
Build-Skripte von Abhängigkeiten sind in `pnpm-workspace.yaml` unter `allowBuilds` freigegeben
|
||||||
|
(pnpm 12 liest das `pnpm`-Feld in `package.json` nicht mehr).
|
||||||
|
|
||||||
|
## Next 16
|
||||||
|
|
||||||
|
`middleware.ts` heißt **`proxy.ts`** und exportiert `proxy()`, Node-Laufzeit. `cookies()`,
|
||||||
|
`headers()`, `params` und `searchParams` sind **async**. Route Handler:
|
||||||
|
`(anfrage, { params }: { params: Promise<{ id: string }> })`. Turbopack ist Standard.
|
||||||
|
`next lint` ist entfernt, `pnpm lint` ruft `eslint` direkt.
|
||||||
|
|
||||||
|
## Prisma 7
|
||||||
|
|
||||||
|
Generator `prisma-client` mit Pflicht-`output` nach `src/generated/prisma`, `datasource` ohne
|
||||||
|
`url`, Zugriff über `@prisma/adapter-pg`. Ein Adapter ist **Pflicht** — `new PrismaClient()` ohne
|
||||||
|
`adapter` wirft. Seed steht in `prisma.config.ts` unter `migrations.seed`. Der Client wird über
|
||||||
|
den Lazy-Proxy in `src/lib/db.ts` geholt, damit `next build` ohne `DATABASE_URL` läuft.
|
||||||
|
|
||||||
|
## Vor jedem Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm lint && pnpm typecheck && pnpm test && pnpm pruefe:doku
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Pre-Commit-Hook erzwingt Lint und Format, der Commit-Msg-Hook Conventional Commits mit
|
||||||
|
höchstens 72 Zeichen im Betreff. Ein Commit pro abgeschlossener, lauffähiger Änderung. Kein
|
||||||
|
`wip` auf `main`. Fußzeile referenziert das Issue: `Closes #12`.
|
||||||
|
|
||||||
|
## Gestaltung
|
||||||
|
|
||||||
|
Dunkel zuerst, ein einziger Akzent (Signal-Lime `#C6F135`) nur für Aktionen, Fortschritt, Fokus
|
||||||
|
und aktive Zustände. Token stehen als CSS-Variablen in `src/app/globals.css` unter `@theme`;
|
||||||
|
Komponenten greifen nur darauf zu, nie auf rohe Hexwerte. Schriften selbst gehostet über
|
||||||
|
`@fontsource-variable`. Icons ausschließlich Lucide-Stroke-SVGs — **keine Emojis**. Keine
|
||||||
|
Verläufe, keine Schatten; Karten mit 1-px-Linie. Buttons als Pillen, 40 px hoch, am Handy 44–46 px.
|
||||||
|
Abschnitte mit zweistelliger Nummer (`01 · Vorbereitung`). Druckansichten und Etiketten immer hell.
|
||||||
|
**Am Handy nie horizontales Scrollen** — bei 390 px geprüft.
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ---------- Basis ----------
|
||||||
|
FROM node:24-alpine AS basis
|
||||||
|
WORKDIR /app
|
||||||
|
# tini beendet Prozesse sauber, openssl wird fuer die Geheimnis-Erzeugung gebraucht.
|
||||||
|
RUN apk add --no-cache tini openssl
|
||||||
|
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||||
|
RUN corepack enable pnpm
|
||||||
|
|
||||||
|
# ---------- Abhaengigkeiten ----------
|
||||||
|
FROM basis AS abhaengigkeiten
|
||||||
|
# pnpm-workspace.yaml muss mit, sonst blockiert pnpm 12 die erlaubten Build-Skripte.
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
COPY prisma ./prisma
|
||||||
|
COPY prisma.config.ts ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# ---------- Bauen ----------
|
||||||
|
FROM abhaengigkeiten AS bauen
|
||||||
|
ARG APP_VERSION=0.0.0
|
||||||
|
ARG GIT_SHA=""
|
||||||
|
ENV NEXT_PUBLIC_APP_VERSION=$APP_VERSION
|
||||||
|
ENV NEXT_PUBLIC_GIT_SHA=$GIT_SHA
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm exec prisma generate && pnpm build
|
||||||
|
|
||||||
|
# ---------- Laufzeit ----------
|
||||||
|
FROM basis AS laufzeit
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
RUN addgroup -g 1001 -S laufzettel && adduser -S laufzettel -u 1001 -G laufzettel
|
||||||
|
COPY --from=bauen --chown=laufzettel:laufzettel /app/.next/standalone ./
|
||||||
|
COPY --from=bauen --chown=laufzettel:laufzettel /app/.next/static ./.next/static
|
||||||
|
COPY --from=bauen --chown=laufzettel:laufzettel /app/public ./public
|
||||||
|
USER laufzettel
|
||||||
|
EXPOSE 3000
|
||||||
|
ENTRYPOINT ["/sbin/tini", "--"]
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# 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:5433
|
||||||
|
pnpm db:migrate
|
||||||
|
pnpm db:seed
|
||||||
|
pnpm dev # http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
`APP_SECRET` erzeugen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -base64 48
|
||||||
|
```
|
||||||
|
|
||||||
|
### Qualitätsschranken
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm lint && pnpm typecheck && pnpm test && pnpm pruefe:doku
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein Pre-Commit-Hook erzwingt Lint und Format, ein Commit-Msg-Hook Conventional Commits.
|
||||||
|
Ende-zu-Ende-Tests: `pnpm test:e2e`.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 `5433` öffnet. 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 muss nur die Datenbank — die App hält keinen veränderlichen Zustand auf der
|
||||||
|
Platte. Das Volume heißt `laufzettel_datenbank`.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Umgebungsvariablen
|
||||||
|
|
||||||
|
Alle Variablen mit Erklärung stehen in [`.env.example`](.env.example). Pflicht sind `APP_URL`,
|
||||||
|
`APP_SECRET`, `POSTGRES_PASSWORD` und `DATABASE_URL`; der Compose-Stand bricht ab, statt mit
|
||||||
|
Standardwerten zu starten.
|
||||||
|
|
||||||
|
## Dokumentation
|
||||||
|
|
||||||
|
- Anleitung für Techniker und Admins — entsteht mit #018, inkl. Bildern und PDF
|
||||||
|
- [Architektur und Entscheidungen](docs/architektur.md)
|
||||||
|
- [Anbindung an authentik](docs/betrieb/authentik.md) — Inbetriebnahme, Proxmox und Reverse Proxy folgen mit #019
|
||||||
|
- [Konventionen](CLAUDE.md) — Sprache, Schichtung, feste Regeln
|
||||||
|
- [Änderungen](CHANGELOG.md) · [Entwicklungstagebuch](docs/devlog.md)
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
Noch nicht festgelegt.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// Conventional Commits. Betreff auf Deutsch, hoechstens 72 Zeichen (siehe CLAUDE.md).
|
||||||
|
const konfiguration = {
|
||||||
|
extends: ["@commitlint/config-conventional"],
|
||||||
|
rules: {
|
||||||
|
"header-max-length": [2, "always", 72],
|
||||||
|
"subject-case": [0],
|
||||||
|
"body-max-line-length": [0],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default konfiguration;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "radix-nova",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks",
|
||||||
|
"cn": "@/lib/utils"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Nur fuer die Entwicklung: Postgres auf Port 5433, damit ein lokales 5432 frei bleibt.
|
||||||
|
# Diese Datei niemals im Betrieb verwenden – dort ist die Datenbank absichtlich zu.
|
||||||
|
|
||||||
|
name: laufzettel-dev
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: laufzettel
|
||||||
|
POSTGRES_PASSWORD: laufzettel
|
||||||
|
POSTGRES_DB: laufzettel
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5433:5432"
|
||||||
|
volumes:
|
||||||
|
- datenbank-dev:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U laufzettel"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
datenbank-dev:
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Dienste fuer die Testsuite. Nicht fuer den Betrieb.
|
||||||
|
# Die LDAP-Quelle fuer den Verzeichnis-Abgleich kommt in #016 dazu, dort wird sie
|
||||||
|
# auch tatsaechlich benutzt und geprueft.
|
||||||
|
|
||||||
|
name: laufzettel-test
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: laufzettel
|
||||||
|
POSTGRES_PASSWORD: laufzettel
|
||||||
|
POSTGRES_DB: laufzettel_test
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5433:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U laufzettel"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Laufzettel – Betriebsstand.
|
||||||
|
#
|
||||||
|
# Die Datenbank ist bewusst von aussen NICHT erreichbar: kein ports:-Mapping und
|
||||||
|
# ein Netz ohne Gateway (internal: true). Nur web und migrate kommen ueber den
|
||||||
|
# Dienstnamen daran. Fuer die Entwicklung gibt es docker-compose.dev.yml.
|
||||||
|
#
|
||||||
|
# Sicherung trotz geschlossenem Port:
|
||||||
|
# docker compose exec -T postgres pg_dump -U laufzettel laufzettel > sicherung.sql
|
||||||
|
|
||||||
|
name: laufzettel
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-laufzettel}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt in der .env}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-laufzettel}
|
||||||
|
# Absichtlich expose statt ports: im LAN und auf dem Host nicht erreichbar.
|
||||||
|
expose:
|
||||||
|
- "5432"
|
||||||
|
networks:
|
||||||
|
- intern
|
||||||
|
volumes:
|
||||||
|
- datenbank:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-laufzettel}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
# Laeuft einmal beim Start und spielt nur die Migrationen ein.
|
||||||
|
# Der Seed wird bewusst nicht automatisch ausgefuehrt, siehe README.
|
||||||
|
migrate:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: bauen
|
||||||
|
restart: "no"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-laufzettel}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-laufzettel}?schema=public}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- intern
|
||||||
|
command: ["pnpm", "exec", "prisma", "migrate", "deploy"]
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: laufzeit
|
||||||
|
args:
|
||||||
|
APP_VERSION: ${APP_VERSION:-0.1.0}
|
||||||
|
GIT_SHA: ${GIT_SHA:-}
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-laufzettel}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-laufzettel}?schema=public}
|
||||||
|
APP_SECRET: ${APP_SECRET:?APP_SECRET fehlt in der .env}
|
||||||
|
APP_URL: ${APP_URL:?APP_URL fehlt in der .env}
|
||||||
|
AUTH_LOCAL_ENABLED: ${AUTH_LOCAL_ENABLED:-false}
|
||||||
|
OIDC_ISSUER: ${OIDC_ISSUER:-}
|
||||||
|
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
|
||||||
|
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||||
|
OIDC_NAME: ${OIDC_NAME:-authentik}
|
||||||
|
OIDC_GROUP_ADMIN: ${OIDC_GROUP_ADMIN:-}
|
||||||
|
OIDC_GROUP_EDITOR: ${OIDC_GROUP_EDITOR:-}
|
||||||
|
OIDC_GROUP_TECHNICIAN: ${OIDC_GROUP_TECHNICIAN:-}
|
||||||
|
OIDC_GROUP_VIEWER: ${OIDC_GROUP_VIEWER:-}
|
||||||
|
OIDC_CUSTOMER_GROUP_PREFIX: ${OIDC_CUSTOMER_GROUP_PREFIX:-}
|
||||||
|
# Standardmaessig nur auf localhost; davor gehoert ein Reverse Proxy.
|
||||||
|
ports:
|
||||||
|
- "${BIND_ADDR:-127.0.0.1}:${APP_PORT:-3000}:3000"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
networks:
|
||||||
|
- intern
|
||||||
|
- extern
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/gesundheit"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
# Kein Gateway: die Datenbank kann weder heraus noch ist sie von aussen erreichbar.
|
||||||
|
intern:
|
||||||
|
internal: true
|
||||||
|
# Nur fuer web: Reverse Proxy, OIDC zu authentik, LDAP zum AD.
|
||||||
|
extern:
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
datenbank:
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Abnahmekriterien für den MVP (v0.1.0)
|
||||||
|
|
||||||
|
Diese Liste wird von [#019](issues/019-betrieb-release.md) abschließend durchgegangen. Jeder
|
||||||
|
Punkt ist entweder abgehakt oder hat ein Folge-Issue — nichts bleibt stillschweigend offen.
|
||||||
|
|
||||||
|
## Betrieb
|
||||||
|
|
||||||
|
- [ ] `docker compose up` startet App und Datenbank, Migrationen laufen automatisch
|
||||||
|
- [ ] Seed läuft per dokumentiertem Befehl, nicht automatisch
|
||||||
|
- [ ] Die Datenbank ist vom Host aus **nicht** erreichbar (`nc -z localhost 5432` schlägt fehl), aus `web` schon
|
||||||
|
- [ ] Der Proxmox-One-Liner rollt einen LXC aus, in dem die App läuft und PostgreSQL nur am Unix-Socket hört
|
||||||
|
- [ ] Erneuter Aufruf des Skripts aktualisiert, ohne Daten oder Geheimnisse zu verlieren
|
||||||
|
- [ ] `shellcheck --severity=warning` über alle Skripte ohne Befund
|
||||||
|
- [ ] Läuft hinter einem Reverse Proxy ohne fest verdrahtete Adressen
|
||||||
|
|
||||||
|
## Anmeldung und Rechte
|
||||||
|
|
||||||
|
- [ ] Login per lokalem Konto funktioniert
|
||||||
|
- [ ] OIDC ist konfigurierbar und für authentik dokumentiert, inkl. Gruppen für Rollen
|
||||||
|
- [ ] Bei `AUTH_LOCAL_ENABLED=false` ist der lokale Login weg und der Endpunkt lehnt ab
|
||||||
|
- [ ] Rollenrechte: jede Rolle gegen jede Aktion getestet, Prüfung ausschließlich serverseitig
|
||||||
|
- [ ] Eine Rollenänderung greift ohne neue Anmeldung; ein deaktivierter Benutzer wird abgewiesen
|
||||||
|
- [ ] Passwörter stehen nur als argon2-Hash in der Datenbank, nie in einer API-Antwort
|
||||||
|
|
||||||
|
## Betriebsmodus
|
||||||
|
|
||||||
|
- [ ] Hauptablauf läuft in `INTERN` ohne jede Kundenanzeige
|
||||||
|
- [ ] Hauptablauf läuft in `DIENSTLEISTUNG` mit Kundenwahl
|
||||||
|
- [ ] Wechsel `INTERN → DIENSTLEISTUNG` übernimmt alle Daten auf den ersten Kunden
|
||||||
|
- [ ] Wechsel zurück wird bei zwei nicht archivierten Kunden abgelehnt
|
||||||
|
- [ ] Im Modus `INTERN` ignoriert die Service-Schicht eine mitgeschickte `kundeId`
|
||||||
|
- [ ] Der Modus ist ohne Neustart umschaltbar, jeder Wechsel steht im `Protokoll`
|
||||||
|
|
||||||
|
## Vorlagen
|
||||||
|
|
||||||
|
- [ ] Veröffentlichte Version ist unveränderlich; „Bearbeiten" erzeugt eine tiefe Kopie als Entwurf
|
||||||
|
- [ ] Höchstens ein Entwurf je Vorlage
|
||||||
|
- [ ] Veröffentlichen einer neuen Version verändert bestehende Läufe nicht
|
||||||
|
- [ ] Export und anschließender Import ergeben eine inhaltsgleiche Vorlage
|
||||||
|
- [ ] Unbekannte Platzhalter werden im Editor als Warnung angezeigt
|
||||||
|
|
||||||
|
## Läufe und Archiv
|
||||||
|
|
||||||
|
- [ ] Lauf ohne Mitarbeiter und ohne Gerät lässt sich starten, abschließen und archivieren
|
||||||
|
- [ ] Abschließen scheitert, solange ein Pflichtschritt offen ist
|
||||||
|
- [ ] `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz
|
||||||
|
- [ ] „Abschnitt als nicht zutreffend markieren" setzt alle Schritte mit einer Notiz
|
||||||
|
- [ ] Archiv-Schreibschutz greift für Kopfdaten, Schrittergebnisse und Kommentare
|
||||||
|
- [ ] Nur `ADMIN` holt einen archivierten Lauf zurück, mit `Protokoll`-Eintrag
|
||||||
|
- [ ] Im Archiv wird über Titel, Notizen, Werte und Kommentare gefunden
|
||||||
|
- [ ] Druckansicht eines Laufs ist sauber und hell
|
||||||
|
|
||||||
|
## Mitarbeiter, Geräte, QR
|
||||||
|
|
||||||
|
- [ ] Mitarbeitersuche liefert standardmäßig nur aktive, Umschalter zeigt auch deaktivierte
|
||||||
|
- [ ] Ein von der Geräteseite gestarteter Lauf hat den Besitzer vorbelegt
|
||||||
|
- [ ] `/t/LZ-7K3F9Q` ohne Anmeldung führt nach dem Login auf die Geräteseite
|
||||||
|
- [ ] „Vorlage zuweisen" erzeugt einen `OFFEN`-Lauf, der unter „Zugewiesen" erscheint; „Starten" setzt `IN_ARBEIT`
|
||||||
|
- [ ] Unregistrierter Tag → Registrierformular → Geräteseite; unbekannter Tag → Hinweis
|
||||||
|
- [ ] `tagCode`: richtiges Alphabet, eindeutig, unveränderlich, nie wiederverwendet
|
||||||
|
- [ ] Etikettendruck in beiden Layouts sauber; QR maschinell zurücklesbar
|
||||||
|
|
||||||
|
## Platzhalter und Befehle
|
||||||
|
|
||||||
|
- [ ] Onboarding: Vorname und Nachname eingeben → UPN, `sam` und Mail werden vorgeschlagen
|
||||||
|
- [ ] Abteilung „Buchhaltung" wählen → `-Path "OU=Buchhaltung,…"` und Gruppen `G-Buchhaltung, G-Alle`
|
||||||
|
- [ ] Kopieren liefert exakt diesen Text, zeichengenau
|
||||||
|
- [ ] „Jörg Müller-Lüdenscheid" ergibt gültige Vorschläge (`joerg.mueller-luedenscheid@…`, `jmuellerluedensch`)
|
||||||
|
- [ ] `{{generiertes_passwort}}` erscheint im Befehl, steht aber nicht in Datenbank, Druckansicht oder `Protokoll`
|
||||||
|
- [ ] Werte mit Anführungszeichen werden für die jeweilige Shell korrekt maskiert
|
||||||
|
- [ ] „Alle Befehle als Skript kopieren" liefert die Schritte in richtiger Reihenfolge
|
||||||
|
- [ ] Unbekannte Platzhalter bleiben stehen; ersetzte Werte sind HTML-escaped
|
||||||
|
|
||||||
|
## Verzeichnis (LDAP)
|
||||||
|
|
||||||
|
- [ ] Erster Abgleich legt Mitarbeiter an, zweiter aktualisiert sie ohne Duplikate
|
||||||
|
- [ ] Entfernte oder deaktivierte Konten werden `aktiv = false`, nie gelöscht
|
||||||
|
- [ ] `{{ou}}` wird korrekt aus dem `dn` abgeleitet
|
||||||
|
- [ ] Das Bind-Passwort erscheint in keiner API-Antwort und keinem Log
|
||||||
|
- [ ] Zwei gleichzeitige Abgleiche derselben Quelle: der zweite wird von der Sperre abgewiesen
|
||||||
|
|
||||||
|
## Oberfläche
|
||||||
|
|
||||||
|
- [ ] Am Handy kein horizontales Scrollen — bei 390 px auf allen Hauptseiten geprüft
|
||||||
|
- [ ] Gestaltung nach Vorgabe: dunkel, ein Akzent, keine Emojis, keine Schatten, keine Verläufe
|
||||||
|
- [ ] Version mit Git-SHA unten links
|
||||||
|
|
||||||
|
## Projektorganisation
|
||||||
|
|
||||||
|
- [ ] Issues für alle Phasen und MVP-Meilensteine existieren
|
||||||
|
- [ ] `CHANGELOG.md` und `docs/devlog.md` sind gepflegt
|
||||||
|
- [ ] `v0.1.0` ist getaggt, Gitea-Release mit Changelog-Auszug
|
||||||
|
- [ ] `docs/anleitung/` vollständig mit Screenshots und als PDF
|
||||||
|
- [ ] `docs/screenshots/` enthält die Meilenstein-Bilder
|
||||||
|
- [ ] `pnpm lint && pnpm typecheck && pnpm test && pnpm pruefe:doku` grün
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Anmeldung über authentik (OIDC)
|
||||||
|
|
||||||
|
Laufzettel spricht generisches OpenID Connect mit Discovery. Der Anbieter muss also nur einen
|
||||||
|
`/.well-known/openid-configuration`-Endpunkt anbieten — authentik tut das. Ein anderer Anbieter
|
||||||
|
(Keycloak, Entra ID, Zitadel) funktioniert genauso, nur heißen die Menüs anders.
|
||||||
|
|
||||||
|
## 1. Provider in authentik anlegen
|
||||||
|
|
||||||
|
**Anwendungen → Provider → Erstellen → OAuth2/OpenID Provider**
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Name | `Laufzettel` |
|
||||||
|
| Authorization flow | `default-provider-authorization-explicit-consent` (oder implicit) |
|
||||||
|
| Client type | `Confidential` |
|
||||||
|
| Redirect URIs | `https://laufzettel.example.de/api/auth/oidc/rueckruf` |
|
||||||
|
| Signing Key | ein vorhandener Schlüssel |
|
||||||
|
| Scopes | `openid`, `profile`, `email` und ein Scope, der `groups` liefert |
|
||||||
|
|
||||||
|
Die Redirect-URI ist `APP_URL` plus `/api/auth/oidc/rueckruf` — exakt so, mit demselben Schema
|
||||||
|
und ohne abschließenden Schrägstrich. Läuft Laufzettel hinter einem Reverse Proxy, ist `APP_URL`
|
||||||
|
die **öffentliche** Adresse, nicht die interne.
|
||||||
|
|
||||||
|
`Client ID` und `Client Secret` notieren.
|
||||||
|
|
||||||
|
## 2. Gruppen in den Token bringen
|
||||||
|
|
||||||
|
Laufzettel liest die Rolle aus dem Claim `groups`. In authentik gibt es dafür den mitgelieferten
|
||||||
|
Scope-Mapping `authentik default OAuth Mapping: OpenID 'profile'` nicht — du brauchst ein eigenes
|
||||||
|
Mapping:
|
||||||
|
|
||||||
|
**Anpassung → Property Mappings → Erstellen → Scope Mapping**
|
||||||
|
|
||||||
|
- Name: `Laufzettel Gruppen`
|
||||||
|
- Scope name: `groups`
|
||||||
|
- Expression:
|
||||||
|
|
||||||
|
```python
|
||||||
|
return {
|
||||||
|
"groups": [gruppe.name for gruppe in request.user.ak_groups.all()],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dieses Mapping beim Provider unter **Advanced protocol settings → Scopes** auswählen.
|
||||||
|
|
||||||
|
## 3. Anwendung anlegen
|
||||||
|
|
||||||
|
**Anwendungen → Anwendungen → Erstellen**, den Provider zuordnen, Slug z. B. `laufzettel`.
|
||||||
|
Über die Anwendung steuerst du per Policy, wer sich überhaupt anmelden darf.
|
||||||
|
|
||||||
|
## 4. Gruppen für die Rollen
|
||||||
|
|
||||||
|
Lege in authentik vier Gruppen an und trage die Mitglieder ein:
|
||||||
|
|
||||||
|
| Gruppe | Rolle in Laufzettel | Darf |
|
||||||
|
|---|---|---|
|
||||||
|
| `lz-admin` | `ADMIN` | alles, inkl. Benutzer, Verzeichnis, Abteilungen, Archiv-Verwaltung |
|
||||||
|
| `lz-redakteur` | `REDAKTEUR` | Vorlagen anlegen, bearbeiten, veröffentlichen |
|
||||||
|
| `lz-techniker` | `TECHNIKER` | Läufe starten, abarbeiten, abschließen, archivieren; Geräte pflegen |
|
||||||
|
| `lz-leser` | `LESER` | nur lesen |
|
||||||
|
|
||||||
|
Ist jemand in mehreren Gruppen, gewinnt die stärkere Rolle.
|
||||||
|
|
||||||
|
## 5. Laufzettel konfigurieren
|
||||||
|
|
||||||
|
In der `.env`:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
APP_URL=https://laufzettel.example.de
|
||||||
|
|
||||||
|
OIDC_ISSUER=https://authentik.example.de/application/o/laufzettel/
|
||||||
|
OIDC_CLIENT_ID=...
|
||||||
|
OIDC_CLIENT_SECRET=...
|
||||||
|
OIDC_NAME=authentik
|
||||||
|
|
||||||
|
OIDC_GROUP_ADMIN=lz-admin
|
||||||
|
OIDC_GROUP_EDITOR=lz-redakteur
|
||||||
|
OIDC_GROUP_TECHNICIAN=lz-techniker
|
||||||
|
OIDC_GROUP_VIEWER=lz-leser
|
||||||
|
```
|
||||||
|
|
||||||
|
Den `OIDC_ISSUER` zeigt authentik beim Provider unter **OpenID Configuration Issuer** an. Der
|
||||||
|
abschließende Schrägstrich gehört dazu.
|
||||||
|
|
||||||
|
Danach neu starten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Was beim Login passiert
|
||||||
|
|
||||||
|
- Ein unbekannter Benutzer wird beim ersten Login **angelegt** (JIT-Provisioning)
|
||||||
|
- Die Rolle wird bei **jedem** Login aus `groups` neu gesetzt — eine Änderung in authentik greift
|
||||||
|
ab der nächsten Anmeldung
|
||||||
|
- Ist der Benutzer in keiner der vier Gruppen, bekommt er `LESER`
|
||||||
|
- Wer vor dem Login eine bestimmte Seite aufgerufen hat (z. B. `/t/LZ-7K3F9Q` nach dem Scannen
|
||||||
|
eines QR-Codes), landet danach genau dort
|
||||||
|
|
||||||
|
## 7. Notfallzugang
|
||||||
|
|
||||||
|
Fällt authentik aus, kommt niemand mehr in Laufzettel. Für diesen Fall gibt es den lokalen Login:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
AUTH_LOCAL_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Er ist standardmäßig **aus**. Ein lokales Admin-Konto legst du über den Seed an
|
||||||
|
(`SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD`) oder als Admin unter **Admin → Benutzer**. Die
|
||||||
|
Passwörter liegen als argon2-Hash in der Datenbank.
|
||||||
|
|
||||||
|
Empfehlung: ein einziges lokales Admin-Konto, Passwort im Passwortmanager, `AUTH_LOCAL_ENABLED`
|
||||||
|
im Normalbetrieb auf `false` und nur im Notfall umgestellt.
|
||||||
|
|
||||||
|
## 8. Fehlersuche
|
||||||
|
|
||||||
|
| Symptom | Ursache |
|
||||||
|
|---|---|
|
||||||
|
| `redirect_uri_mismatch` | Die URI in authentik stimmt nicht zeichengenau mit `APP_URL` + `/api/auth/oidc/rueckruf` überein — oft `http` gegen `https` oder ein abschließender Schrägstrich |
|
||||||
|
| Nach dem Login wieder auf der Anmeldeseite | Das Cookie ist `Secure`, die Verbindung aber `http`. Entweder HTTPS nutzen oder zum Testen über `localhost` gehen |
|
||||||
|
| Jeder bekommt `LESER` | Der Claim `groups` fehlt im Token. Scope-Mapping aus Schritt 2 angelegt und beim Provider ausgewählt? |
|
||||||
|
| Discovery schlägt fehl | `OIDC_ISSUER` falsch oder von der App aus nicht erreichbar. Im Compose-Stand hängt `web` im Netz `extern` — prüfen, ob der Name auflösbar ist |
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
# DEVLOG – Laufzettel
|
||||||
|
|
||||||
|
Entwicklungstagebuch mit Zeiterfassung. Eine Sitzung ist ein Abschnitt; Sub-Agenten-Läufe stehen
|
||||||
|
als Unterpunkte mit Aufgabe und Dauer.
|
||||||
|
|
||||||
|
## Zeitübersicht
|
||||||
|
|
||||||
|
| Issue | Titel | Tatsächlich |
|
||||||
|
|---|---|---|
|
||||||
|
| #005 | M0 – Gerüst, Prozess, Design-Token | 1 h 25 min |
|
||||||
|
| | **Summe** | **1 h 25 min** |
|
||||||
|
|
||||||
|
| Meilenstein | Tatsächlich | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| v0.1.0 | 1 h 25 min | in Arbeit |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-24 · Sitzung 1 · 22:05–23:30
|
||||||
|
|
||||||
|
**Issue:** #005 (M0)
|
||||||
|
**Version:** 0.1.0
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
|
||||||
|
- Umgebung geprüft: Node v24.21.0 nur über nvm (nicht im `PATH` nicht-interaktiver Shells),
|
||||||
|
Docker 29.1.3, Chromium-Systembibliotheken vorhanden, kein `pandoc`
|
||||||
|
- Gitea aufgesetzt: 11 Labels, 5 Meilensteine (`v0.1.0`–`v0.5.0`), 19 Issues (#1–#19), Nummern
|
||||||
|
passen zu den Dateinamen unter `docs/issues/`
|
||||||
|
- Projektgerüst angelegt und alle vier Schranken grün: `lint`, `typecheck`, `test`, `build`
|
||||||
|
- Betriebsstand gehärtet und der Nachweis in `tests/betriebsstand.test.ts` festgeschrieben
|
||||||
|
- Abnahme von #005 wirklich gefahren, nicht behauptet: `docker compose up -d --build` startet den
|
||||||
|
Stack, `migrate` läuft und beendet sich, `web` wartet korrekt darauf, `/api/gesundheit` meldet
|
||||||
|
`ok` — und `5432` ist vom Host nicht erreichbar, aus `web` schon
|
||||||
|
- `scripts/pruefe-doku.mjs` gegen vier eingebaute Fehler geprüft: es findet abweichende Zeiten,
|
||||||
|
fehlende Einträge, Verweise auf fehlende Dateien und fehlende Bilder
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
- **Deutsch im Code, auch für Bezeichner.** Der Auftragstext verlangte Englisch; Florian hat sich
|
||||||
|
für Einheitlichkeit mit ProxLedger entschieden. Verbindliche Namensabbildung in `CLAUDE.md`.
|
||||||
|
- **Kein Auth.js.** v5 steht seit Jahren auf Beta (`5.0.0-beta.32`), authjs.dev führt das Projekt
|
||||||
|
im Wartungsmodus („now part of Better Auth"), die Maintainer nennen „no immediate plans for v5",
|
||||||
|
und `@auth/prisma-adapter` deklariert Prisma nur bis 6 — wir fahren Prisma 7. Stattdessen
|
||||||
|
`openid-client` + `jose` wie in ProxLedger. Dieselbe Überlegung wie dort, nur ein Jahr später
|
||||||
|
noch deutlicher.
|
||||||
|
- **`prisma` exakt auf 7.10.0 gepinnt.** Der `latest`-Tag von `prisma` zeigt auf `8.0.0-rc.17`,
|
||||||
|
der von `@prisma/client` auf `7.10.0`. Ein unbedachtes `pnpm add prisma` hätte einen
|
||||||
|
Versions-Konflikt erzeugt.
|
||||||
|
- **`eslint` bleibt auf 9.x.** Mit eslint 10 bricht das Regelwerk beim Laden ab:
|
||||||
|
`eslint-config-next` bringt `eslint-plugin-react@7.37.5` mit, das `eslint` nur bis `^9.7`
|
||||||
|
unterstützt, und eine neuere Fassung des Plugins gibt es nicht (letzte Veröffentlichung
|
||||||
|
April 2025). Empirisch festgestellt, nicht geraten.
|
||||||
|
- **`typescript` bleibt auf `~6.0.3`.** `typescript-eslint@8.70.1` erlaubt nur
|
||||||
|
`>=4.8.4 <6.1.0`; TypeScript 7.0.2 ist zwar `latest`, aber noch nicht unterstützt.
|
||||||
|
- **`@node-rs/argon2` statt `argon2`.** Liefert vorgebaute musl- und gnu-Binaries, braucht auf
|
||||||
|
`node:24-alpine` also keine C++-Toolchain in der Bau-Stufe.
|
||||||
|
- **`barcode-detector` statt `jsQR`.** `jsqr` ist seit April 2021 unveröffentlicht. Der Polyfill
|
||||||
|
bietet dieselbe API wie das native `BarcodeDetector`, also ein Codepfad statt zwei.
|
||||||
|
- **Seed läuft nicht automatisch.** Die Migrationen schon, der Seed bleibt ein dokumentierter
|
||||||
|
Handgriff — so steht es in den Abnahmekriterien.
|
||||||
|
- **shadcn/ui eingerichtet, aber der Button folgt der Vorgabe, nicht dem Preset.** Der Vertrag
|
||||||
|
von shadcn (`--background`, `--primary`, `--border`, …) wird in `globals.css` aus den
|
||||||
|
Laufzettel-Token gespeist; jedes später kopierte Bauteil sieht damit sofort richtig aus und
|
||||||
|
folgt auch dem hellen Modus. Der Scannen-Knopf nutzt den shadcn-Button, überschreibt aber Form
|
||||||
|
und Höhe (Pille, 44 px am Handy statt 32 px).
|
||||||
|
- **PDF der Anleitung über Playwright.** `pandoc` und `wkhtmltopdf` fehlen und sind ohne root
|
||||||
|
nicht installierbar; Chromium ist für die E2E-Tests ohnehin da.
|
||||||
|
|
||||||
|
### Probleme
|
||||||
|
|
||||||
|
- **Projektverzeichnis gehörte `root`.** `~/Projekte/Laufzettel` war root-eigen, die Sitzung läuft
|
||||||
|
als `florianwilde`, und `sudo` verlangt hier ein Passwort. Bis Florian den `chown` gemacht hat,
|
||||||
|
wurde das Gerüst im Scratchpad vorgebaut und danach hineinverschoben — die Gitea-Arbeit brauchte
|
||||||
|
das Verzeichnis nicht und lief parallel.
|
||||||
|
- **pnpm 12 hat die Einstellung für Build-Skripte umbenannt.** `onlyBuiltDependencies` wird noch
|
||||||
|
von `pnpm config list` angezeigt, aber nicht mehr angewandt; wirksam ist `allowBuilds` als
|
||||||
|
Zuordnung in `pnpm-workspace.yaml`. Das `pnpm`-Feld in `package.json` liest pnpm 12 gar nicht
|
||||||
|
mehr. Gefunden über `pnpm approve-builds --all`, das die richtige Form selbst schreibt.
|
||||||
|
- **Context7-MCP ist nicht autorisiert**, der OAuth-Ablauf geht in dieser Sitzung nicht. Alle
|
||||||
|
Versionen wurden stattdessen direkt gegen das npm-Registry und die offiziellen Docs geprüft.
|
||||||
|
- **pnpm 12 prüft die Lieferkette und lehnt taufrische Pakete ab.** `vite@8.3.1` (8,7 h alt) und
|
||||||
|
`lucide-react@1.48.0` (15,3 h alt) fielen durch. Beide auf die Vorgängerfassung gesetzt, die
|
||||||
|
Richtlinie bleibt an. Bei der Gelegenheit alle direkten Abhängigkeiten auf ihr Alter geprüft.
|
||||||
|
- **`shadcn init` lässt sich nicht skripten.** Es fragt auch mit `--yes` nach einem Preset und
|
||||||
|
bleibt hinter einer Pipe stumm hängen. `shadcn add <bauteil> --yes --overwrite < /dev/null`
|
||||||
|
geht dagegen. Zusätzlich nötig: Pfad `"cn": ["./src/lib/utils"]` in `tsconfig.json` und das
|
||||||
|
Paket `radix-ui`, beides schreibt `init` nicht.
|
||||||
|
- **Port 3000 ist von ProxLedger belegt.** Die Abnahme lief auf 3100 und 3200.
|
||||||
|
- **Kein Gitea-Actions-Runner registriert** (0 Runner). `.gitea/workflows/ci.yml` wird trotzdem
|
||||||
|
angelegt und ist nutzbar, sobald ein Runner da ist; verbindlich sind bis dahin der
|
||||||
|
Pre-Commit-Hook und die Durchläufe vor jedem Commit.
|
||||||
|
|
||||||
|
### Offen
|
||||||
|
|
||||||
|
- Der `pct create`-Teil des Proxmox-Skripts (#019) lässt sich hier nicht ausführen, weil kein
|
||||||
|
PVE-Host erreichbar ist. Geplant: `shellcheck` plus `--dry-run`, und `deploy/install.sh`
|
||||||
|
vollständig in einem `debian:13`-Container geprüft. Der Lauf auf echtem Proxmox braucht eine
|
||||||
|
Gegenprüfung durch Florian, dafür kommt eine Prüfliste.
|
||||||
|
- Der OpenLDAP-Testcontainer wandert von #005 nach #016, wo er tatsächlich benutzt wird — ein
|
||||||
|
Dienst, den noch kein Test anfasst, wäre eine Attrappe.
|
||||||
|
|
||||||
|
### Nächster Schritt
|
||||||
|
|
||||||
|
- #006 Datenmodell: das vollständige Schema in deutscher Benennung, `tagCode`-Generator,
|
||||||
|
Krypto-Helfer, erste Migration, idempotenter Seed-Rumpf.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# #001 Phase 1: MVP
|
||||||
|
|
||||||
|
**Status:** in Arbeit
|
||||||
|
**Meilenstein:** v0.1.0
|
||||||
|
**Labels:** phase-1
|
||||||
|
**Geschätzt:** – **Tatsächlich:** –
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Eine selbst gehostete Checklisten-App für wiederkehrende IT-Arbeiten: versionierte Vorlagen, daraus gestartete Läufe, im Team abgearbeitet, abgeschlossen und unveränderlich archiviert. Ein Lauf hängt optional an einem Mitarbeiter und optional an einem Gerät, woraus im Archiv eine Historie je Gerät und je Mitarbeiter entsteht. Einstieg über QR-Aufkleber mit unveränderlichem `tagCode`. Zwei Betriebsmodi (`INTERN`, `DIENSTLEISTUNG`) in einem Codepfad, zwei Betriebswege (gehärteter Compose-Stack, nativer LXC per One-Liner).
|
||||||
|
|
||||||
|
## Untergeordnete Issues
|
||||||
|
|
||||||
|
- [ ] #005 M0 – Gerüst, Prozess, Design-Token
|
||||||
|
- [ ] #006 M1 – Datenmodell
|
||||||
|
- [ ] #007 M2 – Auth, Rollen, Benutzer
|
||||||
|
- [ ] #008 M3 – Betriebsmodus und Kunden
|
||||||
|
- [ ] #009 M4 – Vorlagen-Versionierung im Service
|
||||||
|
- [ ] #010 M5 – Vorlagen-Editor
|
||||||
|
- [ ] #011 M6 – Mitarbeiter, Abteilungen, Geräte
|
||||||
|
- [ ] #012 M7 – Platzhalter, Namensregeln, Escaping
|
||||||
|
- [ ] #013 M8 – Läufe: Service und Abarbeitung
|
||||||
|
- [ ] #014 M9 – QR-Tags, Geräteseite, Etiketten, Scanner
|
||||||
|
- [ ] #015 M10 – Dashboard und Archiv
|
||||||
|
- [ ] #016 M11 – Verzeichnis und LDAP-Abgleich
|
||||||
|
- [ ] #017 M12 – Seed der sieben Vorlagen
|
||||||
|
- [ ] #018 M13 – E2E-Tests, Anleitung, PDF
|
||||||
|
- [ ] #019 M14 – Betriebswege und Release v0.1.0
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
Die vollständige Liste steht in `docs/abnahme-mvp.md` und wird von #019 abschließend geprüft. Kern:
|
||||||
|
|
||||||
|
- [ ] `docker compose up` startet App und Datenbank, Migrationen laufen automatisch, Datenbank von außen nicht erreichbar
|
||||||
|
- [ ] Proxmox-One-Liner rollt einen lauffähigen LXC aus, erneuter Aufruf aktualisiert
|
||||||
|
- [ ] Lokaler Login funktioniert, OIDC für authentik dokumentiert
|
||||||
|
- [ ] Lauf ohne Mitarbeiter und ohne Gerät: starten, abschließen, archivieren
|
||||||
|
- [ ] Hauptablauf in beiden Betriebsmodi, Moduswechsel in beiden Richtungen nach Regel
|
||||||
|
- [ ] LDAP-Abgleich gegen Testcontainer: anlegen, aktualisieren, deaktivieren
|
||||||
|
- [ ] Veröffentlichte Vorlagenversion unveränderlich, Archiv schreibgeschützt
|
||||||
|
- [ ] `{{generiertes_passwort}}` nirgends persistiert
|
||||||
|
- [ ] Am Handy kein horizontales Scrollen
|
||||||
|
- [ ] `v0.1.0` getaggt, Anleitung mit Screenshots und als PDF
|
||||||
|
|
||||||
|
## Entscheidungen
|
||||||
|
|
||||||
|
- **Deutsch im Code.** Auch Prisma-Modelle, Felder und Enum-Werte sind deutsch, Umlaute ASCII-gefaltet. Abweichung vom Auftragstext, bewusst zugunsten der Einheitlichkeit mit ProxLedger. Verbindliche Namensabbildung in `CLAUDE.md`.
|
||||||
|
- **Kein Auth.js.** v5 ist dauerhaft Beta und im Wartungsmodus, der Prisma-Adapter deckt Prisma 7 nicht ab. Stattdessen `openid-client` + `jose` wie in ProxLedger.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# #002 Phase 2: Verzeichnis-Alternativen, Bausteine, Anhänge
|
||||||
|
|
||||||
|
**Status:** offen
|
||||||
|
**Meilenstein:** v0.2.0
|
||||||
|
**Labels:** phase-2
|
||||||
|
**Geschätzt:** – **Tatsächlich:** –
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Nicht im MVP umsetzen. Hier gesammelt, damit das Datenmodell nichts verbaut.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
|
||||||
|
- [ ] SCIM-Endpunkt als Alternative zum Pull-Abgleich
|
||||||
|
- [ ] Microsoft-Graph-Quelle für reine Entra-ID-Umgebungen ohne LDAP
|
||||||
|
- [ ] Wiederverwendbare Bausteine: Abschnitte, die mehrere Vorlagen teilen
|
||||||
|
- [ ] Abteilungsspezifische Zusatzabschnitte (Fachsoftware je Abteilung)
|
||||||
|
- [ ] Anhänge und Fotos an Schritten
|
||||||
|
- [ ] Live-Aktualisierung per SSE statt Polling
|
||||||
|
- [ ] Benachrichtigungen per Mail und Webhook
|
||||||
|
- [ ] Mitarbeiter-Selbstauskunft: ein Mitarbeiter sieht die Läufe zu seinen Geräten, nur lesend
|
||||||
|
|
||||||
|
## Vorleistung im MVP
|
||||||
|
|
||||||
|
- `Verzeichnisquelle` ist bereits mehrfach anlegbar und trägt einen Typ-fähigen Aufbau, damit Graph und SCIM daneben passen
|
||||||
|
- `VorlagenAbschnitt` hängt an der Version, nicht an der Vorlage — ein geteilter Baustein kann später als eigene Entität referenziert werden
|
||||||
|
- Polling ist in einem Route Handler gekapselt, der sich gegen SSE tauschen lässt
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# #003 Phase 3: Protokoll, bedingte Schritte, Freigabe
|
||||||
|
|
||||||
|
**Status:** offen
|
||||||
|
**Meilenstein:** v0.3.0
|
||||||
|
**Labels:** phase-3
|
||||||
|
**Geschätzt:** – **Tatsächlich:** –
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Nicht im MVP umsetzen. Hier gesammelt, damit das Datenmodell nichts verbaut.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
|
||||||
|
- [ ] PDF-Übergabeprotokoll mit Unterschrift
|
||||||
|
- [ ] Bedingte Schritte und Abschnitte
|
||||||
|
- [ ] Vier-Augen-Freigabe
|
||||||
|
- [ ] Weitere Schritt-Typen
|
||||||
|
|
||||||
|
## Vorleistung im MVP
|
||||||
|
|
||||||
|
- Weil bedingte Abschnitte fehlen, enthält die Onboarding-Vorlage beide Konto-Abschnitte (AD und Entra ID); der nicht zutreffende wird per Abschnitts-Aktion als Ganzes auf `NICHT_ZUTREFFEND` gesetzt. Diese Aktion ist Teil des MVP (#013)
|
||||||
|
- `SchrittTyp` ist ein Enum, `VorlagenSchritt.konfig` ein Json — neue Typen brauchen keine Migration der bestehenden
|
||||||
|
- Die Druckansicht (#015) ist bereits die Vorstufe des PDF-Protokolls
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# #004 Phase 4: Aktionen, Connectors, REST-API
|
||||||
|
|
||||||
|
**Status:** offen
|
||||||
|
**Meilenstein:** v0.4.0
|
||||||
|
**Labels:** phase-4
|
||||||
|
**Geschätzt:** – **Tatsächlich:** –
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Nicht im MVP umsetzen. Der Weg dorthin wird im MVP aber bewusst freigehalten: Die in den Vorlagen vorbereiteten PowerShell-Befehle sind die Vorstufe von Schritten, die die App selbst ausführt.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
|
||||||
|
- [ ] Schritt-Typ `AKTION` mit Connector und Parametern aus Variablen und Person
|
||||||
|
- [ ] Connectors: LDAP/AD schreibend über eigenes Dienstkonto, Microsoft Graph (Entra ID, Exchange Online, Intune), authentik-API, später Ticketsystem und RMM
|
||||||
|
- [ ] Onboarding als Aktion: Konto anlegen, Initialpasswort erzeugen und sicher übergeben, Gruppen laut Abteilung, Postfach und Lizenz
|
||||||
|
- [ ] Offboarding als Aktion: Konto sperren, Sitzungen widerrufen, Postfach in Shared Mailbox wandeln
|
||||||
|
- [ ] Vorschau „das wird passieren", ausdrückliche Bestätigung, Ergebnis-Log am Schritt, Rollback-Hinweis
|
||||||
|
- [ ] Fehler setzen den Schritt auf `FEHLGESCHLAGEN` mit Meldung
|
||||||
|
- [ ] Berechtigung je Connector und Rolle, Geheimnisse verschlüsselt, alle Aufrufe im `Protokoll`
|
||||||
|
- [ ] Wiederkehrende Läufe (v0.5.0)
|
||||||
|
- [ ] REST-API und Webhooks, Start aus Ticketsystem und Wartungsplaner (v0.5.0)
|
||||||
|
- [ ] Zeiterfassung je Lauf, automatische Prüfschritte per RMM-API, Auswertungen, Aufbewahrungsfristen, NFC-Tags (v0.5.0)
|
||||||
|
|
||||||
|
## Vorleistung im MVP
|
||||||
|
|
||||||
|
- `SchrittTyp` ist ein Enum, dem `AKTION` ohne Migration bestehender Daten hinzugefügt werden kann; `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 — `aktualisiertVonId` ist optional und ein Systemlauf ist darstellbar
|
||||||
|
- Das Krypto-Modul für das LDAP-Bind-Passwort (#006) ist schon der Platz für Connector-Geheimnisse
|
||||||
|
- NFC nutzt dieselbe URL `{APP_URL}/t/{tagCode}` wie der QR-Code
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# #005 M0 – Gerüst, Prozess, Design-Token
|
||||||
|
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.1.0
|
||||||
|
**Labels:** phase-1, infra, docs
|
||||||
|
**Geschätzt:** 6 h **Tatsächlich:** 1 h 25 min
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Ein lauffähiges, leeres Projekt mit allen Qualitätsschranken, dem gehärteten Betriebsstand und dem Design-Fundament. Danach ist jeder weitere Meilenstein reine Fachlichkeit.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
|
||||||
|
- [x] Lokalen Branch `master` auf `main` umbenennen (Gitea-Default ist `main`), Remote unverändert lassen
|
||||||
|
- [x] `.nvmrc` = `24`, `packageManager: "pnpm@10.34.5"` über corepack, `engines.node >= 24`
|
||||||
|
- [x] Next 16.3.6 (App Router, TypeScript strict) mit `output: "standalone"`, `src/`-Layout
|
||||||
|
- [x] `tsconfig.json`: `strict`, `noUncheckedIndexedAccess`, `verbatimModuleSyntax`, `forceConsistentCasingInFileNames`, Alias `@/*` → `./src/*`
|
||||||
|
- [x] Tailwind 4 (`@import "tailwindcss"` + `@theme`, `@tailwindcss/postcss`), shadcn/ui initialisieren, `lucide-react`
|
||||||
|
- [x] ESLint flat config nach ProxLedger-Muster (`eslint-config-next/core-web-vitals` + `/typescript` + `eslint-config-prettier/flat`, `globalIgnores` inkl. `src/generated/**`), Prettier (`printWidth: 110`, `singleQuote: false`, `trailingComma: "all"`)
|
||||||
|
- [x] husky + lint-staged + commitlint (`@commitlint/config-conventional`) als Pre-Commit- und Commit-Msg-Hook
|
||||||
|
- [x] Vitest 5 (`vitest.config.mts`, `environment: "node"`, `fileParallelism: false`) und `vite@^7` als Peer; Playwright 1.63 (`playwright.config.ts`)
|
||||||
|
- [x] `Dockerfile` multi-stage (Basis → Abhängigkeiten → Bauen → Laufzeit), `node:24-alpine`, `tini`, Nutzer 1001, non-root
|
||||||
|
- [x] `docker-compose.yml` gehärtet: `postgres` **ohne** `ports:`, Netze `intern` (`internal: true`) und `extern`, `web` auf `${BIND_ADDR:-127.0.0.1}:3000`, `migrate` als One-Shot aus der Bau-Stufe, Healthchecks, `${VAR:?… fehlt in der .env}`
|
||||||
|
- [x] `docker-compose.dev.yml` (nur Postgres, Port 5433) und `docker-compose.test.yml` (Postgres; der OpenLDAP-Dienst kommt mit #016, wo er tatsächlich benutzt wird)
|
||||||
|
- [x] `.env.example` mit allen Variablen und Kommentar je Variable
|
||||||
|
- [x] `GET /api/gesundheit` → `SELECT 1` → 200/503
|
||||||
|
- [x] Sicherheitskopfzeilen in `next.config.ts`, CSP-Nonce in `proxy.ts` (Next 16: nicht `middleware.ts`)
|
||||||
|
- [x] `scripts/pruefe-doku.mjs` und `pnpm pruefe:doku`: Version hat Changelog-Abschnitt, Issue-Dateien und Übersicht stimmen überein, verlinkte Bilder existieren
|
||||||
|
- [x] `scripts/gitea-issues.ts` und `pnpm issues:sync`: spiegelt `docs/issues/*.md` in den Gitea-Tracker
|
||||||
|
- [x] `.gitea/workflows/ci.yml` nach ProxLedger-Muster, erweitert um Playwright und `shellcheck`
|
||||||
|
- [x] Design-Token als CSS-Variablen (Grund `#0B0D10`, Flächen `#14171C`/`#1B1F26`, Linien `#262B33`, Text `#F3F5F7`/`#98A2B3`, Akzent `#C6F135`, Status `#3DDC97`/`#F5B84A`/`#F87171`), heller Modus optional
|
||||||
|
- [x] Schriften selbst gehostet: `@fontsource-variable/space-grotesk`, `-manrope`, `-jetbrains-mono`
|
||||||
|
- [x] Wortmarke `laufzettel` (Space Grotesk 700, Laufweite −0.04em, Akzentpunkt) und Bildmarke `[ ✓ ]` als SVG, Favicon, PWA-Icon, `manifest.ts`
|
||||||
|
- [x] App-Shell: dunkle Sidebar mit „Scannen"-Knopf direkt unter dem Logo, Pillen-Buttons (40 px, am Handy 44–46 px), Karten mit Radius 16–18 px und 1-px-Linie ohne Schatten
|
||||||
|
- [x] Versionsanzeige unten links aus `NEXT_PUBLIC_APP_VERSION` und `NEXT_PUBLIC_GIT_SHA`
|
||||||
|
- [x] `README.md`, `CHANGELOG.md` (Keep a Changelog, deutsch), `docs/devlog.md`, `CLAUDE.md` mit Konventionen und Namensabbildung, `docs/architektur.md`, `docs/abnahme-mvp.md`
|
||||||
|
- [x] Gitea: Labels, Milestones `v0.1.0`…`v0.5.0`, Epic-Issues #001–#004 und Meilenstein-Issues #005–#019
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
- [x] `docker compose up` startet App und Datenbank, Migrationen laufen automatisch
|
||||||
|
- [x] `nc -z localhost 5432` schlägt fehl, `docker compose exec web nc -z postgres 5432` gelingt
|
||||||
|
- [x] Ein Vitest-Test liest `docker-compose.yml` und schlägt an, wenn unter `postgres` ein `ports:` auftaucht
|
||||||
|
- [x] `pnpm lint && pnpm typecheck && pnpm test && pnpm pruefe:doku` grün
|
||||||
|
- [x] Ein Commit ohne Conventional-Commits-Betreff wird vom Hook abgelehnt
|
||||||
|
- [x] Die App zeigt Wortmarke, Bildmarke und Version unten links im vorgegebenen Farbschema
|
||||||
|
|
||||||
|
## Beim Bauen aufgefallen
|
||||||
|
|
||||||
|
- **pnpm 12 hat die Freigabe von Build-Skripten umbenannt.** `onlyBuiltDependencies` wird von
|
||||||
|
`pnpm config list` noch angezeigt, aber nicht mehr angewandt; wirksam ist `allowBuilds` als
|
||||||
|
Zuordnung in `pnpm-workspace.yaml`. Das `pnpm`-Feld in `package.json` liest pnpm 12 gar nicht
|
||||||
|
mehr und warnt darüber.
|
||||||
|
- **pnpm 12 prüft die Lieferkette.** Pakete, die jünger als 24 Stunden sind, werden abgelehnt.
|
||||||
|
`vite@8.3.1` und `lucide-react@1.48.0` erschienen am selben Tag und mussten auf `8.3.0` bzw.
|
||||||
|
`1.47.0` zurück. Die Richtlinie bleibt an — sie ist sinnvoll.
|
||||||
|
- **eslint 10 bricht mit `eslint-config-next`.** `eslint-plugin-react@7.37.5` unterstützt nur bis
|
||||||
|
`eslint ^9.7` und hat keinen Nachfolger. Festgestellt, nicht geraten:
|
||||||
|
`TypeError: contextOrFilename.getFilename is not a function`.
|
||||||
|
- **TypeScript 7 geht noch nicht.** `typescript-eslint@8` erlaubt `>=4.8.4 <6.1.0`.
|
||||||
|
- **`shadcn init` ist nicht skriptfähig** — es fragt nach einem Preset, auch mit `--yes`, und
|
||||||
|
bleibt hinter einer Pipe hängen. `shadcn add <bauteil> --yes --overwrite < /dev/null`
|
||||||
|
funktioniert dagegen. Die Registry importiert den Helfer als blanken Modulnamen `cn`, dafür
|
||||||
|
braucht `tsconfig.json` den Pfad `"cn": ["./src/lib/utils"]`, und die Bauteile brauchen das
|
||||||
|
Paket `radix-ui`.
|
||||||
|
- **Port 3000 war von ProxLedger belegt**, die Abnahme lief deshalb auf 3100 bzw. 3200.
|
||||||
|
- Die Migrationen laufen im Compose-Stand nachweislich automatisch; eingespielt wird ab #006
|
||||||
|
etwas, vorher meldet der Dienst korrekt „No pending migrations to apply".
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# #006 M1 – Datenmodell
|
||||||
|
|
||||||
|
**Status:** offen
|
||||||
|
**Meilenstein:** v0.1.0
|
||||||
|
**Labels:** phase-1, backend
|
||||||
|
**Geschätzt:** 5 h **Tatsächlich:** –
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Das vollständige Schema in deutscher Benennung, sodass alle folgenden Meilensteine nur noch Verhalten ergänzen und keine Strukturänderung mehr brauchen.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
|
||||||
|
- [ ] Prisma 7: Generator `prisma-client` mit `output = "../src/generated/prisma"`, `datasource` ohne `url`, `@prisma/adapter-pg`
|
||||||
|
- [ ] `prisma.config.ts` mit `migrations.seed = "tsx prisma/seed.ts"`
|
||||||
|
- [ ] `src/lib/db.ts`: Lazy-Singleton-Proxy, damit `next build` ohne `DATABASE_URL` läuft, Dev-Instanz an `globalThis`
|
||||||
|
- [ ] Modelle: `Benutzer`, `Kunde`, `Einstellung`, `Verzeichnisquelle`, `Mitarbeiter`, `Abteilung`, `Geraet`, `GeraetetypVorlage`, `Vorlage`, `VorlagenVersion`, `VorlagenVariable`, `VorlagenAbschnitt`, `VorlagenSchritt`, `Lauf`, `Schrittergebnis`, `Kommentar`, `Protokoll`, `AbgleichSperre`
|
||||||
|
- [ ] Enums: `Rolle`, `Betriebsmodus`, `LaufStatus`, `SchrittZustand`, `SchrittTyp`, `GeraeteTyp`, `VersionStatus`, `Herkunft`
|
||||||
|
- [ ] Unique-Constraints: `Geraet.tagCode`, `(laufId, vorlagenSchrittId)` auf `Schrittergebnis`, `(quelleId, externeId)` auf `Mitarbeiter`, `(kundeId, name)` auf `Abteilung`, `(kundeId)` partiell für `isteigeneOrganisation`
|
||||||
|
- [ ] `pg_trgm`-Erweiterung und GIN-Indizes für die Archivsuche über `Lauf.titel`, `Schrittergebnis.notiz`, `Kommentar.rumpf`
|
||||||
|
- [ ] Jede kundenbezogene Zeile trägt `kundeId` als Pflichtfeld: `Geraet`, `Mitarbeiter`, `Abteilung`, `Verzeichnisquelle`, `Lauf`
|
||||||
|
- [ ] `src/lib/tagcode.ts`: Alphabet `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`, Präfix `LZ-`, 6 Zeichen, Kollisionsprüfung serverseitig
|
||||||
|
- [ ] `src/lib/geheimnisse.ts`: AES-256-GCM aus `APP_SECRET` (scrypt-abgeleitet), für `bindPasswortVerschluesselt`
|
||||||
|
- [ ] Erste Migration, `prisma/seed.ts` als idempotenter Rumpf: Admin aus `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` mit `@node-rs/argon2`, eigene Organisation, `Einstellung` mit `Betriebsmodus.INTERN`
|
||||||
|
- [ ] `tests/setup-env.ts`, `tests/global-setup.ts`, `tests/helpers/db.ts` nach ProxLedger-Muster
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
- [ ] `prisma migrate deploy` läuft auf leerer Datenbank durch
|
||||||
|
- [ ] `pnpm db:seed` zweimal hintereinander ohne Fehler und ohne Datenänderung
|
||||||
|
- [ ] Tests: `tagCode` benutzt nur das erlaubte Alphabet, enthält nie `0`, `O`, `1`, `I` oder `L`, ist eindeutig über 10 000 Ziehungen
|
||||||
|
- [ ] Tests: verschlüsseln und entschlüsseln ist verlustfrei, zwei Verschlüsselungen desselben Klartexts unterscheiden sich (eigener IV), ein manipulierter Geheimtext wirft
|
||||||
|
- [ ] `next build` gelingt ohne gesetzte `DATABASE_URL`
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# #007 M2 – Auth, Rollen, Benutzer
|
||||||
|
|
||||||
|
**Status:** offen
|
||||||
|
**Meilenstein:** v0.1.0
|
||||||
|
**Labels:** phase-1, backend, frontend, security
|
||||||
|
**Geschätzt:** 8 h **Tatsächlich:** –
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Anmeldung per generischem OIDC (Ziel authentik) und als Notfallzugang lokal per E-Mail und Passwort, dazu die serverseitige Rechteprüfung, auf der alle weiteren Services aufsetzen.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
|
||||||
|
- [ ] `src/server/auth/oidc.ts`: `openid-client` 6.x mit Discovery, Umgebungsvariablen `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_NAME`; Rücksprung-URL aus `APP_URL` abgeleitet, damit es hinter dem Reverse Proxy stimmt
|
||||||
|
- [ ] `src/lib/sitzung.ts`: `jose`-signiertes Sitzungscookie (HS256, `APP_SECRET` mit mindestens 32 Zeichen erzwungen, Hinweis auf `openssl rand -base64 48`), Nutzlast `{benutzerId, email, name, herkunft}`, Laufzeit 12 h
|
||||||
|
- [ ] Lokaler Login mit `@node-rs/argon2`, nur aktiv bei `AUTH_LOCAL_ENABLED=true`
|
||||||
|
- [ ] Rolle bei jedem OIDC-Login aus dem `groups`-Claim über `OIDC_GROUP_ADMIN`, `OIDC_GROUP_EDITOR`, `OIDC_GROUP_TECHNICIAN`, `OIDC_GROUP_VIEWER` aktualisieren; JIT-Provisioning unbekannter Benutzer beim ersten Login
|
||||||
|
- [ ] `src/server/auth/rollen.ts`: `RECHTE` als eine Tabelle (`satisfies Record<string, readonly Rolle[]>`) plus `darf()` und `ROLLEN_TEXT`
|
||||||
|
- [ ] `src/server/auth/sitzungspflicht.ts`: `angemeldet()` (React-`cache`, ohne Weiterleitung) und `rechtErforderlich(recht)`; Rolle und Aktiv-Status pro Request **aus der Datenbank**, nicht aus dem Token; `sitzungenUngueltigAb` für Widerruf; reine, testbare `sitzungBewerten()`
|
||||||
|
- [ ] `src/proxy.ts`: Allowlist öffentlicher Pfade, Weiterleitung auf `/anmelden?ziel=…`
|
||||||
|
- [ ] Login-Seite im Design, OIDC-Knopf und lokales Formular, Rücksprung auf die ursprünglich angeforderte URL (nötig für `/t/[tagCode]`)
|
||||||
|
- [ ] Admin → Benutzer: Liste mit Rolle und Aktiv-Status, lokale Benutzer anlegen wenn lokaler Login aktiv
|
||||||
|
- [ ] Screenshots Login und Benutzerliste in `docs/screenshots/m2/`
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
- [ ] Anmeldung mit dem Seed-Admin gelingt, Abmelden beendet die Sitzung
|
||||||
|
- [ ] Bei `AUTH_LOCAL_ENABLED=false` ist das lokale Formular nicht vorhanden und der Endpunkt lehnt ab
|
||||||
|
- [ ] Tests fahren jede Rolle gegen jedes Recht: `LESER` darf nichts schreiben, `TECHNIKER` darf keine Verwaltung, `REDAKTEUR` darf Vorlagen, `ADMIN` alles
|
||||||
|
- [ ] Eine Rollenänderung in der Datenbank greift ohne neue Anmeldung
|
||||||
|
- [ ] Ein deaktivierter Benutzer wird mit bestehendem Cookie abgewiesen
|
||||||
|
- [ ] Aufruf von `/t/LZ-7K3F9Q` ohne Anmeldung führt nach dem Login genau dorthin zurück
|
||||||
|
- [ ] Passwörter stehen nur als argon2-Hash in der Datenbank, nie im Klartext und nie in einer API-Antwort
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# #008 M3 – Betriebsmodus und Kunden
|
||||||
|
|
||||||
|
**Status:** offen
|
||||||
|
**Meilenstein:** v0.1.0
|
||||||
|
**Labels:** phase-1, backend, frontend
|
||||||
|
**Geschätzt:** 6 h **Tatsächlich:** –
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Ein Codepfad für beide Betriebsmodi. Im Modus `INTERN` ist die Kunden-Ebene vollständig unsichtbar, ohne dass irgendwo Daten fehlen — dadurch ist der Wechsel später eine Datenänderung und keine Migration.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
|
||||||
|
- [ ] `betriebsmodus()` als zentraler, gecachter Zugriff auf `Einstellung.modus`; alle Listen und Formulare fragen ihn ab
|
||||||
|
- [ ] `kundeErmitteln(eingabe)`: im Modus `INTERN` immer die eigene Organisation, eine mitgeschickte `kundeId` wird **verworfen**
|
||||||
|
- [ ] Moduswechsel-Service: `INTERN → DIENSTLEISTUNG` jederzeit, die eigene Organisation wird zum ersten Kunden (Name und Kürzel werden abgefragt), alle Daten bleiben daran hängen
|
||||||
|
- [ ] Moduswechsel-Service: `DIENSTLEISTUNG → INTERN` nur bei genau einem nicht archivierten Kunden, sonst Ablehnung mit Hinweis
|
||||||
|
- [ ] Beide Richtungen mit Bestätigungsdialog und `Protokoll`-Eintrag, nur `ADMIN`
|
||||||
|
- [ ] Kundenverwaltung im Modus `DIENSTLEISTUNG`: anlegen, `kuerzel` eindeutig 2–6 Zeichen, archivieren statt löschen, Notizen
|
||||||
|
- [ ] Navigation und Beschriftungen je Modus: `INTERN` = Übersicht, Läufe, Vorlagen, Mitarbeiter & Geräte, Archiv, Admin; `DIENSTLEISTUNG` = Übersicht, Läufe, Vorlagen, Kunden, Archiv, Admin und „Mitarbeiter" heißt „Personen"
|
||||||
|
- [ ] Optionale Kundenbindung über `OIDC_CUSTOMER_GROUP_PREFIX` (z. B. `lz-kunde-MUS`): `TECHNIKER`/`LESER` sehen nur ihre Kunden, `ADMIN`/`REDAKTEUR` alles; ohne die Variable keine Einschränkung
|
||||||
|
- [ ] Admin → Einstellungen: Modus mit Wechsel-Dialog, Name der eigenen Organisation, globale Namensregeln, Sperr-OU, Anzeige von `APP_URL` für die QR-Codes
|
||||||
|
- [ ] Screenshots Einstellungen, Wechsel-Dialog, Kundenliste in `docs/screenshots/m3/`
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
- [ ] Im Modus `INTERN` erscheint die eigene Organisation in keiner Liste, keinem Filter, keinem Formular und auf keinem Etikett
|
||||||
|
- [ ] Test: eine Server Action mit mitgeschickter fremder `kundeId` schreibt im Modus `INTERN` trotzdem auf die eigene Organisation
|
||||||
|
- [ ] Test: Wechsel `INTERN → DIENSTLEISTUNG` erhält alle Geräte, Mitarbeiter, Abteilungen und Läufe am ersten Kunden
|
||||||
|
- [ ] Test: Wechsel `DIENSTLEISTUNG → INTERN` wird bei zwei nicht archivierten Kunden mit Hinweis abgelehnt, bei einem gelingt er
|
||||||
|
- [ ] Test: ein Wechsel schreibt einen `Protokoll`-Eintrag mit Vorher- und Nachher-Zustand
|
||||||
|
- [ ] Test: `LESER` und `TECHNIKER` dürfen den Modus nicht wechseln
|
||||||
|
- [ ] Der Modus liegt in der Datenbank und ist ohne Neustart umschaltbar
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# #009 M4 – Vorlagen-Versionierung im Service
|
||||||
|
|
||||||
|
**Status:** offen
|
||||||
|
**Meilenstein:** v0.1.0
|
||||||
|
**Labels:** phase-1, backend
|
||||||
|
**Geschätzt:** 6 h **Tatsächlich:** –
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Die harte Regel der Anwendung: eine veröffentlichte Version ist für immer unveränderlich, damit ein Archiv-Lauf für immer nachvollziehbar bleibt. Diese Regel sitzt in der Service-Schicht, nicht in der Oberfläche.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
|
||||||
|
- [ ] Services für `Vorlage`, `VorlagenVersion`, `VorlagenAbschnitt`, `VorlagenSchritt`, `VorlagenVariable`
|
||||||
|
- [ ] `vorlageVeroeffentlichenService`: setzt `status = VEROEFFENTLICHT`, `veroeffentlichtAm`, `veroeffentlichtVonId`, Änderungsnotiz, `Protokoll`-Eintrag
|
||||||
|
- [ ] `vorlageBearbeitenService`: tiefe Kopie der letzten veröffentlichten Version als neuer Entwurf mit fortlaufender `version`; kopiert Variablen, Abschnitte und Schritte samt `konfig`
|
||||||
|
- [ ] Höchstens ein Entwurf je Vorlage — zweiter Versuch wirft `VorlagenFehler` mit Verweis auf den bestehenden Entwurf
|
||||||
|
- [ ] Jede Schreiboperation auf einer veröffentlichten Version wirft; zentral in `versionMussEntwurfSein()`
|
||||||
|
- [ ] Läufe verweisen ausschließlich auf veröffentlichte Versionen — beim Anlegen geprüft
|
||||||
|
- [ ] `vorlageDuplizierenService`: neue Vorlage mit Entwurf als Kopie
|
||||||
|
- [ ] JSON-Export und -Import mit Zod-Schema, Format in `docs/vorlagen-format.md` dokumentiert (Version des Formats, Abschnitte, Schritte, Variablen, `konfig`)
|
||||||
|
- [ ] Rechte: `REDAKTEUR` und `ADMIN` dürfen anlegen, bearbeiten, veröffentlichen; `TECHNIKER` und `LESER` nicht
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
- [ ] Test: nach dem Veröffentlichen schlägt jede Änderung an Version, Abschnitten, Schritten und Variablen fehl
|
||||||
|
- [ ] Test: „Bearbeiten" erzeugt eine tiefe Kopie — Änderungen am Entwurf lassen die veröffentlichte Version unberührt, auch in verschachtelten `konfig`-Feldern
|
||||||
|
- [ ] Test: ein zweiter Entwurf wird abgelehnt
|
||||||
|
- [ ] Test: ein Lauf auf Version 1 bleibt nach Veröffentlichen von Version 2 unverändert und zeigt weiter die Schritte aus Version 1
|
||||||
|
- [ ] Test: Export und anschließender Import ergeben eine inhaltsgleiche Vorlage
|
||||||
|
- [ ] Test: `TECHNIKER` darf nicht veröffentlichen
|
||||||
|
- [ ] Test: Veröffentlichen schreibt einen `Protokoll`-Eintrag
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# #010 M5 – Vorlagen-Editor
|
||||||
|
|
||||||
|
**Status:** offen
|
||||||
|
**Meilenstein:** v0.1.0
|
||||||
|
**Labels:** phase-1, frontend
|
||||||
|
**Geschätzt:** 10 h **Tatsächlich:** –
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Der Editor, in dem ein Redakteur eine Vorlage baut, ohne den Kopf über Versionen zu verlieren.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
|
||||||
|
- [ ] Vorlagenliste mit Suche, Filter nach Kategorie und Tag, Kennzeichnung „Entwurf vorhanden"
|
||||||
|
- [ ] Editor: Abschnitte und Schritte anlegen, bearbeiten, löschen; Abschnitte mit zweistelliger Nummer (`01 · Vorbereitung`)
|
||||||
|
- [ ] Sortieren per Drag & Drop mit `@dnd-kit`, auch am Handy mit dem Finger bedienbar
|
||||||
|
- [ ] Je Schritt: Titel, Typ (`HAKEN`, `TEXT`, `ZAHL`, `AUSWAHL`, `DATUM`, `BEFEHL`), Pflicht (roter Stern), Hilfetext als Markdown, Doku-Link
|
||||||
|
- [ ] Typabhängige `konfig`-Formulare: Auswahloptionen, Min/Max/Einheit, bei `BEFEHL` die Shell (`powershell`/`bash`/`cmd`), `multiline` und der Befehlstext
|
||||||
|
- [ ] Variablen definieren: `key`, Label, Typ, Optionen, Pflicht, Position
|
||||||
|
- [ ] Vorschau der Vorlage wie im Lauf, mit Beispielwerten
|
||||||
|
- [ ] Veröffentlichen mit Änderungsnotiz, Versionshistorie ansehen, Vorlage duplizieren, JSON-Export und -Import
|
||||||
|
- [ ] Warnung im Editor bei unbekannten Platzhaltern in Titel, Hilfetext und Befehlstext
|
||||||
|
- [ ] Screenshots Liste, Editor, Historie in `docs/screenshots/m5/`
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
- [ ] Eine Vorlage lässt sich vollständig ohne Umweg über die Datenbank anlegen, sortieren, veröffentlichen
|
||||||
|
- [ ] Eine veröffentlichte Version ist im Editor nur lesend; „Bearbeiten" erzeugt sichtbar einen Entwurf
|
||||||
|
- [ ] Unbekannte Platzhalter werden als Warnung angezeigt, blockieren aber das Speichern nicht
|
||||||
|
- [ ] Sortieren funktioniert am Handy bei 390 px Breite ohne horizontales Scrollen
|
||||||
|
- [ ] Der Markdown-Hilfetext rendert ohne rohes HTML
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# #011 M6 – Mitarbeiter, Abteilungen, Geräte
|
||||||
|
|
||||||
|
**Status:** offen
|
||||||
|
**Meilenstein:** v0.1.0
|
||||||
|
**Labels:** phase-1, backend, frontend
|
||||||
|
**Geschätzt:** 9 h **Tatsächlich:** –
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Die Stammdaten, an denen die Historie hängt. Mitarbeiter und Geräte werden nie gelöscht, damit archivierte Läufe für immer auf sie zeigen können.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
|
||||||
|
- [ ] Mitarbeiterliste: Suche über Anzeigename, Mail, UPN und `sam`, Filter nach Abteilung und Aktiv-Status
|
||||||
|
- [ ] Mitarbeitersuche als wiederverwendbare Komponente: standardmäßig nur aktive, Umschalter „auch deaktivierte" fürs Offboarding
|
||||||
|
- [ ] Mitarbeiterseite: Stammdaten, Abteilung, Vorgesetzter, seine Geräte, alle Läufe chronologisch (Onboarding, Übergaben, Offboarding)
|
||||||
|
- [ ] Mitarbeiter manuell anlegen und bearbeiten (`herkunft = MANUELL`); synchronisierte Felder sind bei `herkunft = ABGLEICH` gesperrt
|
||||||
|
- [ ] Deaktivieren statt löschen (`aktiv = false`)
|
||||||
|
- [ ] Abteilungen: Name (eindeutig je Kunde), Ziel-OU, Standardgruppen (`String[]`), Mail-Domain, Notizen
|
||||||
|
- [ ] Geräte: Stammdaten nach Vorgabe, Pflichtregel „`hostname` oder `bezeichnung` muss gesetzt sein", Besitzer optional
|
||||||
|
- [ ] Geräteliste mit Suche über Hostname, Bezeichnung, Seriennummer und `tagCode`, Filter nach Typ
|
||||||
|
- [ ] CSV-Import für Geräte (`hostname`, `label`, `serialNumber`, `inventoryNumber`, `type`, `model`), `tagCode` wird beim Import erzeugt; eigener `src/lib/csv.ts`
|
||||||
|
- [ ] Archivieren statt Löschen, sobald Läufe an einem Gerät oder einer Abteilung hängen
|
||||||
|
- [ ] Screenshots Mitarbeiterliste, Mitarbeiterseite, Geräteliste, Abteilungen in `docs/screenshots/m6/`
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
- [ ] Test: Mitarbeitersuche liefert standardmäßig nur `aktiv = true`; mit Umschalter auch deaktivierte
|
||||||
|
- [ ] Test: ein Gerät ohne `hostname` und ohne `bezeichnung` wird abgelehnt
|
||||||
|
- [ ] Test: ein Gerät mit Läufen lässt sich nicht löschen, nur archivieren
|
||||||
|
- [ ] Test: `tagCode` eines bestehenden Geräts lässt sich nicht ändern
|
||||||
|
- [ ] Test: CSV-Import erzeugt je Zeile einen `tagCode`, meldet fehlerhafte Zeilen einzeln und bricht nicht ab
|
||||||
|
- [ ] Test: bei `herkunft = ABGLEICH` lehnt der Service Änderungen an synchronisierten Feldern ab
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# #012 M7 – Platzhalter, Namensregeln, Escaping
|
||||||
|
|
||||||
|
**Status:** offen
|
||||||
|
**Meilenstein:** v0.1.0
|
||||||
|
**Labels:** phase-1, backend, security
|
||||||
|
**Geschätzt:** 8 h **Tatsächlich:** –
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Reine, framework-freie Service-Schicht mit dichter Testabdeckung. Hier entstehen die fertigen PowerShell-Befehle, die der Techniker kopiert — und hier darf nichts schiefgehen, weder beim Escaping noch beim generierten Passwort.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
|
||||||
|
- [ ] Platzhalter-Auflösung `{{key}}` in Schritt-Titel, Hilfetext und Befehlstext
|
||||||
|
- [ ] Eingebaut aus Gerät und Lauf: `{{hostname}}`, `{{seriennummer}}`, `{{inventarnummer}}`, `{{ticket}}`, `{{datum}}`, `{{techniker}}`; im Modus `DIENSTLEISTUNG` zusätzlich `{{kunde}}` und `{{kundenkuerzel}}`, im Modus `INTERN` leer
|
||||||
|
- [ ] Eingebaut aus dem Mitarbeiter: `{{mitarbeiter}}` (Alias `{{benutzer}}`), `{{vorname}}`, `{{nachname}}`, `{{upn}}`, `{{sam}}`, `{{mail}}`, `{{abteilung}}`, `{{titel}}`, `{{vorgesetzter}}`, `{{vorgesetzter_sam}}`, `{{dn}}`, `{{ou}}`
|
||||||
|
- [ ] Aus der gewählten Abteilung: `{{ou}}` falls nicht vom Mitarbeiter, `{{gruppen}}` kommagetrennt
|
||||||
|
- [ ] `{{ou}}` eines synchronisierten Mitarbeiters ist der Eltern-Container seines `dn` (alles nach dem ersten Komma)
|
||||||
|
- [ ] Dazu die in der Vorlage definierten Variablen; unbekannte Platzhalter bleiben sichtbar stehen
|
||||||
|
- [ ] `{{generiertes_passwort}}`: 20 Zeichen aus Groß, Klein, Ziffern und Sonderzeichen ohne verwechselbare Zeichen, je Anzeige neu erzeugt, **nie** gespeichert; Hinweis „Passwort sofort in den Passwortmanager übernehmen"; in der Druckansicht `[Passwort – nicht gespeichert]`
|
||||||
|
- [ ] Namensregeln (`Einstellung.namensregeln` global, im Modus `DIENSTLEISTUNG` zusätzlich `Kunde.namensregeln` mit Fallback): `upn`, `sam` (höchstens 20 Zeichen), `mail`, `hostname`
|
||||||
|
- [ ] Transliteration (ä→ae, ö→oe, ü→ue, ß→ss, Akzente entfernt), Kleinschreibung, Leerzeichen entfernt, `{nnn}`-Zähler, `{v}` für den ersten Buchstaben des Vornamens, `{kundenkuerzel}`
|
||||||
|
- [ ] Shell-Escaping je Shell: PowerShell (doppelte Anführungszeichen verdoppeln, Backtick maskieren), Bash, Cmd
|
||||||
|
- [ ] HTML-Escaping ersetzter Werte; Markdown im Hilfetext sicher gerendert, kein rohes HTML
|
||||||
|
- [ ] „Alle Befehle als Skript kopieren" je Abschnitt: alle `BEFEHL`-Schritte in Reihenfolge, getrennt durch Leerzeilen, je mit `# Schritt: <Titel>` davor, Zeilenumbruch passend zur Shell
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
- [ ] Test: „Jörg Müller-Lüdenscheid" ergibt `joerg.mueller-luedenscheid@firma.de` und `jmuellerluedensch` (auf 20 Zeichen gekürzt)
|
||||||
|
- [ ] Test: `{{generiertes_passwort}}` erscheint im angezeigten Befehl, steht aber nicht in `Schrittergebnis`, `Kommentar`, `Protokoll` oder der Druckansicht; zwei Aufrufe liefern verschiedene Passwörter
|
||||||
|
- [ ] Test: ein Wert mit `"` und `` ` `` wird für PowerShell korrekt maskiert und bricht den Befehl nicht
|
||||||
|
- [ ] Test: unbekannte Platzhalter bleiben als `{{key}}` stehen
|
||||||
|
- [ ] Test: `{{ou}}` wird aus `CN=Maria Huber,OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local` zu `OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local`
|
||||||
|
- [ ] Test: Skript-Kopie liefert die Befehle in Abschnittsreihenfolge mit Kommentarzeilen
|
||||||
|
- [ ] Test: im Modus `INTERN` sind `{{kunde}}` und `{{kundenkuerzel}}` leer
|
||||||
|
- [ ] Test: ein Wert mit `<script>` landet HTML-escaped in der Anzeige
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# #013 M8 – Läufe: Service und Abarbeitung
|
||||||
|
|
||||||
|
**Status:** offen
|
||||||
|
**Meilenstein:** v0.1.0
|
||||||
|
**Labels:** phase-1, backend, frontend
|
||||||
|
**Geschätzt:** 14 h **Tatsächlich:** –
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Das Herz der Anwendung: ein Lauf wird vorbereitet, abgearbeitet, abgeschlossen und archiviert. Der Archiv-Schreibschutz sitzt an genau einer Stelle.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
|
||||||
|
- [ ] `laufAnlegenService`: aus veröffentlichter Version, Kunde per `kundeErmitteln()`, optional Mitarbeiter (Verzeichnis oder Freitext `mitarbeiterName`), optional Gerät, Ticket, Variablen, Zuweisung, Fälligkeit
|
||||||
|
- [ ] Ein Lauf kann als `OFFEN` angelegt werden, ohne ihn zu starten — das ist der Zuweisungs-Mechanismus, keine eigene Tabelle
|
||||||
|
- [ ] `gestartetAm` und `IN_ARBEIT` erst beim ersten Öffnen zur Bearbeitung
|
||||||
|
- [ ] Titelvorschlag `<Vorlage> – <Hostname oder Mitarbeiter> – <Datum>`
|
||||||
|
- [ ] `schrittergebnisSetzenService`: einzeln je Schritt (kein Gesamtformular), `zustand`, `wert`, `notiz`, `aktualisiertVonId`, `Protokoll`-Eintrag
|
||||||
|
- [ ] `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz, sonst `LaufFehler`
|
||||||
|
- [ ] `abschnittNichtZutreffendService`: setzt alle Schritte eines Abschnitts mit einer Notiz auf `NICHT_ZUTREFFEND` (für die beiden Konto-Abschnitte im Onboarding)
|
||||||
|
- [ ] `laufAbschliessenService`: nur wenn alle Pflichtschritte `ERLEDIGT` oder `NICHT_ZUTREFFEND` sind; setzt `abgeschlossenAm`, `abgeschlossenVonId`
|
||||||
|
- [ ] `laufArchivierenService` und `laufZurueckholenService` (nur `ADMIN`), beide mit `Protokoll`-Eintrag
|
||||||
|
- [ ] `laufMussVeraenderbarSein(lauf)` zentral, aufgerufen vor jeder Änderung an Kopfdaten, `Schrittergebnis` und `Kommentar`
|
||||||
|
- [ ] Kommentare je Lauf und je Schrittergebnis
|
||||||
|
- [ ] „Mit AD-Konto verknüpfen": ein Lauf mit `mitarbeiterName` wird nachträglich über UPN oder Name mit einem `Mitarbeiter` verbunden
|
||||||
|
- [ ] Start-Dialog: Vorlage → Mitarbeiter → Gerät (wählen, neu anlegen oder scannen) → Ticket, Variablen, Zuweisung, Fälligkeit; Vorbelegung je Aufrufort, Besitzer des Geräts als Mitarbeiter vorgeschlagen; Vorschläge aus den Namensregeln für `upn`, `sam`, `mail`
|
||||||
|
- [ ] Abarbeitungsansicht: einklappbare Abschnitte mit `01 ·`-Nummerierung, Fortschrittsbalken in Lime, je Schritt Zustand, Wert, Notiz, Kommentare und „wer wann"
|
||||||
|
- [ ] Erledigte Schritte durchgestrichen in Nebentext-Farbe, Checkbox erledigt als Lime-Kachel mit dunklem Haken, Pflichtschritte mit rotem Stern
|
||||||
|
- [ ] `BEFEHL`-Schritte als Codeblock auf `#0B0D10` mit Lime-Schrift, Kopier-Knopf, Warnung wenn ein Platzhalter unaufgelöst ist; je Abschnitt „Alle Befehle als Skript kopieren"
|
||||||
|
- [ ] `ZAHL` außerhalb von Min/Max wird markiert, aber nicht blockiert
|
||||||
|
- [ ] Route Handler `GET /api/laeufe/[id]/zustand`, Client pollt alle 10 s und pausiert bei verstecktem Tab
|
||||||
|
- [ ] Umschalter „Kompakt / Ausführlich", ausführlich klappt alle Hilfetexte auf
|
||||||
|
- [ ] Abschließen und Archivieren als getrennte Aktionen
|
||||||
|
- [ ] Screenshots Start-Dialog, Lauf Desktop, Lauf Handy, Befehlsschritt in `docs/screenshots/m8/`
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
- [ ] Test: ein Lauf ohne Mitarbeiter und ohne Gerät lässt sich anlegen, abschließen und archivieren
|
||||||
|
- [ ] Test: Abschließen scheitert, solange ein Pflichtschritt `OFFEN` ist
|
||||||
|
- [ ] Test: `NICHT_ZUTREFFEND` ohne Notiz wird abgelehnt, mit Notiz akzeptiert
|
||||||
|
- [ ] Test: an einem archivierten Lauf schlagen Änderung eines Schrittergebnisses, eines Kommentars und der Kopfdaten fehl
|
||||||
|
- [ ] Test: nur `ADMIN` holt einen archivierten Lauf auf `ABGESCHLOSSEN` zurück, mit `Protokoll`-Eintrag
|
||||||
|
- [ ] Test: `gestartetAm` bleibt leer, solange der Lauf nur `OFFEN` ist
|
||||||
|
- [ ] Test: jede Änderung an einem Schrittergebnis, jeder Statuswechsel und jedes Veröffentlichen steht im `Protokoll`
|
||||||
|
- [ ] Test: die Abschnitts-Aktion setzt alle Schritte des Abschnitts mit derselben Notiz
|
||||||
|
- [ ] Zwei Browser auf demselben Lauf sehen die Änderung des anderen nach höchstens 10 s
|
||||||
|
- [ ] Am Handy bei 390 px kein horizontales Scrollen
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# #014 M9 – QR-Tags, Geräteseite, Etiketten, Scanner
|
||||||
|
|
||||||
|
**Status:** offen
|
||||||
|
**Meilenstein:** v0.1.0
|
||||||
|
**Labels:** phase-1, backend, frontend
|
||||||
|
**Geschätzt:** 12 h **Tatsächlich:** –
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Der Einstieg für den Techniker, der am Gerät steht: scannen, sehen was zu tun ist, loslegen.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
|
||||||
|
- [ ] Stapel-Erzeugung von N unregistrierten Tags (Standard 50) für vorgedruckte Aufkleber
|
||||||
|
- [ ] Route `/t/[tagCode]` mit Anmeldepflicht und Rücksprung; drei Fälle: registriert → Geräteseite; existiert unregistriert → Registrierformular; unbekannt → Hinweis „Kein Laufzettel-Tag" mit Link zur Suche
|
||||||
|
- [ ] Registrierformular: Typ, Hostname oder Bezeichnung, Seriennummer, Inventarnummer, optional Besitzer per Mitarbeitersuche; danach Geräteseite
|
||||||
|
- [ ] Geräteseite `/geraete/[id]`, handyoptimiert: Kopf mit Hostname bzw. Bezeichnung, Typ, Besitzer mit Abteilung, Seriennummer, `tagCode` in JetBrains Mono
|
||||||
|
- [ ] Abschnitt „Zugewiesen": alle Läufe zu diesem Gerät in `OFFEN` und `IN_ARBEIT` mit Fortschritt, Zuweisung und Fälligkeit, Knopf „Starten" bzw. „Weiter"
|
||||||
|
- [ ] Abschnitt „Schnellstart": Vorlagen aus `GeraetetypVorlage` für diesen Gerätetyp, ein Tipp startet den Lauf mit vorbelegtem Gerät und Besitzer
|
||||||
|
- [ ] Abschnitt „Vorlage zuweisen": Vorlage wählen, optional Techniker und Fälligkeit, erzeugt einen Lauf in `OFFEN`
|
||||||
|
- [ ] Abschnitt „Historie": abgeschlossene und archivierte Läufe chronologisch
|
||||||
|
- [ ] QR-Erzeugung serverseitig als SVG mit `qrcode`, Fehlerkorrektur M, Inhalt ausschließlich `{APP_URL}/t/{tagCode}`, keine Personendaten, keine externe API
|
||||||
|
- [ ] Etikettendruck mit Print-CSS in zwei Layouts: Etikettendrucker 62 × 29 mm (ein Etikett pro Seite) und A4-Bogen 3 × 8 à 70 × 37 mm; je Etikett QR, `tagCode` lesbar darunter, optional Hostname und Besitzer, kleines Laufzettel-Logo; immer hell
|
||||||
|
- [ ] Im Modus `DIENSTLEISTUNG` zeigt das Etikett zusätzlich das Kundenkürzel, im Modus `INTERN` nie
|
||||||
|
- [ ] Knopf „Etikett drucken" auf der Geräteseite, Stapeldruck für unregistrierte Tags
|
||||||
|
- [ ] „Scannen" in der App: `barcode-detector` (native BarcodeDetector-API mit Polyfill-Fallback), Kamera im Browser, direkter Sprung zur Geräteseite
|
||||||
|
- [ ] Screenshots Geräteseite Handy, Registrierformular, Etikett 62 × 29, A4-Bogen, Scanner in `docs/screenshots/m9/`
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
- [ ] Test: `/t/LZ-7K3F9Q` ohne Anmeldung leitet zum Login und danach auf die Geräteseite von `PC-001`
|
||||||
|
- [ ] Test: „Vorlage zuweisen" erzeugt einen Lauf in `OFFEN`, der unter „Zugewiesen" erscheint; „Starten" öffnet ihn und setzt `IN_ARBEIT`
|
||||||
|
- [ ] Test: unregistrierter Tag führt zum Registrierformular, danach zur Geräteseite; unbekannter Tag zeigt den Hinweis
|
||||||
|
- [ ] Test: mehrere Zuweisungen je Gerät sind gleichzeitig möglich
|
||||||
|
- [ ] Test: der QR-Inhalt ist genau `{APP_URL}/t/{tagCode}` und enthält keine Personendaten
|
||||||
|
- [ ] Test: ein bei 300 dpi gerastertes 62 × 29-mm-Etikett wird von einem QR-Decoder korrekt zurückgelesen
|
||||||
|
- [ ] Beide Druckbilder sind sauber, ohne Sidebar, ohne dunkle Flächen, ohne abgeschnittene Etiketten
|
||||||
|
- [ ] Die Geräteseite ist bei 390 px ohne horizontales Scrollen bedienbar
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# #015 M10 – Dashboard und Archiv
|
||||||
|
|
||||||
|
**Status:** offen
|
||||||
|
**Meilenstein:** v0.1.0
|
||||||
|
**Labels:** phase-1, frontend, backend
|
||||||
|
**Geschätzt:** 8 h **Tatsächlich:** –
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Der Einstieg am Schreibtisch und das Gedächtnis der Anwendung.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
|
||||||
|
- [ ] Dashboard: meine offenen Läufe, überfällige Läufe, zuletzt bearbeitet, Schnellstart der häufigsten Vorlagen
|
||||||
|
- [ ] Archiv-Volltextsuche über Titel, Notizen, Werte und Kommentare in einem gemeinsamen Query-Modul mit `pg_trgm` und `ILIKE`
|
||||||
|
- [ ] Filter nach Vorlage, Mitarbeiter, Abteilung, Gerät, Zeitraum und Bearbeiter; im Modus `DIENSTLEISTUNG` zusätzlich nach Kunde
|
||||||
|
- [ ] Druckansicht eines Laufs: Print-CSS, Kopfdaten, alle Abschnitte und Schritte, Werte, Notizen, Namen, Zeitstempel; ersetzt im MVP den PDF-Export
|
||||||
|
- [ ] In der Druckansicht erscheinen vom Mitarbeiter nur Name, UPN und Abteilung, sonst nichts
|
||||||
|
- [ ] Screenshots Übersicht, Archiv, Druckansicht in `docs/screenshots/m10/`
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
- [ ] Test: ein archivierter Lauf wird über ein Wort aus einer Schritt-Notiz gefunden
|
||||||
|
- [ ] Test: ein archivierter Lauf wird über ein Wort aus einem Kommentar gefunden
|
||||||
|
- [ ] Test: die Filter kombinieren sich (Vorlage und Zeitraum und Bearbeiter)
|
||||||
|
- [ ] Test: `{{generiertes_passwort}}` erscheint nicht in der Druckansicht
|
||||||
|
- [ ] Test: überfällige Läufe sind genau die mit `faelligAm < heute` und Status `OFFEN` oder `IN_ARBEIT`
|
||||||
|
- [ ] Die Druckansicht ist hell, ohne Navigation, und passt auf A4
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# #016 M11 – Verzeichnis und LDAP-Abgleich
|
||||||
|
|
||||||
|
**Status:** offen
|
||||||
|
**Meilenstein:** v0.1.0
|
||||||
|
**Labels:** phase-1, backend, frontend, security
|
||||||
|
**Geschätzt:** 12 h **Tatsächlich:** –
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Die Mitarbeiter der eigenen Firma kommen aus dem Active Directory, damit beim Start eines Laufs nichts abgetippt werden muss. Gilt nur für die eigene Organisation; im Modus `DIENSTLEISTUNG` ist das Verzeichnis-Modul ausgeblendet.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
|
||||||
|
- [ ] `ldapts` 9.x: `ldaps` bevorzugt, StartTLS optional, eigenes CA-Zertifikat hinterlegbar (`caCertPem`), `minVersion: TLSv1.2`
|
||||||
|
- [ ] `searchPaginated` mit `pageSize: 1000` — **nicht** `search` mit `paged`, das puffert alles im Speicher
|
||||||
|
- [ ] `explicitBufferAttributes: ['objectGUID']` und korrekte Umsortierung der gemischt-endianen Bytes zur GUID-Zeichenkette als `externeId`
|
||||||
|
- [ ] Standardfilter `(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))`
|
||||||
|
- [ ] Attribut-Mapping mit AD-Vorgaben: `givenName`, `sn`, `displayName`, `mail`, `userPrincipalName` → `upn`, `sAMAccountName` → `sam`, `department`, `title`, `manager` → `vorgesetzterExterneId`, `distinguishedName` → `dn`
|
||||||
|
- [ ] „Verbindung testen": zeigt die ersten 10 Treffer mit gemappten Feldern, ohne etwas zu schreiben
|
||||||
|
- [ ] `abgleichService`: anlegen, aktualisieren, verschwundene oder deaktivierte Konten auf `aktiv = false` — **nie löschen**; Ergebnis (angelegt/aktualisiert/deaktiviert/Fehler) in `letzterAbgleichStatus`, `letzterAbgleichFehler`, `letzterAbgleichAm` und im `Protokoll`
|
||||||
|
- [ ] Zeitplan mit `node-cron` im selben Prozess, Intervall je Quelle (Standard 60 min), Sperre über `AbgleichSperre` in der Datenbank, damit nur eine Instanz läuft; zusätzlich „Jetzt abgleichen"
|
||||||
|
- [ ] Bind-Passwort verschlüsselt über `src/lib/geheimnisse.ts`, Serializer entfernt `bindPasswortVerschluesselt` aus jeder Antwort
|
||||||
|
- [ ] Admin → Verzeichnis: Quelle anlegen und bearbeiten, Verbindung testen, Abgleich-Status und -Log, „Jetzt abgleichen"
|
||||||
|
- [ ] Manuelle Mitarbeiter und CSV-Import (`givenName`, `sn`, `displayName`, `mail`, `upn`, `sam`, `department`, `title`)
|
||||||
|
- [ ] Abteilungsvorschläge aus den `department`-Werten des Abgleichs, vom Admin in `Abteilung`-Einträge übernehmbar
|
||||||
|
- [ ] Nur die gemappten Attribute speichern: keine Passwörter, keine Gruppenmitgliedschaften des Mitarbeiters
|
||||||
|
- [ ] `docker-compose.test.yml` um OpenLDAP erweitern, mit AD-ähnlichem Fixture-Schema (`sAMAccountName`, `userPrincipalName`, binäres `objectGUID`) — aus #005 hierher verschoben, weil der Dienst erst hier benutzt wird
|
||||||
|
- [ ] Screenshots Verzeichnis, Verbindungstest, Abgleich-Log, CSV-Import in `docs/screenshots/m11/`
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
- [ ] Integrationstest gegen den Container: erster Lauf legt Mitarbeiter an, zweiter Lauf aktualisiert sie ohne Duplikate
|
||||||
|
- [ ] Integrationstest: ein aus dem Verzeichnis entferntes Konto wird `aktiv = false` und bleibt in der Datenbank
|
||||||
|
- [ ] Integrationstest: ein per `userAccountControl` deaktiviertes Konto wird `aktiv = false`
|
||||||
|
- [ ] Test: `{{ou}}` wird korrekt aus dem `dn` abgeleitet
|
||||||
|
- [ ] Test: `objectGUID` als Buffer ergibt reproduzierbar dieselbe GUID-Zeichenkette (bekannter Byte-Vektor als Fixture)
|
||||||
|
- [ ] Test: keine API-Antwort und kein Server-Log enthält das Bind-Passwort im Klartext
|
||||||
|
- [ ] Test: zwei gleichzeitig gestartete Abgleiche derselben Quelle — der zweite wird von der Sperre abgewiesen
|
||||||
|
- [ ] Im Modus `DIENSTLEISTUNG` ist Admin → Verzeichnis nicht erreichbar
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# #017 M12 – Seed der sieben Vorlagen
|
||||||
|
|
||||||
|
**Status:** offen
|
||||||
|
**Meilenstein:** v0.1.0
|
||||||
|
**Labels:** phase-1, backend
|
||||||
|
**Geschätzt:** 8 h **Tatsächlich:** –
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Die App ist nach dem Seed fachlich sofort brauchbar: sieben vollständige, fachlich korrekte Vorlagen als veröffentlichte Version 1.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
|
||||||
|
- [ ] Vorlage 1 „PC Neuinstallation" (Client), Variablen `os_version`, `join_typ`; Abschnitte Vorbereitung, Installation, Einbindung, Software, Sicherheit, Benutzer, Abschluss
|
||||||
|
- [ ] Vorlage 2 „Mitarbeiter-Onboarding" (Benutzer), Variablen `konto_typ`, `vorname`, `nachname`, `upn`, `sam`, `mail`, `abteilung`, `ou`, `gruppen`, `eintrittsdatum`, `vorgesetzter`; **beide** Konto-Abschnitte (AD und Entra ID), weil bedingte Abschnitte erst Phase 3 sind
|
||||||
|
- [ ] Vorlage 3 „Mitarbeiter-Offboarding" (Benutzer), Variablen `austrittsdatum`, `ou_gesperrt`, `postfach_verbleib`, `vertreter`
|
||||||
|
- [ ] Vorlage 4 „Server-Inbetriebnahme Windows-VM" (Server), Variablen `vmid`, `ip_adresse`, `vlan`, `rolle`, `os_version`
|
||||||
|
- [ ] Vorlage 5 „Firewall-Rollout OPNsense" (Netzwerk), Variablen `wan_typ`, `lan_netz`, `vpn_typ`
|
||||||
|
- [ ] Vorlage 6 „Monatlicher Patchday" (Wartung), Variable `monat`
|
||||||
|
- [ ] Vorlage 7 „Backup-Restore-Test" (Wartung), Variablen `system`, `restore_typ`
|
||||||
|
- [ ] Alle Schritte wortgetreu nach Auftrag mit Typ, Pflichtkennzeichen und `konfig`; die im Auftrag genannten Hilfetexte übernehmen, die übrigen fachlich korrekt und kurz ergänzen
|
||||||
|
- [ ] 5 Mitarbeiter, darunter „Maria Huber", `m.huber@firma.example`, `sam` `mhuber`, Abteilung Buchhaltung, Vorgesetzte „Petra Klein"
|
||||||
|
- [ ] 3 Abteilungen: Buchhaltung → `OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local` mit Gruppen `G-Buchhaltung, G-Alle`; Vertrieb; IT
|
||||||
|
- [ ] Eine **deaktivierte** `Verzeichnisquelle` als Beispielkonfiguration
|
||||||
|
- [ ] Geräte `PC-001` (PC, `LZ-7K3F9Q`, Besitzerin Maria Huber) und `NB-001` (Notebook, `LZ-M2XH4T`), dazu 5 unregistrierte Tags
|
||||||
|
- [ ] `GeraetetypVorlage`: PC und NOTEBOOK → „PC Neuinstallation"; SERVER → „Server-Inbetriebnahme Windows-VM" und „Backup-Restore-Test"; FIREWALL → „Firewall-Rollout OPNsense"
|
||||||
|
- [ ] `SEED_MODE=DIENSTLEISTUNG` (Alias `SERVICE`) erzeugt stattdessen „Musterfirma GmbH" (`MUS`) und „Bäckerei Reiter" (`REI`) mit je einer Person, einer Abteilung und einem Gerät
|
||||||
|
- [ ] Alles idempotent über `upsert` mit stabilen Schlüsseln
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
- [ ] `pnpm db:seed` zweimal hintereinander: keine Fehler, keine Duplikate, keine geänderten Zeilen
|
||||||
|
- [ ] Alle sieben Vorlagen liegen als `VEROEFFENTLICHT`, Version 1 vor
|
||||||
|
- [ ] Im Onboarding-Lauf steht im Schritt „Konto anlegen" der vollständige `New-ADUser`-Befehl mit Name, UPN, `-Path "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local"` und der Gruppen-Befehl mit `G-Buchhaltung, G-Alle`
|
||||||
|
- [ ] `SEED_MODE=SERVICE` erzeugt `MUS-PC-001` gemäß Hostname-Regel `{kundenkuerzel}-PC-{nnn}`
|
||||||
|
- [ ] Die Beispiel-Verzeichnisquelle ist deaktiviert und löst keinen Abgleich aus
|
||||||
|
- [ ] Kein Seed-Datensatz enthält ein Klartext-Passwort außer dem Admin-Passwort aus der Umgebung, und das nur als argon2-Hash
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# #018 M13 – E2E-Tests, Anleitung, PDF
|
||||||
|
|
||||||
|
**Status:** offen
|
||||||
|
**Meilenstein:** v0.1.0
|
||||||
|
**Labels:** phase-1, test, docs
|
||||||
|
**Geschätzt:** 14 h **Tatsächlich:** –
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Nachweis, dass die Abläufe wirklich durchlaufen, und eine Anleitung, mit der ein Techniker ohne Einweisung arbeiten kann.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
|
||||||
|
- [ ] Playwright-Hauptablauf: anmelden → „PC Neuinstallation" für `PC-001` starten → Schritte abhaken → Wert eintragen → einen Schritt „nicht zutreffend" mit Notiz → abschließen → archivieren → im Archiv finden → Schreibschutz greift
|
||||||
|
- [ ] Derselbe Hauptablauf mit `SEED_MODE=SERVICE` gegen `MUS-PC-001`, mit Kundenwahl im Start-Dialog
|
||||||
|
- [ ] Playwright-QR-Ablauf: `/t/LZ-7K3F9Q` ohne Anmeldung → Login → Geräteseite → „Vorlage zuweisen" → Lauf erscheint unter „Zugewiesen" → „Starten" setzt `IN_ARBEIT`; dazu unregistrierter Tag → Registrierformular → Geräteseite; unbekannter Tag → Hinweis
|
||||||
|
- [ ] Playwright-Onboarding-Ablauf: Vorlage starten → Vorname und Nachname eingeben → UPN, `sam` und Mail werden vorgeschlagen → Abteilung „Buchhaltung" wählen → im Schritt „Konto anlegen" steht der fertige `New-ADUser`-Befehl → Kopieren liefert exakt diesen Text (Clipboard auslesen)
|
||||||
|
- [ ] Playwright-Mobilprüfung: auf allen Hauptseiten bei 390 × 844 ist `scrollWidth <= clientWidth`
|
||||||
|
- [ ] Playwright-Rollenprüfung: `LESER` sieht keine Schreib-Knöpfe und wird serverseitig abgewiesen
|
||||||
|
- [ ] `pnpm docs:screenshots`: Playwright-Skript, Desktop 1440 × 900 und Handy 390 × 844, Seed-Daten, dunkles Design, mit Markierungen (Rahmen, Nummern) für die beschriebenen Stellen; Ablage `docs/anleitung/bilder/`
|
||||||
|
- [ ] `pnpm docs:pdf`: Anleitung als PDF über Chromium (`page.pdf`, A4, `printBackground`), Ablage `docs/anleitung/laufzettel-anleitung.pdf`
|
||||||
|
- [ ] `docs/anleitung/` auf Deutsch, getrennt für Techniker und Admins: Erste Schritte · Vorlagen bauen · Läufe abarbeiten (Desktop und Handy) · QR-Tags drucken und registrieren · Mitarbeiter und Abteilungen · Betriebsmodus · Admin: Verzeichnis, Benutzer, Einstellungen · Fehlerbehebung
|
||||||
|
- [ ] `README.md` vollständig: Setup, alle Umgebungsvariablen, Betrieb mit Docker Compose, authentik-Anbindung inkl. Gruppen für Rollen, Seed, Sicherungshinweis für die Datenbank
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
- [ ] Alle Playwright-Abläufe grün, in `INTERN` und in `DIENSTLEISTUNG`
|
||||||
|
- [ ] Der Kopier-Knopf liefert exakt den erwarteten Befehlstext, zeichengenau geprüft
|
||||||
|
- [ ] Keine Hauptseite scrollt bei 390 px horizontal
|
||||||
|
- [ ] `pnpm docs:screenshots` erzeugt alle in der Anleitung verlinkten Bilder reproduzierbar neu
|
||||||
|
- [ ] `pnpm pruefe:doku` meldet kein fehlendes Bild
|
||||||
|
- [ ] Das PDF enthält alle Kapitel mit Bildern, Umlaute korrekt, keine abgeschnittenen Seiten
|
||||||
|
- [ ] Die README beschreibt authentik so, dass ein Dritter die Anbindung ohne Rückfrage hinbekommt
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# #019 M14 – Betriebswege und Release v0.1.0
|
||||||
|
|
||||||
|
**Status:** offen
|
||||||
|
**Meilenstein:** v0.1.0
|
||||||
|
**Labels:** phase-1, infra, docs
|
||||||
|
**Geschätzt:** 10 h **Tatsächlich:** –
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Zwei erprobte Wege in den Betrieb und ein sauberes erstes Release.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
|
||||||
|
- [ ] `deploy/install.sh`: native Installation auf Debian 13 — PostgreSQL 17 mit `listen_addresses = ''` (nur Unix-Socket), Node 24 aus dem NodeSource-Repo, corepack/pnpm, Dienstnutzer `laufzettel` ohne Login, Quellcode nach `/opt/laufzettel`, `pnpm install`, `pnpm build`, `prisma migrate deploy`, `prisma db seed`
|
||||||
|
- [ ] Geheimnisse im Zielsystem erzeugen: `APP_SECRET` per `openssl rand -base64 48`, Zufallspasswörter für Datenbank und Admin; Ablage in `/etc/laufzettel/laufzettel.env` mit Modus 0600 und Eigentümer root
|
||||||
|
- [ ] `laufzettel.service`: systemd-Unit für den Next-standalone-Server, `User=laufzettel`, `EnvironmentFile=`, `NoNewPrivileges=yes`, `ProtectSystem=strict`, `PrivateTmp=yes`, `Restart=on-failure`
|
||||||
|
- [ ] Erneuter Aufruf aktualisiert statt neu anzulegen: `git pull`, `pnpm build`, `migrate deploy`, `systemctl restart`; Geheimnisse und Daten bleiben
|
||||||
|
- [ ] `deploy/proxmox/laufzettel.sh`: One-Liner für die Proxmox-Shell — `set -Eeuo pipefail`, `trap` mit Zeilennummer, farbige Ausgabe, Spinner, Prüfung auf PVE 8/9 und root
|
||||||
|
- [ ] `whiptail`-Dialog Standard gegen Erweitert; Standard: nächste freie CTID, Hostname `laufzettel`, Debian 13, 2 Kerne, 2 GB RAM, 8 GB Platte, unprivilegiert, DHCP auf `vmbr0`
|
||||||
|
- [ ] Debian-Vorlage bei Bedarf per `pveam` holen, `pct create`, starten, `deploy/install.sh` im Container ausführen
|
||||||
|
- [ ] `--dry-run`: gibt alle `pct`-Aufrufe nur aus, ohne etwas anzulegen
|
||||||
|
- [ ] Abschlussbildschirm: URL, Admin-Kennung, erzeugtes Passwort, Hinweis auf Reverse Proxy und `docs/betrieb/`
|
||||||
|
- [ ] `docs/betrieb/`: `inbetriebnahme.md`, `proxmox.md`, `reverse-proxy.md`, `authentik.md`, `sicherung.md` (`pg_dump` über den Dienstnamen bzw. den Unix-Socket)
|
||||||
|
- [ ] Prüfliste für den Lauf auf echtem Proxmox, da ich den `pct`-Teil hier nicht ausführen kann
|
||||||
|
- [ ] `docs/abnahme-mvp.md` abschließend durchgehen, Review-Agent gegen alle Kriterien, Befunde als Issues
|
||||||
|
- [ ] Devlog-Phasenzusammenfassung: Gesamtstunden, Stunden je Meilenstein, größte Zeitfresser, Abweichung vom Plan
|
||||||
|
- [ ] `CHANGELOG.md` für `v0.1.0` mit Datum, Version in `package.json`, Tag `v0.1.0`, Gitea-Release mit Changelog-Auszug, Anleitung als PDF beigelegt
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
- [ ] `docker run --rm debian:13` mit `deploy/install.sh` installiert vollständig durch: Postgres läuft, Migration und Seed gelaufen, Dienst antwortet auf `/api/gesundheit`
|
||||||
|
- [ ] Im nativen Weg lauscht PostgreSQL auf keinem TCP-Port (`ss -ltn` zeigt 5432 nicht)
|
||||||
|
- [ ] Ein zweiter Lauf von `deploy/install.sh` aktualisiert und löscht keine Daten
|
||||||
|
- [ ] `bash deploy/proxmox/laufzettel.sh --dry-run` gibt einen plausiblen, vollständigen `pct create`-Aufruf aus
|
||||||
|
- [ ] `shellcheck --severity=warning` über alle Skripte ohne Befund
|
||||||
|
- [ ] Im Compose-Weg ist die Datenbank vom Host nicht erreichbar, aus `web` schon
|
||||||
|
- [ ] `pnpm pruefe:doku` grün, `v0.1.0` getaggt und als Gitea-Release veröffentlicht
|
||||||
|
- [ ] Alle Kriterien aus `docs/abnahme-mvp.md` abgehakt oder als Folge-Issue erfasst
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Issues
|
||||||
|
|
||||||
|
Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Tracker.
|
||||||
|
`pnpm pruefe:doku` erzwingt, dass Dateien und diese Übersicht übereinstimmen.
|
||||||
|
|
||||||
|
| Nr. | Titel | Status | Meilenstein | Geschätzt | Tatsächlich |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| #001 | [Phase 1: MVP](001-epic-phase-1-mvp.md) | in Arbeit | v0.1.0 | – | – |
|
||||||
|
| #002 | [Phase 2: Verzeichnis-Alternativen, Bausteine, Anhänge](002-epic-phase-2.md) | offen | v0.2.0 | – | – |
|
||||||
|
| #003 | [Phase 3: Protokoll, bedingte Schritte, Freigabe](003-epic-phase-3.md) | offen | v0.3.0 | – | – |
|
||||||
|
| #004 | [Phase 4: Aktionen, Connectors, REST-API](004-epic-phase-4.md) | offen | v0.4.0 | – | – |
|
||||||
|
| #005 | [M0 – Gerüst, Prozess, Design-Token](005-geruest-prozess-design.md) | erledigt | v0.1.0 | 6 h | 1 h 25 min |
|
||||||
|
| #006 | [M1 – Datenmodell](006-datenmodell.md) | offen | v0.1.0 | 5 h | – |
|
||||||
|
| #007 | [M2 – Auth, Rollen, Benutzer](007-auth-rollen-benutzer.md) | offen | v0.1.0 | 8 h | – |
|
||||||
|
| #008 | [M3 – Betriebsmodus und Kunden](008-betriebsmodus-kunden.md) | offen | v0.1.0 | 6 h | – |
|
||||||
|
| #009 | [M4 – Vorlagen-Versionierung im Service](009-vorlagen-versionierung.md) | offen | v0.1.0 | 6 h | – |
|
||||||
|
| #010 | [M5 – Vorlagen-Editor](010-vorlagen-editor.md) | offen | v0.1.0 | 10 h | – |
|
||||||
|
| #011 | [M6 – Mitarbeiter, Abteilungen, Geräte](011-mitarbeiter-abteilungen-geraete.md) | offen | v0.1.0 | 9 h | – |
|
||||||
|
| #012 | [M7 – Platzhalter, Namensregeln, Escaping](012-platzhalter-namensregeln.md) | offen | v0.1.0 | 8 h | – |
|
||||||
|
| #013 | [M8 – Läufe: Service und Abarbeitung](013-laeufe-abarbeitung.md) | offen | v0.1.0 | 14 h | – |
|
||||||
|
| #014 | [M9 – QR-Tags, Geräteseite, Etiketten, Scanner](014-qr-tags-geraeteseite.md) | offen | v0.1.0 | 12 h | – |
|
||||||
|
| #015 | [M10 – Dashboard und Archiv](015-dashboard-archiv.md) | offen | v0.1.0 | 8 h | – |
|
||||||
|
| #016 | [M11 – Verzeichnis und LDAP-Abgleich](016-verzeichnis-ldap.md) | offen | v0.1.0 | 12 h | – |
|
||||||
|
| #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | offen | v0.1.0 | 8 h | – |
|
||||||
|
| #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | offen | v0.1.0 | 14 h | – |
|
||||||
|
| #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | offen | v0.1.0 | 10 h | – |
|
||||||
|
| | **Summe** | | | **136 h** | **1 h 25 min** |
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
import prettier from "eslint-config-prettier/flat";
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
prettier,
|
||||||
|
globalIgnores([
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"coverage/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
// Erzeugter Prisma-Client.
|
||||||
|
"src/generated/**",
|
||||||
|
"test-results/**",
|
||||||
|
"playwright-report/**",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sicherheitskopfzeilen fuer alle Antworten. Die CSP selbst wird pro Anfrage in
|
||||||
|
* src/proxy.ts mit einer Nonce gesetzt, weil sie dort dynamisch sein muss.
|
||||||
|
*/
|
||||||
|
const SICHERHEITS_KOPFZEILEN = [
|
||||||
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||||
|
{ key: "X-Frame-Options", value: "DENY" },
|
||||||
|
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||||
|
{ key: "Permissions-Policy", value: "geolocation=(), microphone=(), camera=(self)" },
|
||||||
|
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const konfiguration: NextConfig = {
|
||||||
|
// Fuer das schlanke Laufzeit-Abbild im Dockerfile und fuer die native Installation.
|
||||||
|
output: "standalone",
|
||||||
|
async headers() {
|
||||||
|
return [{ source: "/:pfad*", headers: SICHERHEITS_KOPFZEILEN }];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default konfiguration;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
{
|
||||||
|
"name": "laufzettel",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Checklisten fuer wiederkehrende IT-Arbeiten: versionierte Vorlagen, Laeufe, Archiv, QR-Tags",
|
||||||
|
"packageManager": "pnpm@12.6.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "next build",
|
||||||
|
"db:down": "docker compose -f docker-compose.dev.yml down",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:seed": "prisma db seed",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"db:up": "docker compose -f docker-compose.dev.yml up -d",
|
||||||
|
"dev": "next dev",
|
||||||
|
"docs:pdf": "tsx scripts/anleitung-pdf.ts",
|
||||||
|
"docs:screenshots": "tsx scripts/screenshots.ts",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"issues:sync": "tsx scripts/gitea-issues.ts",
|
||||||
|
"lint": "eslint",
|
||||||
|
"postinstall": "prisma generate",
|
||||||
|
"prepare": "husky",
|
||||||
|
"pruefe:doku": "node scripts/pruefe-doku.mjs",
|
||||||
|
"start": "next start",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||||
|
"@fontsource-variable/manrope": "^5.3.0",
|
||||||
|
"@fontsource-variable/space-grotesk": "^5.3.0",
|
||||||
|
"@hookform/resolvers": "^5.9.1",
|
||||||
|
"@node-rs/argon2": "^2.2.1",
|
||||||
|
"@prisma/adapter-pg": "7.10.0",
|
||||||
|
"@prisma/client": "7.10.0",
|
||||||
|
"barcode-detector": "^3.2.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cn": "^0.4.0",
|
||||||
|
"jose": "^6.2.12",
|
||||||
|
"ldapts": "^9.2.0",
|
||||||
|
"lucide-react": "1.47.0",
|
||||||
|
"next": "16.3.6",
|
||||||
|
"node-cron": "^4.6.0",
|
||||||
|
"openid-client": "^6.8.8",
|
||||||
|
"pg": "^8.23.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"radix-ui": "^1.6.7",
|
||||||
|
"react": "19.3.0",
|
||||||
|
"react-dom": "19.3.0",
|
||||||
|
"react-hook-form": "^7.88.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"tailwind-merge": "^3.7.0",
|
||||||
|
"zod": "^4.6.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@commitlint/cli": "^21.2.3",
|
||||||
|
"@commitlint/config-conventional": "^21.2.3",
|
||||||
|
"@playwright/test": "1.63.0",
|
||||||
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
|
"@types/node": "^24.13.6",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@types/react": "^19.3.0",
|
||||||
|
"@types/react-dom": "^19.3.0",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"eslint": "^9.39.5",
|
||||||
|
"eslint-config-next": "16.3.6",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^17.5.1",
|
||||||
|
"prettier": "^3.9.9",
|
||||||
|
"prisma": "7.10.0",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"tsx": "^4.23.13",
|
||||||
|
"typescript": "~6.0.3",
|
||||||
|
"vite": "8.3.0",
|
||||||
|
"vitest": "5.0.1",
|
||||||
|
"yaml": "^2.9.1"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{ts,tsx,mts,mjs}": [
|
||||||
|
"eslint --fix",
|
||||||
|
"prettier --write"
|
||||||
|
],
|
||||||
|
"*.{json,css,md,yml,yaml}": [
|
||||||
|
"prettier --write"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3000";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests/e2e",
|
||||||
|
// Die Ablaeufe aendern gemeinsame Daten, deshalb nacheinander.
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
forbidOnly: Boolean(process.env["CI"]),
|
||||||
|
retries: process.env["CI"] ? 1 : 0,
|
||||||
|
reporter: process.env["CI"] ? [["list"], ["html", { open: "never" }]] : "list",
|
||||||
|
use: {
|
||||||
|
baseURL: APP_URL,
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
locale: "de-DE",
|
||||||
|
timezoneId: "Europe/Berlin",
|
||||||
|
colorScheme: "dark",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: "desktop", use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } } },
|
||||||
|
{ name: "handy", use: { ...devices["Pixel 7"] } },
|
||||||
|
],
|
||||||
|
});
|
||||||
Generated
+10129
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
|||||||
|
# pnpm 12 fuehrt Build-Skripte von Abhaengigkeiten nur nach ausdruecklicher Freigabe aus.
|
||||||
|
# Nur diese vier brauchen eines; alles andere laeuft ohne. Erweitern mit `pnpm approve-builds`.
|
||||||
|
allowBuilds:
|
||||||
|
"@prisma/engines": true
|
||||||
|
esbuild: true
|
||||||
|
prisma: true
|
||||||
|
unrs-resolver: true
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Tailwind 4 wird ueber sein PostCSS-Plugin eingebunden; eine tailwind.config.js gibt es nicht mehr.
|
||||||
|
const konfiguration = {
|
||||||
|
plugins: { "@tailwindcss/postcss": {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default konfiguration;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
seed: "tsx prisma/seed.ts",
|
||||||
|
},
|
||||||
|
datasource: { url: process.env["DATABASE_URL"] },
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Laufzettel – Datenmodell
|
||||||
|
// Benennung deutsch, Umlaute ASCII-gefaltet (siehe CLAUDE.md, Abschnitt Namensabbildung).
|
||||||
|
// Das vollstaendige Schema entsteht in #006; hier steht nur so viel, wie der
|
||||||
|
// Generator zum Laufen braucht.
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../src/generated/prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Rolle {
|
||||||
|
ADMIN
|
||||||
|
REDAKTEUR
|
||||||
|
TECHNIKER
|
||||||
|
LESER
|
||||||
|
}
|
||||||
|
|
||||||
|
model Benutzer {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
name String
|
||||||
|
rolle Rolle @default(LESER)
|
||||||
|
oidcSub String? @unique
|
||||||
|
passwortHash String?
|
||||||
|
aktiv Boolean @default(true)
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
geaendertAm DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([aktiv])
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 828 B |
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
|
<rect width="512" height="512" fill="#C6F135"/>
|
||||||
|
<g transform="translate(96 96) scale(6.6667)" fill="none" stroke="#0B0D10"
|
||||||
|
stroke-width="5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M15 8H9v32h6"/><path d="M33 8h6v32h-6"/><path d="M17.5 24.5l5 5 9.5-11"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 386 B |
Executable
+128
@@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
/**
|
||||||
|
* Spiegelt docs/issues/NNN-*.md in den Gitea-Tracker. Die Dateien sind die Quelle,
|
||||||
|
* der Tracker die Anzeige.
|
||||||
|
*
|
||||||
|
* Das Token kommt aus GITEA_TOKEN oder, falls das fehlt, aus ~/.git-credentials
|
||||||
|
* fuer denselben Host. Es wird nie ausgegeben.
|
||||||
|
*
|
||||||
|
* pnpm issues:sync spiegelt
|
||||||
|
* pnpm issues:sync --pruefen meldet nur Abweichungen, schreibt nichts
|
||||||
|
*/
|
||||||
|
import { readFileSync, readdirSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const VERZEICHNIS = "docs/issues";
|
||||||
|
const nurPruefen = process.argv.includes("--pruefen");
|
||||||
|
|
||||||
|
function remoteLesen(): { host: string; besitzer: string; repo: string } {
|
||||||
|
const konfiguration = readFileSync(".git/config", "utf8");
|
||||||
|
const treffer = konfiguration.match(
|
||||||
|
/url\s*=\s*https?:\/\/(?<host>[^/]+)\/(?<besitzer>[^/]+)\/(?<repo>[^/\s]+?)(?:\.git)?\s*$/m,
|
||||||
|
);
|
||||||
|
if (!treffer?.groups) throw new Error("Konnte den Remote aus .git/config nicht lesen.");
|
||||||
|
const { host, besitzer, repo } = treffer.groups;
|
||||||
|
return { host: host!, besitzer: besitzer!, repo: repo! };
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenLesen(host: string): string {
|
||||||
|
const ausUmgebung = process.env["GITEA_TOKEN"];
|
||||||
|
if (ausUmgebung) return ausUmgebung;
|
||||||
|
try {
|
||||||
|
const zeilen = readFileSync(join(homedir(), ".git-credentials"), "utf8");
|
||||||
|
const treffer = zeilen.match(new RegExp(`https://[^:]+:([^@]+)@${host.replace(".", "\\.")}`));
|
||||||
|
if (treffer?.[1]) return treffer[1];
|
||||||
|
} catch {
|
||||||
|
// Datei fehlt – dann greift die Meldung unten.
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Kein Token. Setze GITEA_TOKEN oder lege einen Zugang fuer ${host} in ~/.git-credentials ab.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { host, besitzer, repo } = remoteLesen();
|
||||||
|
const API = `https://${host}/api/v1/repos/${besitzer}/${repo}`;
|
||||||
|
const TOKEN = tokenLesen(host);
|
||||||
|
|
||||||
|
async function ruf<T>(methode: string, pfad: string, rumpf?: unknown): Promise<T> {
|
||||||
|
for (let versuch = 1; versuch <= 5; versuch += 1) {
|
||||||
|
try {
|
||||||
|
const antwort = await fetch(API + pfad, {
|
||||||
|
method: methode,
|
||||||
|
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/json" },
|
||||||
|
...(rumpf === undefined ? {} : { body: JSON.stringify(rumpf) }),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
if (!antwort.ok) {
|
||||||
|
throw new Error(`HTTP ${antwort.status} bei ${methode} ${pfad}: ${await antwort.text()}`);
|
||||||
|
}
|
||||||
|
return (await antwort.json().catch(() => null)) as T;
|
||||||
|
} catch (fehler) {
|
||||||
|
if (versuch === 5 || String(fehler).includes("HTTP 4")) throw fehler;
|
||||||
|
await new Promise((f) => setTimeout(f, versuch * 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("unerreichbar");
|
||||||
|
}
|
||||||
|
|
||||||
|
function feld(text: string, name: string): string {
|
||||||
|
return text.match(new RegExp(`\\*\\*${name}:\\*\\*\\s*(.+?)(?=\\s*\\*\\*|$)`, "m"))?.[1]?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
type Label = { id: number; name: string };
|
||||||
|
type Meilenstein = { id: number; title: string };
|
||||||
|
type Issue = { number: number; title: string };
|
||||||
|
|
||||||
|
const labels = new Map((await ruf<Label[]>("GET", "/labels?limit=100")).map((l) => [l.name, l.id] as const));
|
||||||
|
const meilensteine = new Map(
|
||||||
|
(await ruf<Meilenstein[]>("GET", "/milestones?state=all")).map((m) => [m.title, m.id] as const),
|
||||||
|
);
|
||||||
|
const vorhanden = new Map(
|
||||||
|
(await ruf<Issue[]>("GET", "/issues?state=all&limit=100")).map((i) => [i.title, i.number] as const),
|
||||||
|
);
|
||||||
|
|
||||||
|
let geaendert = 0;
|
||||||
|
for (const name of readdirSync(VERZEICHNIS)
|
||||||
|
.filter((n) => /^\d{3}-.+\.md$/.test(n))
|
||||||
|
.sort()) {
|
||||||
|
const text = readFileSync(join(VERZEICHNIS, name), "utf8");
|
||||||
|
const kopf = text.match(/^#\s*#(\d+)\s+(.+)$/m);
|
||||||
|
if (!kopf) throw new Error(`${name}: keine Kopfzeile der Form "# #007 Titel".`);
|
||||||
|
const titel = kopf[2]!.trim();
|
||||||
|
|
||||||
|
const labelNamen = feld(text, "Labels")
|
||||||
|
.split(",")
|
||||||
|
.map((n) => n.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const unbekannt = labelNamen.filter((n) => !labels.has(n));
|
||||||
|
if (unbekannt.length > 0) {
|
||||||
|
console.warn(` ${name}: unbekannte Labels: ${unbekannt.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nutzlast: Record<string, unknown> = {
|
||||||
|
title: titel,
|
||||||
|
body:
|
||||||
|
`${text.slice(kopf.index! + kopf[0].length).replace(/^\n+/, "")}\n\n---\n\n` +
|
||||||
|
`Quelle: \`${VERZEICHNIS}/${name}\` — dort gepflegt, hierher gespiegelt.`,
|
||||||
|
labels: labelNamen.filter((n) => labels.has(n)).map((n) => labels.get(n)!),
|
||||||
|
};
|
||||||
|
const meilenstein = meilensteine.get(feld(text, "Meilenstein"));
|
||||||
|
if (meilenstein !== undefined) nutzlast["milestone"] = meilenstein;
|
||||||
|
|
||||||
|
const nummer = vorhanden.get(titel);
|
||||||
|
if (nurPruefen) {
|
||||||
|
if (nummer === undefined) console.log(` fehlt im Tracker: ${titel}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (nummer === undefined) {
|
||||||
|
const neu = await ruf<Issue>("POST", "/issues", nutzlast);
|
||||||
|
const warnung = String(neu.number) === String(Number(name.slice(0, 3))) ? "" : " ⚠ Nummer weicht ab";
|
||||||
|
console.log(` angelegt #${neu.number} ${titel}${warnung}`);
|
||||||
|
} else {
|
||||||
|
await ruf("PATCH", `/issues/${nummer}`, nutzlast);
|
||||||
|
console.log(` aktualisiert #${nummer} ${titel}`);
|
||||||
|
}
|
||||||
|
geaendert += 1;
|
||||||
|
}
|
||||||
|
console.log(nurPruefen ? "Prüfung beendet." : `Fertig, ${geaendert} Issues gespiegelt.`);
|
||||||
Executable
+144
@@ -0,0 +1,144 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Prueft, dass Dokumentation und Code nicht auseinanderlaufen. Laeuft in CI und
|
||||||
|
* vor jedem Commit. Geprueft wird:
|
||||||
|
*
|
||||||
|
* 1. Die Version aus package.json hat einen Abschnitt im Changelog.
|
||||||
|
* 2. Jede Issue-Datei steht in der Uebersicht – und umgekehrt.
|
||||||
|
* 3. Geschaetzte und tatsaechliche Zeiten stimmen zwischen Datei und Uebersicht.
|
||||||
|
* 4. Alle in der Anleitung verlinkten Bilder sind vorhanden.
|
||||||
|
* 5. Kein Markdown verweist auf eine Datei, die es nicht gibt.
|
||||||
|
*/
|
||||||
|
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
||||||
|
import { join, dirname, resolve } from "node:path";
|
||||||
|
|
||||||
|
const wurzel = resolve(import.meta.dirname, "..");
|
||||||
|
const befunde = [];
|
||||||
|
|
||||||
|
function melde(text) {
|
||||||
|
befunde.push(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lies(pfad) {
|
||||||
|
return readFileSync(join(wurzel, pfad), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function feld(text, name) {
|
||||||
|
const treffer = text.match(new RegExp(`\\*\\*${name}:\\*\\*\\s*(.+?)(?=\\s*\\*\\*|$)`, "m"));
|
||||||
|
return treffer?.[1]?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 1. Version im Changelog ----------------------------------------------
|
||||||
|
{
|
||||||
|
const { version } = JSON.parse(lies("package.json"));
|
||||||
|
const changelog = lies("CHANGELOG.md");
|
||||||
|
const veroeffentlicht = changelog.includes(`## [${version}]`);
|
||||||
|
const unveroeffentlicht = /^## \[Unveröffentlicht\]/m.test(changelog);
|
||||||
|
if (!veroeffentlicht && !unveroeffentlicht) {
|
||||||
|
melde(
|
||||||
|
`Version ${version} hat keinen Abschnitt im Changelog und es gibt keinen Abschnitt ` +
|
||||||
|
`[Unveröffentlicht].`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 2. und 3. Issue-Dateien gegen die Uebersicht --------------------------
|
||||||
|
{
|
||||||
|
const verz = "docs/issues";
|
||||||
|
const dateien = readdirSync(join(wurzel, verz))
|
||||||
|
.filter((n) => /^\d{3}-.+\.md$/.test(n))
|
||||||
|
.sort();
|
||||||
|
const uebersicht = lies(join(verz, "README.md"));
|
||||||
|
|
||||||
|
for (const name of dateien) {
|
||||||
|
if (!uebersicht.includes(`(${name})`)) {
|
||||||
|
melde(`${verz}/${name} fehlt in ${verz}/README.md.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = lies(join(verz, name));
|
||||||
|
const kopf = text.match(/^#\s*#(\d+)\s+(.+)$/m);
|
||||||
|
if (!kopf) {
|
||||||
|
melde(`${verz}/${name} hat keine Kopfzeile der Form "# #007 Titel".`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [, nummer, titel] = kopf;
|
||||||
|
if (Number(nummer) !== Number(name.slice(0, 3))) {
|
||||||
|
melde(`${verz}/${name} nennt #${nummer}, der Dateiname sagt ${name.slice(0, 3)}.`);
|
||||||
|
}
|
||||||
|
const zeile = uebersicht.split("\n").find((z) => z.includes(`(${name})`));
|
||||||
|
for (const [beschriftung, wert] of [
|
||||||
|
["Status", feld(text, "Status")],
|
||||||
|
["Geschätzt", feld(text, "Geschätzt")],
|
||||||
|
["Tatsächlich", feld(text, "Tatsächlich")],
|
||||||
|
]) {
|
||||||
|
if (wert && !zeile.includes(`| ${wert} |`)) {
|
||||||
|
melde(`${verz}/${name}: "${beschriftung}: ${wert}" weicht von der Übersicht ab.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!zeile.includes(titel)) {
|
||||||
|
melde(`${verz}/${name}: Titel "${titel}" weicht von der Übersicht ab.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const treffer of uebersicht.matchAll(/\((\d{3}-[^)]+\.md)\)/g)) {
|
||||||
|
if (!dateien.includes(treffer[1])) {
|
||||||
|
melde(`${verz}/README.md verweist auf ${treffer[1]}, die Datei fehlt.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 4. Verlinkte Bilder in der Anleitung ----------------------------------
|
||||||
|
{
|
||||||
|
const verz = join(wurzel, "docs/anleitung");
|
||||||
|
if (existsSync(verz)) {
|
||||||
|
const sammeln = (ordner) =>
|
||||||
|
readdirSync(ordner, { withFileTypes: true }).flatMap((eintrag) => {
|
||||||
|
const pfad = join(ordner, eintrag.name);
|
||||||
|
if (eintrag.isDirectory()) return sammeln(pfad);
|
||||||
|
return eintrag.name.endsWith(".md") ? [pfad] : [];
|
||||||
|
});
|
||||||
|
for (const datei of sammeln(verz)) {
|
||||||
|
const text = readFileSync(datei, "utf8");
|
||||||
|
for (const treffer of text.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) {
|
||||||
|
const ziel = treffer[1];
|
||||||
|
if (/^https?:/.test(ziel)) continue;
|
||||||
|
if (!existsSync(resolve(dirname(datei), ziel))) {
|
||||||
|
melde(`${datei.replace(`${wurzel}/`, "")} verlinkt das fehlende Bild ${ziel}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 5. Verweise zwischen Markdown-Dateien --------------------------------
|
||||||
|
{
|
||||||
|
const sammeln = (ordner) =>
|
||||||
|
readdirSync(ordner, { withFileTypes: true }).flatMap((eintrag) => {
|
||||||
|
if (["node_modules", ".git", ".next"].includes(eintrag.name)) return [];
|
||||||
|
const pfad = join(ordner, eintrag.name);
|
||||||
|
if (eintrag.isDirectory()) return sammeln(pfad);
|
||||||
|
return eintrag.name.endsWith(".md") ? [pfad] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const datei of sammeln(wurzel)) {
|
||||||
|
const text = readFileSync(datei, "utf8");
|
||||||
|
for (const treffer of text.matchAll(/(?<!!)\[[^\]]*\]\(([^)]+)\)/g)) {
|
||||||
|
const ziel = treffer[1];
|
||||||
|
if (/^(https?:|mailto:|#)/.test(ziel)) continue;
|
||||||
|
const ohneAnker = ziel.split("#")[0];
|
||||||
|
if (!ohneAnker) continue;
|
||||||
|
if (!existsSync(resolve(dirname(datei), ohneAnker))) {
|
||||||
|
melde(`${datei.replace(`${wurzel}/`, "")} verweist auf ${ohneAnker}, das es nicht gibt.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Ergebnis --------------------------------------------------------------
|
||||||
|
if (befunde.length > 0) {
|
||||||
|
console.error(`\nDokumentation und Code laufen auseinander (${befunde.length}):\n`);
|
||||||
|
for (const b of befunde) console.error(` - ${b}`);
|
||||||
|
console.error("");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log("Dokumentation stimmt.");
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
/**
|
||||||
|
* Erzeugt die Screenshots reproduzierbar neu – fuer die Meilenstein-Belege unter
|
||||||
|
* docs/screenshots/<meilenstein>/ und fuer die Anleitung unter docs/anleitung/bilder/.
|
||||||
|
*
|
||||||
|
* Bilder werden nie von Hand nachbearbeitet. Aendert sich die Oberflaeche, laeuft
|
||||||
|
* dieses Skript erneut.
|
||||||
|
*
|
||||||
|
* pnpm docs:screenshots alle Seiten nach docs/anleitung/bilder/
|
||||||
|
* pnpm docs:screenshots --ziel m0 nach docs/screenshots/m0/
|
||||||
|
* pnpm docs:screenshots --nur uebersicht nur diese Seite
|
||||||
|
*
|
||||||
|
* Voraussetzung: die App laeuft (pnpm start oder pnpm dev). Adresse ueber
|
||||||
|
* E2E_APP_URL, Standard http://127.0.0.1:3000.
|
||||||
|
*/
|
||||||
|
import { mkdir } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { chromium, type Browser } from "@playwright/test";
|
||||||
|
|
||||||
|
const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3000";
|
||||||
|
|
||||||
|
const ANSICHTEN = [
|
||||||
|
{ name: "desktop", breite: 1440, hoehe: 900 },
|
||||||
|
{ name: "handy", breite: 390, hoehe: 844 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Seiten, die abgebildet werden. Waechst mit jedem Meilenstein. */
|
||||||
|
const SEITEN: readonly { readonly name: string; readonly pfad: string }[] = [
|
||||||
|
{ name: "uebersicht", pfad: "/" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function argument(name: string): string | undefined {
|
||||||
|
const index = process.argv.indexOf(`--${name}`);
|
||||||
|
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function warteAufApp(): Promise<void> {
|
||||||
|
for (let versuch = 1; versuch <= 30; versuch += 1) {
|
||||||
|
try {
|
||||||
|
const antwort = await fetch(APP_URL, { signal: AbortSignal.timeout(2000) });
|
||||||
|
if (antwort.ok) return;
|
||||||
|
} catch {
|
||||||
|
// noch nicht da
|
||||||
|
}
|
||||||
|
await new Promise((f) => setTimeout(f, 1000));
|
||||||
|
}
|
||||||
|
throw new Error(`Die App antwortet nicht auf ${APP_URL}. Erst "pnpm start" laufen lassen.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erzeugt die PWA-Kacheln aus dem SVG, damit keine Bilddatei von Hand entsteht. */
|
||||||
|
async function kachelnErzeugen(browser: Browser): Promise<void> {
|
||||||
|
const svg = new URL(`${APP_URL}/icon-kachel.svg`).toString();
|
||||||
|
for (const kante of [192, 512]) {
|
||||||
|
const seite = await browser.newPage({ viewport: { width: kante, height: kante } });
|
||||||
|
await seite.goto(svg, { waitUntil: "load" });
|
||||||
|
await seite.screenshot({ path: join("public", `icon-${kante}.png`), omitBackground: false });
|
||||||
|
await seite.close();
|
||||||
|
console.log(` public/icon-${kante}.png`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const zielMeilenstein = argument("ziel");
|
||||||
|
const nur = argument("nur");
|
||||||
|
const ziel = zielMeilenstein
|
||||||
|
? join("docs", "screenshots", zielMeilenstein)
|
||||||
|
: join("docs", "anleitung", "bilder");
|
||||||
|
|
||||||
|
await warteAufApp();
|
||||||
|
await mkdir(ziel, { recursive: true });
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
try {
|
||||||
|
await kachelnErzeugen(browser);
|
||||||
|
|
||||||
|
for (const seite of SEITEN.filter((s) => !nur || s.name === nur)) {
|
||||||
|
for (const ansicht of ANSICHTEN) {
|
||||||
|
const kontext = await browser.newContext({
|
||||||
|
viewport: { width: ansicht.breite, height: ansicht.hoehe },
|
||||||
|
colorScheme: "dark",
|
||||||
|
locale: "de-DE",
|
||||||
|
timezoneId: "Europe/Berlin",
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
});
|
||||||
|
const tab = await kontext.newPage();
|
||||||
|
await tab.goto(`${APP_URL}${seite.pfad}`, { waitUntil: "networkidle" });
|
||||||
|
// Schriften muessen geladen sein, sonst wackelt das Bild zwischen Laeufen.
|
||||||
|
await tab.evaluate(() => document.fonts.ready);
|
||||||
|
|
||||||
|
const datei = join(ziel, `${seite.name}-${ansicht.name}.png`);
|
||||||
|
await tab.screenshot({ path: datei, fullPage: ansicht.name === "desktop" });
|
||||||
|
|
||||||
|
// Am Handy darf nie horizontal gescrollt werden – hier gleich mitgeprueft.
|
||||||
|
if (ansicht.name === "handy") {
|
||||||
|
const masse = await tab.evaluate(() => ({
|
||||||
|
scroll: document.documentElement.scrollWidth,
|
||||||
|
sichtbar: document.documentElement.clientWidth,
|
||||||
|
}));
|
||||||
|
if (masse.scroll > masse.sichtbar) {
|
||||||
|
throw new Error(`${seite.pfad} scrollt am Handy horizontal: ${masse.scroll} > ${masse.sichtbar}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ${datei}`);
|
||||||
|
await kontext.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
console.log("Screenshots erzeugt.");
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
/** Healthcheck fuer Docker und den Reverse Proxy. Prueft, ob die Datenbank antwortet. */
|
||||||
|
export async function GET(): Promise<Response> {
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
|
return Response.json({ status: "ok" });
|
||||||
|
} catch (fehler) {
|
||||||
|
const meldung = fehler instanceof Error ? fehler.message : "unbekannter Fehler";
|
||||||
|
return Response.json({ status: "fehler", meldung }, { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Laufzettel – Gestaltung.
|
||||||
|
* Dunkel zuerst. Ein einziger Akzent (Signal-Lime), nur fuer Aktionen, Fortschritt,
|
||||||
|
* Fokus und aktive Zustaende. Keine Verlaeufe, keine Schatten.
|
||||||
|
* Druckansichten und Etiketten sind immer hell, siehe Abschnitt @media print.
|
||||||
|
*/
|
||||||
|
@theme {
|
||||||
|
--color-grund: #0b0d10;
|
||||||
|
--color-flaeche: #14171c;
|
||||||
|
--color-flaeche-hoch: #1b1f26;
|
||||||
|
--color-linie: #262b33;
|
||||||
|
--color-text: #f3f5f7;
|
||||||
|
--color-nebentext: #98a2b3;
|
||||||
|
|
||||||
|
--color-akzent: #c6f135;
|
||||||
|
--color-auf-akzent: #0b0d10;
|
||||||
|
--color-akzent-flaeche: rgb(198 241 53 / 0.12);
|
||||||
|
|
||||||
|
--color-erledigt: #3ddc97;
|
||||||
|
--color-faellig: #f5b84a;
|
||||||
|
--color-fehler: #f87171;
|
||||||
|
|
||||||
|
--font-titel: "Space Grotesk Variable", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-sans: "Manrope Variable", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono Variable", ui-monospace, monospace;
|
||||||
|
|
||||||
|
--radius-karte: 1rem;
|
||||||
|
--radius-pille: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Heller Modus ist optional und nur aktiv, wenn er ausdruecklich gewaehlt wurde. */
|
||||||
|
:root[data-thema="hell"] {
|
||||||
|
--color-grund: #f6f7f4;
|
||||||
|
--color-flaeche: #ffffff;
|
||||||
|
--color-flaeche-hoch: #ffffff;
|
||||||
|
--color-linie: #d9ddd4;
|
||||||
|
--color-text: #0b0d10;
|
||||||
|
--color-nebentext: #5b6472;
|
||||||
|
--color-akzent: #5e8a00;
|
||||||
|
--color-auf-akzent: #ffffff;
|
||||||
|
--color-akzent-flaeche: rgb(94 138 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Vertrag von shadcn/ui, gespeist aus den Laufzettel-Token oben.
|
||||||
|
* Dadurch sieht jede per `pnpm dlx shadcn@latest add <bauteil>` kopierte
|
||||||
|
* Komponente sofort nach Laufzettel aus, ohne dass sie angefasst werden muss –
|
||||||
|
* und der helle Modus greift automatisch mit.
|
||||||
|
*/
|
||||||
|
:root {
|
||||||
|
--background: var(--color-grund);
|
||||||
|
--foreground: var(--color-text);
|
||||||
|
--card: var(--color-flaeche);
|
||||||
|
--card-foreground: var(--color-text);
|
||||||
|
--popover: var(--color-flaeche-hoch);
|
||||||
|
--popover-foreground: var(--color-text);
|
||||||
|
--primary: var(--color-akzent);
|
||||||
|
--primary-foreground: var(--color-auf-akzent);
|
||||||
|
--secondary: var(--color-flaeche-hoch);
|
||||||
|
--secondary-foreground: var(--color-text);
|
||||||
|
--muted: var(--color-flaeche-hoch);
|
||||||
|
--muted-foreground: var(--color-nebentext);
|
||||||
|
--accent: var(--color-akzent-flaeche);
|
||||||
|
--accent-foreground: var(--color-akzent);
|
||||||
|
--destructive: var(--color-fehler);
|
||||||
|
--destructive-foreground: var(--color-grund);
|
||||||
|
--border: var(--color-linie);
|
||||||
|
--input: var(--color-linie);
|
||||||
|
--ring: var(--color-akzent);
|
||||||
|
--radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-grund);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
/* Am Handy darf nie horizontal gescrollt werden. */
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
.zahl {
|
||||||
|
font-family: var(--font-titel);
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
samp,
|
||||||
|
.mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fokus immer sichtbar und immer im Akzent. */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-akzent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #0b0d10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nicht-drucken {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||||
|
<rect width="48" height="48" rx="10" fill="#0B0D10"/>
|
||||||
|
<g fill="none" stroke="#C6F135" stroke-width="5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M15 8H9v32h6"/><path d="M33 8h6v32h-6"/><path d="M17.5 24.5l5 5 9.5-11"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 340 B |
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import "@fontsource-variable/space-grotesk";
|
||||||
|
import "@fontsource-variable/manrope";
|
||||||
|
import "@fontsource-variable/jetbrains-mono";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "laufzettel",
|
||||||
|
description: "Checklisten fuer wiederkehrende IT-Arbeiten",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: "#0B0D10",
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WurzelLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="de">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
return {
|
||||||
|
name: "laufzettel",
|
||||||
|
short_name: "laufzettel",
|
||||||
|
description: "Checklisten fuer wiederkehrende IT-Arbeiten",
|
||||||
|
start_url: "/",
|
||||||
|
display: "standalone",
|
||||||
|
background_color: "#0B0D10",
|
||||||
|
theme_color: "#0B0D10",
|
||||||
|
lang: "de",
|
||||||
|
icons: [
|
||||||
|
{ src: "/icon-kachel.svg", sizes: "any", type: "image/svg+xml", purpose: "any" },
|
||||||
|
{ src: "/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Huelle } from "@/components/huelle";
|
||||||
|
|
||||||
|
export default function Startseite() {
|
||||||
|
return (
|
||||||
|
<Huelle aktiv="/">
|
||||||
|
<h1 className="text-2xl font-bold">Übersicht</h1>
|
||||||
|
<p className="mt-2 max-w-prose text-nebentext">
|
||||||
|
Das Gerüst steht: Gestaltung, Betriebsstand, Qualitätsschranken und Prozess. Die Fachlichkeit kommt ab
|
||||||
|
dem Datenmodell.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section className="mt-8 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{[
|
||||||
|
{ nummer: "01", titel: "Datenmodell", issue: "#006" },
|
||||||
|
{ nummer: "02", titel: "Anmeldung und Rollen", issue: "#007" },
|
||||||
|
{ nummer: "03", titel: "Betriebsmodus und Kunden", issue: "#008" },
|
||||||
|
].map((karte) => (
|
||||||
|
<article key={karte.nummer} className="rounded-karte border border-linie bg-flaeche p-5">
|
||||||
|
<p className="mono text-xs text-nebentext">
|
||||||
|
<span className="text-akzent">{karte.nummer}</span> · als Nächstes
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-lg font-semibold">{karte.titel}</h2>
|
||||||
|
<p className="mono mt-1 text-xs text-nebentext">{karte.issue}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</Huelle>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Bildmarke: die Checkbox aus dem Terminal, `[ ✓ ]`, als Monolinie.
|
||||||
|
* `currentColor`, damit sie in der Oberflaeche Lime und im Druck dunkel ist.
|
||||||
|
*/
|
||||||
|
export function Bildmarke({ className, titel }: { className?: string; titel?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
role={titel ? "img" : "presentation"}
|
||||||
|
aria-label={titel}
|
||||||
|
aria-hidden={titel ? undefined : true}
|
||||||
|
>
|
||||||
|
<path d="M15 8H9v32h6" />
|
||||||
|
<path d="M33 8h6v32h-6" />
|
||||||
|
<path d="M17.5 24.5l5 5 9.5-11" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Archive,
|
||||||
|
Building2,
|
||||||
|
FileText,
|
||||||
|
LayoutDashboard,
|
||||||
|
ListChecks,
|
||||||
|
Menu,
|
||||||
|
ScanLine,
|
||||||
|
Settings,
|
||||||
|
Users,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Bildmarke } from "@/components/bildmarke";
|
||||||
|
import { Wortmarke } from "@/components/wortmarke";
|
||||||
|
import { navigationFuer, type Betriebsmodus, type NavEintrag } from "@/lib/navigation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { versionText } from "@/lib/version";
|
||||||
|
|
||||||
|
const SYMBOLE: Record<string, LucideIcon> = {
|
||||||
|
LayoutDashboard,
|
||||||
|
ListChecks,
|
||||||
|
FileText,
|
||||||
|
Users,
|
||||||
|
Building2,
|
||||||
|
Archive,
|
||||||
|
Settings,
|
||||||
|
};
|
||||||
|
|
||||||
|
function NavListe({ modus, aktiv }: { modus: Betriebsmodus; aktiv: string }) {
|
||||||
|
return (
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{navigationFuer(modus).map((eintrag) => (
|
||||||
|
<li key={eintrag.pfad}>
|
||||||
|
<NavZeile eintrag={eintrag} aktiv={eintrag.pfad === aktiv} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavZeile({ eintrag, aktiv }: { eintrag: NavEintrag; aktiv: boolean }) {
|
||||||
|
const Symbol = SYMBOLE[eintrag.symbol] ?? LayoutDashboard;
|
||||||
|
const inhalt = (
|
||||||
|
<>
|
||||||
|
<Symbol className="size-[18px] shrink-0" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<span className="truncate">{eintrag.beschriftung}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const gemeinsam =
|
||||||
|
"flex min-h-11 items-center gap-3 rounded-pille px-3 text-sm transition-colors md:min-h-10";
|
||||||
|
|
||||||
|
// Noch nicht vorhandene Seiten werden nicht verlinkt, damit kein Klick ins Leere geht.
|
||||||
|
if (!eintrag.verfuegbar) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`${gemeinsam} cursor-not-allowed text-nebentext/50`}
|
||||||
|
title={`Kommt mit #${String(eintrag.ausIssue).padStart(3, "0")}`}
|
||||||
|
aria-disabled="true"
|
||||||
|
>
|
||||||
|
{inhalt}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={eintrag.pfad}
|
||||||
|
aria-current={aktiv ? "page" : undefined}
|
||||||
|
className={`${gemeinsam} ${
|
||||||
|
aktiv
|
||||||
|
? "bg-akzent-flaeche font-semibold text-akzent"
|
||||||
|
: "text-nebentext hover:bg-flaeche-hoch hover:text-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{inhalt}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScannenKnopf({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
// Pille statt der shadcn-Vorgabe rounded-lg, und 44 px am Handy / 40 px am
|
||||||
|
// Schreibtisch statt 32 px – so verlangt es die Gestaltungsvorgabe.
|
||||||
|
<Button asChild className={cn("h-11 gap-2 rounded-pille px-4 text-sm font-semibold md:h-10", className)}>
|
||||||
|
<Link href="/scannen">
|
||||||
|
<ScanLine className="size-[18px]" strokeWidth={2} aria-hidden="true" />
|
||||||
|
Scannen
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Huelle der Anwendung: dunkle Seitenleiste am Schreibtisch, am Handy eine
|
||||||
|
* Kopfzeile mit aufklappbarem Menue. Der Scannen-Knopf sitzt an beiden Stellen
|
||||||
|
* ganz oben, weil der Techniker damit anfaengt.
|
||||||
|
*/
|
||||||
|
export function Huelle({
|
||||||
|
children,
|
||||||
|
modus = "INTERN",
|
||||||
|
aktiv = "/",
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
modus?: Betriebsmodus;
|
||||||
|
aktiv?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-dvh md:flex">
|
||||||
|
{/* Handy: Kopfzeile mit Menue */}
|
||||||
|
<header className="sticky top-0 z-20 border-b border-linie bg-flaeche md:hidden">
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
|
<Bildmarke className="size-6 shrink-0 text-akzent" />
|
||||||
|
<Wortmarke className="text-lg" />
|
||||||
|
<ScannenKnopf className="ml-auto" />
|
||||||
|
<details className="relative">
|
||||||
|
<summary
|
||||||
|
className="flex size-11 cursor-pointer list-none items-center justify-center rounded-pille text-nebentext"
|
||||||
|
aria-label="Menü"
|
||||||
|
>
|
||||||
|
<Menu className="size-5" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
</summary>
|
||||||
|
<nav className="absolute right-0 z-30 mt-2 w-56 rounded-karte border border-linie bg-flaeche-hoch p-2">
|
||||||
|
<NavListe modus={modus} aktiv={aktiv} />
|
||||||
|
</nav>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Schreibtisch: Seitenleiste */}
|
||||||
|
<aside className="hidden w-60 shrink-0 flex-col border-r border-linie bg-flaeche p-4 md:flex">
|
||||||
|
<Link href="/" className="mb-4 flex items-center gap-2">
|
||||||
|
<Bildmarke className="size-7 shrink-0 text-akzent" />
|
||||||
|
<Wortmarke className="text-xl" />
|
||||||
|
</Link>
|
||||||
|
<ScannenKnopf className="mb-6" />
|
||||||
|
<nav aria-label="Hauptnavigation">
|
||||||
|
<NavListe modus={modus} aktiv={aktiv} />
|
||||||
|
</nav>
|
||||||
|
<span className="mono mt-auto pt-6 text-xs text-nebentext">{versionText()}</span>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="min-w-0 flex-1 p-4 md:p-8">{children}</main>
|
||||||
|
|
||||||
|
{/* Am Handy steht die Version unten im Inhalt, nicht fest ueber allem. */}
|
||||||
|
<span className="mono block px-4 pb-4 text-xs text-nebentext md:hidden">{versionText()}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "cn";
|
||||||
|
import { Slot } from "radix-ui";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Wortmarke: `laufzettel` in Space Grotesk 700, Laufweite -0.04em,
|
||||||
|
* dahinter ein Punkt in Akzentfarbe (Durchmesser etwa 0,18 em).
|
||||||
|
*/
|
||||||
|
export function Wortmarke({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`font-titel font-bold tracking-[-0.04em] text-text ${className ?? ""}`}
|
||||||
|
aria-label="laufzettel"
|
||||||
|
>
|
||||||
|
laufzettel
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="ml-[0.1em] inline-block rounded-full bg-akzent align-baseline"
|
||||||
|
style={{ width: "0.18em", height: "0.18em" }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
|
import { PrismaClient } from "@/generated/prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zugriff auf die Datenbank.
|
||||||
|
*
|
||||||
|
* Der Client wird erst beim ersten Zugriff gebaut. Dadurch laeuft `next build`
|
||||||
|
* ohne gesetzte DATABASE_URL durch, obwohl Module den Import auf oberster Ebene
|
||||||
|
* haben. In der Entwicklung liegt die Instanz an globalThis, damit Hot Reload
|
||||||
|
* nicht bei jedem Speichern eine neue Verbindung aufbaut.
|
||||||
|
*/
|
||||||
|
function clientBauen(): PrismaClient {
|
||||||
|
const url = process.env["DATABASE_URL"];
|
||||||
|
if (!url) {
|
||||||
|
throw new Error("DATABASE_URL ist nicht gesetzt. Siehe .env.example.");
|
||||||
|
}
|
||||||
|
return new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalerZwischenspeicher = globalThis as typeof globalThis & {
|
||||||
|
laufzettelPrisma?: PrismaClient;
|
||||||
|
};
|
||||||
|
|
||||||
|
let instanz: PrismaClient | undefined;
|
||||||
|
|
||||||
|
function instanzHolen(): PrismaClient {
|
||||||
|
if (process.env["NODE_ENV"] !== "production") {
|
||||||
|
globalerZwischenspeicher.laufzettelPrisma ??= clientBauen();
|
||||||
|
return globalerZwischenspeicher.laufzettelPrisma;
|
||||||
|
}
|
||||||
|
instanz ??= clientBauen();
|
||||||
|
return instanz;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prisma = new Proxy({} as PrismaClient, {
|
||||||
|
get(_ziel, eigenschaft, empfaenger) {
|
||||||
|
return Reflect.get(instanzHolen(), eigenschaft, empfaenger);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Die Seitenleiste an einer Stelle beschrieben.
|
||||||
|
*
|
||||||
|
* `verfuegbar` wird umgestellt, sobald der Meilenstein die Seite liefert. So zeigt
|
||||||
|
* die Huelle von Anfang an die echte Struktur, ohne auf Seiten zu verlinken, die
|
||||||
|
* es noch nicht gibt. Die Beschriftungen haengen am Betriebsmodus (#008).
|
||||||
|
*/
|
||||||
|
export type Betriebsmodus = "INTERN" | "DIENSTLEISTUNG";
|
||||||
|
|
||||||
|
export type NavEintrag = {
|
||||||
|
readonly pfad: string;
|
||||||
|
readonly beschriftung: string;
|
||||||
|
/** Beschriftung im Modus Dienstleistung, falls sie abweicht. */
|
||||||
|
readonly beschriftungDienstleistung?: string;
|
||||||
|
/** Nur in diesem Modus zeigen. Fehlt der Wert, gilt der Eintrag in beiden. */
|
||||||
|
readonly nurModus?: Betriebsmodus;
|
||||||
|
readonly symbol: string;
|
||||||
|
readonly verfuegbar: boolean;
|
||||||
|
/** Issue, das die Seite liefert – nur fuer den Hinweis, solange sie fehlt. */
|
||||||
|
readonly ausIssue?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NAVIGATION: readonly NavEintrag[] = [
|
||||||
|
{ pfad: "/", beschriftung: "Übersicht", symbol: "LayoutDashboard", verfuegbar: true },
|
||||||
|
{ pfad: "/laeufe", beschriftung: "Läufe", symbol: "ListChecks", verfuegbar: false, ausIssue: 13 },
|
||||||
|
{ pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", verfuegbar: false, ausIssue: 10 },
|
||||||
|
{
|
||||||
|
pfad: "/mitarbeiter",
|
||||||
|
beschriftung: "Mitarbeiter & Geräte",
|
||||||
|
nurModus: "INTERN",
|
||||||
|
symbol: "Users",
|
||||||
|
verfuegbar: false,
|
||||||
|
ausIssue: 11,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pfad: "/kunden",
|
||||||
|
beschriftung: "Kunden",
|
||||||
|
nurModus: "DIENSTLEISTUNG",
|
||||||
|
symbol: "Building2",
|
||||||
|
verfuegbar: false,
|
||||||
|
ausIssue: 8,
|
||||||
|
},
|
||||||
|
{ pfad: "/archiv", beschriftung: "Archiv", symbol: "Archive", verfuegbar: false, ausIssue: 15 },
|
||||||
|
{ pfad: "/admin", beschriftung: "Admin", symbol: "Settings", verfuegbar: false, ausIssue: 8 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function navigationFuer(modus: Betriebsmodus): readonly NavEintrag[] {
|
||||||
|
return NAVIGATION.filter((e) => e.nurModus === undefined || e.nurModus === modus).map((e) =>
|
||||||
|
modus === "DIENSTLEISTUNG" && e.beschriftungDienstleistung
|
||||||
|
? { ...e, beschriftung: e.beschriftungDienstleistung }
|
||||||
|
: e,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
/** Klassen zusammenfuehren, spaetere Tailwind-Klassen gewinnen. Von shadcn/ui erwartet. */
|
||||||
|
export function cn(...eingaben: ClassValue[]): string {
|
||||||
|
return twMerge(clsx(eingaben));
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Version und Git-Stand fuer die Anzeige unten links.
|
||||||
|
* Beide Werte werden beim Bauen gesetzt (Docker-Build-Arg bzw. deploy/install.sh).
|
||||||
|
*/
|
||||||
|
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.0.0-dev";
|
||||||
|
export const GIT_SHA = (process.env["NEXT_PUBLIC_GIT_SHA"] ?? "").slice(0, 7);
|
||||||
|
|
||||||
|
export function versionText(): string {
|
||||||
|
return GIT_SHA ? `v${VERSION} · ${GIT_SHA}` : `v${VERSION}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In Next 16 heisst die frueher middleware.ts genannte Datei proxy.ts und laeuft
|
||||||
|
* in der Node-Laufzeit.
|
||||||
|
*
|
||||||
|
* Hier wird die Content-Security-Policy pro Anfrage mit einer Nonce gesetzt. Sie
|
||||||
|
* muss dynamisch sein, deshalb steht sie nicht bei den statischen Kopfzeilen in
|
||||||
|
* next.config.ts. Die Anmeldepflicht kommt in #007 dazu.
|
||||||
|
*/
|
||||||
|
export function proxy(anfrage: NextRequest): NextResponse {
|
||||||
|
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
|
||||||
|
|
||||||
|
const richtlinie = [
|
||||||
|
`default-src 'self'`,
|
||||||
|
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
|
||||||
|
// Tailwind erzeugt Stilangaben zur Laufzeit, deshalb unsafe-inline fuer Stile.
|
||||||
|
`style-src 'self' 'unsafe-inline'`,
|
||||||
|
`img-src 'self' data: blob:`,
|
||||||
|
`font-src 'self'`,
|
||||||
|
// Die Kamera des Scanners liefert Bilddaten als blob:.
|
||||||
|
`media-src 'self' blob:`,
|
||||||
|
`connect-src 'self'`,
|
||||||
|
`frame-ancestors 'none'`,
|
||||||
|
`base-uri 'self'`,
|
||||||
|
`form-action 'self'`,
|
||||||
|
`object-src 'none'`,
|
||||||
|
].join("; ");
|
||||||
|
|
||||||
|
const kopfzeilen = new Headers(anfrage.headers);
|
||||||
|
kopfzeilen.set("x-nonce", nonce);
|
||||||
|
|
||||||
|
const antwort = NextResponse.next({ request: { headers: kopfzeilen } });
|
||||||
|
antwort.headers.set("Content-Security-Policy", richtlinie);
|
||||||
|
return antwort;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// Statische Dateien und Bilder brauchen keine CSP-Nonce.
|
||||||
|
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|webp|woff2?)$).*)"],
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Datenbank im Betriebsstand ist absichtlich von aussen nicht erreichbar.
|
||||||
|
* Diese Tests halten das fest, damit ein ports:-Mapping nicht versehentlich
|
||||||
|
* zurueckkommt. Siehe docs/betrieb/inbetriebnahme.md.
|
||||||
|
*/
|
||||||
|
function compose(datei: string): Record<string, never> {
|
||||||
|
return parse(readFileSync(datei, "utf8")) as Record<string, never>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Betriebsstand: docker-compose.yml", () => {
|
||||||
|
const stand = compose("docker-compose.yml") as unknown as {
|
||||||
|
services: Record<string, { ports?: unknown[]; networks?: string[] }>;
|
||||||
|
networks: Record<string, { internal?: boolean } | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("veroeffentlicht keinen Port der Datenbank", () => {
|
||||||
|
expect(stand.services["postgres"]?.ports).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("haengt die Datenbank nur in das interne Netz", () => {
|
||||||
|
expect(stand.services["postgres"]?.networks).toEqual(["intern"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hat das interne Netz ohne Gateway", () => {
|
||||||
|
expect(stand.networks["intern"]?.internal).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("laesst nur den Webdienst in das externe Netz", () => {
|
||||||
|
const aussen = Object.entries(stand.services)
|
||||||
|
.filter(([, dienst]) => dienst.networks?.includes("extern"))
|
||||||
|
.map(([name]) => name);
|
||||||
|
expect(aussen).toEqual(["web"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bindet den Webdienst standardmaessig nur auf localhost", () => {
|
||||||
|
expect(stand.services["web"]?.ports).toEqual(["${BIND_ADDR:-127.0.0.1}:${APP_PORT:-3000}:3000"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bricht ohne Geheimnisse ab, statt mit Standardwerten zu starten", () => {
|
||||||
|
const roh = readFileSync("docker-compose.yml", "utf8");
|
||||||
|
expect(roh).toContain("${APP_SECRET:?");
|
||||||
|
expect(roh).toContain("${POSTGRES_PASSWORD:?");
|
||||||
|
expect(roh).toContain("${APP_URL:?");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Betriebsstand: Entwicklungsdatenbank", () => {
|
||||||
|
it("laeuft auf 5433, damit ein lokales 5432 frei bleibt", () => {
|
||||||
|
const stand = compose("docker-compose.dev.yml") as unknown as {
|
||||||
|
services: Record<string, { ports?: string[] }>;
|
||||||
|
};
|
||||||
|
expect(stand.services["postgres"]?.ports).toEqual(["127.0.0.1:5433:5432"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt die Testdatenbank an und spielt die Migrationen ein.
|
||||||
|
* Ohne TEST_DATABASE_URL wird nur gewarnt; Datenbanktests ueberspringen sich dann
|
||||||
|
* selbst ueber `describe.runIf(datenbankVerfuegbar)`.
|
||||||
|
*/
|
||||||
|
export default async function aufbau(): Promise<void> {
|
||||||
|
const url = process.env["TEST_DATABASE_URL"];
|
||||||
|
if (!url) {
|
||||||
|
console.warn(
|
||||||
|
"\n TEST_DATABASE_URL ist nicht gesetzt – Datenbanktests werden uebersprungen." +
|
||||||
|
"\n Datenbank starten mit: pnpm db:up\n",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { execFileSync } = await import("node:child_process");
|
||||||
|
execFileSync("pnpm", ["exec", "prisma", "migrate", "deploy"], {
|
||||||
|
stdio: "inherit",
|
||||||
|
env: { ...process.env, DATABASE_URL: url },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
|
import { PrismaClient } from "@/generated/prisma/client";
|
||||||
|
|
||||||
|
const url = process.env["TEST_DATABASE_URL"] ?? process.env["DATABASE_URL"];
|
||||||
|
|
||||||
|
/** Ohne Testdatenbank ueberspringen sich die Datenbanktests statt fehlzuschlagen. */
|
||||||
|
export const datenbankVerfuegbar = Boolean(url);
|
||||||
|
|
||||||
|
export const testPrisma = new PrismaClient({
|
||||||
|
adapter: new PrismaPg({
|
||||||
|
connectionString: url ?? "postgresql://localhost:5432/laufzettel_test",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Leert alle Tabellen; die Reihenfolge spielt dank CASCADE keine Rolle. */
|
||||||
|
export async function datenbankLeeren(): Promise<void> {
|
||||||
|
const tabellen = await testPrisma.$queryRaw<{ tablename: string }[]>`
|
||||||
|
SELECT tablename FROM pg_tables
|
||||||
|
WHERE schemaname = 'public' AND tablename NOT LIKE '_prisma%'
|
||||||
|
`;
|
||||||
|
if (tabellen.length === 0) return;
|
||||||
|
const liste = tabellen.map((t) => `"public"."${t.tablename}"`).join(", ");
|
||||||
|
await testPrisma.$executeRawUnsafe(`TRUNCATE ${liste} RESTART IDENTITY CASCADE`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { navigationFuer } from "@/lib/navigation";
|
||||||
|
|
||||||
|
describe("Seitenleiste", () => {
|
||||||
|
it("zeigt im Modus Intern die Kunden-Ebene nicht", () => {
|
||||||
|
const pfade = navigationFuer("INTERN").map((e) => e.pfad);
|
||||||
|
expect(pfade).not.toContain("/kunden");
|
||||||
|
expect(pfade).toContain("/mitarbeiter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zeigt im Modus Dienstleistung Kunden statt Mitarbeiter & Geräte", () => {
|
||||||
|
const pfade = navigationFuer("DIENSTLEISTUNG").map((e) => e.pfad);
|
||||||
|
expect(pfade).toContain("/kunden");
|
||||||
|
expect(pfade).not.toContain("/mitarbeiter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verlinkt keinen Eintrag, dessen Seite es noch nicht gibt", () => {
|
||||||
|
for (const eintrag of navigationFuer("INTERN")) {
|
||||||
|
if (!eintrag.verfuegbar) {
|
||||||
|
expect(eintrag.ausIssue, `${eintrag.pfad} braucht eine Issue-Nummer`).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
|
||||||
|
// Tests laufen immer gegen die Testdatenbank, nie gegen die Entwicklungsdatenbank.
|
||||||
|
if (process.env["TEST_DATABASE_URL"]) {
|
||||||
|
process.env["DATABASE_URL"] = process.env["TEST_DATABASE_URL"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fuer Tests, die ein Geheimnis brauchen, ohne eine .env zu verlangen.
|
||||||
|
process.env["APP_SECRET"] ??= "test-geheimnis-mit-mindestens-32-zeichen-laenge";
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { versionText } from "@/lib/version";
|
||||||
|
|
||||||
|
describe("Versionsanzeige", () => {
|
||||||
|
it("zeigt die Version mit fuehrendem v", () => {
|
||||||
|
expect(versionText()).toMatch(/^v\d+\.\d+\.\d+/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"cn": ["./src/lib/utils"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
"**/*.mts",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
include: ["tests/**/*.test.ts", "src/**/*.test.ts"],
|
||||||
|
environment: "node",
|
||||||
|
globalSetup: ["tests/global-setup.ts"],
|
||||||
|
setupFiles: ["tests/setup-env.ts"],
|
||||||
|
// Datenbanktests teilen sich eine Datenbank und duerfen sich nicht ueberholen.
|
||||||
|
fileParallelism: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user