feat: Projektgerüst, gehärteten Betriebsstand und Prozess aufsetzen
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

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:
Florian Wilde
2026-09-24 23:33:49 +02:00
co-authored by Claude Opus 5
commit 49ce06f206
84 changed files with 13511 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules
.next
.git
.gitea
coverage
test-results
playwright-report
docs/screenshots
docs/anleitung/bilder
.env
.env.*
!.env.example
+12
View File
@@ -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
+66
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+124
View File
@@ -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
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
pnpm exec commitlint --edit "$1"
+1
View File
@@ -0,0 +1 @@
pnpm lint-staged
+1
View File
@@ -0,0 +1 @@
24
+8
View File
@@ -0,0 +1,8 @@
.next
node_modules
coverage
docs
*.md
pnpm-lock.yaml
src/generated
prisma/migrations
+6
View File
@@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 110
}
+31
View File
@@ -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)
+131
View File
@@ -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
View File
@@ -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"]
+149
View File
@@ -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.
+11
View File
@@ -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;
+26
View File
@@ -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": {}
}
+25
View File
@@ -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:
+20
View File
@@ -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
+97
View File
@@ -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:
+95
View File
@@ -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
+108
View File
@@ -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.
+122
View File
@@ -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
View File
@@ -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.
+48
View File
@@ -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.
+27
View File
@@ -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
+23
View File
@@ -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
+31
View File
@@ -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
+68
View File
@@ -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".
+33
View File
@@ -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`
+33
View File
@@ -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
+33
View File
@@ -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
+32
View File
@@ -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
+31
View File
@@ -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
+47
View File
@@ -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
+38
View File
@@ -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
+28
View File
@@ -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
+39
View File
@@ -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
+37
View File
@@ -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
+33
View File
@@ -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
+38
View File
@@ -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
+27
View File
@@ -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

+21
View File
@@ -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/**",
]),
]);
+23
View File
@@ -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;
+99
View File
@@ -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"
]
}
}
+25
View File
@@ -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"] } },
],
});
+10129
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -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
+6
View File
@@ -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;
+11
View File
@@ -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"] },
});
+34
View File
@@ -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

+7
View File
@@ -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

+128
View File
@@ -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.`);
+144
View File
@@ -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.");
+110
View File
@@ -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.");
+12
View File
@@ -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 });
}
}
+139
View File
@@ -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;
}
}
+6
View File
@@ -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

+24
View File
@@ -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>
);
}
+19
View File
@@ -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" },
],
};
}
+29
View File
@@ -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>
);
}
+24
View File
@@ -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>
);
}
+150
View File
@@ -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>
);
}
+64
View File
@@ -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 };
+19
View File
@@ -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>
);
}
+39
View File
@@ -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);
},
});
+53
View File
@@ -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,
);
}
+7
View File
@@ -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));
}
+10
View File
@@ -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}`;
}
+41
View File
@@ -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?)$).*)"],
};
+58
View File
@@ -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"]);
});
});
+22
View File
@@ -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 },
});
}
+24
View File
@@ -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`);
}
+24
View File
@@ -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);
}
}
});
});
+9
View File
@@ -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";
+8
View File
@@ -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+/);
});
});
+35
View File
@@ -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"]
}
+16
View File
@@ -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,
},
});