This commit was merged in pull request #59.
This commit is contained in:
@@ -195,6 +195,33 @@ Zwei Wege: die Person aus der Liste wählen, oder — wenn das Konto gerade erst
|
||||
entstanden und noch nicht abgeglichen ist — ihren **UPN** eintragen. Danach **Verknüpfen**. Ohne
|
||||
das findet die Person in ihrer Historie später nichts wieder.
|
||||
|
||||
## Fotos und Dateien an einem Schritt
|
||||
|
||||
Manches lässt sich nicht abhaken, es muss gezeigt werden: das Typenschild, der Zustand bei der
|
||||
Übergabe, die Rechnung. An jedem Schritt stehen dafür zwei Knöpfe:
|
||||
|
||||
- **Foto** öffnet am Handy direkt die Kamera. Am Rechner zeigt der Browser den Dateiwähler — dort
|
||||
gibt es keine Kamera-App.
|
||||
- **Datei** öffnet den Dateiwähler.
|
||||
|
||||
Abgeschickt wird **sofort nach der Wahl**, ohne zweiten Knopf. Ein Foto, das du gerade gemacht hast,
|
||||
willst du nicht noch bestätigen.
|
||||
|
||||
Erlaubt sind **Bilder** (JPEG, PNG, WebP, HEIC) und **PDF**, höchstens 10 MB je Datei und 20 Dateien
|
||||
je Lauf. Beide Grenzen lassen sich beim Betrieb ändern (`ANHANG_MAX_MB`, `ANHAENGE_JE_LAUF`). Eine zu
|
||||
große Datei wird schon im Browser abgewiesen — am Handy im Mobilfunk ist das der Unterschied zwischen
|
||||
einer Meldung und einer Minute Warten.
|
||||
|
||||
Bilder erscheinen als Miniatur, ein PDF als Zeile mit Namen. Ein Klick öffnet die Datei in einem
|
||||
neuen Tab; ein PDF wird dabei heruntergeladen statt im Browser angezeigt.
|
||||
|
||||
**An einem archivierten Lauf kommt kein Anhang mehr dazu und keiner weg** — dieselbe Regel wie für
|
||||
Schritte und Kommentare. Ansehen geht weiter: ein Foto vom Typenschild ist Teil des Belegs.
|
||||
|
||||
> **Für den Betrieb wichtig:** Die Dateien liegen im Dateisystem, in der Datenbank stehen nur ihre
|
||||
> Namen und Größen. Eine Sicherung, die nur die Datenbank mitnimmt, verliert sie — siehe
|
||||
> `docs/betrieb/sicherung.md`.
|
||||
|
||||
## Abschließen
|
||||
|
||||
**Lauf abschließen** geht nur, wenn kein Pflichtschritt mehr offen ist. Erledigt und „nicht
|
||||
|
||||
@@ -77,6 +77,36 @@ Zurückholen wird protokolliert.
|
||||
**Vorbeugen:** archiviere erst, wenn wirklich alles drinsteht. Es gibt keinen Grund zur Eile — ein
|
||||
abgeschlossener Lauf steht auch schon im Archiv und ist durchsuchbar.
|
||||
|
||||
## „Die Datei zu … fehlt im Dateisystem“
|
||||
|
||||
**Ursache:** In der Datenbank steht die Zeile eines Anhangs, die Datei selbst ist nicht da. Fast
|
||||
immer heißt das: es wurde eine Sicherung eingespielt, die nur die Datenbank enthielt. Die Dateien der
|
||||
Anhänge liegen im Dateisystem, nicht in der Datenbank.
|
||||
|
||||
**Was tun:** Das Verzeichnis aus derselben Sicherungsrunde zurückspielen —
|
||||
`docs/betrieb/sicherung.md` beschreibt beide Wege. Gibt es kein Archiv der Dateien, sind sie weg; die
|
||||
Zeilen lassen sich dann nur entfernen, indem jemand den Anhang in der Oberfläche löscht.
|
||||
|
||||
**Zweitursache, seltener:** nach einer Rücksicherung gehören die Dateien `root` statt dem
|
||||
Dienstnutzer. Der Dienst kommt dann nicht an sie heran, obwohl sie daliegen:
|
||||
|
||||
```bash
|
||||
sudo ls -l /var/lib/laufzettel/anhaenge
|
||||
sudo chown -R laufzettel:laufzettel /var/lib/laufzettel/anhaenge
|
||||
```
|
||||
|
||||
**Vorbeugen:** Datenbank und Anhänge im **selben** Skript sichern, direkt hintereinander. Getrennt
|
||||
gesichert passen die beiden Hälften irgendwann nicht mehr zusammen.
|
||||
|
||||
## „Dateien vom Typ … lassen sich nicht anhängen“
|
||||
|
||||
**Ursache:** Erlaubt sind Bilder (JPEG, PNG, WebP, HEIC) und PDF. Alles andere wird abgewiesen —
|
||||
auch SVG, und das ist Absicht: ein SVG ist ein Dokument, das Skripte ausführen kann, und ein Anhang,
|
||||
den jemand öffnet, soll kein Angriffsweg sein.
|
||||
|
||||
**Was tun:** Ein Office-Dokument oder eine Textdatei als PDF ausgeben. Ein Screenshot wird zu PNG,
|
||||
sobald du ihn speicherst statt ihn aus der Zwischenablage zu ziehen.
|
||||
|
||||
## „Kein Laufzettel-Tag“ nach dem Scannen
|
||||
|
||||
**Ursache:** einer von drei Fällen, und Laufzettel unterscheidet sie absichtlich nicht — die
|
||||
|
||||
+123
-23
@@ -11,12 +11,19 @@ ebenfalls einfach, aber nur, wenn man es einmal geübt hat.
|
||||
|
||||
## Was gesichert werden muss
|
||||
|
||||
Zwei Dinge, und nur diese zwei:
|
||||
Drei Dinge, und nur diese drei:
|
||||
|
||||
| | Was | Wo |
|
||||
|---|---|---|
|
||||
| **1** | **Die Datenbank.** Alles Veränderliche steckt darin: Läufe, Vorlagen, Schrittergebnisse, Kommentare, Geräte, Personen, Tag-Codes, Protokoll. | `pg_dump`, siehe unten |
|
||||
| **1** | **Die Datenbank.** Läufe, Vorlagen, Schrittergebnisse, Kommentare, Geräte, Personen, Tag-Codes, Protokoll. | `pg_dump`, siehe unten |
|
||||
| **2** | **Die Geheimnisse.** `APP_SECRET` und das Datenbank-Passwort. | Compose: die `.env` · Nativ: `/etc/laufzettel/laufzettel.env` |
|
||||
| **3** | **Die Anhänge.** Die Dateien und Fotos an den Schritten. | Volume `laufzettel_anhaenge` · Nativ: `ANHAENGE_PFAD` |
|
||||
|
||||
> **Die Anhänge sind seit #032 dabei, und sie sind die Falle.** In der Datenbank stehen nur ihre
|
||||
> **Metadaten** — Name, Typ, Größe, Prüfsumme. Die Dateien selbst liegen im Dateisystem. Eine
|
||||
> Sicherung, die nur `pg_dump` macht, sieht vollständig aus und ist es nicht: nach dem Zurückspielen
|
||||
> stehen die Anhänge in der Oberfläche, und jeder Klick darauf meldet, dass die Datei fehlt.
|
||||
> Datenbank und Verzeichnis gehören **zum selben Zeitpunkt** gesichert.
|
||||
|
||||
**Warum die Geheimnisse dazugehören:** `APP_SECRET` verschlüsselt nicht nur die Sitzungs-Cookies,
|
||||
sondern auch die gespeicherten LDAP-Bind-Passwörter. Spielst du eine Datenbank in eine Installation
|
||||
@@ -29,9 +36,8 @@ Sicher die beiden Dinge getrennt und leg das Geheimnis dahin, wo auch die restli
|
||||
Hauses liegen — in den Passwortmanager. Eine Datei mit `APP_SECRET` neben dem Datenbank-Abzug auf
|
||||
derselben Netzwerkfreigabe hebt den Zweck der Verschlüsselung auf.
|
||||
|
||||
**Was nicht gesichert werden muss:** Die Anwendung hält keinen veränderlichen Zustand auf der Platte.
|
||||
Kein Upload-Verzeichnis, keine erzeugten Dateien. Der Quellcode kommt aus Git, `node_modules` und
|
||||
`.next` entstehen beim Bauen neu, das PDF der Anleitung liegt am Release.
|
||||
**Was nicht gesichert werden muss:** Der Quellcode kommt aus Git, `node_modules` und `.next`
|
||||
entstehen beim Bauen neu, das PDF der Anleitung liegt am Release.
|
||||
|
||||
**Was der Abzug bewusst nicht enthält:** ein `{{generiertes_passwort}}`. Es wird bei jeder Anzeige neu
|
||||
erzeugt und nie gespeichert — nicht im Schrittergebnis, nicht im Kommentar, nicht im Protokoll. Wer
|
||||
@@ -73,6 +79,28 @@ Das Volume heißt `laufzettel_datenbank`. Es mit `docker run --volumes-from` weg
|
||||
**keine** gleichwertige Sicherung: ein Dateiabzug eines laufenden PostgreSQL ist im besten Fall
|
||||
brauchbar und im schlechteren still beschädigt. Nimm `pg_dump`.
|
||||
|
||||
#### Und die Anhänge
|
||||
|
||||
Hier gilt das Umgekehrte: ein Dateiabzug ist genau richtig, denn es sind gewöhnliche Dateien.
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v laufzettel_anhaenge:/daten:ro \
|
||||
-v "$PWD":/ziel \
|
||||
debian:13 tar czf /ziel/anhaenge-$(date +%F).tar.gz -C /daten .
|
||||
```
|
||||
|
||||
Kurz nachsehen, dass etwas drin ist:
|
||||
|
||||
```bash
|
||||
tar tzf anhaenge-$(date +%F).tar.gz | head -5
|
||||
tar tzf anhaenge-$(date +%F).tar.gz | wc -l
|
||||
```
|
||||
|
||||
Ist die Datei leer und es gibt Anhänge in der Oberfläche, stimmt der Volume-Name nicht — `docker
|
||||
volume ls | grep anhaenge` nennt den richtigen. Er trägt den Projektnamen als Präfix, und der kommt
|
||||
aus dem Verzeichnisnamen.
|
||||
|
||||
### Zurückspielen
|
||||
|
||||
Die Datenbank muss dafür leer sein — ein Abzug in eine gefüllte Datenbank gespielt bringt lauter
|
||||
@@ -91,10 +119,18 @@ docker compose exec -T postgres createdb -U laufzettel -O laufzettel laufzettel
|
||||
# 3. Abzug einspielen
|
||||
gunzip -c sicherung-2026-09-24.sql.gz | docker compose exec -T postgres psql -U laufzettel laufzettel
|
||||
|
||||
# 4. Migrationen nachziehen – nötig, wenn der Abzug von einer älteren Fassung stammt
|
||||
# 4. Die Anhänge zurück ins Volume – **vorher leeren**, sonst bleiben Dateien
|
||||
# liegen, auf die keine Zeile mehr zeigt
|
||||
docker run --rm -v laufzettel_anhaenge:/daten debian:13 sh -c 'rm -rf /daten/* /daten/.[!.]*' || true
|
||||
docker run --rm \
|
||||
-v laufzettel_anhaenge:/daten \
|
||||
-v "$PWD":/quelle:ro \
|
||||
debian:13 tar xzf /quelle/anhaenge-2026-09-24.tar.gz -C /daten
|
||||
|
||||
# 5. Migrationen nachziehen – nötig, wenn der Abzug von einer älteren Fassung stammt
|
||||
docker compose run --rm migrate pnpm exec prisma migrate deploy
|
||||
|
||||
# 5. Wieder anfahren
|
||||
# 6. Wieder anfahren
|
||||
docker compose start web
|
||||
curl -s http://127.0.0.1:3000/api/gesundheit
|
||||
```
|
||||
@@ -118,6 +154,14 @@ auf —, aber alles, was er anlegen würde, steht schon im Abzug.
|
||||
sudo -u postgres pg_dump -Fc laufzettel > /var/backups/laufzettel-$(date +%F).dump
|
||||
```
|
||||
|
||||
Und die Anhänge dazu — gewöhnliche Dateien, also `tar`:
|
||||
|
||||
```bash
|
||||
# Der Pfad steht in ANHAENGE_PFAD; ohne Angabe liegt er unter /opt/laufzettel/daten/anhaenge.
|
||||
ANHAENGE=$(sudo sh -c '. /etc/laufzettel/laufzettel.env; echo "${ANHAENGE_PFAD:-/opt/laufzettel/daten/anhaenge}"')
|
||||
sudo tar czf /var/backups/laufzettel-anhaenge-$(date +%F).tar.gz -C "$ANHAENGE" .
|
||||
```
|
||||
|
||||
Und das Geheimnis, einmalig und in den Passwortmanager:
|
||||
|
||||
```bash
|
||||
@@ -141,16 +185,29 @@ sudo -u postgres createdb -O laufzettel laufzettel
|
||||
# 3. Abzug einspielen
|
||||
sudo -u postgres pg_restore -d laufzettel --no-owner /var/backups/laufzettel-2026-09-24.dump
|
||||
|
||||
# 4. Migrationen nachziehen, wenn der Abzug älter ist als der Code
|
||||
# 4. Die Anhänge zurück – **vorher leeren**, sonst bleiben Dateien liegen, auf
|
||||
# die keine Zeile mehr zeigt
|
||||
ANHAENGE=$(sudo sh -c '. /etc/laufzettel/laufzettel.env; echo "${ANHAENGE_PFAD:-/opt/laufzettel/daten/anhaenge}"')
|
||||
sudo rm -rf "$ANHAENGE"
|
||||
sudo install -d -o laufzettel -g laufzettel -m 0750 "$ANHAENGE"
|
||||
sudo tar xzf /var/backups/laufzettel-anhaenge-2026-09-24.tar.gz -C "$ANHAENGE"
|
||||
sudo chown -R laufzettel:laufzettel "$ANHAENGE"
|
||||
|
||||
# 5. Migrationen nachziehen, wenn der Abzug älter ist als der Code
|
||||
sudo bash /opt/laufzettel/deploy/install.sh
|
||||
|
||||
# 5. Wieder anfahren und nachsehen
|
||||
# 6. Wieder anfahren und nachsehen
|
||||
sudo systemctl start laufzettel
|
||||
curl -s http://127.0.0.1:3000/api/gesundheit
|
||||
```
|
||||
|
||||
Schritt 4 ist der Installationsaufruf, weil er die Migrationen mitbringt und den Dienst gleich
|
||||
neu startet — Schritt 5 ist dann nur noch die Gegenprobe. Willst du nur die Migrationen:
|
||||
Schritt 5 ist der Installationsaufruf, weil er die Migrationen mitbringt und den Dienst gleich
|
||||
neu startet — Schritt 6 ist dann nur noch die Gegenprobe.
|
||||
|
||||
**Der `chown` in Schritt 4 ist nicht Beiwerk.** `tar` legt die Dateien mit den Besitzern aus dem
|
||||
Archiv an; läuft die Rücksicherung als `root`, gehören sie danach `root`, und der Dienstnutzer
|
||||
`laufzettel` kommt nicht mehr an sie heran. Jeder Anhang meldet dann „Datei fehlt“, obwohl sie
|
||||
daliegt. Willst du nur die Migrationen:
|
||||
|
||||
```bash
|
||||
cd /opt/laufzettel
|
||||
@@ -170,11 +227,30 @@ After=postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=postgres
|
||||
# %i-freies Einzeiler-Skript: pg_dump in eine Datei mit Datum, dann alles
|
||||
# aelter als 30 Tage weg.
|
||||
ExecStart=/bin/bash -c 'pg_dump -Fc laufzettel > /var/backups/laufzettel-$(date +%%F).dump'
|
||||
ExecStartPost=/usr/bin/find /var/backups -name 'laufzettel-*.dump' -mtime +30 -delete
|
||||
# `root`, nicht `postgres`: der Timer sichert jetzt auch das Anhang-Verzeichnis,
|
||||
# und das gehoert dem Dienstnutzer `laufzettel`. Der Datenbankteil laeuft
|
||||
# weiterhin als `postgres`, ueber `runuser`.
|
||||
User=root
|
||||
ExecStart=/usr/local/sbin/laufzettel-sichern
|
||||
ExecStartPost=/usr/bin/find /var/backups -name 'laufzettel-*' -mtime +30 -delete
|
||||
```
|
||||
|
||||
`/usr/local/sbin/laufzettel-sichern`, Modus 0750:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Datenbank und Anhaenge zum **selben** Zeitpunkt. Getrennt gesichert ergeben
|
||||
# beide Haelften kein vollstaendiges Bild (#032).
|
||||
set -Eeuo pipefail
|
||||
tag=$(date +%F)
|
||||
runuser -u postgres -- pg_dump -Fc laufzettel > "/var/backups/laufzettel-$tag.dump"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
. /etc/laufzettel/laufzettel.env
|
||||
anhaenge="${ANHAENGE_PFAD:-/opt/laufzettel/daten/anhaenge}"
|
||||
if [ -d "$anhaenge" ]; then
|
||||
tar czf "/var/backups/laufzettel-anhaenge-$tag.tar.gz" -C "$anhaenge" .
|
||||
fi
|
||||
```
|
||||
|
||||
`/etc/systemd/system/laufzettel-sicherung.timer`:
|
||||
@@ -193,19 +269,22 @@ RandomizedDelaySec=15m
|
||||
WantedBy=timers.target
|
||||
```
|
||||
|
||||
Einrichten, einschalten, und **einmal von Hand auslösen** — eine Sicherung, die
|
||||
nur im Timer steht, hat noch nie eine Datei erzeugt:
|
||||
|
||||
```bash
|
||||
sudo install -m 0750 laufzettel-sichern /usr/local/sbin/laufzettel-sichern
|
||||
sudo install -d -o postgres -g postgres -m 0700 /var/backups
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now laufzettel-sicherung.timer
|
||||
sudo systemctl start laufzettel-sicherung.service
|
||||
ls -l /var/backups/laufzettel-*
|
||||
sudo systemctl list-timers laufzettel-sicherung.timer
|
||||
```
|
||||
|
||||
Einmal von Hand auslösen und nachsehen, ob wirklich eine Datei entstanden ist:
|
||||
|
||||
```bash
|
||||
sudo systemctl start laufzettel-sicherung.service
|
||||
ls -l /var/backups/
|
||||
```
|
||||
Nach dem Lauf müssen **zwei** Dateien daliegen: `laufzettel-<Datum>.dump` und
|
||||
`laufzettel-anhaenge-<Datum>.tar.gz`. Fehlt die zweite, gibt es entweder keine Anhänge, oder
|
||||
`ANHAENGE_PFAD` zeigt woandershin als gedacht.
|
||||
|
||||
### Compose, als Cron-Eintrag
|
||||
|
||||
@@ -218,6 +297,12 @@ In der Crontab von root (`sudo crontab -e`):
|
||||
Das `%` muss in einer Crontab als `\%` geschrieben werden, sonst schneidet cron die Zeile ab — der
|
||||
häufigste Fehler an dieser Stelle, und er fällt erst auf, wenn die Datei fehlt.
|
||||
|
||||
Und die Anhänge direkt danach, damit beide Hälften vom selben Zeitpunkt stammen:
|
||||
|
||||
```cron
|
||||
20 3 * * * docker run --rm -v laufzettel_anhaenge:/daten:ro -v /var/backups:/ziel debian:13 tar czf /ziel/laufzettel-anhaenge-$(date +\%F).tar.gz -C /daten . && find /var/backups -name 'laufzettel-anhaenge-*' -mtime +30 -delete
|
||||
```
|
||||
|
||||
### Und dann weg vom Rechner
|
||||
|
||||
Eine Sicherung, die auf derselben Platte liegt wie die Datenbank, hilft gegen einen Tippfehler und
|
||||
@@ -258,6 +343,21 @@ sudo -u postgres dropdb probe
|
||||
Kommt bei `count(*)` eine plausible Zahl heraus, ist die Sicherung brauchbar. Kommt eine Fehlermeldung
|
||||
oder eine Null, hast du es jetzt erfahren und nicht im Ernstfall.
|
||||
|
||||
### Und die Anhänge mitprüfen
|
||||
|
||||
Die Zahl in der Datenbank gegen die Zahl der Dateien halten — passt sie nicht, fehlt eine der beiden
|
||||
Hälften:
|
||||
|
||||
```bash
|
||||
# Compose: Zeilen in der Datenbank …
|
||||
docker compose exec -T postgres psql -U laufzettel laufzettel -tAc 'SELECT count(*) FROM "Anhang"'
|
||||
# … gegen Dateien im Archiv
|
||||
tar tzf anhaenge-2026-09-24.tar.gz | grep -c '\.' || true
|
||||
```
|
||||
|
||||
Stimmen die Zahlen nicht überein, ist entweder der Abzug älter als das Archiv oder umgekehrt. Beides
|
||||
zum **selben Zeitpunkt** sichern — am einfachsten im selben Skript, direkt hintereinander.
|
||||
|
||||
## Umzug auf einen neuen Server
|
||||
|
||||
Derselbe Weg, nur mit einer Zwischenstation:
|
||||
@@ -284,6 +384,6 @@ Die PostgreSQL-Hauptfassung sollte auf dem Zielsystem dieselbe oder eine neuere
|
||||
| `ERROR: relation "Benutzer" already exists` | Du spielst in eine gefüllte Datenbank. Erst `dropdb`/`createdb`, siehe oben. |
|
||||
| `role "laufzettel" does not exist` beim Zurückspielen auf einem anderen System | Der Abzug nennt Eigentümer, die dort fehlen. `pg_restore --no-owner` beziehungsweise `pg_dump --no-owner` beim Erzeugen. |
|
||||
| Nach dem Zurückspielen schlägt der Verzeichnis-Abgleich fehl | `APP_SECRET` ist nicht mehr dasselbe, und das Bind-Passwort ist unlesbar. Entweder den alten Wert eintragen und neu starten, oder unter **Admin → Verzeichnis** das Bind-Passwort neu setzen. |
|
||||
| Nach dem Zurückspielen fehlen Tabellen oder Spalten | Der Abzug ist älter als der Code. `prisma migrate deploy` nachziehen, siehe Schritt 4 oben. |
|
||||
| Nach dem Zurückspielen fehlen Tabellen oder Spalten | Der Abzug ist älter als der Code. `prisma migrate deploy` nachziehen, siehe Schritt 5 oben. |
|
||||
| Die Anwendung startet nach dem Zurückspielen nicht | `docker compose logs web` beziehungsweise `journalctl -u laufzettel -n 50 --no-pager`. Meist läuft noch die Migration, oder sie ist gescheitert. |
|
||||
| Ein Tag-Code taucht doppelt auf | Kann nach einem Zurückspielen nicht passieren; ein partieller Eindeutigkeitsindex in der Datenbank verhindert es. Siehst du es doch, ist der Abzug nicht sauber eingespielt — Datenbank leeren und noch einmal von vorn. |
|
||||
|
||||
+89
-2
@@ -24,12 +24,19 @@ als Unterpunkte mit Aufgabe und Dauer.
|
||||
| #019 | M14 – Betriebswege und Release v0.1.0 | 5 h 25 min |
|
||||
| #025 | Healthcheck gibt die rohe Datenbankfehlermeldung heraus | 25 min |
|
||||
| #026 | Zwei Konventionen präzisieren | 20 min |
|
||||
| | **Summe** | **36 h 05 min** |
|
||||
| #020 | Vorgesetzter auch für von Hand angelegte Personen | 1 h 20 min |
|
||||
| #021 | Stabiler Schlüssel für Seed-Vorlagen | 55 min |
|
||||
| #022 | Schnellstart je Gerätetyp pflegen | 1 h 10 min |
|
||||
| #023 | Protokoll ansehen | 1 h 50 min |
|
||||
| #030 | M17 – Wiederverwendbare Bausteine | 2 h 25 min |
|
||||
| #031 | M18 – Abteilungsspezifische Zusatzabschnitte | 1 h 35 min |
|
||||
| #032 | M19 – Anhänge und Fotos an Schritten | 3 h 05 min |
|
||||
| | **Summe** | **48 h 25 min** |
|
||||
|
||||
| Meilenstein | Tatsächlich | Status |
|
||||
|---|---|---|
|
||||
| v0.1.0 | 35 h 20 min | veröffentlicht |
|
||||
| v0.2.0 | 45 min | in Arbeit |
|
||||
| v0.2.0 | 13 h 05 min | in Arbeit |
|
||||
|
||||
---
|
||||
|
||||
@@ -1089,3 +1096,83 @@ Sechs Folge-Issues für `v0.2.0`: Vorgesetzter für von Hand angelegte Personen
|
||||
Schlüssel für Seed-Vorlagen (#021), Schnellstart je Gerätetyp pflegen (#022), Protokollansicht
|
||||
(#023). Dazu die Punkte, die der Abnahme-Review aufwirft. Nichts davon blockiert den Betrieb; alles
|
||||
davon steht geschrieben, statt verschwiegen zu werden.
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-04 · Sitzung 17 · der ganze Tag
|
||||
|
||||
**Issues:** #021, #020, #022, #023, #046/#047, Phase-2-Plan, #030 (M17), #031 (M18), #032 (M19)
|
||||
**Version:** 0.2.0 (unveröffentlicht)
|
||||
|
||||
Eine lange Sitzung mit acht zusammengeführten Änderungen. Der Devlog-Eintrag kommt am Ende des Tages
|
||||
und nicht nach jeder einzelnen — das ist der Grund, warum er alles auf einmal trägt.
|
||||
|
||||
### Erledigt
|
||||
|
||||
- **Der Rückstand für `v0.2.0` ist leer.** Stabiler Seed-Schlüssel (#021), Vorgesetzter für von Hand
|
||||
angelegte Personen (#020), Schnellstart je Gerätetyp in der Oberfläche pflegen (#022), und das
|
||||
Protokoll ist ansehbar geworden (#023) — geschrieben wurde es seit dem Datenmodell, ansehen konnte
|
||||
es niemand.
|
||||
- **Phase 2 in acht Meilensteine geschnitten** (#028–#035), mit drei Entscheidungen, die vor dem
|
||||
ersten Codepfad fallen mussten.
|
||||
- **M17 Bausteine** (#030): ein Abschnitt, den mehrere Vorlagen teilen, versioniert nach denselben
|
||||
Regeln wie eine Vorlage. Eingesetzt wird er als tiefe Kopie beim **Veröffentlichen** — damit ändert
|
||||
eine neue Baustein-Fassung keine veröffentlichte Vorlagenversion und keinen laufenden Lauf.
|
||||
- **M18 Abteilungsabschnitte** (#031): ein Abschnitt kann sagen, für welche Abteilungen er gilt.
|
||||
Welche Abschnitte ein Lauf hat, wird beim Start festgeschrieben.
|
||||
- **M19 Anhänge** (#032): Fotos und Dateien an einem Schritt, am Handy direkt aus der Kamera. Dateien
|
||||
im Dateisystem, Metadaten in der Datenbank, jeder Abruf prüft Recht und Mandant.
|
||||
- **Sicherung und Rücksicherung der Anhänge wirklich gefahren** — Abzug gezogen, Datenbank verworfen,
|
||||
Verzeichnis gelöscht, zurückgespielt, und die ausgelieferten Bytes gegen die gespeicherte
|
||||
`pruefsumme` gehalten.
|
||||
- 1246 Vitest-Tests (von 1130 am Morgen), alle Schranken grün.
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
- **Ein Baustein wird beim Veröffentlichen aufgelöst, nicht beim Lesen.** Eine Auflösung zur Laufzeit
|
||||
wäre weniger Code gewesen und hätte die Zusage „eine veröffentlichte Version wird nie verändert“
|
||||
gebrochen — über eine Hintertür, die niemand so nennt.
|
||||
- **`Lauf.geltendeAbschnitte` ist leer heißt „alle“.** Damit verhalten sich Läufe von vor #031
|
||||
unverändert, ohne Migration von Daten.
|
||||
- **Anhänge ins Dateisystem, nicht als `bytea`.** Ein `pg_dump` mit Fotos ist nach einem Jahr kein
|
||||
Abzug mehr, den jemand einspielt. Der Preis ist die zweite Hälfte der Sicherung, und der steht
|
||||
jetzt in `docs/betrieb/sicherung.md`.
|
||||
- **Hochgeladen wird über eine Server Action, nicht über einen zweiten Endpunkt.** Das Issue hatte
|
||||
einen Route Handler vorgesehen; der wäre eine zweite Tür mit eigener Rechteprüfung. Der Route
|
||||
Handler bleibt für das Ausliefern, und er ruft `rechtErforderlich` selbst — die Hülle unter `(app)`
|
||||
deckt Route Handler nicht ab.
|
||||
- **Die Regel „geltende Abschnitte“ liegt in `src/lib`**, nicht in einem Lesemodell. Drei Schichten
|
||||
brauchen sie; lebte sie in einem Lesemodell, importierte ein Dienst daraus.
|
||||
|
||||
### Probleme
|
||||
|
||||
- **Ein Fehler, den neunzehn grüne Tests nicht sehen konnten.** Der Schritt hat zwei Felder `datei`
|
||||
in einem Formular — „Foto“ und „Datei“ —, abgeschickt wird immer beides, und `FormData.get` nahm
|
||||
das erste, also das leere. Wer „Datei“ wählte, bekam „Keine Datei gewählt“, während „Foto“ ging.
|
||||
Kein Dienst-Test schickt zwei Felder; gefunden hat es erst die Klickprobe am gebauten Stand. Die
|
||||
Lehre ist dieselbe wie in #019: **Fehler im dokumentierten Weg eines Dritten sind für eine grüne
|
||||
Suite unsichtbar.** Seitdem gibt es `tests/e2e/anhaenge.spec.ts`, der bewusst über den zweiten
|
||||
Knopf hochlädt, und die Gegenprobe wurde gefahren: mit `get` statt `getAll` wird er rot.
|
||||
- **`docs/betrieb/sicherung.md` enthielt einen falschen Satz**, nicht bloß eine Lücke: „Kein
|
||||
Upload-Verzeichnis, keine erzeugten Dateien.“ Mit den Anhängen hätte eine Sicherung nach dieser
|
||||
Anleitung still Daten verloren. Die Datei ist neu geschrieben.
|
||||
- **Zweimal habe ich dem Produkt angelastet, was mein Prüfskript falsch machte** — ein zu weiter
|
||||
`role="status"`-Selektor und eine Prüfung direkt nach `reload()`, als `networkidle` zu früh
|
||||
zurückkam. Die Datenbank hat beide Male entschieden, und sie hat dem Produkt recht gegeben.
|
||||
- **Einmal die Ursache falsch benannt.** Ich hielt eine `positionenSauber`-Prüfung für die Behebung
|
||||
des Schnellstart-Fehlers; die Gegenprobe zeigte, dass die Tests ohne sie grün bleiben. Die
|
||||
Behebung ist der deterministische Stichentscheid beim Sortieren.
|
||||
- **Ein Numerusfehler in einem eigenen Hinweistext** („Ein Abschnitt gilt … und *bleiben* weg“),
|
||||
gefunden beim Durchklicken. Alles, was ein Mensch liest, schreibt sich richtig.
|
||||
- **`tests/datenbankregeln.test.ts` wurde rot** — genau wie gewollt: er zählt die handgeschriebenen
|
||||
Datenbankobjekte namentlich auf, und die zwei neuen CHECK-Bedingungen und zwei zusammengesetzten
|
||||
Fremdschlüssel standen nicht drin.
|
||||
- **`/daten/` fehlte in `.gitignore`.** Ohne das läge das erste hochgeladene Foto im nächsten Commit.
|
||||
|
||||
### Nächster Schritt
|
||||
|
||||
- **M20 (#033)** Live-Aktualisierung per SSE statt Polling, dann **M21 (#034)** Benachrichtigungen,
|
||||
**M22 (#035)** Selbstauskunft. **M15 (#028)** Graph-Quelle und **M16 (#029)** SCIM sind die beiden
|
||||
großen verbliebenen.
|
||||
- Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host.
|
||||
- **#027** Die Editor-Abläufe lassen ihre Vorlagen in der Entwicklungsdatenbank stehen.
|
||||
|
||||
+56
-18
@@ -1,10 +1,10 @@
|
||||
# #032 M19 – Anhänge und Fotos an Schritten
|
||||
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Im Tracker:** [#52](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/52)
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, backend, frontend, infra
|
||||
**Geschätzt:** 10 h **Tatsächlich:** –
|
||||
**Geschätzt:** 10 h **Tatsächlich:** 3 h 05 min
|
||||
|
||||
## Ziel
|
||||
|
||||
@@ -14,7 +14,7 @@ können, am Handy direkt aus der Kamera.
|
||||
|
||||
## Zu entscheiden
|
||||
|
||||
- [ ] **Dateien im Dateisystem, Metadaten in der Datenbank** — Empfehlung. Ein Pfad aus der
|
||||
- [x] **Dateien im Dateisystem, Metadaten in der Datenbank** — Empfehlung. Ein Pfad aus der
|
||||
Umgebung (`ANHAENGE_PFAD`), darunter nach Kunde und Lauf gegliedert. Gegen `bytea` in Postgres
|
||||
spricht die Sicherung: ein `pg_dump` mit Fotos ist nach einem Jahr kein `pg_dump` mehr, das
|
||||
jemand einspielt. Gegen S3 spricht, dass diese Anwendung selbst gehostet läuft und keinen
|
||||
@@ -23,32 +23,70 @@ können, am Handy direkt aus der Kamera.
|
||||
ein Volume, und `docs/betrieb/sicherung.md` muss beides nennen. Eine Sicherung, die nur die
|
||||
Datenbank mitnimmt, verliert ab hier Daten — das ist genau die Art Zusage, die dieses Projekt
|
||||
sonst teuer bezahlt hat.
|
||||
- [ ] **Keine Verkleinerung auf dem Server im ersten Schritt.** Ein Handyfoto hat heute 3–6 MB; eine
|
||||
- [x] **Keine Verkleinerung auf dem Server im ersten Schritt.** Ein Handyfoto hat heute 3–6 MB; eine
|
||||
Obergrenze (etwa 10 MB je Datei, 20 Dateien je Lauf) ist ehrlicher als eine Umwandlung, die
|
||||
Metadaten verliert und eine Bildbibliothek ins Projekt zieht.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] Modell `Anhang`: `schrittergebnisId`, `kundeId`, Dateiname, Medientyp, Größe, Prüfsumme,
|
||||
- [x] Modell `Anhang`: `schrittergebnisId`, `kundeId`, Dateiname, Medientyp, Größe, Prüfsumme,
|
||||
`erstelltVonId`, `erstelltAm`; Datei daneben im Dateisystem
|
||||
- [ ] Hochladen über einen Route Handler mit `rechtErforderlich` — kein Weg daran vorbei, und
|
||||
- [x] Hochladen über einen Route Handler mit `rechtErforderlich` — kein Weg daran vorbei, und
|
||||
**jeder** Abruf prüft Recht und Mandant, auch der des Bildes selbst
|
||||
- [ ] Grenzen: Medientypen auf Bilder und PDF, Größe je Datei und Anzahl je Lauf, beides in der
|
||||
- [x] Grenzen: Medientypen auf Bilder und PDF, Größe je Datei und Anzahl je Lauf, beides in der
|
||||
Umgebung einstellbar; eine abgewiesene Datei nennt den Grund
|
||||
- [ ] Am Handy direkt aus der Kamera (`capture="environment"`), am Rechner aus dem Dateiwähler
|
||||
- [ ] **Der Archiv-Schreibschutz gilt auch hier**: an einem archivierten Lauf kommt kein Anhang mehr
|
||||
- [x] Am Handy direkt aus der Kamera (`capture="environment"`), am Rechner aus dem Dateiwähler
|
||||
- [x] **Der Archiv-Schreibschutz gilt auch hier**: an einem archivierten Lauf kommt kein Anhang mehr
|
||||
dazu und keiner weg. `laufMussVeraenderbarSein` sitzt an genau einer Stelle — dort anschließen
|
||||
- [ ] Druckansicht: Bilder als Miniaturen mit Dateinamen, PDF nur als Zeile
|
||||
- [ ] `docs/betrieb/sicherung.md`, `docker-compose.yml`, `deploy/install.sh`: Volume, Rechte,
|
||||
- [x] Druckansicht: Bilder als Miniaturen mit Dateinamen, PDF nur als Zeile
|
||||
- [x] `docs/betrieb/sicherung.md`, `docker-compose.yml`, `deploy/install.sh`: Volume, Rechte,
|
||||
Sicherung **und** Rückspielung
|
||||
- [ ] Anleitung Kapitel 02 und 08
|
||||
- [x] Anleitung Kapitel 02 und 08
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Test: Anhang an einem Schritt hochladen, in der Abarbeitung und im Druck sichtbar
|
||||
- [ ] Test: an einem archivierten Lauf wird das Hochladen abgewiesen
|
||||
- [ ] Test: ein Anhang eines fremden Kunden ist über seine Adresse **nicht** abrufbar
|
||||
- [ ] Test: eine zu große Datei und ein falscher Medientyp werden mit Grund abgewiesen
|
||||
- [ ] Test: `docker-compose.yml` hat ein Volume für die Anhänge, und `sicherung.md` nennt es (wie
|
||||
- [x] Test: Anhang an einem Schritt hochladen, in der Abarbeitung und im Druck sichtbar
|
||||
- [x] Test: an einem archivierten Lauf wird das Hochladen abgewiesen
|
||||
- [x] Test: ein Anhang eines fremden Kunden ist über seine Adresse **nicht** abrufbar
|
||||
- [x] Test: eine zu große Datei und ein falscher Medientyp werden mit Grund abgewiesen
|
||||
- [x] Test: `docker-compose.yml` hat ein Volume für die Anhänge, und `sicherung.md` nennt es (wie
|
||||
`tests/betriebsstand.test.ts` es für die Datenbank schon prüft)
|
||||
- [ ] Sicherung und **Rückspielung** einmal wirklich gefahren, nicht nur beschrieben
|
||||
- [x] Sicherung und **Rückspielung** einmal wirklich gefahren, nicht nur beschrieben
|
||||
|
||||
## Beim Umsetzen dazugekommen
|
||||
|
||||
- **Hochgeladen wird über eine Server Action, nicht über einen eigenen Route Handler.** Die Aufgabe
|
||||
oben nannte einen Route Handler; der wäre eine zweite Tür mit eigener Rechteprüfung. Eine Server
|
||||
Action prüft dasselbe Recht an derselben Stelle wie jede andere Schreiboperation am Lauf. Ein
|
||||
Route Handler bleibt für das **Ausliefern** — dort braucht es eine Adresse, die ein `<img>` holen
|
||||
kann, und `src/app/api/anhaenge/[id]/route.ts` ruft selbst `rechtErforderlich("lesen")`, weil die
|
||||
Hülle unter `(app)` Route Handler nicht abdeckt.
|
||||
- **Der Pfad entsteht aus der Kennung, nie aus dem Dateinamen.** `<ANHAENGE_PFAD>/<kundeId>/<laufId>/<id>.<endung>`.
|
||||
Der Name vom Browser wird trotzdem gesäubert, weil er in einer Antwortkopfzeile landet.
|
||||
- **Kein SVG**, obwohl es ein Bild ist: ein SVG ist ein Dokument, das Skripte ausführt. Darum eine
|
||||
Liste erlaubter Medientypen und kein `image/*`.
|
||||
- **Reihenfolge beim Anlegen:** prüfen, Datei schreiben (`flag: "wx"`), dann die Zeile — und bei
|
||||
einem Fehler die Datei wieder weg. Eine Datei ohne Zeile findet niemand; eine Zeile ohne Datei ist
|
||||
ein Anhang, der beim Öffnen fehlschlägt. Beim Entfernen umgekehrt: erst die Zeile, dann die Datei.
|
||||
- **Zwei Felder `datei` in einem Formular — und `FormData.get` nahm das falsche.** „Foto“ und
|
||||
„Datei“ stehen im selben Formular, abgeschickt wird immer beides, und das leere Feld kam zuerst:
|
||||
wer „Datei“ wählte, bekam „Keine Datei gewählt“, während „Foto“ ging. Neunzehn grüne Dienst-Tests
|
||||
sahen davon nichts, weil kein Dienst-Test zwei Felder schickt. Gefunden hat es erst die Klickprobe
|
||||
am gebauten Stand. Die Regel liegt jetzt als reine Funktion `gewaehlteDatei()` in `src/lib`, und
|
||||
`tests/e2e/anhaenge.spec.ts` lädt bewusst **über den zweiten Knopf** hoch. Gegenprobe gefahren:
|
||||
mit `get` statt `getAll` wird der Ablauf rot.
|
||||
- **`docs/betrieb/sicherung.md` war nicht nur unvollständig, sondern an einer Stelle falsch** — dort
|
||||
stand „Kein Upload-Verzeichnis, keine erzeugten Dateien“. Die Datei ist neu geschrieben: drei
|
||||
Dinge statt zwei, `tar` für beide Betriebswege, der `chown`-Fallstrick nach einer Rücksicherung als
|
||||
`root`, ein Sicherungsskript für den systemd-Timer, das Datenbank und Anhänge im **selben** Lauf
|
||||
mitnimmt, und die Gegenprobe über die Zahlen.
|
||||
- **Sicherung und Rücksicherung wirklich gefahren**, nicht beschrieben: Abzug gezogen, Datenbank
|
||||
verworfen und neu angelegt, Verzeichnis gelöscht, erst nur die Datenbank zurückgespielt — und da
|
||||
meldete der Abruf genau den dokumentierten Satz „Die Datei zu … fehlt im Dateisystem“ —, dann die
|
||||
Anhänge dazu, und die ausgelieferten Bytes gegen die gespeicherte `pruefsumme` geprüft. Beide
|
||||
stimmten.
|
||||
- **`tests/datenbankregeln.test.ts` musste nachziehen.** Er zählt die handgeschriebenen
|
||||
Datenbankobjekte namentlich auf; die zwei neuen CHECK-Bedingungen und die beiden zusammengesetzten
|
||||
Fremdschlüssel standen nicht drin, und der Test wurde dadurch rot — genau wie gewollt.
|
||||
- **`/daten/` ist jetzt in `.gitignore`.** Ohne das läge das erste hochgeladene Foto im nächsten
|
||||
Commit.
|
||||
|
||||
@@ -36,8 +36,8 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
|
||||
| #029 | [M16 – SCIM-Endpunkt als Alternative zum Abgleich](029-scim-endpunkt.md) | offen | v0.2.0 | 12 h | – |
|
||||
| #030 | [M17 – Wiederverwendbare Bausteine](030-bausteine.md) | erledigt | v0.2.0 | 12 h | 2 h 25 min |
|
||||
| #031 | [M18 – Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | erledigt | v0.2.0 | 8 h | 1 h 35 min |
|
||||
| #032 | [M19 – Anhänge und Fotos an Schritten](032-anhaenge.md) | offen | v0.2.0 | 10 h | – |
|
||||
| #032 | [M19 – Anhänge und Fotos an Schritten](032-anhaenge.md) | erledigt | v0.2.0 | 10 h | 3 h 05 min |
|
||||
| #033 | [M20 – Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | offen | v0.2.0 | 6 h | – |
|
||||
| #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | offen | v0.2.0 | 10 h | – |
|
||||
| #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | offen | v0.2.0 | 8 h | – |
|
||||
| | **Summe** | | | **229 h** | **45 h 20 min** |
|
||||
| | **Summe** | | | **229 h** | **48 h 25 min** |
|
||||
|
||||
Reference in New Issue
Block a user