# 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`, `runs`, `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/ ./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":"","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 --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__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.