stammtisch-hersbruck/backend/README.md
Daniel Michelberger 279796111c wip: Der abgebrochene Kurbeler-Umbau, wie er hier liegengeblieben ist
Kein fertiger Stand, sondern ein Zwischenstand: Hier wurde im September 2026
angefangen, die App auf „Kurbeler" umzubenennen — Wortmarke, Icons, brand.ts,
Benachrichtigungen, das Benutzerverzeichnis. Weitergegangen ist es dann im
eigenen Repo `~/Dokumente/Projekte/kurbeler`, das die vollständige Historie
dieses Repos mitgenommen hat.

Committet wird das hier nicht, weil es gebraucht würde, sondern damit es nicht
als Haufen unversionierter Dateien im Arbeitsverzeichnis verrottet. Wer in
zwei Jahren nachsieht, findet so eine Geschichte statt eines Rätsels — und das
Stammtisch-Wappen, das nur hier je existiert hat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTw6xVYgjMy9AQtfs1Gdyu
2026-09-11 15:42:33 +02:00

294 lines
13 KiB
Markdown

# Kurbeler — 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`.
| Datei | Zweck |
|---|---|
| `clock.pb.js` | Uhrenabgleich für die Zeitnahme |
| `team_invites.pb.js` | `GET /api/team-invites/{token}`, `POST …/accept`, `POST …/join` |
| `team_invites_lib.js` | Gemeinsame Helfer der drei Einladungs-Routen |
| `directory.pb.js` | `GET /api/directory/users` — Benutzerverzeichnis des Superadmins |
| `directory_lib.js` | Helfer dazu |
| `mail_lib.js` | Absender, Vorlagen, Versand — alles, was Mails verschickt, geht hier durch |
| `notifications.pb.js` | Auslöser der Benachrichtigungen und der tägliche Versand |
| `notifications_lib.js` | Voreinstellungen, Empfängerkreis, Warteschlange |
| `views/invite.html` | Vorlage der Einladungsmail |
| `views/digest.html` | Vorlage der Tageszusammenfassung |
Vorlagen werden mit `$template.loadFiles(...).render(data)` gefüllt — Go's
`html/template`. Die Platzhalter stehen deshalb in `{{ }}` und werden dabei
escaped; Trailnamen und Kommentartexte kommen aus der Datenbank und dürfen
kein Markup einschleusen.
## Mails
SMTP wird im Admin-UI eingerichtet (Settings → Mail settings). Zwei
Umgebungsvariablen kommen dazu, beide in `.env.example` beschrieben:
| Variable | Wofür |
|---|---|
| `APP_URL` | Adresse des **Frontends**. Aus ihr entstehen die Links in Mails. |
| `APP_NAME` | Optional. Name in Betreff und Text; ohne sie steht dort „Kurbeler". |
`APP_URL` ist ausdrücklich nicht `settings().meta.appURL` — die zeigt auf
PocketBase selbst, und ein Einladungslink dorthin führte auf die API statt auf
die Seite, die das Konto anlegt. Sie ist auch nicht der `Origin` des
Aufrufers: Der steht unter dessen Kontrolle, und ein Link in einer Mail, den
ein Fremder auf seinen eigenen Server zeigen lassen kann, ist eine
Phishing-Vorlage mit unserem Absender darauf. Fehlt die Variable, verweigern
die Mail-Routen den Dienst.
### Einladung statt Passwort
`POST /api/team-invites/send` ist der Ersatz für „Konto anlegen" im Kader.
Dort tippte die Teamleitung Adresse **und Passwort** ein und musste dieses
Passwort anschließend irgendwie zum Fahrer bringen — über einen Kanal, den
niemand geplant hat. Sie kannte es außerdem.
Jetzt legt die Route eine persönliche Einladung an (`team_invites` mit
`email` und `rider`) und verschickt sie. Das Passwort entsteht erst beim
Empfänger auf `/invite/{token}`. Die Adresse ist dabei gebunden: Das Feld auf
der Seite ist gesperrt, und weil ein gesperrtes Feld nur eine Bitte ist, prüft
`/accept` sie noch einmal.
Die allgemeinen **Einladungslinks bleiben unverändert** — sie gelten jedem,
der sie hat, und lassen `email` und `rider` leer.
### Benachrichtigungen: eine Mail am Tag
Kein Auslöser verschickt eine Mail. Alle legen einen Eintrag in
`notifications`, und `cronAdd('notifications_digest', '0 6 * * *', …)` macht
daraus einmal täglich je Konto eine Zusammenfassung.
Das ist der ganze Punkt: Ein Trailkommentar, dem drei Antworten folgen, löste
sonst vier Mails aus. Ein Verein, der sich die nicht leisten kann, schaltet
nach drei Tagen alles ab.
Nebenwirkung, die zählt: Ein Fehler im Mailversand kann nie das Speichern
eines Events verhindern — der Auslöser schreibt nur in eine Tabelle.
Scheitert der Versand an ein Konto, bleiben dessen Einträge unversendet stehen
und sind am nächsten Morgen wieder dabei.
Was ohne Einstellungen gilt, steht als `DEFAULTS` in `notifications_lib.js`
und noch einmal in `frontend/src/lib/stores/notificationPrefs.svelte.ts`.
**Beide Listen müssen zusammenpassen** — stünde dort etwas anderes, zeigte die
Oberfläche einen Schalter an, der nichts mit dem zu tun hat, was verschickt
wird. Die Linie: An heißt „betrifft mich", aus heißt „ist viel".
`notifications` ist über die API nicht beschreibbar (create/update/delete auf
`null`, in PocketBase „nur Superuser"). Gefüllt wird sie ausschließlich aus
den Hooks, die an den Regeln vorbeilaufen — sonst schriebe sich jeder selbst
Meldungen, oder anderen.
### Warum das Verzeichnis eine Route braucht
Konten entstehen mit `emailVisibility: false` (`teams.svelte.ts`,
`createMember`). PocketBase gibt die E-Mail-Adresse damit nur ihrem Inhaber
heraus — in jedem `users`-Datensatz, den jemand anderes liest, ist das Feld
leer. Ein Verzeichnis ohne Adressen wäre aber eine Liste von Namen, in der
niemand ein Konto wiedererkennt.
Das Flag umzulegen wäre eine Zeile und zugleich ein Loch: `users.listRule`
gilt appweit (`@request.auth.id != ""`), jede angemeldete Person läse danach
jede Adresse — quer über alle Teams. Der Hook liest sie stattdessen
serverseitig und gibt sie nur dem heraus, der sie sehen darf.
`superadmin` wird dabei frisch aus der Datenbank gelesen und nicht aus dem
mitgeschickten Auth-Record: Der Token hält den Stand von der Anmeldung fest,
eine entzogene Rolle wirkte sonst bis zu seinem Ablauf nicht.
## Wer darf ein Konto löschen?
Zwei, und sonst niemand — `users.deleteRule` (siehe
`1757340000_users_superadmin_delete.js`):
| Wer | Wo |
|---|---|
| Der Inhaber selbst | Einstellungen → Profil |
| Superadmin | Einstellungen → Benutzer |
Die Teamleitung ausdrücklich **nicht**. Sie verwaltet Fahrer und
Mitgliedschaften; im Kader gibt es dafür genau einen Eingriff an einer fremden
Person, „Zugang zum Team entziehen" (`teams.removeMember`). Der nimmt das Konto
aus `teams.users`/`teams.admins` — Fahrer, Teilnahmen und Zeiten bleiben
stehen. Sie sind die Geschichte des Teams und nicht die des Logins.
Ein Konto, das ein Team besitzt, lässt sich nicht löschen: `teams.owner` ist
eine Pflichtrelation ohne cascadeDelete, PocketBase müsste sie beim Löschen
leeren und bräche dabei den Datensatz. Beide Oberflächen prüfen das vorher und
sagen, welches Team im Weg steht — sonst stünde dort ein Datenbankfehler.
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.