stammtisch-hersbruck/backend/README.md
Daniel Michelberger 893509be9d refactor: Aus Runs werden Stages, bis in die Datenbank
"Run" hiess in dieser App immer schon der abgesteckte Abschnitt eines
Events, auf dem gefahren und gestoppt wird — also das, was im Rennsport
Stage heisst. "Run" ist daneben der einzelne Durchgang eines Fahrers,
und genau der steht hier als `times`. Zwei Bedeutungen fuer ein Wort, an
einer Stelle, an der beide vorkommen.

Die Migration benennt um statt neu anzulegen: Collection und Feld
behalten ihre IDs, PocketBase benennt Tabelle und Spalte um, die Daten
bleiben stehen. Gesucht wird ueber die Collection-ID und nicht ueber den
Namen, damit sie auch auf einer frischen Datenbank durchlaeuft, deren
Snapshot die Collection bereits `stages` nennt.

Im Frontend wandert `times.run` zu `times.stage`, der Store heisst
stages.svelte.ts, und aus /events/[id]/runs/[runId] wird
/events/[id]/stages/[stageId].

Nicht umbenannt: `running`, `allRunning` und RunningTimesToast. Das sind
laufende Zeiten und keine Stages — dieselben Buchstaben, andere Sache.

Und die Artikel: Der Run war maskulin, die Stage ist feminin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:16:22 +02:00

185 lines
8.1 KiB
Markdown

# stammtisch-hersbruck.de — Backend (PocketBase)
PocketBase-Instanz für Events, Läufe, Fahrer, Zeiten und Teams.
## Lokal starten
`.env` aus der Vorlage anlegen (die `.env` selbst steht in `.gitignore`):
cp .env.example .env
und die Werte eintragen — `.env.example` beschreibt jede Variable. Dann:
docker compose up -d --build
Admin-UI: http://127.0.0.1:8090/_/ — Login mit `SUPERUSER_EMAIL`/`SUPERUSER_PASSWORD`.
Damit das Frontend gegen diese Instanz läuft, in `../frontend/.env`
`PUBLIC_PB_URL=http://127.0.0.1:8090` setzen.
## Schema
Das Schema liegt als versionierte Migration in `pb_migrations/` und wird beim
Start automatisch angewendet. Es enthält die sechs fachlichen Collections
`users`, `teams`, `events`, `stages`, `riders`, `times`.
`1754400000_init_schema.js` ist ein Snapshot der produktiven Instanz. Er wurde
per `GET /api/collections` abgezogen und ruft `importCollections` mit
`deleteMissing = false` auf — die Migration legt an und aktualisiert, löscht
aber nichts, was nicht im Snapshot steht.
### Schema-Änderungen aus dem Admin-UI holen
`pb_migrations/` wird **nicht** in den Container gemountet — die Migrationen
kommen über `COPY` ins Image (siehe `Dockerfile`). Das ist Absicht: Ein
Bind-Mount überdeckt das Image-Verzeichnis, und auf einem Deployment-Host ohne
ausgechecktes Repo legt Docker dort ein leeres Verzeichnis an. PocketBase
findet dann keine Migration und startet mit einer Datenbank ohne Collections.
Die Kehrseite: Eine im Admin-UI erzeugte Migration liegt zunächst nur im
Container und muss von Hand herausgeholt werden:
# Welche Dateien sind dazugekommen?
docker compose exec pocketbase ls /pb/pb_migrations
# Die neue Datei ins Repo kopieren und committen
docker compose cp pocketbase:/pb/pb_migrations/<datei> ./pb_migrations/
Ohne diesen Schritt ist die Änderung beim nächsten `--build` verloren.
Ein frisch gestarteter Container hat damit dasselbe Schema wie die produktive
Instanz — aber **keine** Daten.
### Regeln: der Login-Vorspann ist Pflicht
Jede Regel, die `@request.auth.id` erwähnt, beginnt mit
`@request.auth.id != "" && (…)`. Das ist kein Stilmittel, sondern schließt ein
Loch:
**Eine leere Relation ist in PocketBase gleich dem leeren `@request.auth.id`
einer anonymen Anfrage.** `team.admins.id ?= @request.auth.id` ist damit für
jedes Team **ohne Admins** wahr — und ohne Admins ist jedes frisch angelegte
Team. Dasselbe gilt für jede optionale Relation: `created_by`, `stewards`,
`rider.user`.
Nachgewiesen an einer lokalen Instanz mit dem Schema aus diesem Repo: Ein
`POST` auf `/api/collections/riders/records` **ohne jeden Token** legte den
Fahrer an. Betroffen waren nach demselben Muster `teams` (update), `riders`,
`trails`, `trail_versions`, `trail_flags`, `trail_markers`, `trail_comments`,
`event_series` und `event_participants`.
`1754501700_rules_require_login.js` setzt den Vorspann vor jede betroffene
Regel. Er kann nichts erlauben, was vorher verboten war — er nimmt nur die
anonyme Anfrage heraus, die keine dieser Regeln je erfüllen sollte. Gelesen
werden die Regeln aus der Datenbank, eine Handänderung aus dem Admin-UI wird
also abgesichert und nicht überschrieben.
Nach dem Deploy einmal gegenprüfen — das muss scheitern:
curl -X POST https://api.stammtisch-hersbruck.de/api/collections/riders/records \
-H 'Content-Type: application/json' \
-d '{"team":"<TEAM_ID>","name":"Test"}'
Wer eine neue Regel schreibt, setzt den Vorspann mit. Sonst reißt dasselbe
Loch an der nächsten Stelle wieder auf.
### `1754501300` scheitert auf einer frischen Datenbank
Bekannt und **nicht behoben**: Bei `docker compose up --build` mit leerem
`pb_data` legt der Snapshot `1754400000` die Collection `riders` bereits ohne
das Feld `event` an. `1754501300_event_participants.js` sucht danach mit
`findRecordsByFilter('riders', 'event != "")` und bricht mit
`unknown field "event"` ab — PocketBase startet dann gar nicht.
Auf der produktiven Instanz tritt das nicht auf, dort gibt es das Feld noch.
Angefasst wurde die Migration deshalb nicht; wer lokal von Null startet,
braucht diese eine Zeile:
const existing = riders.fields.getByName('event')
? app.findRecordsByFilter('riders', 'event != ""', '', 0, 0)
: []
## Hooks (`pb_hooks/`)
Serverseitiges JavaScript, das PocketBase beim Start lädt (`--hooksDir`). Wie
`pb_migrations/` kommt das Verzeichnis per `COPY` ins Image — eine Änderung
wirkt also erst nach `docker compose up -d --build`.
Bisher liegt dort ein Anliegen: die öffentlichen Routen der Einladungslinks.
| Datei | Zweck |
|---|---|
| `team_invites.pb.js` | `GET /api/team-invites/{token}`, `POST …/accept`, `POST …/join` |
| `team_invites_lib.js` | Gemeinsame Helfer der drei Routen |
Warum überhaupt ein Hook? Wer über einen Link beitritt, ist noch nicht
angemeldet und muss drei Dinge auf einmal tun, die keine Collection-Regel
einzeln erlauben darf: ein Konto anlegen, sich in `teams.users` eintragen und
einen Fahrer anlegen. Ein Hook arbeitet an den Regeln vorbei und kann genau
diese Kombination erlauben — gegen einen gültigen Token und sonst nicht.
**Zwei Fallstricke, an denen die erste Fassung scheiterte:**
1. Jeder Route-Handler läuft in einer **eigenen** JS-Laufzeit. Funktionen, die
in derselben Datei neben `routerAdd` stehen, sind im Handler *nicht*
sichtbar (`ReferenceError`). Gemeinsamer Code gehört deshalb in ein Modul,
das der Handler sich per ``require(`${__hooks}/…`)`` selbst holt.
2. Was `record.get()` bei einer Mehrfach-Relation liefert, ist ein Go-Slice
und kein JS-Array — `indexOf` und Konsorten sind darauf nicht verlässlich.
Anhängen geht über den Modifier `record.set('users+', id)`, Prüfen über
einen Filter.
## Datenpersistenz
Sämtliche Laufzeitdaten liegen in der SQLite-DB unter `/pb/pb_data`: Datensätze,
Uploads, die SMTP-Einstellungen und der Superuser. Ohne gemountetes Volume auf
diesem Pfad sind sie nach jedem Redeploy verloren.
Das Volume-Mapping `./pb_data:/pb/pb_data` steht in der `docker-compose.yaml`,
damit die Persistenz-Konfiguration versioniert im Repo liegt.
**Trügerisches Signal:** Die Collections kommen aus der Migration und sind nach
einem Redeploy auch dann da, wenn gar kein Volume existiert. Sie taugen **nicht**
als Nachweis funktionierender Persistenz — das zeigt nur ein Datensatz, der in
keiner Migration steht.
Mount prüfen:
docker inspect <container> --format '{{json .Mounts}}' | jq
Erscheint dort kein Eintrag mit `"Destination": "/pb/pb_data"`, fehlt die Persistenz.
`pb_data/` steht in `.gitignore` und gehört dort auch hin.
## Superuser-Verhalten
`entrypoint.sh` ruft `superuser create` auf — bewusst **nicht** `upsert`.
Existiert der Account bereits, scheitert `create` mit „Value must be unique" und
das Passwort bleibt unangetastet. Eine im Admin-UI vorgenommene
Passwortänderung überlebt damit jeden Redeploy.
| Situation | Verhalten |
|---|---|
| Erster Start, Variablen gesetzt | Superuser wird angelegt |
| Neustart, Account existiert | „Superuser existiert bereits — Passwort bleibt unverändert" |
| Passwort im UI geändert, dann Neustart | UI-Passwort gilt weiter, Env wird ignoriert |
| Variablen nicht gesetzt | Übersprungen, PocketBase gibt Installer-Link im Log aus |
| Passwort zu kurz (< 8 Zeichen) | Container bricht mit Exitcode 1 ab |
Passwort später ändern: **im Admin-UI**, nicht über die Env eine Änderung der
Env-Variable hat keine Wirkung mehr, sobald der Account existiert.
## PocketBase aktualisieren
Die Version ist über das Build-Arg `PB_VERSION` gepinnt (Dockerfile und
`docker-compose.yaml`). Wert hochsetzen, committen, neu bauen.
Ein `latest` gibt es bewusst nicht: Der GitHub-Asset heißt
`pocketbase_<version>_linux_amd64.zip`, enthält die Version also im Dateinamen.
Der Pin ist auch gewollt sonst zieht jeder Rebuild unangekündigt eine neue
Version, inklusive möglicher DB-Schema-Migrationen.
Vor einem Update: Changelog prüfen
(https://github.com/pocketbase/pocketbase/releases) und `pb_data` sichern
PocketBase migriert die DB beim Start automatisch, ein Downgrade ist danach
nicht mehr ohne Weiteres möglich.