10 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5 8ab4b97655 chore(release): 0.1.0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 15:51:52 +02:00
Florian WildeandClaude Opus 5 b4d8c19ce4 docs: DEVLOG-Eintrag für M1
Ziel, Erledigtes, die Entscheidungen mit Begründung, die Probleme samt
Lösung und die offenen Punkte. Dazu die eigenen Fehler dieser Sitzung —
die Commits auf main statt auf dem Meilenstein-Zweig, der vermischte
Doku-Commit und das ungeprüfte Skript — damit sie nicht wiederkehren.

Dauer laut zeiten.csv: 161 Minuten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:48:55 +02:00
Florian WildeandClaude Opus 5 688d9f59a8 docs: Handbuchbilder aufnehmen
20 Bilder aus der Wegwerf-Instanz: fünf Seiten, je hell und dunkel,
Schreibtisch und Handy. Ausschließlich erfundene Daten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:46:16 +02:00
Florian WildeandClaude Opus 5 6d04bc2c1c fix(oberflaeche): Kopfleiste einzeilig halten, Demo-Uhr feststellen
Zwei Mängel, die erst in den aufgenommenen Bildern auffielen.

Auf Handybreite brach die Kopfleiste um und drängte den Namen der
Anwendung in die zweite Zeile, mitten zwischen die Menüpunkte. Sie bleibt
jetzt auf allen Breiten einzeilig; wird es eng, scrollt die Navigation
waagerecht.

Das Tagesdatum auf der Startseite wird serverseitig gerendert, weshalb
Playwrights Uhr nicht griff — die Bilder hätten sich täglich geändert. In
der Screenshot-Instanz (DEMO_MODUS=true) steht die Uhr jetzt fest. Reine
Funktionen bekommen den Zeitpunkt weiterhin als Parameter; src/server/
zeitgeber.ts ist die eine Stelle, an der er entsteht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:36:35 +02:00
Florian WildeandClaude Opus 5 cf28ec19f5 test: Rollentrennung end-to-end absichern
Legt einen Benutzer ohne Verwaltungsrechte an und prüft, dass der direkte
Aufruf der Protokollseite auf den Hinweis führt — nicht nur, dass der
Menüpunkt fehlt. Sich darauf zu verlassen, dass die Oberfläche Knöpfe
ausblendet, wäre kein Nachweis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:32:52 +02:00
Florian WildeandClaude Opus 5 a88273117c chore: Screenshot-Kette und Demo-Seed
npm run screenshots startet eine Wegwerf-Instanz in einem eigenen
Compose-Projekt mit eigener Datenbank, spielt ausschließlich erfundene
Daten ein, fährt die Seiten mit Playwright ab und räumt am Ende alles
wieder weg. Je Seite entstehen vier Bilder: hell und dunkel, Schreibtisch
und Handy. Die Uhr steht fest, damit sich zwischen zwei Läufen nur das
ändert, was sich wirklich geändert hat.

Der Demo-Seed verweigert den Dienst ohne DEMO_MODUS=true — er würde sonst
erfundene Daten in eine echte Datenbank schreiben.

Test- und Werkzeugordner sind jetzt aus dem Docker-Kontext ausgenommen.
Ein Typfehler in scripts/screenshots.ts hatte den Bau des Abbilds
blockiert, obwohl die Datei dort nichts zu suchen hat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:29:52 +02:00
Florian WildeandClaude Opus 5 e3390c88f4 refactor: middleware in proxy umbenennen
Next 16 hat die Dateikonvention "middleware" abgekündigt und in "proxy"
umbenannt; der Bau warnte bei jedem Lauf. Umbenannt sind Datei und
Funktion, die Aufgabe bleibt dieselbe.

Die E2E-Tests laufen unverändert durch: die Umleitung samt gemerktem Ziel
funktioniert weiter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:29:52 +02:00
Florian WildeandClaude Opus 5 56b5356708 docs: README, Schnellstart, Handbuch, Fahrplan und CHANGELOG
README mit Funktionsumfang, Bildergalerie, allen Umgebungsvariablen als
Tabelle und dem Betrieb inklusive Sicherung und Wiederherstellung.

Der Schnellstart führt in acht Schritten von git clone zur ersten
Anmeldung, jeder Schritt mit erwartetem Ergebnis. Er endet mit dem
Hinterlegen eines Passkeys statt beim ersten Kalenderkonto oder der
ersten Aufgabe — beides gibt es erst mit M3 und M2. Das ist in ROADMAP
und Handbuch so vermerkt, damit niemand danach sucht.

Handbuch mit Seiten zu Anmelden, Bereichen und Protokoll.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:29:51 +02:00
Florian WildeandClaude Opus 5 f91d015cc4 feat(betrieb): Abbild, Compose-Stapel und Betriebsskripte
Dreistufiges Dockerfile mit standalone-Ausgabe; im Laufzeitabbild liegen
weder Quelltext noch Bauwerkzeuge, und es läuft unter einem eigenen
Benutzer.

Die Migrationen erledigt ein eigener Einmaldienst aus der Bau-Stufe. Der
Versuch, nur die Prisma-CLI ins Laufzeitabbild zu kopieren, scheiterte an
ihren transitiven Modulen; sie alle mitzunehmen hätte das Abbild
aufgebläht. Der Dienst läuft vor der App und muss sauber durchlaufen.

Zwei Stellen mussten faul werden, weil Next beim Bauen Seitendaten
sammelt und dabei Module lädt, die es zur Bauzeit noch ohne
Umgebungsvariablen gibt:
- Der Prisma-Client entsteht erst beim ersten Zugriff, über einen Proxy.
- Die Auth.js-Konfiguration wird als Funktion übergeben.
Ebenso liest prisma.config.ts die URL über process.env statt über env(),
das beim Bauen ohne Datenbank abbricht.

Dazu backup.sh, update.sh mit Rückrollpfad und scripts/release.sh. Der
Dump wurde nachweislich in eine Wegwerf-Datenbank zurückgespielt und
stimmt zeilengenau mit dem Original überein. Alle Skripte sind
shellcheck-sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:08:04 +02:00
Florian WildeandClaude Opus 5 126ad93301 test: Integrations-, Migrations- und E2E-Tests ergänzen
Der Integrationstest prüft gegen eine echte Datenbank, was Vitest nicht
sieht: Eindeutigkeit, Fremdschlüssel, Kaskaden und dass das Protokoll den
gelöschten Benutzer überlebt. Er läuft bewusst ohne umschließende
Transaktion — er prüft absichtlich Fälle, die scheitern müssen, und ein
gescheiterter Befehl bricht in PostgreSQL die ganze Transaktion ab.
Stattdessen tragen alle Testdaten dieselbe Kennung und werden am Ende
wieder entfernt.

Der Migrationstest legt eine Wegwerf-Datenbank an, spielt alle
Migrationen ein und vergleicht das Ergebnis mit dem Schema. Dass er
Abweichungen wirklich bemerkt, wurde mit einem probeweise ergänzten Feld
nachgewiesen.

Der E2E-Rauchtest deckt den Weg ab, der in M1 trägt: Umleitung ohne
Anmeldung samt gemerktem Ziel, Abweisung falscher Zugangsdaten,
erfolgreiche Anmeldung und die Verwaltungsseite.

Dafür neu: eine Middleware, die nur den angefragten Pfad als Kopfzeile
durchreicht. Ein Layout kennt seinen Pfad nicht, braucht ihn aber für die
Rückkehr nach der Anmeldung. Anmeldelogik steht dort bewusst nicht — die
Middleware läuft im Edge-Umfeld ohne Prisma.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 13:10:54 +02:00
60 changed files with 2626 additions and 43 deletions
+16 -1
View File
@@ -6,7 +6,22 @@ node_modules
logs
sicherungen
coverage
# Nur für die Entwicklung. Halten den Kontext klein und sorgen dafür, dass ein
# defektes Werkzeugskript nicht den Bau des Abbilds blockiert – geprüft werden
# sie ohnehin von "npm run pruefen".
e2e
screenshots
test
test-results
playwright-report
docs/bilder
playwright.config.ts
playwright.screenshots.config.ts
vitest.config.ts
docs
devlog
*.sh
scripts/pruefen.mjs
scripts/integrationstest.ts
scripts/migrationstest.ts
scripts/screenshots.ts
+1
View File
@@ -39,3 +39,4 @@ Thumbs.db
.vscode/*
!.vscode/extensions.json
.idea/
screenshot-lauf.log
+51
View File
@@ -0,0 +1,51 @@
# Änderungen
Alle nennenswerten Änderungen an diesem Projekt stehen hier.
Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht]
## [0.1.0] – 2026-09-10
### Hinzugefügt
- Projektgerüst mit Next 16 (App Router, `output: "standalone"`), React 19,
TypeScript strict, Tailwind 4 und shadcn-Primitiven.
- `npm run pruefen` bündelt Prettier, ESLint, `tsc --noEmit`, Vitest und
ShellCheck. ShellCheck läuft über das Docker-Abbild, wenn es lokal fehlt.
- Datenmodell für Benutzer mit Rolle, OIDC-Konten, Passkeys, Bereiche und
Audit-Einträge; PostgreSQL 16 über Prisma 7 mit pg-Treiberadapter.
- Anmeldung über einen generischen OIDC-Anbieter, über Passkeys
(SimpleWebAuthn) und wahlweise über E-Mail und Passwort.
- Rollen `BENUTZER` und `ADMIN`. Jede schreibende Aktion prüft die Rolle
serverseitig in der Seite **und** noch einmal in der Server Action.
- Audit-Protokoll für jede schreibende Aktion, mit Protokollseite für die
Verwaltung. Details werden vor dem Speichern gesäubert.
- Instanzsteuerung über `INSTANZ=firma|daheim`. Was eine Instanz nicht darf,
liefert dort 404 und lädt seine Konfiguration gar nicht erst.
- Bereiche mit Farbe, Arbeitsfenster und Tageskapazität je Wochentag.
- Zeitrechnung durchgehend in Europe/Berlin, mit Tagesgrenzen, Wochenbeginn
am Montag und deutschen Anzeigeformaten.
- `/api/health` mit Status, Version, Instanz und Erreichbarkeit der Datenbank.
- Hell und Dunkel, Navigation mit Rollenfilter, Oberfläche auf Deutsch.
- Dockerfile in drei Stufen und Compose-Stapel mit eigenem Migrationsdienst.
- `backup.sh` (pg_dump im Custom-Format, `.env` und Uploads),
`update.sh` (mit Rückrollpfad), `scripts/release.sh` und `scripts/zeit.sh`.
- Tests: Unit mit Vitest, Integrationstest und Migrationstest gegen eine
echte Datenbank, E2E-Rauchtest mit Playwright.
- `npm run screenshots` nimmt die Handbuchbilder in einer Wegwerf-Instanz mit
ausschließlich erfundenen Daten auf.
- README, Schnellstart, Handbuch, ROADMAP und DEVLOG.
### Sicherheit
- Anmeldung, Passwort und Passkey antworten bei Fehlschlag immer gleich,
damit sich nicht abfragen lässt, wer ein Konto hat.
- Sitzungen als JWT, aber Rolle und Sperrstatus werden bei jeder Anfrage
frisch aus der Datenbank gelesen — eine Sperre wirkt sofort.
- Die WebAuthn-Herausforderung wird nach einem Versuch verworfen, gleich ob
er geglückt ist.
- Der Demo-Seed verweigert den Dienst ohne `DEMO_MODUS=true`.
- `allowDangerousEmailAccountLinking` ist bewusst aktiv: je Instanz gibt es
genau einen vertrauenswürdigen OIDC-Anbieter, der die Adresse selbst prüft.
+52
View File
@@ -0,0 +1,52 @@
# syntax=docker/dockerfile:1
# ---------------------------------------------------------------------------
# Planer – Abbild für den Betrieb.
#
# Die Stufe "bauen" dient zugleich als Abbild für den Migrationsdienst: die
# Prisma-CLI bringt eine ganze Reihe Module mit, die im schlanken
# Laufzeitabbild nichts zu suchen haben. Siehe docker-compose.yml.
# ---------------------------------------------------------------------------
FROM node:24-alpine AS abhaengigkeiten
WORKDIR /app
RUN apk add --no-cache libc6-compat
COPY package.json package-lock.json ./
# Die Bauwerkzeuge werden gebraucht und bleiben in dieser Stufe.
RUN npm ci --ignore-scripts && npm rebuild prisma @prisma/engines esbuild unrs-resolver
FROM node:24-alpine AS bauen
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=abhaengigkeiten /app/node_modules ./node_modules
COPY . .
# Der Prisma-Client wird erzeugt, bevor Next baut.
RUN npx prisma generate && npm run build
FROM node:24-alpine AS betrieb
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0 \
TZ=Europe/Berlin
RUN apk add --no-cache tzdata curl \
&& addgroup --system --gid 1001 planer \
&& adduser --system --uid 1001 --ingroup planer planer
# Die standalone-Ausgabe bringt nur die wirklich benutzten Module mit.
COPY --from=bauen --chown=planer:planer /app/.next/standalone ./
COPY --from=bauen --chown=planer:planer /app/.next/static ./.next/static
COPY --from=bauen --chown=planer:planer /app/public ./public
USER planer
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=5 \
CMD curl -fsS http://127.0.0.1:3000/api/health || exit 1
CMD ["node", "server.js"]
+267
View File
@@ -0,0 +1,267 @@
# Planer
Aufgaben, Ideen, Projekte und Zeitblöcke an einem Ort.
Die Kernidee: Kalender aus Microsoft 365 (Firma, Gewerbe) und Google (privat)
lesen und Aufgaben per Ziehen und Ablegen als Zeitblöcke direkt in diese
Kalender schreiben — inklusive Rückweg, wenn ein Block im Kalender verschoben
wird.
> **Stand:** Meilenstein 1 von 8. Die Grundlage steht: Anmeldung, Rollen,
> Protokoll, Bereiche und der Betrieb. Aufgaben kommen mit M2, das Lesen der
> Kalender mit M3, die Wochenplanung mit M4. Der [Fahrplan](ROADMAP.md) sagt,
> was wann folgt.
- **[Schnellstart](docs/SCHNELLSTART.md)** – in zehn Minuten von `git clone`
zur ersten Anmeldung
- **[Handbuch](docs/handbuch/)** – Anleitung für den Alltag
- **[Fahrplan](ROADMAP.md)** – was geplant ist und was bewusst nicht
- **[Änderungen](CHANGELOG.md)** – was sich je Version getan hat
## Funktionsumfang
### Heute
- Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und
Passwort — je Instanz einzeln zuschaltbar
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
serverseitig geprüft und hinterlässt einen Protokolleintrag
- Bereiche mit Farbe, Arbeitsfenster und Tageskapazität je Wochentag
- Hell und Dunkel, Oberfläche durchgehend auf Deutsch
- Betrieb über Docker Compose, mit Sicherung, Aktualisierung und Healthcheck
### Geplant
Aufgaben und Projekte, Kalender lesen und schreiben, Wochenplanung per Ziehen
und Ablegen, Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und
ein MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
## Bildergalerie
| | Hell | Dunkel |
| ------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| Anmelden | ![Anmeldeseite, hell](docs/bilder/anmelden-hell-schreibtisch.png) | ![Anmeldeseite, dunkel](docs/bilder/anmelden-dunkel-schreibtisch.png) |
| Start | ![Startseite, hell](docs/bilder/start-hell-schreibtisch.png) | ![Startseite, dunkel](docs/bilder/start-dunkel-schreibtisch.png) |
| Bereiche | ![Bereiche, hell](docs/bilder/bereiche-hell-schreibtisch.png) | ![Bereiche, dunkel](docs/bilder/bereiche-dunkel-schreibtisch.png) |
| Einstellungen | ![Einstellungen, hell](docs/bilder/einstellungen-hell-schreibtisch.png) | ![Einstellungen, dunkel](docs/bilder/einstellungen-dunkel-schreibtisch.png) |
| Protokoll | ![Protokoll, hell](docs/bilder/protokoll-hell-schreibtisch.png) | ![Protokoll, dunkel](docs/bilder/protokoll-dunkel-schreibtisch.png) |
Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit
ausschließlich erfundenen Daten — niemals aus einer Instanz mit echten Daten.
## Die zwei Instanzen
Ein Abbild, zwei Deployments, gesteuert über `INSTANZ`. Die Umschaltung wird
**serverseitig erzwungen**: Was eine Instanz nicht darf, liefert dort 404 und
lädt seine Konfiguration gar nicht erst.
| | `firma` | `daheim` |
| -------------- | -------------------------------------- | ------------------------------ |
| Anmeldung | OIDC Entra ID | OIDC authentik |
| Kalenderkonten | Graph (Firmen-Tenant) | Graph (Gewerbe-Tenant), Google |
| Mail | Graph Firmenpostfach | Graph Gewerbepostfach, Gmail |
| Integrationen | Zammad, Wartungsplaner, Spiegel senden | Telegram, Spiegel empfangen |
Ein falsch geschriebener Wert bricht den Start ab, statt still auf einen
Standard zu fallen — in der Firma dürfen keine Provider anlaufen, die dort
nichts zu suchen haben.
## Umgebungsvariablen
Alle Werte stehen in `.env`. Vorlage: [`.env.example`](.env.example).
`.env` gehört niemals ins Git.
### Pflicht
| Variable | Beispiel | Bedeutung |
| ----------------- | ---------------------------------------------------- | ---------------------------------------------- |
| `INSTANZ` | `daheim` | `firma` oder `daheim` |
| `APP_URL` | `https://planer.example.de` | Öffentliche Adresse, ohne Schrägstrich am Ende |
| `DATABASE_URL` | `postgresql://planer:…@db:5432/planer?schema=public` | Verbindung zur Datenbank |
| `AUTH_SECRET` | _(32 Byte Base64)_ | Signiert Sitzungen. `openssl rand -base64 32` |
| `WEBAUTHN_RP_ID` | `planer.example.de` | Nackte Domain, ohne Schema und Port |
| `WEBAUTHN_ORIGIN` | `https://planer.example.de` | Exakt die Herkunft im Browser |
### Datenbank im Compose-Stapel
| Variable | Standard | Bedeutung |
| ------------------- | ---------- | ---------------------------------------------- |
| `POSTGRES_USER` | `planer` | Benutzer der Datenbank |
| `POSTGRES_PASSWORD` | _(keiner)_ | Pflicht. Muss zu `DATABASE_URL` passen |
| `POSTGRES_DB` | `planer` | Name der Datenbank |
| `POSTGRES_PORT` | `5432` | Nach außen veröffentlichter Port |
| `APP_PORT` | `3000` | Nach außen veröffentlichter Port der Anwendung |
### Anmeldung
| Variable | Standard | Bedeutung |
| -------------------- | -------- | ------------------------------------------ |
| `AUTH_TRUST_HOST` | `false` | Hinter einem Reverse Proxy auf `true` |
| `ANMELDUNG_PASSWORT` | `false` | Anmeldung mit E-Mail und Passwort erlauben |
| `OIDC_NAME` | _(leer)_ | Beschriftung des Anmeldeknopfs |
| `OIDC_ISSUER` | _(leer)_ | Issuer-URL des Anbieters |
| `OIDC_CLIENT_ID` | _(leer)_ | Client-ID |
| `OIDC_CLIENT_SECRET` | _(leer)_ | Client-Secret |
| `WEBAUTHN_RP_NAME` | `Planer` | Name, den das Gerät beim Passkey anzeigt |
OIDC ist aktiv, sobald Issuer, Client-ID und Secret gesetzt sind. Der
Rückleit-URI beim Anbieter lautet `<APP_URL>/api/auth/callback/oidc`.
### Einrichtung und Sonstiges
| Variable | Standard | Bedeutung |
| ---------------- | --------------- | ---------------------------------------------------------------------------------------- |
| `ADMIN_EMAIL` | _(leer)_ | Erstes Verwaltungskonto, wird vom Seed angelegt |
| `ADMIN_PASSWORT` | _(leer)_ | Mindestens 12 Zeichen. Ohne den Wert entsteht ein Konto, das nur OIDC oder Passkey kennt |
| `TZ` | `Europe/Berlin` | Zeitzone für Anzeigen und Tagesgrenzen |
| `DEMO_MODUS` | `false` | Nur für die Screenshot-Instanz. Im Betrieb niemals `true` |
## Betrieb
### Starten und stoppen
```bash
docker compose up -d # starten
docker compose logs -f app # Protokoll mitlesen
docker compose down # stoppen, Daten bleiben
```
Der Stapel besteht aus drei Diensten: `db`, ein Einmaldienst `migration`, der
die Migrationen einspielt und sich beendet, und `app`.
### Healthcheck
```bash
curl https://planer.example.de/api/health
```
```json
{
"status": "ok",
"version": "0.1.0",
"instanz": "daheim",
"datenbank": "ok",
"zeit": "2026-03-17T08:20:00.000Z"
}
```
Die Auskunft ist bewusst ohne Anmeldung erreichbar und enthält deshalb keine
Zählstände, Namen oder Adressen. Bei `"status": "fehler"` antwortet der
Endpunkt mit HTTP 503.
### Aktualisieren
```bash
./update.sh # auf den neuesten Tag
./update.sh --nur-pruefen # nur zeigen, was passieren würde
./update.sh --version v0.2.0 # auf eine bestimmte Version
./update.sh --ja # ohne Rückfrage
```
Das Skript prüft die Voraussetzungen, zeigt die Änderungen aus dem CHANGELOG,
legt eine Sicherung an, wechselt auf den Ziel-Tag, baut, startet und wartet
auf einen gesunden `/api/health` mit der erwarteten Version. Klappt das nicht,
rollt es auf die vorherige Version zurück und nennt den Pfad der Sicherung.
Jeder Lauf wird nach `logs/update-<Datum>.log` protokolliert.
Die **Datenbank wird beim Zurückrollen nicht automatisch zurückgespielt** —
das wäre bei einem Teilfehler gefährlicher als hilfreich. Das Skript nennt den
Befehl dafür.
### Sicherung und Wiederherstellung
```bash
./backup.sh # Datenbank, .env und Uploads
./backup.sh --tage 90 # längere Aufbewahrung
./backup.sh --nur-datenbank # nur der Dump
```
Je Lauf entstehen zwei Dateien in `sicherungen/`:
ein `pg_dump` im Custom-Format und ein tar.gz mit `.env` und `uploads`.
Wiederherstellen:
```bash
# Datenbank
docker compose exec -T db pg_restore -U planer -d planer --clean --if-exists \
< sicherungen/planer-db-2026-03-17_02-00-00.dump
# Dateien
tar -xzf sicherungen/planer-dateien-2026-03-17_02-00-00.tar.gz
```
Die Sicherung enthält `.env` und damit alle Geheimnisse. Sie gehört so
geschützt wie die Datenbank selbst.
Nächtlich per cron:
```cron
0 2 * * * cd /opt/planer && ./backup.sh --tage 30 >> logs/backup.log 2>&1
```
## Entwicklung
```bash
npm install
docker compose up -d db # nur die Datenbank
npm run db:migrieren # Migrationen einspielen
npm run db:seed # Bereiche und Verwaltungskonto
npm run dev # Entwicklungsserver
```
### Prüfungen
```bash
npm run pruefen # Prettier, ESLint, tsc, Vitest, ShellCheck
npm run test:integration # gegen eine echte Datenbank
npm run test:migration # Migrationen gegen eine Wegwerf-Datenbank
npm run test:e2e # Playwright gegen die gebaute Anwendung
npm run screenshots # Handbuchbilder neu aufnehmen
```
`npm run pruefen` muss vor jedem Commit grün sein. Fehlt `shellcheck` lokal,
wird das Docker-Abbild `koalaman/shellcheck` benutzt.
### Aufbau
```
prisma/ Schema, Migrationen, Seed, Demo-Seed
scripts/ Prüf-, Test- und Betriebsskripte
src/app/ Seiten und API-Routen; (app) = angemeldeter Bereich
src/components/ Bausteine; ui/ enthält die Primitiven
src/lib/ Reine Logik ohne DB und ohne Systemzeit, je Datei ein Test
src/server/ DB-nahe Dienste, Server Actions, Provider
e2e/ Playwright-Rauchtests
screenshots/ Aufnahme der Handbuchbilder
docs/ Schnellstart, Handbuch, Bilder
devlog/ Entwicklungstagebuch und Zeiterfassung
```
Reine Funktionen bekommen die aktuelle Zeit als Parameter, damit sie ohne
Tricks prüfbar bleiben. Die Tests laufen bewusst in UTC — so fällt sofort auf,
wenn irgendwo die Ortszeit des Servers durchschlägt statt Europe/Berlin.
### Bekannte Meldungen
`npm audit` meldet vier Funde in transitiven Abhängigkeiten der
**`prisma`-CLI** (`deepmerge-ts` über `@prisma/config`, `mysql2`). Beide sind
Entwicklungsabhängigkeiten und erreichen das Laufzeitabbild nicht; MySQL wird
ohnehin nie geladen. Der angebotene Fix wäre ein Rückschritt auf Prisma 6.
## Sicherheit
- Anmeldung, Passwort und Passkey antworten bei Fehlschlag immer gleich, damit
sich nicht abfragen lässt, wer hier ein Konto hat.
- Sitzungen sind JWT, aber Rolle und Sperrstatus werden bei jeder Anfrage
frisch aus der Datenbank gelesen — eine Sperre wirkt sofort.
- Jede schreibende Aktion prüft die Rolle in der Seite **und** noch einmal in
der Server Action. Die Oberfläche blendet Knöpfe nur zusätzlich aus.
- Audit-Details werden vor dem Speichern gesäubert: Felder, deren Name nach
Passwort, Token, Betreff oder Mailinhalt aussieht, werden ersetzt.
- Keine Geheimnisse, Tokens, Mailinhalte oder Termin-Betreffs in Protokollen.
Gefundene Schwachstellen bitte nicht als Issue anlegen, sondern direkt melden.
## Lizenz
Privates Projekt, keine Lizenz zur Weiterverwendung.
+124
View File
@@ -0,0 +1,124 @@
# Fahrplan
Acht Meilensteine. Jeder endet mit einer lauffähigen Version auf `main`,
grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
| Meilenstein | Ziel | Status |
| --------------------------------------- | ---------------------------- | --------- |
| [M1](#m1--fundament) | Fundament | in Arbeit |
| [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | geplant |
| [M3](#m3--kalender-lesen) | Kalender lesen | geplant |
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | geplant |
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | geplant |
| [M6](#m6--integrationen) | Integrationen | geplant |
| [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant |
| [M8](#m8--mcp) | MCP | geplant |
---
## M1 – Fundament
**Ziel:** Ein angemeldeter, betreibbarer Rumpf. Noch keine Aufgaben, kein
Kalender — aber alles, worauf die späteren Meilensteine aufsetzen.
- [x] Projektgerüst: Next 16, React 19, TypeScript strict, Tailwind 4
- [x] `npm run pruefen`: Prettier, ESLint, tsc, Vitest, ShellCheck
- [x] Datenmodell für Anmeldung, Passkeys, Bereiche und Audit
- [x] Anmeldung über OIDC, Passkey und wahlweise Passwort
- [x] Rollen `BENUTZER` und `ADMIN`, serverseitig in Seite **und** Server Action
- [x] Audit-Eintrag je schreibender Aktion, Details werden gesäubert
- [x] Instanzsteuerung `INSTANZ=firma|daheim` mit 404 statt Hinweis
- [x] Bereiche verwalten: Farbe, Arbeitsfenster, Tageskapazität
- [x] Protokollseite für die Verwaltung
- [x] `/api/health` mit Status, Version und Instanz
- [x] Dockerfile, Compose-Stapel mit eigenem Migrationsdienst
- [x] `backup.sh`, `update.sh`, `scripts/release.sh`, `scripts/zeit.sh`
- [x] Unit-, Integrations-, Migrations- und E2E-Tests
- [x] Screenshot-Kette mit eigener Wegwerf-Instanz
- [x] README, Schnellstart, Handbuch, CHANGELOG, DEVLOG
**Bewusst verschoben:** `Aufgabe` und `Zeitblock` kommen mit M2 und M4,
`Kalenderkonto` mit M3 — jeweils mit den Feldern, die der Provider dann
wirklich braucht, statt heute geraten.
## M2 – Aufgaben und Projekte
**Ziel:** Alles erfassen und wiederfinden, noch ohne Kalender.
- [ ] `Aufgabe` mit Typ, Status, Priorität, Aufwand, Fälligkeit, Quelle
- [ ] Eindeutigkeit `[quelle, quelleRef]`, Integrationen upserten
- [ ] Inbox: schnell erfassen, später einsortieren
- [ ] Ideen getrennt von Aufgaben
- [ ] Projekte mit Arbeitspaketen und Meilensteinen
- [ ] Listen mit Filtern nach Bereich, Status und Fälligkeit
- [ ] Handbuchseiten und Bilder
## M3 – Kalender lesen
**Ziel:** Termine aus Microsoft 365 und Google sehen.
- [ ] `Kalenderkonto` mit Anbieter, Tokens und Delta-Verweis
- [ ] OAuth-Verbindung je Instanz: Graph immer, Google nur daheim
- [ ] Graph-Delta-Sync und Google-Sync über `fetch`, Antworten mit zod geprüft
- [ ] Ganztägige Termine zählen nicht zur Belegung
- [ ] Anonymisierte Beispielantworten in `test/fixtures/`
- [ ] Hintergrundjob mit node-cron
## M4 – Wochenplanung und Zeitblöcke
**Ziel:** Aufgaben per Ziehen und Ablegen in den Kalender schreiben — und
Verschiebungen von dort zurückholen.
- [ ] `Zeitblock` an genau einer Aufgabe und einem Kalenderkonto
- [ ] Wochenansicht mit `@dnd-kit/core`
- [ ] Schreiben in den Kalender, Rückweg beim Verschieben
- [ ] Schleifenschutz über `hash = sha256(startISO|endeISO)`
- [ ] Konflikte: der Kalender gewinnt (HTTP 412 auf If-Match)
- [ ] Belegung gegen die Tageskapazität des Bereichs
## M5 – Benachrichtigungen
- [ ] Web-Push mit `web-push`
- [ ] Tagesüberblick per E-Mail über nodemailer
- [ ] Erinnerungen an Fälligkeiten, Jobs über node-cron
- [ ] Je Benutzer einstellbar, mit Ruhezeiten
## M6 – Integrationen
- [ ] Zammad und Wartungsplaner-API (nur `firma`)
- [ ] Telegram (nur `daheim`)
- [ ] Spiegel: `firma` sendet, `daheim` empfängt
- [ ] Jede Integration upsertet über `[quelle, quelleRef]`
## M7 – Schnellerfassung und API
- [ ] Erfassung vom Handy, auch offline
- [ ] Öffentliche API mit Token je Anwendung
- [ ] Rechte je Token, jede Nutzung im Protokoll
## M8 – MCP
- [ ] MCP-Server über `@modelcontextprotocol/sdk`
- [ ] Lesen und Anlegen von Aufgaben und Zeitblöcken
- [ ] Rechte wie bei der API, jede Nutzung im Protokoll
---
## Ideen für später
- Auswertung: wie viel Zeit ging tatsächlich in welchen Bereich
- Wiederkehrende Aufgaben mit eigenem Rhythmus
- Vorlagen für wiederkehrende Projekte
- Verknüpfung von Aufgaben untereinander (blockiert, gehört zu)
- Export der eigenen Daten als JSON
## Bewusst nicht geplant
- **Wiederkehrende Wartungsaufgaben mit Nachweispflicht.** Die bleiben im
Wartungsplaner. Der Planer liest nur Fälligkeiten über dessen API.
- **Mehrbenutzerbetrieb mit geteilten Aufgaben.** Der Planer ist für eine
Person gedacht; Kollegen melden sich in der Firmeninstanz nur an ihren
eigenen Daten an.
- **Eigene Kalenderdarstellung als Ersatz für Outlook.** Der Planer schreibt
in die vorhandenen Kalender, er ersetzt sie nicht.
- **Zeiterfassung zur Abrechnung.** Dafür gibt es andere Werkzeuge.
Executable
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# Sichert Datenbank, .env und Uploads des Planers.
# Wiederherstellung: siehe README.md, Abschnitt "Sicherung und Wiederherstellung".
set -euo pipefail
WURZEL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$WURZEL"
ZIEL="${WURZEL}/sicherungen"
AUFBEWAHRUNG_TAGE=30
NUR_DATENBANK=0
hilfe() {
cat <<'ENDE'
Verwendung: backup.sh [Optionen]
--ziel <Pfad> Ablageort der Sicherung (Standard: ./sicherungen)
--tage <Anzahl> Aufbewahrung in Tagen (Standard: 30, 0 = unbegrenzt)
--nur-datenbank Nur den Datenbank-Dump erzeugen
--hilfe Diese Übersicht
Erzeugt je Lauf zwei Dateien:
planer-db-<Zeitstempel>.dump Datenbank im Custom-Format von pg_dump
planer-dateien-<Zeitstempel>.tar.gz .env und der Ordner uploads
ENDE
}
meldung() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1"; }
fehler() {
printf '%s Fehler: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >&2
exit 1
}
while [ $# -gt 0 ]; do
case "$1" in
--ziel)
ZIEL="${2:-}"
[ -n "$ZIEL" ] || fehler '--ziel braucht einen Pfad.'
shift 2
;;
--tage)
AUFBEWAHRUNG_TAGE="${2:-}"
case "$AUFBEWAHRUNG_TAGE" in
''|*[!0-9]*) fehler '--tage braucht eine ganze Zahl.' ;;
esac
shift 2
;;
--nur-datenbank)
NUR_DATENBANK=1
shift
;;
--hilfe|-h)
hilfe
exit 0
;;
*) fehler "Unbekannte Option '$1'. 'backup.sh --hilfe' zeigt die Übersicht." ;;
esac
done
command -v docker >/dev/null 2>&1 || fehler 'docker fehlt.'
[ -f "$WURZEL/.env" ] || fehler '.env fehlt – im Projektverzeichnis aufrufen.'
# Nur die benötigten Werte lesen, nicht die ganze Datei ausführen.
lies_env() {
local schluessel="$1" standard="${2:-}"
local wert
wert="$(grep -E "^${schluessel}=" "$WURZEL/.env" | tail -1 | cut -d= -f2- || true)"
wert="${wert%\"}"
wert="${wert#\"}"
printf '%s' "${wert:-$standard}"
}
PG_BENUTZER="$(lies_env POSTGRES_USER planer)"
PG_DATENBANK="$(lies_env POSTGRES_DB planer)"
DB_CONTAINER="$(docker compose ps -q db)"
[ -n "$DB_CONTAINER" ] || fehler 'Der Datenbank-Container läuft nicht. Erst "docker compose up -d db".'
ZEITSTEMPEL="$(date '+%Y-%m-%d_%H-%M-%S')"
mkdir -p "$ZIEL"
DUMP="$ZIEL/planer-db-${ZEITSTEMPEL}.dump"
meldung "Datenbank sichern nach $DUMP"
# -Fc ist das Custom-Format: komprimiert und mit pg_restore selektiv einspielbar.
docker exec "$DB_CONTAINER" pg_dump -U "$PG_BENUTZER" -d "$PG_DATENBANK" -Fc > "$DUMP"
if [ ! -s "$DUMP" ]; then
rm -f "$DUMP"
fehler 'Der Dump ist leer geblieben.'
fi
meldung "Datenbank gesichert ($(du -h "$DUMP" | cut -f1))."
if [ "$NUR_DATENBANK" -eq 0 ]; then
DATEIEN="$ZIEL/planer-dateien-${ZEITSTEMPEL}.tar.gz"
meldung "Dateien sichern nach $DATEIEN"
ZU_SICHERN=(.env)
[ -d "$WURZEL/uploads" ] && ZU_SICHERN+=(uploads)
tar -czf "$DATEIEN" -C "$WURZEL" "${ZU_SICHERN[@]}"
meldung "Dateien gesichert ($(du -h "$DATEIEN" | cut -f1))."
fi
if [ "$AUFBEWAHRUNG_TAGE" -gt 0 ]; then
meldung "Sicherungen älter als $AUFBEWAHRUNG_TAGE Tage entfernen."
find "$ZIEL" -maxdepth 1 -type f -name 'planer-*' -mtime "+$AUFBEWAHRUNG_TAGE" -print -delete
fi
meldung 'Sicherung abgeschlossen.'
+137
View File
@@ -0,0 +1,137 @@
# Entwicklungstagebuch
Neuester Eintrag oben. Die Dauer je Sitzung stammt aus `devlog/zeiten.csv`,
die Übersicht aus `scripts/zeit.sh summe`.
## Zeit je Meilenstein
| Meilenstein | Art | Minuten | Stunden |
| ----------- | ------ | ------- | -------- |
| M1 | claude | 161 | 2,68 |
| **Gesamt** | | **161** | **2,68** |
---
## 2026-09-10 – M1: Fundament
**Dauer:** 161 Minuten (12:06–14:47), Art `claude`.
**Ziel der Sitzung:** Das Projekt von Grund auf aufsetzen: Gerüst,
Datenbank, Anmeldung, Rollen, Audit, Instanzsteuerung, Betrieb und
Dokumentation.
### Erledigt
- Projektgerüst mit Next 16, React 19, TypeScript strict, Tailwind 4,
alle Versionen exakt gepinnt
- `npm run pruefen` als Sammelprüfung: Prettier, ESLint, `tsc`, Vitest,
ShellCheck
- Datenmodell und erste Migration: Benutzer mit Rolle, OIDC-Konten,
Passkeys, WebAuthn-Herausforderungen, Bereiche, Audit
- Anmeldung über OIDC, Passkey und Passwort, je Instanz zuschaltbar
- Rollenprüfung in Seite **und** Server Action, Audit je Schreibaktion
- Instanzsteuerung mit 404 statt Hinweis
- Bereiche verwalten, Protokollseite, Startseite, Einstellungen
- `/api/health`, Dockerfile, Compose-Stapel mit Migrationsdienst
- `backup.sh`, `update.sh`, `scripts/release.sh`, `scripts/zeit.sh`
- Unit-, Integrations-, Migrations- und E2E-Tests
- Screenshot-Kette mit eigener Wegwerf-Instanz
- README, Schnellstart, Handbuch, ROADMAP, CHANGELOG
### Entscheidungen mit Begründung
**Prisma 7.10.0 statt der notierten Fassung 6.** Fassung 6 ist überholt,
Fassung 8 liegt nur als RC auf `latest`. Prisma 7 nimmt die Verbindung
nicht mehr im Schema entgegen — sie steht in `prisma.config.ts`, der
Client bekommt den pg-Treiberadapter.
**TypeScript 6.0.3 statt 7.0.2.** TS 7 ist zwar `latest`, aber
`typescript-eslint@8.70` verlangt `typescript >=4.8.4 <6.1.0`. Mit TS 7
wäre die Lint-Kette kaputt gewesen.
**ESLint 9.39.5 statt 10.** Das in `eslint-config-next@16` gebündelte
`eslint-plugin-react` ruft noch `context.getFilename()`, das ESLint 10
entfernt hat. Der Lint-Lauf brach reproduzierbar ab.
**Eigener Auth.js-Adapter.** Der mitgelieferte `@auth/prisma-adapter`
greift fest auf `prisma.user` und `prisma.account` zu; unsere Modelle
heißen `Benutzer` und `Konto`. Der Adapter ist schmal und ersparte es,
die Fachbegriffe der Konvention zu opfern.
**Passkeys direkt über SimpleWebAuthn 14.** Der WebAuthn-Provider von
Auth.js ist als experimentell markiert und auf SimpleWebAuthn 9
festgelegt. Direkt angebunden bleibt die Fassung aktuell und der Ablauf
nachvollziehbar. Für die npm-Auflösung brauchte es `overrides`, weil
next-auth die älteren Fassungen als optionale Peers deklariert.
**Sitzungen als JWT, Rolle trotzdem aus der Datenbank.** Credentials-
Provider verlangen JWT. Damit eine Sperre nicht bis zum Ablauf des Tokens
wirkungslos bleibt, liest der `session`-Callback Rolle und Status bei
jeder Anfrage frisch. Die Tabellen `sitzung` und `verifizierungs_token`
wären damit tot und wurden wieder entfernt, solange es nur eine einzige
Migration gab.
**Migrationen als eigener Compose-Dienst.** Der Versuch, nur die
Prisma-CLI ins schlanke Laufzeitabbild zu kopieren, scheiterte an ihren
transitiven Modulen (`effect` über `@prisma/config`). Alle mitzunehmen
hätte das Abbild aufgebläht. Ein Einmaldienst aus der Bau-Stufe läuft vor
der App und muss sauber durchlaufen.
**Aufgabe, Zeitblock und Kalenderkonto bewusst nicht modelliert.** Sie
kommen mit M2, M4 und M3 — dann mit den Feldern, die der jeweilige
Provider wirklich braucht, statt heute geraten.
### Probleme und Lösungen
| Problem | Lösung |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `prisma validate` lehnte `url` im Schema ab | Prisma 7 erwartet die Verbindung in `prisma.config.ts` und einen Treiberadapter im Client. |
| Integrationstest brach nach der ersten erwarteten Verletzung ab | In PostgreSQL bricht ein fehlgeschlagener Befehl die ganze Transaktion ab. Der Test läuft jetzt ohne Sammeltransaktion; alle Testdaten tragen eine Kennung und werden am Ende entfernt. |
| `prisma migrate diff` kannte `--from-url` nicht | In Prisma 7 heißen die Flaggen anders. Der Test vergleicht jetzt die eingespielte Wegwerf-Datenbank über `--from-config-datasource` gegen das Schema — und braucht damit keine Schattendatenbank. |
| Nach der Anmeldung ging es nicht zum ursprünglichen Ziel zurück | Das Layout leitet um, bevor die Seite drankommt, und kennt seinen Pfad nicht. Ein schlanker Proxy reicht ihn als Kopfzeile durch — ohne Anmeldelogik, weil er im Edge-Umfeld läuft. |
| Der Docker-Bau brach beim Sammeln der Seitendaten ab | Prisma-Client und Auth.js-Konfiguration wurden beim Laden des Moduls ausgewertet und brauchten Umgebungsvariablen, die es zur Bauzeit nicht gibt. Beide entstehen jetzt faul. |
| Ein Typfehler in `scripts/screenshots.ts` blockierte den Abbild-Bau | Entwicklungsskripte gehören nicht in den Docker-Kontext. `.dockerignore` schließt sie aus; geprüft werden sie von `npm run pruefen`. |
| ESLint beanstandete `setState` im Effekt | Der Hydratations-Trick im Farbschema-Knopf war unnötig: `next-themes` setzt die `dark`-Klasse vor der Hydratation, das erledigt CSS ohne Zustand. |
### Eigene Fehler, die ich korrigiert habe
- Die ersten vier Commits lagen direkt auf `main` statt auf einem
Meilenstein-Zweig und ohne Attributionszeile. Historie umgebaut:
`main` trägt nur noch einen leeren Wurzel-Commit, die Arbeit liegt auf
`m1-fundament`.
- Ein `git add -A` zog die Proxy-Umbenennung in den Doku-Commit. Wieder
getrennt.
- `scripts/screenshots.ts` wurde geschrieben, ohne danach `npm run
pruefen` laufen zu lassen; der Typfehler fiel erst im Docker-Bau auf.
### Offene Punkte
- Die `origin`-URL fehlt noch. Push und Merge stehen ohnehin erst nach
der Freigabe an.
- Der Schnellstart endet beim Passkey statt beim ersten Kalenderkonto und
der ersten Aufgabe — beides gibt es erst mit M3 und M2.
- Der Demo-Seed enthält noch keine erfundenen Termine; einen
Demo-Kalenderprovider gibt es erst mit M3.
- Lokal weichen die Ports ab (Datenbank 5435, App 3001), weil 5432 und
3000 auf diesem Rechner belegt sind. In `.env.example` stehen die
üblichen Werte.
### Commits
- de1f923 chore: Projektgerüst und Werkzeugkette einrichten
- 748d032 feat(datenbank): Datenmodell für Anmeldung, Bereiche und Audit
- e2b00f4 feat(instanz): Instanzsteuerung mit serverseitigem Riegel
- 19ca082 feat(zeit): Zeitrechnung und Anzeigeformate in Europe/Berlin
- 4ff67c1 feat(oberflaeche): Grundlayout, Navigation und Farbschema
- cc013fd feat(anmeldung): OIDC, Passkeys und Passwort mit Auth.js v5
- e9a2b4b feat(bereiche): Bereiche verwalten mit Rollenprüfung und Audit
- 1876ae4 feat(betrieb): Health-Endpunkt und Seed für eine frische Datenbank
- 24a799d chore: tsconfig-Anpassungen von Next 16 übernehmen
- 126ad93 test: Integrations-, Migrations- und E2E-Tests ergänzen
- f91d015 feat(betrieb): Abbild, Compose-Stapel und Betriebsskripte
- 56b5356 docs: README, Schnellstart, Handbuch, Fahrplan und CHANGELOG
- e3390c8 refactor: middleware in proxy umbenennen
- a882731 chore: Screenshot-Kette und Demo-Seed
- cf28ec1 test: Rollentrennung end-to-end absichern
- 6d04bc2 fix(oberflaeche): Kopfleiste einzeilig halten, Demo-Uhr feststellen
- 688d9f5 docs: Handbuchbilder aufnehmen
+1
View File
@@ -1 +1,2 @@
datum;start;ende;minuten;meilenstein;art;notiz
2026-09-10;12:06;14:47;161;M1;claude;M1 Fundament fertiggestellt
1 datum start ende minuten meilenstein art notiz
2 2026-09-10 12:06 14:47 161 M1 claude M1 Fundament fertiggestellt
+61
View File
@@ -0,0 +1,61 @@
# Wegwerf-Instanz nur für "npm run screenshots".
#
# Eigenes Compose-Projekt (planer-screenshots), eigenes Volume, eigene Ports.
# Sie kommt der Entwicklungs- oder Betriebsinstanz damit nicht in die Quere und
# wird nach dem Lauf samt Volume wieder entfernt.
services:
db:
image: postgres:16-alpine
restart: 'no'
environment:
POSTGRES_USER: demo
POSTGRES_PASSWORD: demo
POSTGRES_DB: demo
TZ: Europe/Berlin
ports:
- '${SCREENSHOT_DB_PORT:-5455}:5432'
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U demo -d demo']
interval: 3s
timeout: 3s
retries: 20
start_period: 10s
migration:
build:
context: .
dockerfile: Dockerfile
target: bauen
restart: 'no'
depends_on:
db:
condition: service_healthy
environment:
TZ: Europe/Berlin
DATABASE_URL: postgresql://demo:demo@db:5432/demo?schema=public
command: node node_modules/prisma/build/index.js migrate deploy
app:
build:
context: .
dockerfile: Dockerfile
target: betrieb
restart: 'no'
depends_on:
migration:
condition: service_completed_successfully
environment:
NODE_ENV: production
TZ: Europe/Berlin
INSTANZ: daheim
APP_URL: http://127.0.0.1:${SCREENSHOT_APP_PORT:-3200}
DATABASE_URL: postgresql://demo:demo@db:5432/demo?schema=public
AUTH_SECRET: nur-fuer-die-screenshot-instanz-nicht-geheim
AUTH_TRUST_HOST: 'true'
ANMELDUNG_PASSWORT: 'true'
WEBAUTHN_RP_ID: 127.0.0.1
WEBAUTHN_RP_NAME: Planer
WEBAUTHN_ORIGIN: http://127.0.0.1:${SCREENSHOT_APP_PORT:-3200}
DEMO_MODUS: 'true'
ports:
- '${SCREENSHOT_APP_PORT:-3200}:3000'
+22 -12
View File
@@ -20,15 +20,36 @@ services:
retries: 10
start_period: 20s
# Einmaldienst: spielt die Migrationen ein und beendet sich. Läuft aus der
# Bau-Stufe, weil die Prisma-CLI eine Reihe Module braucht, die im schlanken
# Laufzeitabbild nichts zu suchen haben.
migration:
build:
context: .
dockerfile: Dockerfile
target: bauen
container_name: planer-migration
restart: 'no'
depends_on:
db:
condition: service_healthy
environment:
TZ: ${TZ:-Europe/Berlin}
DATABASE_URL: postgresql://${POSTGRES_USER:-planer}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-planer}?schema=public
command: node node_modules/prisma/build/index.js migrate deploy
app:
build:
context: .
dockerfile: Dockerfile
target: betrieb
container_name: planer-app
restart: unless-stopped
depends_on:
db:
condition: service_healthy
migration:
condition: service_completed_successfully
environment:
NODE_ENV: production
TZ: ${TZ:-Europe/Berlin}
@@ -45,21 +66,10 @@ services:
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:?WEBAUTHN_RP_ID fehlt in .env}
WEBAUTHN_RP_NAME: ${WEBAUTHN_RP_NAME:-Planer}
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:?WEBAUTHN_ORIGIN fehlt in .env}
# Der Demo-Modus ist im Betrieb nie erlaubt.
DEMO_MODUS: 'false'
ports:
- '${APP_PORT:-3000}:3000'
healthcheck:
test:
[
'CMD',
'node',
'-e',
"fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 5
start_period: 40s
volumes:
db-daten:
+155
View File
@@ -0,0 +1,155 @@
# Schnellstart
Von `git clone` bis zur ersten Anmeldung in höchstens zehn Minuten.
Jeder Befehl ist zum Kopieren, jeder Schritt nennt das erwartete Ergebnis.
## Voraussetzungen
| Werkzeug | Fassung | Prüfen mit |
| ------------------ | ---------------- | ------------------------ |
| Docker mit Compose | 25 oder neuer | `docker compose version` |
| Git | 2.40 oder neuer | `git --version` |
| Node.js | 22.12 oder neuer | `node -v` |
Node wird nur für die Entwicklung gebraucht. Für den reinen Betrieb genügen
Docker und Git.
## 1. Holen und Verzeichnis wechseln
```bash
git clone <REMOTE_URL> planer
cd planer
```
**Erwartet:** Das Verzeichnis `planer` enthält unter anderem `package.json`,
`docker-compose.yml` und `.env.example`.
## 2. Konfiguration anlegen
```bash
cp .env.example .env
```
Jetzt die Pflichtwerte setzen. Die Geheimnisse werden erzeugt, nicht erfunden:
```bash
# Signiert die Sitzungen. Ohne diesen Wert startet die Anwendung nicht.
openssl rand -base64 32
# Ein Passwort für die Datenbank.
openssl rand -hex 16
```
Unter Windows ohne OpenSSL geht auch:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
```
Diese Werte in `.env` eintragen:
| Variable | Beispiel | Bedeutung |
| ------------------- | -------------------------------------------------------------------- | ------------------------------------------------------- |
| `INSTANZ` | `daheim` | `firma` oder `daheim` – bestimmt, was es überhaupt gibt |
| `APP_URL` | `http://localhost:3000` | Adresse ohne Schrägstrich am Ende |
| `AUTH_SECRET` | _(erzeugt)_ | Signatur der Sitzungen |
| `POSTGRES_PASSWORD` | _(erzeugt)_ | Passwort der Datenbank |
| `DATABASE_URL` | `postgresql://planer:<Passwort>@localhost:5432/planer?schema=public` | muss dasselbe Passwort enthalten |
| `WEBAUTHN_RP_ID` | `localhost` | nackte Domain, ohne Schema und Port |
| `WEBAUTHN_ORIGIN` | `http://localhost:3000` | exakt wie im Browser, mit Port |
| `ADMIN_EMAIL` | `du@example.de` | erstes Verwaltungskonto |
| `ADMIN_PASSWORT` | _(mindestens 12 Zeichen)_ | nur nötig, wenn du dich ohne OIDC anmelden willst |
> **Sind die Ports 5432 oder 3000 auf deinem Rechner belegt?**
> Dann `POSTGRES_PORT` beziehungsweise `APP_PORT` in `.env` auf einen freien
> Wert setzen. `DATABASE_URL`, `APP_URL` und `WEBAUTHN_ORIGIN` müssen denselben
> Port nennen.
**Erwartet:** `.env` liegt im Projektverzeichnis. Sie gehört niemals ins Git —
`.gitignore` sorgt dafür.
## 3. Starten
```bash
docker compose up -d --build
```
Beim ersten Mal dauert der Bau einige Minuten. Der Ablauf ist: Datenbank
starten, Migrationen einspielen, Anwendung starten.
**Erwartet:**
```bash
docker compose ps
```
zeigt `db` als `healthy` und `app` als `Up`. Der Dienst `migration` taucht
nicht mehr auf — er hat seine Arbeit getan und sich beendet.
## 4. Prüfen, ob alles läuft
```bash
curl http://localhost:3000/api/health
```
**Erwartet:**
```json
{ "status": "ok", "version": "0.1.0", "instanz": "daheim", "datenbank": "ok" }
```
Steht dort `"datenbank": "fehler"`, stimmt `DATABASE_URL` nicht mit
`POSTGRES_PASSWORD` überein.
## 5. Erste Bereiche und das Verwaltungskonto anlegen
```bash
docker compose run --rm migration node node_modules/prisma/build/index.js db seed
```
**Erwartet:** Die Ausgabe nennt die vier angelegten Bereiche (Firma, Gewerbe,
Privat, Homelab) und bestätigt das Verwaltungskonto.
## 6. Anmelden
Im Browser <http://localhost:3000> öffnen.
**Erwartet:** Die Anmeldeseite. Mit `ADMIN_EMAIL` und `ADMIN_PASSWORT`
anmelden — danach steht oben „Guten Tag" und darunter die vier Bereiche.
> Ist OIDC eingerichtet, erscheint zusätzlich ein Knopf mit dem Namen aus
> `OIDC_NAME`. Der Rückleit-URI, den du beim Anbieter hinterlegen musst,
> lautet `<APP_URL>/api/auth/callback/oidc`.
## 7. Einen Passkey hinterlegen
Auf **Einstellungen** gehen, unter _Passkeys_ einen Namen für das Gerät
eintragen (etwa „Arbeitsrechner") und auf **Anlegen** klicken. Das Gerät
fragt nach Fingerabdruck, Gesicht oder PIN.
**Erwartet:** Der Passkey steht in der Liste. Ab jetzt genügt zum Anmelden die
E-Mail-Adresse und **Mit Passkey anmelden**.
> Hinterlege am besten zwei Passkeys auf verschiedenen Geräten. Sonst sperrst
> du dich aus, wenn ein Gerät verlorengeht.
## 8. Aufräumen
Wenn `ADMIN_PASSWORT` nur zur Einrichtung gedient hat, kannst du es jetzt
zusammen mit `ADMIN_EMAIL` aus `.env` entfernen. Das Konto bleibt bestehen.
## Wie es weitergeht
- [Handbuch](handbuch/) – Anleitung für den Alltag
- [README](../README.md) – alle Umgebungsvariablen, Betrieb, Sicherung
- [ROADMAP](../ROADMAP.md) – was in den nächsten Meilensteinen kommt
## Wenn etwas klemmt
| Symptom | Ursache und Abhilfe |
| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `INSTANZ fehlt in .env` beim Start | `INSTANZ` ist leer. Erlaubt sind nur `firma` und `daheim`. |
| `Die Konfiguration ist unvollständig` | Die Meldung nennt die fehlende Variable. Sie nennt nie den Wert. |
| `port is already allocated` | Ein anderer Dienst hält den Port. `APP_PORT` oder `POSTGRES_PORT` ändern. |
| Passkey lässt sich nicht anlegen | `WEBAUTHN_ORIGIN` muss exakt der Adresse im Browser entsprechen, inklusive Port. `WEBAUTHN_RP_ID` ist die nackte Domain, ohne `https://`. |
| Anmeldung schlägt immer fehl | Bewusst gibt es nur eine Meldung für alle Fälle. Prüfe im Protokoll der Anwendung: `docker compose logs app`. |
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

+36
View File
@@ -0,0 +1,36 @@
# Handbuch
Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
## Erste Schritte
1. **[Anmelden](anmelden.md)** – die drei Wege hinein, Passkeys einrichten,
was tun, wenn nichts mehr geht
2. **[Bereiche einrichten](bereiche.md)** – Farbe, Arbeitsfenster und
Tageskapazität festlegen
## Verwaltung
3. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
## Was noch kommt
Diese Seiten entstehen mit den nächsten Meilensteinen:
| Thema | Meilenstein |
| ------------------------------- | ----------- |
| Aufgaben erfassen, Inbox, Ideen | M2 |
| Projekte und Arbeitspakete | M2 |
| Kalenderkonten verbinden | M3 |
| Wochenplanung und Zeitblöcke | M4 |
| Benachrichtigungen | M5 |
| Integrationen | M6 |
| Schnellerfassung vom Handy, API | M7 |
| MCP | M8 |
Der [Fahrplan](../../ROADMAP.md) sagt, was in welchem Meilenstein steckt.
---
Zur Einrichtung einer neuen Instanz: [Schnellstart](../SCHNELLSTART.md).
Für Betrieb, Sicherung und alle Umgebungsvariablen: [README](../../README.md).
+83
View File
@@ -0,0 +1,83 @@
# Anmelden
Es gibt drei Wege in den Planer. Welche davon zu sehen sind, hängt davon ab,
was für deine Instanz eingerichtet wurde.
![Die Anmeldeseite](../bilder/anmelden-hell-schreibtisch.png)
| Weg | Wann sinnvoll |
| -------------------------------- | --------------------------------------------------------------------------------------------------- |
| **Unternehmensanmeldung** (OIDC) | In der Firma der Normalfall. Ein Klick, die Anmeldung übernimmt Entra ID beziehungsweise authentik. |
| **Passkey** | Der bequemste Weg am eigenen Gerät: Fingerabdruck, Gesicht oder Geräte-PIN, kein Passwort. |
| **E-Mail und Passwort** | Der Rückfallweg. Oft nur daheim freigeschaltet. |
## Mit Passkey anmelden
1. E-Mail-Adresse eintragen.
2. Auf **Mit Passkey anmelden** klicken.
3. Das Gerät fragt nach Fingerabdruck, Gesicht oder PIN.
Fertig — du landest auf der Startseite.
> **Warum erst die E-Mail-Adresse?** Damit der Planer weiß, welche Passkeys er
> anbieten soll. Er antwortet dabei für unbekannte Adressen genauso wie für
> bekannte, damit sich von außen nicht abfragen lässt, wer hier ein Konto hat.
## Einen Passkey einrichten
Das geht erst, wenn du angemeldet bist — also beim ersten Mal über OIDC oder
über E-Mail und Passwort.
1. Oben rechts auf **Einstellungen**.
2. Im Abschnitt _Passkeys_ einen Namen für das Gerät eintragen, etwa
„Arbeitsrechner" oder „Diensthandy".
3. Auf **Anlegen** klicken und die Abfrage des Geräts bestätigen.
![Passkeys in den Einstellungen](../bilder/einstellungen-hell-schreibtisch.png)
Der Passkey steht danach in der Liste, mit Anlegedatum und letzter Benutzung.
> **Lege zwei Passkeys an, auf zwei verschiedenen Geräten.** Sonst sperrst du
> dich aus, sobald ein Gerät verlorengeht oder zurückgesetzt wird.
Unter jedem Eintrag steht, ob der Passkey **geräteübergreifend** ist — dann
liegt er im Schlüsselbund von Apple, Google oder deinem Passwortmanager und ist
auf deinen anderen Geräten ebenfalls verfügbar. Steht dort **nur dieses
Gerät**, ist er an genau dieses Gerät gebunden.
## Einen Passkey entfernen
In den Einstellungen auf das Papierkorb-Symbol neben dem Eintrag. Der Passkey
ist sofort ungültig; auf dem Gerät selbst bleibt ein Rest zurück, den du dort
im Schlüsselbund löschen kannst.
## Abmelden
Oben rechts auf das Symbol mit dem Pfeil aus der Tür.
## Wenn es klemmt
| Was du siehst | Was dahintersteckt |
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| „Anmeldung nicht möglich. Bitte Angaben prüfen." | Diese eine Meldung gilt für alle Fälle: falsches Passwort, unbekannte Adresse, gesperrtes Konto. Das ist Absicht — sonst ließe sich abfragen, wer hier ein Konto hat. |
| Es passiert nichts nach dem Klick auf **Mit Passkey anmelden** | Du hast den Systemdialog abgebrochen. Einfach noch einmal klicken. |
| „Dieses Gerät ist hier schon hinterlegt." | Für dieses Gerät gibt es bereits einen Passkey. Ein zweiter bringt nichts. |
| Der Passkey-Knopf tut gar nichts | Der Browser unterstützt WebAuthn nicht, oder die Seite läuft nicht über HTTPS. Passkeys brauchen eine sichere Verbindung — `localhost` ausgenommen. |
| Passkey lässt sich nicht anlegen | Meist stimmt die Konfiguration nicht: `WEBAUTHN_ORIGIN` muss exakt der Adresse im Browser entsprechen, inklusive Port. Siehe [README](../../README.md). |
## Ausgesperrt?
Wenn kein Weg mehr hineinführt, hilft nur jemand mit Zugriff auf den Server.
Dort lässt sich mit `ADMIN_EMAIL` und `ADMIN_PASSWORT` in der `.env` und einem
erneuten Seed-Lauf wieder ein Passwort setzen — siehe
[Schnellstart, Schritt 5](../SCHNELLSTART.md).
## Rollen
Unten in der Fußzeile und in den Einstellungen steht, welche Rolle du hast.
- **Benutzer** – sieht und plant die eigenen Sachen.
- **Verwaltung** – darf zusätzlich Bereiche ändern und das
[Protokoll](protokoll.md) einsehen.
Die Rolle vergibt die Verwaltung; sie lässt sich nicht selbst hochstufen.
+80
View File
@@ -0,0 +1,80 @@
# Bereiche einrichten
Ein **Bereich** ist ein Lebensbereich: Firma, Gewerbe, Privat, Homelab. Jede
Aufgabe und jeder Zeitblock gehört später zu genau einem davon.
Der Bereich bestimmt drei Dinge:
- **die Farbe**, an der du im Kalender sofort erkennst, worum es geht,
- **das Arbeitsfenster** — die Tageszeit, in der für diesen Bereich überhaupt
geplant wird,
- **die Tageskapazität** — wie viele Minuten je Wochentag höchstens
hineingehen.
![Die Bereichsseite](../bilder/bereiche-hell-schreibtisch.png)
> Bereiche ändern darf nur die **Verwaltung**. Als Benutzer siehst du sie,
> aber ohne die Formulare.
## Einen Bereich anlegen
1. Im Menü auf **Bereiche**.
2. Ganz unten unter _Neuen Bereich anlegen_ die Felder ausfüllen.
3. Auf **Bereich anlegen** klicken.
| Feld | Bedeutung |
| ---------------------------- | ----------------------------------------------------------------------------------------------- |
| **Name** | Zwei bis 40 Zeichen, muss eindeutig sein |
| **Farbe** | Wird zum Farbpunkt in Listen und später im Kalender |
| **Arbeitsfenster von / bis** | Zwischen diesen Uhrzeiten wird geplant. Muss nach dem Beginn enden |
| **Tageskapazität** | Minuten je Wochentag. 0 heißt: an diesem Tag gar nicht |
| **In Benutzung** | Abgehakt lassen. Ohne Haken bleibt der Bereich erhalten, taucht aber nicht mehr zur Auswahl auf |
| **Reihenfolge** | Kleinere Zahlen stehen weiter oben |
## Was eine sinnvolle Kapazität ist
Nicht die Zeit, die der Tag hergibt — die Zeit, die **wirklich für geplante
Arbeit** übrig bleibt, nach Besprechungen, Störungen und Pausen.
Ein brauchbarer Anfang:
| Bereich | Montag bis Donnerstag | Freitag | Wochenende |
| ------- | --------------------- | ------- | --------------- |
| Firma | 360 min (6 h) | 240 min | 0 |
| Gewerbe | 90 min | 60 min | Samstag 180 min |
| Privat | 60 min | 120 min | je 240 min |
| Homelab | 0 bis 60 min | 0 | Samstag 120 min |
Wenn am Ende der Woche regelmäßig etwas liegenbleibt, ist die Kapazität zu
hoch angesetzt, nicht der Tag zu kurz.
> Die Tageskapazität darf nie länger sein als das Arbeitsfenster. Ein Fenster
> von 08:00 bis 17:00 fasst höchstens 540 Minuten; trägst du mehr ein, weist
> das Formular es ab.
## Einen Bereich ändern
Auf **Bearbeiten** unter dem Bereich klicken, ändern, **Speichern**. Mit
**Schließen** verwirfst du, was du noch nicht gespeichert hast.
## Einen Bereich loswerden
Zwei Wege, und meistens ist der erste der richtige:
1. **Stilllegen** – den Haken bei _In Benutzung_ entfernen und speichern. Alles
Bisherige bleibt erhalten, der Bereich taucht nur nicht mehr zur Auswahl auf.
2. **Löschen** – der rote Knopf. Endgültig.
> Ab M2 hängen Aufgaben an Bereichen. Dann ist Stilllegen fast immer die
> richtige Wahl, weil beim Löschen auch die Zuordnung verlorenginge.
Jede Änderung landet im [Protokoll](protokoll.md).
## Typische Fehler
| Meldung | Was zu tun ist |
| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| „Diesen Namen gibt es schon." | Namen sind eindeutig. Vielleicht existiert der Bereich stillgelegt schon. |
| „Das Arbeitsfenster muss nach seinem Beginn enden." | Von-Zeit liegt hinter der Bis-Zeit. Über Mitternacht hinweg geht nicht — dafür lieber zwei Bereiche. |
| „Keine Tageskapazität darf länger sein als das Arbeitsfenster." | Entweder die Kapazität senken oder das Fenster weiten. |
| „Dazu fehlt dir die Berechtigung." | Du bist als Benutzer angemeldet, nicht als Verwaltung. |
+57
View File
@@ -0,0 +1,57 @@
# Protokoll
Jede schreibende Aktion im Planer hinterlässt einen Eintrag: wer, wann, was,
und woran. Einträge lassen sich weder ändern noch löschen.
Die Seite steht nur der **Verwaltung** offen. Als Benutzer siehst du den
Menüpunkt gar nicht erst; wer die Adresse direkt aufruft, landet auf einem
Hinweis.
![Die Protokollseite](../bilder/protokoll-hell-schreibtisch.png)
## Was dort steht
| Spalte | Bedeutung |
| ------------- | --------------------------------------------------------------------- |
| **Zeitpunkt** | Datum und Uhrzeit in Europe/Berlin |
| **Wer** | Die E-Mail-Adresse zum Zeitpunkt der Aktion |
| **Was** | Die Aktion, in der Form `objekt.verb` |
| **Objekt** | Die Art des betroffenen Objekts, dahinter die fachlichen Einzelheiten |
Angezeigt werden die letzten 100 Einträge, die neuesten zuerst.
## Die Aktionen
| Aktion | Bedeutung |
| ------------------- | --------------------------------------------------- |
| `bereich.angelegt` | Ein neuer Bereich ist entstanden |
| `bereich.geaendert` | Name, Farbe, Fenster oder Kapazität wurden geändert |
| `bereich.geloescht` | Ein Bereich wurde entfernt |
| `passkey.angelegt` | Jemand hat ein Gerät hinterlegt |
| `passkey.entfernt` | Ein Gerät wurde entfernt |
Mit jedem Meilenstein kommen Aktionen hinzu.
## Was dort bewusst **nicht** steht
- Keine Passwörter, Hashes oder Tokens.
- Keine Termin-Betreffs und keine Mailinhalte.
- Keine Zugangsdaten der Kalenderkonten.
Das ist doppelt abgesichert: Solche Felder werden gar nicht erst übergeben,
und vor dem Speichern ersetzt der Planer zusätzlich jedes Feld, dessen Name
nach einem Geheimnis aussieht, durch `[entfernt]`. Wenn du dort `[entfernt]`
liest, hat dieser zweite Riegel gegriffen.
## Warum die E-Mail-Adresse und nicht nur die Kennung
Wird ein Benutzer gelöscht, verschwinden seine Passkeys und verknüpften
Konten — der Protokolleintrag bleibt. Die Verknüpfung zum Konto wird gelöst,
die Adresse bleibt als Nachweis stehen. Sonst wäre das Protokoll nach einer
Löschung wertlos.
## Aufbewahrung
Einträge werden nicht automatisch entfernt. Die Tabelle wächst langsam — ein
Eintrag je Änderung, nicht je Seitenaufruf. Sollte sie einmal stören, gehört
das Aufräumen in die Sicherungsroutine und nicht in die Anwendung.
+79
View File
@@ -0,0 +1,79 @@
import { expect, test } from '@playwright/test'
/**
* Rauchtest der Anmeldung.
*
* Prüft den Weg, der in M1 wirklich trägt: Wer nicht angemeldet ist, kommt
* nirgends hin; mit richtigem Passwort schon; und die Verwaltungsseite bleibt
* verschlossen, wenn die Rolle nicht reicht.
*
* Voraussetzung: "npm run db:seed" ist gelaufen und ADMIN_EMAIL sowie
* ADMIN_PASSWORT sind gesetzt.
*/
const EMAIL = process.env.ADMIN_EMAIL ?? ''
const PASSWORT = process.env.ADMIN_PASSWORT ?? ''
test.describe('Anmeldung', () => {
test.skip(
!EMAIL || !PASSWORT,
'ADMIN_EMAIL und ADMIN_PASSWORT sind nicht gesetzt – ohne sie gibt es kein Testkonto.',
)
test('leitet nicht Angemeldete zur Anmeldeseite', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveURL(/\/anmelden/)
await expect(page.getByRole('heading', { name: 'Planer' })).toBeVisible()
})
test('merkt sich das Ziel und kehrt nach der Anmeldung dorthin zurück', async ({
page,
}) => {
await page.goto('/bereiche')
await expect(page).toHaveURL(/\/anmelden\?weiter=%2Fbereiche/)
})
test('weist falsche Zugangsdaten ab, ohne zu verraten warum', async ({ page }) => {
await page.goto('/anmelden')
await page.getByLabel('E-Mail-Adresse').fill(EMAIL)
await page.getByLabel('Passwort').fill('ganz-sicher-falsch-123')
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
await expect(page.getByText('Anmeldung nicht möglich')).toBeVisible()
await expect(page).toHaveURL(/\/anmelden/)
})
test('lässt mit richtigem Passwort hinein und wieder hinaus', async ({ page }) => {
await page.goto('/anmelden')
await page.getByLabel('E-Mail-Adresse').fill(EMAIL)
await page.getByLabel('Passwort').fill(PASSWORT)
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
await expect(page.getByRole('link', { name: 'Bereiche' })).toBeVisible()
await page.getByTitle('Abmelden').click()
await expect(page).toHaveURL(/\/anmelden/)
})
test('zeigt der Verwaltung das Protokoll', async ({ page }) => {
await page.goto('/anmelden')
await page.getByLabel('E-Mail-Adresse').fill(EMAIL)
await page.getByLabel('Passwort').fill(PASSWORT)
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
await page.getByRole('link', { name: 'Protokoll' }).click()
await expect(page.getByRole('heading', { name: 'Protokoll' })).toBeVisible()
})
})
test('der Health-Endpunkt antwortet ohne Anmeldung', async ({ request }) => {
const antwort = await request.get('/api/health')
expect(antwort.ok()).toBe(true)
const inhalt = (await antwort.json()) as Record<string, unknown>
expect(inhalt.status).toBe('ok')
expect(inhalt.datenbank).toBe('ok')
expect(typeof inhalt.version).toBe('string')
})
+74
View File
@@ -0,0 +1,74 @@
import { PrismaPg } from '@prisma/adapter-pg'
import { expect, test, type Page } from '@playwright/test'
import bcrypt from 'bcryptjs'
import { PrismaClient } from '../src/generated/prisma/client.js'
/**
* Prüft, dass die Rollentrennung serverseitig hält.
*
* Die Oberfläche blendet Knöpfe aus — darauf allein darf man sich nicht
* verlassen. Deshalb ruft dieser Test die Verwaltungsseite direkt auf, statt
* nach einem Menüpunkt zu suchen.
*/
const EMAIL = 'zzz-e2e-benutzer@example.invalid'
const PASSWORT = 'nur-fuer-den-test-1234'
const verbindung = process.env.DATABASE_URL ?? ''
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
test.describe('Rollentrennung', () => {
test.skip(!verbindung, 'DATABASE_URL ist nicht gesetzt.')
test.beforeAll(async () => {
await db.benutzer.deleteMany({ where: { email: EMAIL } })
await db.benutzer.create({
data: {
email: EMAIL,
name: 'Testbenutzer ohne Rechte',
rolle: 'BENUTZER',
passwortHash: await bcrypt.hash(PASSWORT, 10),
},
})
})
test.afterAll(async () => {
await db.auditEintrag.deleteMany({ where: { benutzerEmail: EMAIL } })
await db.benutzer.deleteMany({ where: { email: EMAIL } })
await db.$disconnect()
})
async function anmelden(seite: Page): Promise<void> {
await seite.goto('/anmelden')
await seite.getByLabel('E-Mail-Adresse').fill(EMAIL)
await seite.getByLabel('Passwort').fill(PASSWORT)
await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
await expect(seite.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
}
test('zeigt einem Benutzer den Menüpunkt Protokoll gar nicht', async ({ page }) => {
await anmelden(page)
await expect(page.getByRole('link', { name: 'Protokoll' })).toHaveCount(0)
})
test('lässt einen Benutzer auch beim direkten Aufruf nicht ins Protokoll', async ({
page,
}) => {
await anmelden(page)
await page.goto('/verwaltung/protokoll')
await expect(page).toHaveURL(/\/keine-berechtigung/)
await expect(
page.getByRole('heading', { name: /Dazu fehlt dir die Berechtigung/ }),
).toBeVisible()
})
test('zeigt einem Benutzer die Bereiche, aber ohne Formulare', async ({ page }) => {
await anmelden(page)
await page.goto('/bereiche')
await expect(page.getByRole('heading', { name: 'Bereiche' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Bearbeiten' })).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Bereich anlegen' })).toHaveCount(0)
})
})
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "planer",
"version": "0.0.0",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "planer",
"version": "0.0.0",
"version": "0.1.0",
"dependencies": {
"@auth/prisma-adapter": "2.11.3",
"@date-fns/tz": "1.5.0",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "planer",
"version": "0.0.0",
"version": "0.1.0",
"private": true,
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
"type": "module",
@@ -26,7 +26,8 @@
"db:migrieren": "prisma migrate dev",
"db:einspielen": "prisma migrate deploy",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio"
"db:studio": "prisma studio",
"db:demo-seed": "tsx prisma/demo-seed.ts"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
+46
View File
@@ -0,0 +1,46 @@
import { defineConfig, devices } from '@playwright/test'
import 'dotenv/config'
const PORT = Number(process.env.E2E_PORT ?? 3100)
const BASIS = `http://127.0.0.1:${PORT}`
/**
* E2E-Tests gegen eine echte, gebaute Anwendung.
*
* Der Testserver läuft auf einem eigenen Port, damit er einer laufenden
* Entwicklungsinstanz nicht in die Quere kommt. Die Datenbank ist dieselbe wie
* im Entwicklungsbetrieb – die Tests legen nur Daten mit eigenem Präfix an und
* räumen hinter sich auf.
*/
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 1 : 0,
workers: 1,
reporter: process.env.CI ? 'github' : 'list',
timeout: 30_000,
use: {
baseURL: BASIS,
locale: 'de-DE',
timezoneId: 'Europe/Berlin',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: 'npm run start',
url: `${BASIS}/api/health`,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
PORT: String(PORT),
APP_URL: BASIS,
WEBAUTHN_ORIGIN: BASIS,
WEBAUTHN_RP_ID: '127.0.0.1',
},
},
})
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from '@playwright/test'
const PORT = process.env.SCREENSHOT_APP_PORT ?? '3200'
/**
* Eigene Konfiguration für die Screenshot-Instanz. Sie startet keinen Server —
* darum kümmert sich scripts/screenshots.ts über Docker Compose.
*/
export default defineConfig({
testDir: './screenshots',
fullyParallel: false,
workers: 1,
reporter: 'list',
timeout: 60_000,
use: {
baseURL: `http://127.0.0.1:${PORT}`,
locale: 'de-DE',
timezoneId: 'Europe/Berlin',
},
})
+6 -2
View File
@@ -1,5 +1,5 @@
import 'dotenv/config'
import { defineConfig, env } from 'prisma/config'
import { defineConfig } from 'prisma/config'
export default defineConfig({
schema: 'prisma/schema.prisma',
@@ -8,6 +8,10 @@ export default defineConfig({
seed: 'tsx prisma/seed.ts',
},
datasource: {
url: env('DATABASE_URL'),
// Bewusst über process.env statt über den env()-Helfer: "prisma generate"
// läuft auch beim Bauen des Abbilds, wo es noch gar keine Datenbank gibt.
// env() bricht dort ab. Fehlt die URL für migrate oder studio, meldet
// Prisma das selbst.
url: process.env.DATABASE_URL ?? '',
},
})
+172
View File
@@ -0,0 +1,172 @@
/**
* Daten für die Screenshot-Instanz.
*
* Alles hier ist frei erfunden. Diese Datei darf niemals gegen eine
* Datenbank mit echten Daten laufen — deshalb bricht sie ab, wenn
* DEMO_MODUS nicht ausdrücklich auf true steht.
*
* Aufruf: über npm run screenshots, nicht von Hand.
*/
import 'dotenv/config'
import { PrismaPg } from '@prisma/adapter-pg'
import bcrypt from 'bcryptjs'
import { PrismaClient } from '../src/generated/prisma/client.js'
if (process.env.DEMO_MODUS !== 'true') {
console.error(
'Der Demo-Seed läuft nur mit DEMO_MODUS=true. Das ist Absicht: er würde ' +
'sonst erfundene Daten in eine echte Datenbank schreiben.',
)
process.exit(1)
}
const verbindung = process.env.DATABASE_URL
if (!verbindung) {
console.error('DATABASE_URL ist nicht gesetzt.')
process.exit(1)
}
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
export const DEMO_EMAIL = 'martina.beispiel@planer.invalid'
export const DEMO_PASSWORT = 'demo-instanz-nur-fuer-bilder'
const BEREICHE = [
{
name: 'Firma',
farbe: '#2563eb',
reihenfolge: 1,
arbeitsfensterVon: 450,
arbeitsfensterBis: 990,
kapazitaetMo: 360,
kapazitaetDi: 360,
kapazitaetMi: 360,
kapazitaetDo: 360,
kapazitaetFr: 240,
kapazitaetSa: 0,
kapazitaetSo: 0,
},
{
name: 'Gewerbe',
farbe: '#7c3aed',
reihenfolge: 2,
arbeitsfensterVon: 1020,
arbeitsfensterBis: 1260,
kapazitaetMo: 90,
kapazitaetDi: 90,
kapazitaetMi: 90,
kapazitaetDo: 90,
kapazitaetFr: 60,
kapazitaetSa: 180,
kapazitaetSo: 0,
},
{
name: 'Privat',
farbe: '#16a34a',
reihenfolge: 3,
arbeitsfensterVon: 1020,
arbeitsfensterBis: 1320,
kapazitaetMo: 60,
kapazitaetDi: 60,
kapazitaetMi: 60,
kapazitaetDo: 60,
kapazitaetFr: 120,
kapazitaetSa: 240,
kapazitaetSo: 240,
},
{
name: 'Homelab',
farbe: '#ea580c',
reihenfolge: 4,
arbeitsfensterVon: 1140,
arbeitsfensterBis: 1380,
kapazitaetMo: 0,
kapazitaetDi: 60,
kapazitaetMi: 0,
kapazitaetDo: 60,
kapazitaetFr: 0,
kapazitaetSa: 120,
kapazitaetSo: 60,
},
]
/** Erfundene Protokolleinträge, damit die Protokollseite nicht leer wirkt. */
const PROTOKOLL = [
{ aktion: 'bereich.angelegt', objektTyp: 'Bereich', details: { name: 'Homelab' } },
{
aktion: 'bereich.geaendert',
objektTyp: 'Bereich',
details: { vorher: 'Nebenerwerb', nachher: 'Gewerbe' },
},
{
aktion: 'passkey.angelegt',
objektTyp: 'Passkey',
details: { bezeichnung: 'Diensthandy' },
},
]
async function main(): Promise<void> {
console.log('Demo-Daten einspielen.')
for (const bereich of BEREICHE) {
await db.bereich.upsert({
where: { name: bereich.name },
update: bereich,
create: bereich,
})
}
const benutzer = await db.benutzer.upsert({
where: { email: DEMO_EMAIL },
update: { rolle: 'ADMIN', name: 'Martina Beispiel' },
create: {
email: DEMO_EMAIL,
name: 'Martina Beispiel',
rolle: 'ADMIN',
passwortHash: await bcrypt.hash(DEMO_PASSWORT, 10),
},
})
await db.auditEintrag.deleteMany({})
// Feste Zeitpunkte, damit die Bilder zwischen zwei Läufen gleich bleiben.
let versatz = 0
for (const eintrag of PROTOKOLL) {
versatz += 37
await db.auditEintrag.create({
data: {
zeitpunkt: new Date(Date.UTC(2026, 2, 17, 9, versatz % 60)),
benutzerId: benutzer.id,
benutzerEmail: benutzer.email,
aktion: eintrag.aktion,
objektTyp: eintrag.objektTyp,
details: eintrag.details,
instanz: 'daheim',
},
})
}
await db.passkey.deleteMany({ where: { benutzerId: benutzer.id } })
await db.passkey.create({
data: {
benutzerId: benutzer.id,
credentialId: 'demo-passkey-diensthandy',
publicKey: Buffer.from([0]),
bezeichnung: 'Diensthandy',
gesichert: true,
erstelltAm: new Date(Date.UTC(2026, 1, 3, 8, 15)),
zuletztBenutzt: new Date(Date.UTC(2026, 2, 17, 7, 42)),
},
})
console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`)
}
main()
.then(async () => {
await db.$disconnect()
})
.catch(async (fehler: unknown) => {
console.error(fehler)
await db.$disconnect()
process.exit(1)
})
+1
View File
@@ -0,0 +1 @@
# Der Ordner bleibt im Git, damit der Docker-Bau ihn kopieren kann.
+88
View File
@@ -0,0 +1,88 @@
import { mkdir } from 'node:fs/promises'
import { test, type Page } from '@playwright/test'
/**
* Nimmt die Bilder für das Handbuch auf.
*
* Je Seite vier Bilder: hell und dunkel, Schreibtisch und Handy. Die Uhr steht
* auf einem festen Zeitpunkt, damit sich zwischen zwei Läufen nur das ändert,
* was sich wirklich geändert hat.
*/
const ZIEL = 'docs/bilder'
const SCHREIBTISCH = { width: 1440, height: 900 }
const HANDY = { width: 390, height: 844 }
// Dienstag, 17. März 2026, 09:20 Uhr in Berlin.
const FESTE_ZEIT = new Date('2026-03-17T08:20:00.000Z')
const EMAIL = 'martina.beispiel@planer.invalid'
const PASSWORT = 'demo-instanz-nur-fuer-bilder'
type Aufnahme = {
name: string
pfad: string
/** Wird vor der Aufnahme ausgeführt, z. B. um ein Formular aufzuklappen. */
vorbereiten?: (seite: Page) => Promise<void>
}
const AUFNAHMEN: Aufnahme[] = [
{ name: 'anmelden', pfad: '/anmelden' },
{ name: 'start', pfad: '/' },
{ name: 'bereiche', pfad: '/bereiche' },
{ name: 'einstellungen', pfad: '/einstellungen' },
{ name: 'protokoll', pfad: '/verwaltung/protokoll' },
]
async function anmelden(seite: Page): Promise<void> {
await seite.goto('/anmelden')
await seite.getByLabel('E-Mail-Adresse').fill(EMAIL)
await seite.getByLabel('Passwort').fill(PASSWORT)
await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
await seite.waitForURL('/')
}
test.beforeAll(async () => {
await mkdir(ZIEL, { recursive: true })
})
for (const schema of ['hell', 'dunkel'] as const) {
for (const [geraet, groesse] of [
['schreibtisch', SCHREIBTISCH],
['handy', HANDY],
] as const) {
test(`Bilder: ${schema}, ${geraet}`, async ({ browser }) => {
const kontext = await browser.newContext({
viewport: groesse,
colorScheme: schema === 'dunkel' ? 'dark' : 'light',
deviceScaleFactor: 2,
})
const seite = await kontext.newPage()
await seite.clock.setFixedTime(FESTE_ZEIT)
for (const aufnahme of AUFNAHMEN) {
if (aufnahme.pfad !== '/anmelden') {
// Für alles außer der Anmeldeseite braucht es eine Sitzung.
if (
!(await seite.context().cookies()).some((k) => k.name.includes('session'))
) {
await anmelden(seite)
}
}
await seite.goto(aufnahme.pfad)
await seite.waitForLoadState('networkidle')
await aufnahme.vorbereiten?.(seite)
// Der Farbschema-Umschalter und Schriften sollen fertig sein.
await seite.waitForTimeout(300)
await seite.screenshot({
path: `${ZIEL}/${aufnahme.name}-${schema}-${geraet}.png`,
fullPage: true,
})
}
await kontext.close()
})
}
}
+208
View File
@@ -0,0 +1,208 @@
/**
* Integrationstest gegen eine echte Datenbank.
*
* Prüft, was Vitest nicht abdeckt: Eindeutigkeit, Fremdschlüssel, Kaskaden
* und dass das Audit-Protokoll den Benutzer überlebt.
*
* Bewusst ohne umschließende Transaktion: Der Test prüft absichtlich Fälle,
* die scheitern müssen, und ein gescheiterter Befehl bricht in PostgreSQL die
* ganze Transaktion ab. Stattdessen tragen alle Testdaten dieselbe Kennung
* und werden am Ende wieder entfernt — auch wenn der Test unterwegs abbricht.
*
* Aufruf: npm run test:integration
*/
import 'dotenv/config'
import { PrismaPg } from '@prisma/adapter-pg'
import { PrismaClient } from '../src/generated/prisma/client.js'
const verbindung = process.env.DATABASE_URL
if (!verbindung) {
console.error('DATABASE_URL ist nicht gesetzt.')
process.exit(1)
}
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
type Pruefung = { name: string; ok: boolean; hinweis?: string }
const ergebnisse: Pruefung[] = []
function pruefe(name: string, bedingung: boolean, hinweis?: string): void {
ergebnisse.push(hinweis ? { name, ok: bedingung, hinweis } : { name, ok: bedingung })
}
/** Erwartet, dass die Aktion scheitert. */
async function scheitert(name: string, tun: () => Promise<unknown>): Promise<void> {
try {
await tun()
pruefe(name, false, 'Die Aktion war erlaubt, obwohl sie scheitern sollte.')
} catch {
pruefe(name, true)
}
}
const kennung = `zzz-test-${Date.now()}`
const testEmail = `${kennung}@example.invalid`
async function aufraeumen(): Promise<void> {
// Reihenfolge egal, die Kaskaden erledigen den Rest; der Audit-Eintrag
// hängt bewusst an keinem Benutzer mehr.
await db.auditEintrag.deleteMany({ where: { benutzerEmail: testEmail } })
await db.benutzer.deleteMany({ where: { email: testEmail } })
await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } })
await db.webauthnChallenge.deleteMany({ where: { kennung } })
}
async function main(): Promise<void> {
// --- Benutzer und Eindeutigkeit der Adresse ------------------------------
const benutzer = await db.benutzer.create({
data: { email: testEmail, name: 'Testkonto' },
})
pruefe('Benutzer anlegen', benutzer.id.length > 0)
pruefe('Rolle ist im Zweifel BENUTZER', benutzer.rolle === 'BENUTZER')
pruefe('Konto ist im Zweifel aktiv', benutzer.aktiv === true)
await scheitert('E-Mail-Adresse ist eindeutig', () =>
db.benutzer.create({ data: { email: testEmail } }),
)
// --- Bereich und Eindeutigkeit des Namens --------------------------------
const bereich = await db.bereich.create({
data: { name: `${kennung}-bereich`, farbe: '#2563eb', kapazitaetMo: 120 },
})
pruefe('Bereich anlegen', bereich.name.endsWith('-bereich'))
pruefe(
'Arbeitsfenster hat brauchbare Standardwerte',
bereich.arbeitsfensterVon === 480 && bereich.arbeitsfensterBis === 1020,
)
await scheitert('Bereichsname ist eindeutig', () =>
db.bereich.create({ data: { name: bereich.name, farbe: '#000000' } }),
)
// --- Passkey, Eindeutigkeit und Fremdschlüssel ---------------------------
const passkey = await db.passkey.create({
data: {
benutzerId: benutzer.id,
credentialId: `${kennung}-cred`,
publicKey: Buffer.from([1, 2, 3]),
bezeichnung: 'Testgerät',
},
})
pruefe('Passkey anlegen', passkey.counter === 0n)
pruefe(
'Zähler ist ein BigInt und beginnt bei null',
typeof passkey.counter === 'bigint',
)
await scheitert('Credential-ID ist eindeutig', () =>
db.passkey.create({
data: {
benutzerId: benutzer.id,
credentialId: passkey.credentialId,
publicKey: Buffer.from([4]),
bezeichnung: 'Zweitgerät',
},
}),
)
await scheitert('Passkey ohne gültigen Benutzer ist nicht möglich', () =>
db.passkey.create({
data: {
benutzerId: 'gibt-es-nicht',
credentialId: `${kennung}-waise`,
publicKey: Buffer.from([9]),
bezeichnung: 'Waise',
},
}),
)
// --- Öffentlicher Schlüssel kommt unverändert zurück ---------------------
const gelesen = await db.passkey.findUnique({ where: { id: passkey.id } })
pruefe(
'Der öffentliche Schlüssel kommt Byte für Byte zurück',
Buffer.from(gelesen?.publicKey ?? []).equals(Buffer.from([1, 2, 3])),
)
// --- Konto und Eindeutigkeit je Anbieter ---------------------------------
await db.konto.create({
data: {
benutzerId: benutzer.id,
type: 'oidc',
provider: 'oidc',
providerAccountId: kennung,
},
})
await scheitert('Ein Anbieterkonto ist nur einmal verknüpfbar', () =>
db.konto.create({
data: {
benutzerId: benutzer.id,
type: 'oidc',
provider: 'oidc',
providerAccountId: kennung,
},
}),
)
// --- Audit überlebt den Benutzer ----------------------------------------
const audit = await db.auditEintrag.create({
data: {
benutzerId: benutzer.id,
benutzerEmail: benutzer.email,
aktion: 'test.durchgefuehrt',
objektTyp: 'Bereich',
objektId: bereich.id,
details: { name: bereich.name },
instanz: 'daheim',
},
})
pruefe('Audit-Eintrag anlegen', audit.id.length > 0)
await db.benutzer.delete({ where: { id: benutzer.id } })
const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } })
pruefe('Audit-Eintrag überlebt das Löschen des Benutzers', nachher !== null)
pruefe(
'Die Kennung wird gelöst, die Adresse bleibt als Nachweis stehen',
nachher?.benutzerId === null && nachher?.benutzerEmail === testEmail,
)
pruefe(
'Passkeys verschwinden mit dem Benutzer',
(await db.passkey.count({ where: { credentialId: passkey.credentialId } })) === 0,
)
pruefe(
'Verknüpfte Konten verschwinden mit dem Benutzer',
(await db.konto.count({ where: { providerAccountId: kennung } })) === 0,
)
}
let abbruch: unknown = null
main()
.catch((fehler: unknown) => {
abbruch = fehler
})
.finally(async () => {
await aufraeumen()
const uebrig =
(await db.benutzer.count({ where: { email: testEmail } })) +
(await db.bereich.count({ where: { name: { startsWith: kennung } } }))
pruefe('Aufräumen hat nichts übrig gelassen', uebrig === 0)
await db.$disconnect()
if (abbruch) {
console.error('Der Integrationstest ist abgebrochen:', abbruch)
process.exit(1)
}
const gefallen = ergebnisse.filter((e) => !e.ok)
for (const e of ergebnisse) {
console.log(`${e.ok ? '✓' : '✗'} ${e.name}${e.hinweis ? ` – ${e.hinweis}` : ''}`)
}
console.log(
`\n${ergebnisse.length - gefallen.length} von ${ergebnisse.length} Prüfungen bestanden.`,
)
process.exit(gefallen.length === 0 ? 0 : 1)
})
+121
View File
@@ -0,0 +1,121 @@
/**
* Migrationstest.
*
* Legt eine Wegwerf-Datenbank an, spielt alle Migrationen der Reihe nach ein
* und prüft anschließend, ob das Ergebnis genau dem Schema entspricht. Damit
* fällt auf, wenn jemand das Schema ändert, ohne eine Migration zu erzeugen —
* beim Aufspielen im Betrieb wäre das sonst erst spät zu merken.
*
* Aufruf: npm run test:migration
*/
import 'dotenv/config'
import { spawnSync } from 'node:child_process'
import { join } from 'node:path'
import { Client } from 'pg'
const WURZEL = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')
const verbindung = process.env.DATABASE_URL
if (!verbindung) {
console.error('DATABASE_URL ist nicht gesetzt.')
process.exit(1)
}
const testDatenbank = `planer_migrationstest_${Date.now()}`
/** Dieselbe Verbindung, aber auf eine andere Datenbank gerichtet. */
function mitDatenbank(url: string, name: string): string {
const zerlegt = new URL(url)
zerlegt.pathname = `/${name}`
return zerlegt.toString()
}
function prisma(argumente: string[], zusatz: Record<string, string>): number {
const ergebnis = spawnSync(
process.execPath,
[join(WURZEL, 'node_modules', 'prisma', 'build', 'index.js'), ...argumente],
{
cwd: WURZEL,
stdio: 'inherit',
env: { ...process.env, ...zusatz },
},
)
return ergebnis.status ?? 1
}
async function aufVerwaltungsdatenbank<T>(
tun: (klient: Client) => Promise<T>,
): Promise<T> {
// "postgres" ist auf jedem Server vorhanden und wird hier nur benutzt, um
// die Wegwerf-Datenbank anzulegen und wieder zu entfernen.
const klient = new Client({ connectionString: mitDatenbank(verbindung!, 'postgres') })
await klient.connect()
try {
return await tun(klient)
} finally {
await klient.end()
}
}
async function main(): Promise<number> {
console.log(`▸ Wegwerf-Datenbank ${testDatenbank} anlegen`)
await aufVerwaltungsdatenbank((k) => k.query(`CREATE DATABASE "${testDatenbank}"`))
const testUrl = mitDatenbank(verbindung!, testDatenbank)
console.log('▸ Alle Migrationen einspielen')
if (prisma(['migrate', 'deploy'], { DATABASE_URL: testUrl }) !== 0) {
console.error('✗ Die Migrationen ließen sich nicht einspielen.')
return 1
}
console.log('▸ Ergebnis mit dem Schema vergleichen')
// --exit-code liefert 2, wenn ein Unterschied bleibt.
const abweichung = prisma(
[
'migrate',
'diff',
// Die Quelle ist die eben eingespielte Wegwerf-Datenbank, das Ziel das
// Schema. So braucht der Vergleich keine Schattendatenbank.
'--from-config-datasource',
'--to-schema',
'prisma/schema.prisma',
'--exit-code',
],
{ DATABASE_URL: testUrl },
)
if (abweichung === 2) {
console.error(
'\n✗ Nach dem Einspielen weicht die Datenbank vom Schema ab.\n' +
' Vermutlich fehlt eine Migration: "npx prisma migrate dev --name <name>".',
)
return 1
}
if (abweichung !== 0) {
console.error('✗ Der Vergleich ließ sich nicht durchführen.')
return 1
}
console.log('\n✓ Die Migrationen ergeben genau das Schema.')
return 0
}
let code = 1
try {
code = await main()
} catch (fehler) {
console.error('Der Migrationstest ist abgebrochen:', fehler)
} finally {
await aufVerwaltungsdatenbank(async (k) => {
// Offene Verbindungen trennen, sonst lässt sich die Datenbank nicht löschen.
await k.query(
'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1',
[testDatenbank],
)
await k.query(`DROP DATABASE IF EXISTS "${testDatenbank}"`)
})
console.log(`▸ Wegwerf-Datenbank ${testDatenbank} entfernt`)
}
process.exit(code)
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Setzt eine neue Version: package.json, CHANGELOG, Commit und Tag.
# Pusht bewusst nicht – das geschieht erst nach ausdrücklicher Freigabe.
set -euo pipefail
WURZEL="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$WURZEL"
meldung() { printf '▸ %s\n' "$1"; }
fehler() {
printf 'Fehler: %s\n' "$1" >&2
exit 1
}
hilfe() {
cat <<'ENDE'
Verwendung: scripts/release.sh <version>
<version> Semantische Version ohne führendes v, z. B. 0.1.0
Ablauf:
1. Sauberen Arbeitsbaum und grüne Prüfungen sicherstellen
2. Version in package.json setzen
3. "Unveröffentlicht" im CHANGELOG in einen datierten Abschnitt überführen
4. chore(release): <version> committen und Tag v<version> anlegen
Es wird nicht gepusht.
ENDE
}
VERSION="${1:-}"
case "$VERSION" in
''|--hilfe|-h) hilfe; [ -z "$VERSION" ] && exit 1; exit 0 ;;
esac
printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' \
|| fehler "\"$VERSION\" sieht nicht nach einer semantischen Version aus (z. B. 0.1.0)."
TAG="v$VERSION"
# --- 1. Voraussetzungen ----------------------------------------------------
meldung 'Arbeitsbaum prüfen.'
[ -d .git ] || fehler 'Kein Git-Arbeitsbaum.'
[ -z "$(git status --porcelain)" ] || fehler 'Der Arbeitsbaum ist nicht sauber.'
git rev-parse -q --verify "refs/tags/$TAG" >/dev/null \
&& fehler "Den Tag $TAG gibt es schon."
[ -f CHANGELOG.md ] || fehler 'CHANGELOG.md fehlt.'
grep -q '^## \[Unveröffentlicht\]' CHANGELOG.md \
|| fehler 'Im CHANGELOG fehlt der Abschnitt "## [Unveröffentlicht]".'
INHALT="$(awk '
/^## \[Unveröffentlicht\]/ { sammeln = 1; next }
sammeln && /^## / { exit }
sammeln { print }
' CHANGELOG.md | grep -v '^[[:space:]]*$' || true)"
[ -n "$INHALT" ] || fehler 'Der Abschnitt "Unveröffentlicht" ist leer.'
meldung 'Prüfungen laufen lassen.'
npm run pruefen || fehler 'Die Prüfungen sind nicht grün.'
# --- 2. Version setzen -----------------------------------------------------
meldung "Version auf $VERSION setzen."
npm version "$VERSION" --no-git-tag-version --allow-same-version >/dev/null
# --- 3. CHANGELOG ----------------------------------------------------------
meldung 'CHANGELOG fortschreiben.'
HEUTE="$(date '+%Y-%m-%d')"
TEMP="$(mktemp)"
awk -v version="$VERSION" -v heute="$HEUTE" '
/^## \[Unveröffentlicht\]/ {
print "## [Unveröffentlicht]"
print ""
print "## [" version "] – " heute
next
}
{ print }
' CHANGELOG.md > "$TEMP"
mv "$TEMP" CHANGELOG.md
npx prettier --write package.json CHANGELOG.md >/dev/null 2>&1 || true
# --- 4. Commit und Tag -----------------------------------------------------
meldung "Commit und Tag $TAG anlegen."
git add package.json package-lock.json CHANGELOG.md
git commit -m "chore(release): $VERSION"
git tag -a "$TAG" -m "Planer $VERSION"
meldung "Fertig. $TAG ist angelegt, aber nicht gepusht."
printf '\nNach der Freigabe:\n'
printf ' git push origin %s\n' "$(git rev-parse --abbrev-ref HEAD)"
printf ' git push origin %s\n' "$TAG"
+142
View File
@@ -0,0 +1,142 @@
/**
* Nimmt die Bilder für das Handbuch auf.
*
* Startet eine Wegwerf-Instanz in einem eigenen Compose-Projekt mit eigener
* Datenbank, spielt ausschließlich erfundene Daten ein, fährt die Seiten mit
* Playwright ab und räumt am Ende alles wieder weg — auch wenn unterwegs etwas
* schiefgeht.
*
* An eine Instanz mit echten Daten kommt dieser Ablauf nicht heran: die
* Datenbank entsteht neu, wird nur über ihren eigenen Port angesprochen und
* samt Volume wieder entfernt.
*
* Aufruf: npm run screenshots
*/
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const WURZEL = fileURLToPath(new URL('..', import.meta.url))
const PROJEKT = 'planer-screenshots'
const COMPOSE_DATEI = 'docker-compose.screenshots.yml'
const APP_PORT = process.env.SCREENSHOT_APP_PORT ?? '3200'
const DB_PORT = process.env.SCREENSHOT_DB_PORT ?? '5455'
const UMGEBUNG = {
...process.env,
SCREENSHOT_APP_PORT: APP_PORT,
SCREENSHOT_DB_PORT: DB_PORT,
}
function meldung(text: string): void {
console.log(`▸ ${text}`)
}
function laufen(
befehl: string,
argumente: string[],
zusatz: Record<string, string> = {},
): number {
const ergebnis = spawnSync(befehl, argumente, {
cwd: WURZEL,
stdio: 'inherit',
env: { ...UMGEBUNG, ...zusatz },
})
return ergebnis.status ?? 1
}
function compose(argumente: string[]): number {
return laufen('docker', ['compose', '-p', PROJEKT, '-f', COMPOSE_DATEI, ...argumente])
}
function node(skript: string, zusatz: Record<string, string>): number {
return laufen(
process.execPath,
[`${WURZEL}node_modules/tsx/dist/cli.mjs`, skript],
zusatz,
)
}
async function wartenAufGesundheit(sekunden: number): Promise<boolean> {
const ziel = `http://127.0.0.1:${APP_PORT}/api/health`
const frist = Date.now() + sekunden * 1000
while (Date.now() < frist) {
try {
const antwort = await fetch(ziel)
if (antwort.ok) {
const inhalt = (await antwort.json()) as { status?: string }
if (inhalt.status === 'ok') return true
}
} catch {
// Noch nicht da, gleich noch einmal.
}
await new Promise((weiter) => setTimeout(weiter, 2000))
}
return false
}
function aufraeumen(): void {
meldung('Wegwerf-Instanz entfernen.')
compose(['down', '-v', '--remove-orphans'])
}
async function main(): Promise<number> {
if (laufen('docker', ['info']) !== 0) {
console.error('Der Docker-Daemon läuft nicht.')
return 1
}
// Reste eines abgebrochenen Laufs zuerst wegräumen.
compose(['down', '-v', '--remove-orphans'])
meldung(`Wegwerf-Instanz bauen und starten (App auf ${APP_PORT}, DB auf ${DB_PORT}).`)
if (compose(['up', '-d', '--build']) !== 0) {
console.error('Die Wegwerf-Instanz ließ sich nicht starten.')
return 1
}
meldung('Auf die Anwendung warten.')
if (!(await wartenAufGesundheit(180))) {
console.error('Die Anwendung ist nicht gesund geworden.')
compose(['logs', '--tail', '50', 'app'])
return 1
}
meldung('Erfundene Daten einspielen.')
const demoUrl = `postgresql://demo:demo@127.0.0.1:${DB_PORT}/demo?schema=public`
if (node('prisma/demo-seed.ts', { DATABASE_URL: demoUrl, DEMO_MODUS: 'true' }) !== 0) {
console.error('Der Demo-Seed ist fehlgeschlagen.')
return 1
}
meldung('Bilder aufnehmen.')
const playwright = laufen(
process.execPath,
[
`${WURZEL}node_modules/@playwright/test/cli.js`,
'test',
'--config',
'playwright.screenshots.config.ts',
],
{ SCREENSHOT_APP_PORT: APP_PORT },
)
if (playwright !== 0) {
console.error('Die Aufnahme ist fehlgeschlagen.')
return 1
}
meldung('Fertig. Die Bilder liegen in docs/bilder/.')
return 0
}
let code = 1
try {
code = await main()
} catch (fehler) {
console.error('Der Screenshot-Lauf ist abgebrochen:', fehler)
} finally {
aufraeumen()
}
process.exit(code)
+4 -2
View File
@@ -1,7 +1,7 @@
import { Kopfleiste } from '@/components/kopfleiste'
import { INSTANZ_NAME } from '@/lib/instanz'
import { versionAnzeige } from '@/lib/version'
import { verlangeAnmeldung } from '@/server/berechtigung'
import { angefragterPfad, verlangeAnmeldung } from '@/server/berechtigung'
import { aktuelleInstanz } from '@/server/instanz'
/**
@@ -14,7 +14,9 @@ export default async function AngemeldetesLayout({
}: {
children: React.ReactNode
}) {
const benutzer = await verlangeAnmeldung()
// Der Pfad kommt aus der Kopfzeile des Proxys, damit es nach der
// Anmeldung dort weitergeht, wo man hinwollte.
const benutzer = await verlangeAnmeldung(await angefragterPfad())
const instanz = aktuelleInstanz()
return (
+3 -2
View File
@@ -10,10 +10,11 @@ import { wochenKapazitaet } from '@/lib/bereich'
import { alsDatum, alsDauer } from '@/lib/zeit'
import { verlangeAnmeldung } from '@/server/berechtigung'
import { db } from '@/server/db'
import { jetzt } from '@/server/zeitgeber'
export default async function StartSeite() {
const benutzer = await verlangeAnmeldung('/')
const jetzt = new Date()
const heute = jetzt()
const bereiche = await db.bereich.findMany({
where: { aktiv: true },
@@ -26,7 +27,7 @@ export default async function StartSeite() {
<div className="flex flex-col gap-8">
<header>
<h1 className="text-2xl font-semibold tracking-tight">Guten Tag, {anrede}</h1>
<p className="text-gedaempft-vordergrund mt-1 text-sm">{alsDatum(jetzt)}</p>
<p className="text-gedaempft-vordergrund mt-1 text-sm">{alsDatum(heute)}</p>
</header>
<section className="flex flex-col gap-4">
+13 -5
View File
@@ -35,12 +35,20 @@ export function Kopfleiste({
return (
<header className="border-rand bg-karte border-b">
<div className="mx-auto flex w-full max-w-5xl flex-wrap items-center gap-x-6 gap-y-3 px-4 py-3">
<Link href="/" className="text-base font-semibold tracking-tight">
{/*
Bewusst eine einzige Zeile auf allen Breiten: Wird es eng, scrollt die
Navigation waagerecht, statt umzubrechen und den Namen der Anwendung in
die zweite Zeile zu drängen.
*/}
<div className="mx-auto flex w-full max-w-5xl items-center gap-3 px-4 py-3 sm:gap-6">
<Link href="/" className="shrink-0 text-base font-semibold tracking-tight">
Planer
</Link>
<nav aria-label="Hauptnavigation" className="flex flex-1 flex-wrap gap-1">
<nav
aria-label="Hauptnavigation"
className="flex min-w-0 flex-1 gap-1 overflow-x-auto"
>
{sichtbar.map((eintrag) => {
const aktiv =
eintrag.pfad === '/' ? pfad === '/' : pfad.startsWith(eintrag.pfad)
@@ -50,7 +58,7 @@ export function Kopfleiste({
href={eintrag.pfad}
aria-current={aktiv ? 'page' : undefined}
className={cn(
'rounded-md px-3 py-1.5 text-sm transition-colors',
'shrink-0 rounded-md px-3 py-1.5 text-sm whitespace-nowrap transition-colors',
aktiv
? 'bg-gedaempft text-vordergrund font-medium'
: 'text-gedaempft-vordergrund hover:bg-gedaempft hover:text-vordergrund',
@@ -65,7 +73,7 @@ export function Kopfleiste({
})}
</nav>
<div className="flex items-center gap-1">
<div className="flex shrink-0 items-center gap-1">
<FarbschemaKnopf />
<span
className="text-gedaempft-vordergrund hidden text-xs sm:inline"
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse, type NextRequest } from 'next/server'
export const PFAD_KOPFZEILE = 'x-planer-pfad'
/**
* Reicht den angefragten Pfad als Kopfzeile weiter.
*
* Ein Layout kennt seinen eigenen Pfad nicht, braucht ihn aber, um nach der
* Anmeldung wieder dorthin zurückzuführen. Hier steht bewusst keine
* Anmeldelogik: der Proxy läuft im Edge-Umfeld, in dem weder Prisma noch
* der Adapter verfügbar sind. Geprüft wird weiterhin im Layout, in der Seite
* und in jeder Server Action.
*/
export function proxy(anfrage: NextRequest) {
const kopfzeilen = new Headers(anfrage.headers)
kopfzeilen.set(PFAD_KOPFZEILE, `${anfrage.nextUrl.pathname}${anfrage.nextUrl.search}`)
return NextResponse.next({ request: { headers: kopfzeilen } })
}
export const config = {
matcher: [
// Alles außer den Auth-Routen, statischen Dateien und Bildern.
'/((?!api/auth|_next/static|_next/image|favicon.ico).*)',
],
}
+7 -4
View File
@@ -3,8 +3,11 @@ import NextAuth from 'next-auth'
import { authKonfiguration } from '@/server/auth/config'
/**
* Einstiegspunkt fuer Auth.js. Die Konfiguration wird erst hier ausgewertet,
* damit fehlende Umgebungsvariablen beim Start auffallen und nicht beim
* Einlesen des Moduls.
* Einstiegspunkt für Auth.js.
*
* Die Konfiguration wird als Funktion übergeben und dadurch erst bei der
* ersten Anfrage ausgewertet. Sie prüft Umgebungsvariablen, die es beim Bauen
* des Abbilds noch nicht gibt – bei sofortiger Auswertung bräche der Bau ab,
* sobald Next die Seitendaten der Auth-Routen sammelt.
*/
export const { handlers, auth, signIn, signOut } = NextAuth(authKonfiguration())
export const { handlers, auth, signIn, signOut } = NextAuth(() => authKonfiguration())
+12
View File
@@ -1,9 +1,11 @@
import 'server-only'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import type { Session } from 'next-auth'
import type { Rolle } from '@/generated/prisma/enums'
import { rolleReichtAus } from '@/lib/rollen'
import { auth } from '@/server/auth'
import { PFAD_KOPFZEILE } from '@/proxy'
/**
* Rollenpruefung.
@@ -72,3 +74,13 @@ export async function pruefeRolle(verlangt: Rolle): Promise<Angemeldet> {
export async function verlangeAnmeldungFuerApi(): Promise<Angemeldet | null> {
return angemeldet()
}
/**
* Der angefragte Pfad, den der Proxy durchgereicht hat. Wird gebraucht,
* um nach der Anmeldung wieder dorthin zurueckzufuehren.
*/
export async function angefragterPfad(): Promise<string | undefined> {
const kopfzeilen = await headers()
const pfad = kopfzeilen.get(PFAD_KOPFZEILE)
return pfad && pfad.startsWith('/') ? pfad : undefined
}
+26 -10
View File
@@ -3,13 +3,19 @@ import { PrismaPg } from '@prisma/adapter-pg'
import { PrismaClient } from '@/generated/prisma/client'
/**
* Ein einziger Prisma-Client je Prozess. Im Entwicklungsbetrieb ueberlebt er
* das Neuladen der Module, damit nicht bei jedem Speichern ein neuer
* Verbindungspool entsteht.
* Ein einziger Prisma-Client je Prozess.
*
* Der Client wird erst beim ersten Zugriff erzeugt, nicht beim Laden des
* Moduls. Beim Bauen des Abbilds gibt es noch keine DATABASE_URL – Next sammelt
* dabei aber Seitendaten und lädt dieses Modul. Würde hier sofort verbunden,
* bräche der Bau ab.
*
* Im Entwicklungsbetrieb überlebt der Client das Neuladen der Module, damit
* nicht bei jedem Speichern ein neuer Verbindungspool entsteht.
*/
const globalerBereich = globalThis as unknown as {
prisma: PrismaClient | undefined
planerPrisma: PrismaClient | undefined
}
function erzeugen(): PrismaClient {
@@ -17,15 +23,25 @@ function erzeugen(): PrismaClient {
if (!verbindung) {
throw new Error('DATABASE_URL ist nicht gesetzt.')
}
const adapter = new PrismaPg({ connectionString: verbindung })
return new PrismaClient({
adapter,
adapter: new PrismaPg({ connectionString: verbindung }),
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
})
}
export const db: PrismaClient = globalerBereich.prisma ?? erzeugen()
if (process.env.NODE_ENV !== 'production') {
globalerBereich.prisma = db
function klient(): PrismaClient {
globalerBereich.planerPrisma ??= erzeugen()
return globalerBereich.planerPrisma
}
export const db: PrismaClient = new Proxy({} as PrismaClient, {
get(_ziel, eigenschaft) {
const echt = klient() as unknown as Record<string | symbol, unknown>
const wert = echt[eigenschaft]
// Methoden an den echten Client binden, sonst zeigt "this" auf den Proxy.
if (typeof wert === 'function') {
return (wert as (...argumente: unknown[]) => unknown).bind(echt)
}
return wert
},
})
+20
View File
@@ -0,0 +1,20 @@
import 'server-only'
import { umgebung } from '@/server/umgebung'
/**
* Die aktuelle Zeit — für alles, was serverseitig gerendert wird.
*
* Reine Funktionen in src/lib bekommen den Zeitpunkt weiterhin als Parameter.
* Hier steht nur die eine Stelle, an der er entsteht.
*
* In der Screenshot-Instanz (DEMO_MODUS=true) steht die Uhr fest. Sonst würde
* sich in jedem Bild das Tagesdatum ändern und die Bilder wären bei jedem Lauf
* neu, ohne dass sich etwas Sichtbares geändert hätte.
*/
/** Dienstag, 17. März 2026, 09:20 Uhr in Europe/Berlin. */
export const DEMO_ZEITPUNKT = new Date('2026-03-17T08:20:00.000Z')
export function jetzt(): Date {
return umgebung().DEMO_MODUS ? DEMO_ZEITPUNKT : new Date()
}
+2 -1
View File
@@ -36,8 +36,9 @@
"**/*.tsx",
".next/types/**/*.ts",
"vitest.config.ts",
".next/dev/types/**/*.ts",
"playwright.config.ts",
".next/dev/types/**/*.ts"
"playwright.screenshots.config.ts"
],
"exclude": ["node_modules", ".next", "out"]
}
Executable
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env bash
# Aktualisiert den Planer auf eine neue Version.
# Für Linux mit Docker Compose. Läuft im Projektverzeichnis.
set -euo pipefail
WURZEL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$WURZEL"
ZIELVERSION=""
OHNE_RUECKFRAGE=0
OHNE_BACKUP=0
NUR_PRUEFEN=0
WARTEZEIT=180
LOGVERZEICHNIS="$WURZEL/logs"
LOGDATEI="$LOGVERZEICHNIS/update-$(date '+%Y-%m-%d_%H-%M-%S').log"
hilfe() {
cat <<'ENDE'
Verwendung: update.sh [Optionen]
--version vX.Y.Z Auf diese Version aktualisieren statt auf die neueste
--ja Ohne Rückfrage durchlaufen
--ohne-backup Keine Sicherung anlegen (nicht empfohlen)
--nur-pruefen Nur zeigen, was passieren würde
--wartezeit <s> Sekunden, die auf einen gesunden Start gewartet wird
(Standard: 180)
--hilfe Diese Übersicht
Ablauf: Voraussetzungen prüfen, Versionen ermitteln, Änderungen zeigen,
sichern, Ziel-Tag auschecken, bauen, starten, auf /api/health warten.
Schlägt etwas fehl, wird auf die vorherige Version zurückgerollt.
ENDE
}
meldung() { printf '%s %s\n' "$(date '+%H:%M:%S')" "$1" | tee -a "$LOGDATEI"; }
fehler() {
printf '%s Fehler: %s\n' "$(date '+%H:%M:%S')" "$1" | tee -a "$LOGDATEI" >&2
exit 1
}
while [ $# -gt 0 ]; do
case "$1" in
--version)
ZIELVERSION="${2:-}"
[ -n "$ZIELVERSION" ] || fehler '--version braucht eine Angabe wie v0.1.0.'
shift 2
;;
--ja) OHNE_RUECKFRAGE=1; shift ;;
--ohne-backup) OHNE_BACKUP=1; shift ;;
--nur-pruefen) NUR_PRUEFEN=1; shift ;;
--wartezeit)
WARTEZEIT="${2:-}"
case "$WARTEZEIT" in
''|*[!0-9]*) fehler '--wartezeit braucht eine ganze Zahl.' ;;
esac
shift 2
;;
--hilfe|-h) hilfe; exit 0 ;;
*) fehler "Unbekannte Option '$1'. 'update.sh --hilfe' zeigt die Übersicht." ;;
esac
done
mkdir -p "$LOGVERZEICHNIS"
# --- 1. Voraussetzungen ----------------------------------------------------
meldung 'Voraussetzungen prüfen.'
for werkzeug in git docker curl jq; do
command -v "$werkzeug" >/dev/null 2>&1 || fehler "$werkzeug fehlt."
done
docker compose version >/dev/null 2>&1 || fehler 'docker compose fehlt.'
[ -d "$WURZEL/.git" ] || fehler 'Kein Git-Arbeitsbaum – im Projektverzeichnis aufrufen.'
[ -f "$WURZEL/.env" ] || fehler '.env fehlt.'
if [ -n "$(git status --porcelain)" ]; then
fehler 'Der Arbeitsbaum ist nicht sauber. Erst aufräumen oder committen.'
fi
APP_PORT="$(grep -E '^APP_PORT=' .env | tail -1 | cut -d= -f2- || true)"
APP_PORT="${APP_PORT:-3000}"
HEALTH_URL="http://127.0.0.1:${APP_PORT}/api/health"
# --- 2. Versionen ermitteln ------------------------------------------------
meldung 'Versionen ermitteln.'
INSTALLIERT="$(curl -fsS --max-time 5 "$HEALTH_URL" 2>/dev/null | jq -r '.version // empty' || true)"
if [ -z "$INSTALLIERT" ]; then
INSTALLIERT="$(jq -r '.version' package.json)"
meldung "Die laufende Instanz war nicht erreichbar; nehme package.json: $INSTALLIERT"
else
meldung "Installiert: $INSTALLIERT"
fi
VORHER_REF="$(git rev-parse --abbrev-ref HEAD)"
[ "$VORHER_REF" != 'HEAD' ] || VORHER_REF="$(git rev-parse HEAD)"
git fetch --tags --quiet origin || fehler 'git fetch ist fehlgeschlagen.'
if [ -z "$ZIELVERSION" ]; then
ZIELVERSION="$(git tag --list 'v*' --sort=-v:refname | head -1)"
[ -n "$ZIELVERSION" ] || fehler 'Es gibt keinen Versions-Tag auf origin.'
fi
git rev-parse -q --verify "refs/tags/${ZIELVERSION}" >/dev/null \
|| fehler "Den Tag $ZIELVERSION gibt es nicht."
meldung "Ziel: $ZIELVERSION"
if [ "v$INSTALLIERT" = "$ZIELVERSION" ]; then
meldung 'Die Zielversion läuft bereits. Nichts zu tun.'
exit 0
fi
# --- 3. Änderungen zeigen --------------------------------------------------
if [ -f CHANGELOG.md ]; then
meldung "Änderungen bis $ZIELVERSION laut CHANGELOG.md:"
# Vom Abschnitt der Zielversion bis zum Abschnitt der installierten Version.
awk -v ziel="${ZIELVERSION#v}" -v alt="$INSTALLIERT" '
$0 ~ "^## \\[" ziel "\\]" { zeigen = 1 }
zeigen && $0 ~ "^## \\[" alt "\\]" { exit }
zeigen { print " " $0 }
' CHANGELOG.md | tee -a "$LOGDATEI"
fi
if [ "$NUR_PRUEFEN" -eq 1 ]; then
meldung "Nur geprüft: $INSTALLIERT würde auf ${ZIELVERSION#v} aktualisiert."
exit 0
fi
if [ "$OHNE_RUECKFRAGE" -eq 0 ]; then
printf 'Jetzt auf %s aktualisieren? [j/N] ' "$ZIELVERSION"
read -r antwort
case "$antwort" in
[jJ]|[jJ][aA]) ;;
*) meldung 'Abgebrochen.'; exit 0 ;;
esac
fi
# --- 4. Sicherung ----------------------------------------------------------
SICHERUNG=''
if [ "$OHNE_BACKUP" -eq 0 ]; then
meldung 'Sicherung anlegen.'
./backup.sh --tage 30 | tee -a "$LOGDATEI"
SICHERUNG="$(find "$WURZEL/sicherungen" -maxdepth 1 -name 'planer-db-*.dump' -print0 2>/dev/null \
| xargs -0 ls -t 2>/dev/null | head -1 || true)"
else
meldung 'Sicherung übersprungen (--ohne-backup).'
fi
zurueckrollen() {
meldung "Zurückrollen auf $VORHER_REF."
git checkout --quiet "$VORHER_REF" || meldung 'Das Auschecken der alten Version schlug fehl.'
docker compose build >>"$LOGDATEI" 2>&1 || meldung 'Der Neubau der alten Version schlug fehl.'
docker compose up -d >>"$LOGDATEI" 2>&1 || meldung 'Der Start der alten Version schlug fehl.'
if [ -n "$SICHERUNG" ]; then
meldung "Die Datenbank wurde NICHT zurückgespielt. Sicherung: $SICHERUNG"
meldung 'Wiederherstellen mit:'
meldung " docker compose exec -T db pg_restore -U planer -d planer --clean --if-exists < \"$SICHERUNG\""
fi
meldung "Vollständiges Protokoll: $LOGDATEI"
}
# --- 5. Auschecken, bauen, starten ----------------------------------------
meldung "Auf $ZIELVERSION wechseln."
if ! git checkout --quiet "$ZIELVERSION"; then
fehler "Der Wechsel auf $ZIELVERSION schlug fehl."
fi
meldung 'Abbild bauen.'
if ! docker compose build >>"$LOGDATEI" 2>&1; then
meldung 'Der Bau ist fehlgeschlagen.'
zurueckrollen
exit 1
fi
meldung 'Dienste starten.'
if ! docker compose up -d >>"$LOGDATEI" 2>&1; then
meldung 'Der Start ist fehlgeschlagen.'
zurueckrollen
exit 1
fi
# --- 6. Auf Gesundheit warten ---------------------------------------------
meldung "Auf $HEALTH_URL warten (bis zu ${WARTEZEIT}s)."
ERWARTET="${ZIELVERSION#v}"
VERGANGEN=0
GESUND=0
while [ "$VERGANGEN" -lt "$WARTEZEIT" ]; do
ANTWORT="$(curl -fsS --max-time 5 "$HEALTH_URL" 2>/dev/null || true)"
if [ -n "$ANTWORT" ]; then
STATUS="$(printf '%s' "$ANTWORT" | jq -r '.status // empty')"
LAEUFT="$(printf '%s' "$ANTWORT" | jq -r '.version // empty')"
if [ "$STATUS" = 'ok' ] && [ "$LAEUFT" = "$ERWARTET" ]; then
GESUND=1
break
fi
fi
sleep 5
VERGANGEN=$((VERGANGEN + 5))
done
# --- 7. Ergebnis -----------------------------------------------------------
if [ "$GESUND" -eq 0 ]; then
meldung "Die neue Version meldet sich nicht gesund mit Version $ERWARTET."
docker compose logs --tail 50 app >>"$LOGDATEI" 2>&1 || true
zurueckrollen
exit 1
fi
meldung "Fertig. Der Planer läuft in Version $ERWARTET."
meldung "Protokoll: $LOGDATEI"