From fae50d32094303d44a8ac803be44322c39ee3862 Mon Sep 17 00:00:00 2001 From: Daniel Michelberger Date: Wed, 2 Sep 2026 16:03:17 +0200 Subject: [PATCH] feat: Fahrer werden zum Kader, Events bekommen Teilnehmer und Serien Damit sind alle offenen Punkte aus TODO.md gebaut. Fahrer gehoeren ab jetzt dem Team, nicht mehr einem Event. Wer bei einem Termin dabei ist, steht in event_participants - dort haengt auch die Startnummer, denn die gilt je Event: Derselbe Fahrer kann in zwei Rennen zwei Nummern haben und beim Stammtisch gar keine. Die vorhandenen Daten wandern in der Migration mit. Events tragen zwei Schalter. timing blendet Runs und Zeitnahme ein - der woechentliche Stammtisch braucht sie nicht. participation regelt, wie man dazukommt: offen (jeder traegt sich selbst ein), bestaetigung (Anmeldung, Ersteller oder Team-Admin bestaetigt) oder geschlossen. Durchgesetzt wird das serverseitig, nicht nur im Formular. Serien erzeugen echte Einzeltermine statt einer Wiederholungsregel, aus der Termine nur gerechnet werden. Nur so laesst sich beantworten, wer am 12. kommt, und nur so faellt eine einzelne Woche aus. Gerechnet wird auf Kalendertagen: ueber die Zeitumstellung hinweg kippte ein Termin sonst auf den Vortag - dafuer gibt es einen Test. Ausserdem: Teammitglieder duerfen den Zustand eines Trails melden. Die Regel gibt genau die drei Statusfelder frei und schliesst jedes andere aus; ohne das haette man Mitgliedern das ganze Objekt oeffnen muessen. Und bestehende Konten lassen sich wieder per E-Mail ins Team holen, wofuer users.listRule sich oeffnet. Die neuen Regeln sind ungetestet - weder Docker noch eine lokale PocketBase-Instanz standen zur Verfuegung. TODO.md sagt, was nach dem Deploy durchzuspielen ist. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o --- TODO.md | 157 +- .../pb_migrations/1754400000_init_schema.js | 3815 +++++++++-------- .../1754501100_trails_status_report.js | 39 + .../1754501200_users_list_for_invites.js | 22 + .../1754501300_event_participants.js | 146 + .../1754501400_events_timing_participation.js | 93 + .../pb_migrations/1754501500_event_series.js | 100 + .../lib/components/RunningTimesToast.svelte | 16 +- frontend/src/lib/components/TimeDialog.svelte | 21 +- .../lib/stores/eventParticipants.svelte.ts | 184 + frontend/src/lib/stores/eventSeries.svelte.ts | 183 + frontend/src/lib/stores/eventSeries.test.ts | 50 + frontend/src/lib/stores/events.svelte.ts | 7 + frontend/src/lib/stores/riders.svelte.ts | 31 +- frontend/src/lib/types.d.ts | 64 +- frontend/src/routes/dashboard/+layout.svelte | 11 +- .../src/routes/dashboard/events/+page.svelte | 330 +- .../routes/dashboard/events/[id]/+page.svelte | 352 +- .../events/[id]/riders/[riderId]/+page.svelte | 10 +- .../events/[id]/runs/[runId]/+page.svelte | 25 +- .../src/routes/dashboard/riders/+page.svelte | 37 +- .../dashboard/settings/riders/+page.svelte | 62 +- .../dashboard/settings/team/+page.svelte | 148 +- 23 files changed, 3818 insertions(+), 2085 deletions(-) create mode 100644 backend/pb_migrations/1754501100_trails_status_report.js create mode 100644 backend/pb_migrations/1754501200_users_list_for_invites.js create mode 100644 backend/pb_migrations/1754501300_event_participants.js create mode 100644 backend/pb_migrations/1754501400_events_timing_participation.js create mode 100644 backend/pb_migrations/1754501500_event_series.js create mode 100644 frontend/src/lib/stores/eventParticipants.svelte.ts create mode 100644 frontend/src/lib/stores/eventSeries.svelte.ts create mode 100644 frontend/src/lib/stores/eventSeries.test.ts diff --git a/TODO.md b/TODO.md index 0bd550e..eebb955 100644 --- a/TODO.md +++ b/TODO.md @@ -1,73 +1,7 @@ # Offene Punkte -Stand: 2026-09-02. Was hier steht, ist bewusst nicht erledigt — entweder weil -es eine Entscheidung braucht oder weil es außerhalb des Repos passiert. - -## Entscheidungen - -### Trail-Status: dürfen Mitglieder melden? - -Anzeige und Herkunft (`status_changed`, `status_by`) sind fertig. Ändern darf -den Status weiterhin nur, wer Pate oder Team-Admin ist — `trails.updateRule` -lässt nichts anderes zu. - -Sollen alle Teammitglieder melden dürfen, braucht es eine Regel, die genau ein -Feld freigibt: `team.users.id ?= @request.auth.id` kombiniert mit -`@request.body.:isset = false` für **jedes** andere Feld. Das -funktioniert, wird aber lang und bricht still, sobald ein Feld dazukommt. -Deshalb erst nach ausdrücklicher Entscheidung. - -### Fahrer: Kader oder Event-Teilnehmer? - -`riders.event` bindet einen Fahrer an genau ein Event. Ein in den Einstellungen -angelegter Fahrer ohne Event steht deshalb nur im Verzeichnis und taucht in -keiner Zeitnahme auf; der Dialog hat dafür ein optionales Event-Feld. - -Ein echter Team-Kader, aus dem man Fahrer in Events übernimmt, wäre eine -n:m-Beziehung — also eine Schema-Änderung, keine Oberflächenfrage. - -### Mitglied per E-Mail einladen - -Die alte Einladung über eine E-Mail-Suche ist entfallen und durch „Mitglied -anlegen" ersetzt. Sie konnte nie funktionieren: `users.listRule` gibt nur den -eigenen Datensatz frei, die Suche lief immer ins Leere. - -Ein **bestehendes** fremdes Konto in ein Team zu holen braucht eine offenere -`listRule` (z. B. `@request.auth.id != ""`). Das erlaubt allerdings, alle -Konten der Instanz aufzulisten — deshalb bewusst nicht ohne Rücksprache. - -### Ausfahrten: geplant, noch nicht gebaut - -Ausfahrten werden **nicht** zu einem zweiten Konzept neben Events, sondern -Events bekommen Schalter. Ein Bikepark-Ausflug mit gestoppten Läufen ist beides -zugleich; eine Trennung wäre willkürlich und würde Datum, Rechte und Oberfläche -doppelt führen. - -Geplant, in dieser Reihenfolge: - -1. **`events.timing`** (bool). Runs und Zeitnahme erscheinen nur, wenn gesetzt. - Der wöchentliche Stammtisch braucht sie nicht. -2. **Teilnahme** als eigene Collection `event_participants` (Event, Person, - Status, Zeitpunkt) — nicht als Liste am Event, sonst hängen Status und - Zeitpunkt nirgends, und Gäste ohne Konto passen nicht hinein. - - `events.participation` steuert, wie man hineinkommt: - - | Wert | Bedeutung | - |---|---| - | `offen` | Jedes Teammitglied trägt sich selbst ein | - | `bestaetigung` | Anmeldung möglich, Ersteller oder Team-Admin bestätigt | - | `geschlossen` | Nur Ersteller oder Team-Admin trägt Teilnehmer ein | - -3. **Serien** (`event_series`): Regel, Zeitraum und Vorlage; daraus entstehen - echte Einzeltermine. Bewusst nicht ein Datensatz mit Wiederholungsregel, aus - dem Termine nur gerechnet werden — sobald „wer kommt am 12.?" oder „diese - Woche fällt aus" beantwortet werden soll, braucht jeder Termin einen eigenen - Datensatz. - -Offen: Sind Teilnehmer **Nutzerkonten** oder **Fahrer**? Für den Stammtisch -wären es Konten, für eine gemeinsame Ausfahrt eher Fahrer (die es auch ohne -Konto gibt). Beides zugleich geht, macht die Collection aber breiter. +Stand: 2026-09-02. Die fachlichen Entscheidungen sind gefallen und gebaut; was +hier steht, passiert außerhalb des Repos oder wartet auf den nächsten Deploy. ## Vor dem nächsten Deploy @@ -83,34 +17,47 @@ Kacheln ohne Schlüssel weiter — die Karte bleibt also sichtbar. ### Migrationen greifen beim nächsten Start -Sechs neue Migrationen liegen in `backend/pb_migrations/`: +Elf neue Migrationen liegen in `backend/pb_migrations/`: -- `1754500500_riders_user_and_admin_rules.js` -- `1754500600_events_dates_instead_of_status.js` -- `1754500700_trail_status_meta.js` -- `1754500800_trail_markers_custom.js` -- `1754500900_users_superadmin.js` -- `1754501000_teams_logo.js` +| Datei | Was sie tut | +|---|---| +| `1754500500_riders_user_and_admin_rules` | `riders.user`, Fahrerpflege nur noch Teamleitung, `users.viewRule`/`createRule` gelockert | +| `1754500600_events_dates_instead_of_status` | `events.status` **raus**, `starts`/`ends` rein | +| `1754500700_trail_status_meta` | `trails.status_changed`, `trails.status_by` | +| `1754500800_trail_markers_custom` | `trail_markers.label`/`icon`, `flag` nicht mehr Pflicht | +| `1754500900_users_superadmin` | `users.superadmin`, gesetzt für stammtisch@dne.name | +| `1754501000_teams_logo` | `teams.logo` | +| `1754501100_trails_status_report` | Mitglieder dürfen den Trail-Zustand melden | +| `1754501200_users_list_for_invites` | `users.listRule` gelockert (Kontosuche) | +| `1754501300_event_participants` | neue Collection, **verschiebt Daten**, entfernt `riders.event`/`riders.number` | +| `1754501400_events_timing_participation` | `events.timing`/`participation`, Regeln für Selbsteintrag | +| `1754501500_event_series` | neue Collection `event_series`, `events.series` | Solange sie nicht gelaufen sind, scheitert alles, was auf den neuen Regeln aufbaut. Am deutlichsten beim Anlegen eines Mitglieds oder eines Fahrer-Logins: **„Only superusers can perform this action"** — genau das sagt die noch -unveraenderte `users.createRule` (null = nur Superuser). +unveränderte `users.createRule` (null = nur Superuser). -**Achtung:** Die mittlere entfernt `events.status` **samt seiner Werte**. Das -ist so gewollt (Events werden datiert statt bewertet), aber es ist ein -Datenverlust und nicht zurückzunehmen. +**Drei Dinge sind nicht zurückzunehmen:** -Ebenfalls neu: Fahrer anlegen, ändern und löschen darf ab dann nur noch die -Teamleitung. Mitglieder ohne Admin-Rolle verlieren diese Möglichkeit auch auf -der Event-Seite. +1. `events.status` verschwindet samt seiner Werte. +2. `1754501300` legt für jeden Fahrer mit Event einen Teilnehmer an und + entfernt danach `riders.event` und `riders.number`. Eine Startnummer an + einem Fahrer **ohne** Event hat danach keinen Platz mehr und geht verloren — + sie war ohnehin nirgends sichtbar, weil alle Listen nach Event filterten. +3. Bestehende Events werden auf `timing = true` und `participation = + geschlossen` gesetzt. Genau so wurden sie bisher benutzt; wer einen + Stammtisch daraus machen will, schaltet die Zeitnahme im Dialog ab. + +Ebenfalls neu und bewusst so: Jeder Eingeloggte kann Konten auflisten +(`users.listRule`), damit sich bestehende Konten wieder per E-Mail ins Team +holen lassen. E-Mail-Adressen bleiben über `emailVisibility` verborgen. ### Typen einmal neu erzeugen -`frontend/src/lib/types.d.ts` wurde bei den letzten Schema-Änderungen von Hand -ergänzt (`riders.user`, `trails.status_by`/`status_changed`, -`events.starts`/`ends` statt `status`). Grund: `pocketbase-typegen` braucht -native sqlite3-Bindings, die in der Arbeitsumgebung nicht gebaut werden durften. +`frontend/src/lib/types.d.ts` wurde bei allen Schema-Änderungen von Hand +gepflegt. Grund: `pocketbase-typegen` braucht native sqlite3-Bindings, die in +der Arbeitsumgebung nicht gebaut werden durften. ```bash cd frontend && npm run generate-pocketbase-types @@ -119,6 +66,24 @@ cd frontend && npm run generate-pocketbase-types Die Datei sollte danach unverändert bleiben. Tut sie es nicht, gilt das Ergebnis des Generators. +### Regeln einmal durchspielen + +Die Zugriffsregeln der neuen Collections konnten nirgends laufen — weder Docker +noch eine lokale PocketBase-Instanz standen zur Verfügung. Sie sind nach dem +Muster der vorhandenen Collections geschrieben, aber ungetestet. Nach dem Deploy +einmal ausprobieren: + +- Ein einfaches Teammitglied trägt sich bei einem **offenen** Event selbst ein. +- Dasselbe bei einem Event **mit Bestätigung** — es muss als „Angefragt" + landen, nicht als „Dabei". +- Bei einem **geschlossenen** Event darf es gar nicht gehen. +- Ein Mitglied meldet einen Trail-Zustand (muss gehen) und versucht, den Namen + des Trails zu ändern (darf nicht gehen). + +Der Selbsteintrag setzt außerdem voraus, dass das Konto der Person mit einem +Fahrer verknüpft ist (Einstellungen → Fahrer → Konto). Ohne Verknüpfung +erscheint der Knopf gar nicht erst. + ### Logo des Stammtischs zuordnen `teams.logo` gibt es ab `1754501000`. Solange nichts hinterlegt ist, zeigt die @@ -128,14 +93,14 @@ Deploy unter Einstellungen → Team → Logo hoch. ## Kleinigkeiten -- Der Melder eines Trail-Markers („wer") zeigt bis zum Deploy „Unbekannt". Die - gelockerte `users.viewRule` kommt mit `1754500500`. -- Die Rolle **Superadmin** existiert seit `1754500900` und ist für - stammtisch@dne.name gesetzt, gibt in der App aber noch keine besonderen - Rechte. Sie ist bewusst nur in PocketBase vergebbar: Die `updateRule` der - users-Collection schließt `superadmin` vom Selbstsetzen aus. Sobald klar ist, - was sie dürfen soll, kommen die Regeln dazu. -- Flag-Typen bleiben **teamspezifisch**. Ein neues Team bekommt beim Anlegen - den hartkodierten Standardsatz aus `trailFlags.svelte.ts`, den es danach frei - ändern kann. Eine appweite Verwaltung gibt es damit bewusst nicht. +- Die Rolle **Superadmin** ist gesetzt, gibt in der App aber noch keine + besonderen Rechte. Sie ist bewusst nur in PocketBase vergebbar: Die + `updateRule` der users-Collection schließt `superadmin` vom Selbstsetzen aus. + Sobald klar ist, was sie dürfen soll, kommen die Regeln dazu. +- Flag-Typen sind **teamspezifisch**. Ein neues Team bekommt beim Anlegen den + hartkodierten Standardsatz aus `trailFlags.svelte.ts` und kann ihn frei + ändern. +- Serien erzeugen bis zu zwölf Termine je Klick und überspringen, was schon + angelegt ist. Ein Termin, der ausfällt, wird als Event gelöscht oder + verschoben — die Serie bleibt davon unberührt. - `NAMING.md` und `frontend/static/pedaler.svg` liegen bewusst nur lokal. diff --git a/backend/pb_migrations/1754400000_init_schema.js b/backend/pb_migrations/1754400000_init_schema.js index 7681761..4ed98ba 100644 --- a/backend/pb_migrations/1754400000_init_schema.js +++ b/backend/pb_migrations/1754400000_init_schema.js @@ -9,1773 +9,2104 @@ migrate((app) => { const collections = [ - { - "id": "_pb_users_auth_", - "listRule": "id = @request.auth.id", - "viewRule": "@request.auth.id != \"\"", - "createRule": "@request.auth.id != \"\"", - "updateRule": "id = @request.auth.id && @request.body.superadmin:isset = false", - "deleteRule": "id = @request.auth.id", - "name": "users", - "type": "auth", - "fields": [ - { - "autogeneratePattern": "[a-z0-9]{15}", - "help": "", - "hidden": false, - "id": "text3208210256", - "max": 15, - "min": 15, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" + { + "id": "_pb_users_auth_", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "id = @request.auth.id && @request.body.superadmin:isset = false", + "deleteRule": "id = @request.auth.id", + "name": "users", + "type": "auth", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cost": 10, + "help": "", + "hidden": true, + "id": "password901924565", + "max": 0, + "min": 8, + "name": "password", + "pattern": "", + "presentable": false, + "required": true, + "system": true, + "type": "password" + }, + { + "autogeneratePattern": "[a-zA-Z0-9_]{50}", + "help": "", + "hidden": true, + "id": "text2504183744", + "max": 60, + "min": 30, + "name": "tokenKey", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "exceptDomains": null, + "help": "", + "hidden": false, + "id": "email3885137012", + "name": "email", + "onlyDomains": null, + "presentable": false, + "required": false, + "system": true, + "type": "email" + }, + { + "help": "", + "hidden": false, + "id": "bool1547992806", + "name": "emailVisibility", + "presentable": false, + "required": false, + "system": true, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "bool256245529", + "name": "verified", + "presentable": false, + "required": false, + "system": true, + "type": "bool" + }, + { + "autogeneratePattern": "users[0-9]{6}", + "help": "", + "hidden": false, + "id": "text4166911607", + "max": 150, + "min": 3, + "name": "username", + "pattern": "^[\\w][\\w\\.\\-]*$", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1579384326", + "max": 255, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "bool_user_superadmin", + "name": "superadmin", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "file376926767", + "maxSelect": 1, + "maxSize": 5242880, + "mimeTypes": [ + "image/jpeg", + "image/png", + "image/svg+xml", + "image/gif", + "image/webp" + ], + "name": "avatar", + "presentable": false, + "protected": false, + "required": false, + "system": false, + "thumbs": null, + "type": "file" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `__pb_users_auth__username_idx` ON `users` (username COLLATE NOCASE)", + "CREATE UNIQUE INDEX `__pb_users_auth__email_idx` ON `users` (`email`) WHERE `email` != ''", + "CREATE UNIQUE INDEX `__pb_users_auth__tokenKey_idx` ON `users` (`tokenKey`)" + ], + "created": "2023-10-28 21:35:49.020Z", + "updated": "2026-08-06 15:05:38.398Z", + "system": false, + "authRule": "", + "manageRule": null, + "authAlert": { + "enabled": true, + "emailTemplate": { + "subject": "Login from a new location", + "body": "

Hello,

\n

We noticed a login to your {APP_NAME} account from a new location.

\n

If this was you, you may disregard this email.

\n

If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.

\n

\n Thanks,
\n {APP_NAME} team\n

" + } }, - { - "cost": 10, - "help": "", - "hidden": true, - "id": "password901924565", - "max": 0, - "min": 8, - "name": "password", - "pattern": "", - "presentable": false, - "required": true, - "system": true, - "type": "password" + "oauth2": { + "providers": [], + "mappedFields": { + "id": "", + "name": "", + "username": "username", + "avatarURL": "" + }, + "enabled": false }, - { - "autogeneratePattern": "[a-zA-Z0-9_]{50}", - "help": "", - "hidden": true, - "id": "text2504183744", - "max": 60, - "min": 30, - "name": "tokenKey", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": true, - "system": true, - "type": "text" + "passwordAuth": { + "enabled": true, + "identityFields": [ + "email", + "username" + ] }, - { - "exceptDomains": null, - "help": "", - "hidden": false, - "id": "email3885137012", - "name": "email", - "onlyDomains": null, - "presentable": false, - "required": false, - "system": true, - "type": "email" + "mfa": { + "enabled": false, + "duration": 1800, + "rule": "" }, - { - "help": "", - "hidden": false, - "id": "bool1547992806", - "name": "emailVisibility", - "presentable": false, - "required": false, - "system": true, - "type": "bool" + "otp": { + "enabled": false, + "duration": 180, + "length": 8, + "emailTemplate": { + "subject": "OTP for {APP_NAME}", + "body": "

Hello,

\n

Your one-time password is: {OTP}

\n

If you didn't ask for the one-time password, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" + } }, - { - "help": "", - "hidden": false, - "id": "bool256245529", - "name": "verified", - "presentable": false, - "required": false, - "system": true, - "type": "bool" + "authToken": { + "duration": 1209600 }, - { - "autogeneratePattern": "users[0-9]{6}", - "help": "", - "hidden": false, - "id": "text4166911607", - "max": 150, - "min": 3, - "name": "username", - "pattern": "^[\\w][\\w\\.\\-]*$", - "presentable": false, - "primaryKey": false, - "required": true, - "system": false, - "type": "text" + "passwordResetToken": { + "duration": 1800 }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "text1579384326", - "max": 255, - "min": 0, - "name": "name", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" + "emailChangeToken": { + "duration": 1800 }, - { - "hidden": false, - "id": "bool_user_superadmin", - "name": "superadmin", - "presentable": false, - "required": false, - "system": false, - "type": "bool" + "verificationToken": { + "duration": 604800 }, - { - "help": "", - "hidden": false, - "id": "file376926767", - "maxSelect": 1, - "maxSize": 5242880, - "mimeTypes": [ - "image/jpeg", - "image/png", - "image/svg+xml", - "image/gif", - "image/webp" - ], - "name": "avatar", - "presentable": false, - "protected": false, - "required": false, - "system": false, - "thumbs": null, - "type": "file" + "fileToken": { + "duration": 120 }, - { - "hidden": false, - "id": "autodate2990389176", - "name": "created", - "onCreate": true, - "onUpdate": false, - "presentable": false, - "system": false, - "type": "autodate" + "verificationTemplate": { + "subject": "Verify your {APP_NAME} email", + "body": "

Hello,

\n

Thank you for joining us at {APP_NAME}.

\n

Click on the button below to verify your email address.

\n

\n Verify\n

\n

\n Thanks,
\n {APP_NAME} team\n

" }, - { - "hidden": false, - "id": "autodate3332085495", - "name": "updated", - "onCreate": true, - "onUpdate": true, - "presentable": false, - "system": false, - "type": "autodate" - } - ], - "indexes": [ - "CREATE UNIQUE INDEX `__pb_users_auth__username_idx` ON `users` (username COLLATE NOCASE)", - "CREATE UNIQUE INDEX `__pb_users_auth__email_idx` ON `users` (`email`) WHERE `email` != ''", - "CREATE UNIQUE INDEX `__pb_users_auth__tokenKey_idx` ON `users` (`tokenKey`)" - ], - "created": "2023-10-28 21:35:49.020Z", - "updated": "2026-08-06 15:05:38.398Z", - "system": false, - "authRule": "", - "manageRule": null, - "authAlert": { - "enabled": true, - "emailTemplate": { - "subject": "Login from a new location", - "body": "

Hello,

\n

We noticed a login to your {APP_NAME} account from a new location.

\n

If this was you, you may disregard this email.

\n

If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.

\n

\n Thanks,
\n {APP_NAME} team\n

" + "resetPasswordTemplate": { + "subject": "Reset your {APP_NAME} password", + "body": "

Hello,

\n

Click on the button below to reset your password.

\n

\n Reset password\n

\n

If you didn't ask to reset your password, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" + }, + "confirmEmailChangeTemplate": { + "subject": "Confirm your {APP_NAME} new email address", + "body": "

Hello,

\n

Click on the button below to confirm your new email address.

\n

\n Confirm new email\n

\n

If you didn't ask to change your email address, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" } }, - "oauth2": { - "providers": [], - "mappedFields": { - "id": "", - "name": "", - "username": "username", - "avatarURL": "" - }, - "enabled": false + { + "id": "pbc_1568971955", + "listRule": "users.id ?= @request.auth.id", + "viewRule": "users.id ?= @request.auth.id", + "createRule": "@request.auth.id != \"\"", + "updateRule": "owner.id ?= @request.auth.id || admins.id ?= @request.auth.id", + "deleteRule": "owner.id ?= @request.auth.id", + "name": "teams", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1579384326", + "max": 100, + "min": 1, + "name": "name", + "pattern": "", + "presentable": true, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation3479234172", + "maxSelect": 1, + "minSelect": 1, + "name": "owner", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation2732594447", + "maxSelect": 999, + "minSelect": 0, + "name": "admins", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation344172009", + "maxSelect": 999, + "minSelect": 0, + "name": "users", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "help": "", + "hidden": false, + "id": "file_team_logo", + "maxSelect": 1, + "maxSize": 5242880, + "mimeTypes": [ + "image/jpeg", + "image/png", + "image/svg+xml", + "image/gif", + "image/webp" + ], + "name": "logo", + "presentable": false, + "protected": false, + "required": false, + "system": false, + "thumbs": null, + "type": "file" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "created": "2026-08-06 12:06:09.035Z", + "updated": "2026-08-06 12:06:09.035Z", + "system": false }, - "passwordAuth": { - "enabled": true, - "identityFields": [ - "email", - "username" - ] + { + "id": "xiqlzggqxg1e2y7", + "listRule": "team.users.id ?= @request.auth.id", + "viewRule": "team.users.id ?= @request.auth.id", + "createRule": "team.users.id ?= @request.auth.id", + "updateRule": "team.users.id ?= @request.auth.id", + "deleteRule": "team.users.id ?= @request.auth.id", + "name": "events", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "g7nkasyb", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": true, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "7n5qjiax", + "max": 0, + "min": 0, + "name": "location", + "pattern": "", + "presentable": true, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "hdp9vbeh", + "max": 0, + "min": 0, + "name": "gps", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation_event_created_by", + "maxSelect": 1, + "minSelect": 0, + "name": "created_by", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "bool_event_timing", + "name": "timing", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "select_event_participation", + "maxSelect": 1, + "name": "participation", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "offen", + "bestaetigung", + "geschlossen" + ] + }, + { + "cascadeDelete": false, + "collectionId": "pbc_event_series", + "help": "", + "hidden": false, + "id": "relation_event_series", + "maxSelect": 1, + "minSelect": 0, + "name": "series", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": true, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date_event_starts", + "max": "", + "min": "", + "name": "starts", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "date_event_ends", + "max": "", + "min": "", + "name": "ends", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1568971955", + "help": "", + "hidden": false, + "id": "relation3303056927", + "maxSelect": 1, + "minSelect": 0, + "name": "team", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + } + ], + "indexes": [], + "created": "2026-08-06 12:06:09.033Z", + "updated": "2026-08-06 12:06:09.033Z", + "system": false }, - "mfa": { - "enabled": false, - "duration": 1800, - "rule": "" + { + "id": "jo49lycyfs2sz2c", + "listRule": "team.users.id ?= @request.auth.id", + "viewRule": "team.users.id ?= @request.auth.id", + "createRule": "team.users.id ?= @request.auth.id", + "updateRule": "team.users.id ?= @request.auth.id", + "deleteRule": "team.users.id ?= @request.auth.id", + "name": "runs", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "lnfys3xe", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "xiqlzggqxg1e2y7", + "help": "", + "hidden": false, + "id": "fqqevsrf", + "maxSelect": 1, + "minSelect": 0, + "name": "event", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1568971955", + "help": "", + "hidden": false, + "id": "relation3303056927", + "maxSelect": 1, + "minSelect": 0, + "name": "team", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + } + ], + "indexes": [], + "created": "2026-08-06 12:06:09.034Z", + "updated": "2026-08-06 12:06:09.034Z", + "system": false }, - "otp": { - "enabled": false, - "duration": 180, - "length": 8, - "emailTemplate": { - "subject": "OTP for {APP_NAME}", - "body": "

Hello,

\n

Your one-time password is: {OTP}

\n

If you didn't ask for the one-time password, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" - } + { + "id": "spgs7e4anufen9u", + "listRule": "team.users.id ?= @request.auth.id", + "viewRule": "team.users.id ?= @request.auth.id", + "createRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", + "updateRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", + "deleteRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", + "name": "riders", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "hcc93nbj", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "hc38dfyc", + "maxSelect": 1, + "maxSize": 5242880, + "mimeTypes": [], + "name": "avatar", + "presentable": false, + "protected": false, + "required": false, + "system": false, + "thumbs": [], + "type": "file" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation_rider_user", + "maxSelect": 1, + "minSelect": 0, + "name": "user", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2208304744", + "max": 0, + "min": 0, + "name": "firstname", + "pattern": "", + "presentable": true, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text824489398", + "max": 0, + "min": 0, + "name": "lastname", + "pattern": "", + "presentable": true, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1568971955", + "help": "", + "hidden": false, + "id": "relation3303056927", + "maxSelect": 1, + "minSelect": 0, + "name": "team", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + } + ], + "indexes": [], + "created": "2026-08-06 12:06:09.031Z", + "updated": "2026-08-06 12:06:09.031Z", + "system": false }, - "authToken": { - "duration": 1209600 + { + "id": "iomx9mcd1ko22ma", + "listRule": "team.users.id ?= @request.auth.id", + "viewRule": "team.users.id ?= @request.auth.id", + "createRule": "team.users.id ?= @request.auth.id", + "updateRule": "team.users.id ?= @request.auth.id", + "deleteRule": "team.users.id ?= @request.auth.id", + "name": "times", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "3k55v0ld", + "maxSelect": 1, + "name": "status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "active", + "finished", + "dnf", + "dns", + "dsq" + ] + }, + { + "cascadeDelete": false, + "collectionId": "spgs7e4anufen9u", + "help": "", + "hidden": false, + "id": "r3ixummt", + "maxSelect": 1, + "minSelect": 0, + "name": "rider", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "help": "", + "hidden": false, + "id": "mfhbdhhe", + "max": "", + "min": "", + "name": "start", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "ybjm5n0k", + "maxSelect": 1, + "minSelect": 0, + "name": "startedBy", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "help": "", + "hidden": false, + "id": "nkmjfeqt", + "max": "", + "min": "", + "name": "stop", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "o2fp9hvv", + "maxSelect": 1, + "minSelect": 0, + "name": "stoppedBy", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "dky8o1vw", + "max": 0, + "min": 0, + "name": "comment", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "jo49lycyfs2sz2c", + "help": "", + "hidden": false, + "id": "ltfephwe", + "maxSelect": 1, + "minSelect": 0, + "name": "run", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "help": "", + "hidden": false, + "id": "number2728239544", + "max": null, + "min": null, + "name": "correction", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1568971955", + "help": "", + "hidden": false, + "id": "relation3303056927", + "maxSelect": 1, + "minSelect": 0, + "name": "team", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + } + ], + "indexes": [], + "created": "2026-08-06 12:06:09.032Z", + "updated": "2026-08-06 12:06:09.032Z", + "system": false }, - "passwordResetToken": { - "duration": 1800 + { + "id": "pbc_1369982065", + "listRule": "team.users.id ?= @request.auth.id", + "viewRule": "team.users.id ?= @request.auth.id", + "createRule": "team.users.id ?= @request.auth.id", + "updateRule": "stewards.id ?= @request.auth.id || team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id || (team.users.id ?= @request.auth.id && @request.body.team:isset = false && @request.body.name:isset = false && @request.body.description:isset = false && @request.body.stewards:isset = false && @request.body.event:isset = false && @request.body.current:isset = false && @request.body.created_by:isset = false && @request.body.status_by = @request.auth.id)", + "deleteRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", + "name": "trails", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": true, + "collectionId": "pbc_1568971955", + "help": "", + "hidden": false, + "id": "relation3303056927", + "maxSelect": 1, + "minSelect": 0, + "name": "team", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1579384326", + "max": 200, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "convertURLs": false, + "help": "", + "hidden": false, + "id": "editor1843675174", + "maxSize": 0, + "name": "description", + "presentable": false, + "required": false, + "system": false, + "type": "editor" + }, + { + "help": "", + "hidden": false, + "id": "select2063623452", + "maxSelect": 1, + "name": "status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "offen", + "eingeschraenkt", + "gesperrt" + ] + }, + { + "hidden": false, + "id": "date_trail_status_changed", + "max": "", + "min": "", + "name": "status_changed", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation_trail_status_by", + "maxSelect": 1, + "minSelect": 0, + "name": "status_by", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation1348713833", + "maxSelect": 0, + "minSelect": 0, + "name": "stewards", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "xiqlzggqxg1e2y7", + "help": "", + "hidden": false, + "id": "relation1001261735", + "maxSelect": 1, + "minSelect": 0, + "name": "event", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation3725765462", + "maxSelect": 1, + "minSelect": 0, + "name": "created_by", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1570473209", + "help": "", + "hidden": false, + "id": "relation3706926091", + "maxSelect": 1, + "minSelect": 0, + "name": "current", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + } + ], + "indexes": [], + "created": "2026-08-06 14:55:19.498Z", + "updated": "2026-08-06 14:55:19.503Z", + "system": false }, - "emailChangeToken": { - "duration": 1800 + { + "id": "pbc_1570473209", + "listRule": "trail.team.users.id ?= @request.auth.id", + "viewRule": "trail.team.users.id ?= @request.auth.id", + "createRule": "trail.stewards.id ?= @request.auth.id || trail.team.owner.id ?= @request.auth.id || trail.team.admins.id ?= @request.auth.id", + "updateRule": null, + "deleteRule": "trail.team.owner.id ?= @request.auth.id || trail.team.admins.id ?= @request.auth.id", + "name": "trail_versions", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": true, + "collectionId": "pbc_1369982065", + "help": "", + "hidden": false, + "id": "relation2993194383", + "maxSelect": 1, + "minSelect": 0, + "name": "trail", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "help": "", + "hidden": false, + "id": "file3275261007", + "maxSelect": 1, + "maxSize": 10485760, + "mimeTypes": [ + "application/gpx+xml", + "application/xml", + "text/xml" + ], + "name": "gpx", + "presentable": false, + "protected": false, + "required": false, + "system": false, + "thumbs": null, + "type": "file" + }, + { + "help": "", + "hidden": false, + "id": "json3664062777", + "maxSize": 5000000, + "name": "geojson", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "help": "", + "hidden": false, + "id": "json1009404545", + "maxSize": 2000000, + "name": "elevation", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "help": "", + "hidden": false, + "id": "json967432143", + "maxSize": 1000, + "name": "bounds", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "help": "", + "hidden": false, + "id": "number2109440323", + "max": null, + "min": null, + "name": "distance_m", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number4251987185", + "max": null, + "min": null, + "name": "ascent_m", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3485334036", + "max": 500, + "min": 0, + "name": "note", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation3823579430", + "maxSelect": 1, + "minSelect": 0, + "name": "uploaded_by", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "help": "", + "hidden": false, + "id": "json1889387860", + "maxSize": 2000000, + "name": "coord_distances", + "presentable": false, + "required": false, + "system": false, + "type": "json" + } + ], + "indexes": [], + "created": "2026-08-06 14:55:19.501Z", + "updated": "2026-08-06 17:20:30.496Z", + "system": false }, - "verificationToken": { - "duration": 604800 + { + "id": "pbc_3747930671", + "listRule": "team.users.id ?= @request.auth.id", + "viewRule": "team.users.id ?= @request.auth.id", + "createRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", + "updateRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", + "deleteRule": "(team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id) && @collection.trail_markers.flag ?!= id", + "name": "trail_flags", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": true, + "collectionId": "pbc_1568971955", + "help": "", + "hidden": false, + "id": "relation3303056927", + "maxSelect": 1, + "minSelect": 0, + "name": "team", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text245846248", + "max": 60, + "min": 0, + "name": "label", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1704208859", + "max": 40, + "min": 0, + "name": "icon", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1716930793", + "max": 9, + "min": 0, + "name": "color", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "select4133540203", + "maxSelect": 1, + "name": "severity", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "info", + "warnung", + "kritisch" + ] + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "created": "2026-08-06 14:55:19.508Z", + "updated": "2026-08-06 15:11:28.425Z", + "system": false }, - "fileToken": { - "duration": 120 + { + "id": "pbc_39880540", + "listRule": "team.users.id ?= @request.auth.id", + "viewRule": "team.users.id ?= @request.auth.id", + "createRule": "team.users.id ?= @request.auth.id", + "updateRule": "created_by.id ?= @request.auth.id || trail.stewards.id ?= @request.auth.id || team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", + "deleteRule": "created_by.id ?= @request.auth.id || trail.stewards.id ?= @request.auth.id || team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", + "name": "trail_markers", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": true, + "collectionId": "pbc_1369982065", + "help": "", + "hidden": false, + "id": "relation2993194383", + "maxSelect": 1, + "minSelect": 0, + "name": "trail", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": true, + "collectionId": "pbc_1568971955", + "help": "", + "hidden": false, + "id": "relation3303056927", + "maxSelect": 1, + "minSelect": 0, + "name": "team", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_3747930671", + "help": "", + "hidden": false, + "id": "relation3522489242", + "maxSelect": 1, + "minSelect": 0, + "name": "flag", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text_marker_label", + "max": 120, + "min": 0, + "name": "label", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text_marker_icon", + "max": 60, + "min": 0, + "name": "icon", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number2499937429", + "max": null, + "min": null, + "name": "lat", + "onlyInt": false, + "presentable": false, + "required": true, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "number2518964612", + "max": null, + "min": null, + "name": "lng", + "onlyInt": false, + "presentable": false, + "required": true, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3485334036", + "max": 500, + "min": 0, + "name": "note", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "bool3084178383", + "name": "resolved", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation3725765462", + "maxSelect": 1, + "minSelect": 0, + "name": "created_by", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "created": "2026-08-06 14:55:19.510Z", + "updated": "2026-08-06 14:55:19.510Z", + "system": false }, - "verificationTemplate": { - "subject": "Verify your {APP_NAME} email", - "body": "

Hello,

\n

Thank you for joining us at {APP_NAME}.

\n

Click on the button below to verify your email address.

\n

\n Verify\n

\n

\n Thanks,
\n {APP_NAME} team\n

" + { + "id": "pbc_467227137", + "listRule": "team.users.id ?= @request.auth.id", + "viewRule": "team.users.id ?= @request.auth.id", + "createRule": "team.users.id ?= @request.auth.id", + "updateRule": "created_by.id ?= @request.auth.id || trail.stewards.id ?= @request.auth.id || team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", + "deleteRule": "created_by.id ?= @request.auth.id || trail.stewards.id ?= @request.auth.id || team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", + "name": "trail_comments", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": true, + "collectionId": "pbc_1369982065", + "help": "", + "hidden": false, + "id": "relation2993194383", + "maxSelect": 1, + "minSelect": 0, + "name": "trail", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": true, + "collectionId": "pbc_1568971955", + "help": "", + "hidden": false, + "id": "relation3303056927", + "maxSelect": 1, + "minSelect": 0, + "name": "team", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text999008199", + "max": 2000, + "min": 0, + "name": "text", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation3725765462", + "maxSelect": 1, + "minSelect": 0, + "name": "created_by", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "created": "2026-08-06 14:55:19.513Z", + "updated": "2026-08-06 14:55:19.513Z", + "system": false }, - "resetPasswordTemplate": { - "subject": "Reset your {APP_NAME} password", - "body": "

Hello,

\n

Click on the button below to reset your password.

\n

\n Reset password\n

\n

If you didn't ask to reset your password, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" + { + "id": "pbc_event_participants", + "listRule": "event.team.users.id ?= @request.auth.id", + "viewRule": "event.team.users.id ?= @request.auth.id", + "createRule": "event.created_by.id ?= @request.auth.id || event.team.owner.id ?= @request.auth.id || event.team.admins.id ?= @request.auth.id || (event.team.users.id ?= @request.auth.id && rider.user.id ?= @request.auth.id && ((event.participation = \"offen\" && status = \"zugesagt\") || (event.participation = \"bestaetigung\" && status = \"angefragt\")))", + "updateRule": "event.created_by.id ?= @request.auth.id || event.team.owner.id ?= @request.auth.id || event.team.admins.id ?= @request.auth.id || (rider.user.id ?= @request.auth.id && @request.body.rider:isset = false && @request.body.event:isset = false && @request.body.number:isset = false && @request.body.status != \"zugesagt\")", + "deleteRule": "event.created_by.id ?= @request.auth.id || event.team.owner.id ?= @request.auth.id || event.team.admins.id ?= @request.auth.id", + "name": "event_participants", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": true, + "collectionId": "xiqlzggqxg1e2y7", + "help": "", + "hidden": false, + "id": "relation_participant_event", + "maxSelect": 1, + "minSelect": 0, + "name": "event", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": true, + "collectionId": "spgs7e4anufen9u", + "help": "", + "hidden": false, + "id": "relation_participant_rider", + "maxSelect": 1, + "minSelect": 0, + "name": "rider", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text_participant_number", + "max": 20, + "min": 0, + "name": "number", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "select_participant_status", + "maxSelect": 1, + "name": "status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "angefragt", + "zugesagt", + "abgesagt" + ] + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation_participant_created_by", + "maxSelect": 1, + "minSelect": 0, + "name": "created_by", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate_created", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate_updated", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false }, - "confirmEmailChangeTemplate": { - "subject": "Confirm your {APP_NAME} new email address", - "body": "

Hello,

\n

Click on the button below to confirm your new email address.

\n

\n Confirm new email\n

\n

If you didn't ask to change your email address, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" + { + "id": "pbc_event_series", + "listRule": "team.users.id ?= @request.auth.id", + "viewRule": "team.users.id ?= @request.auth.id", + "createRule": "team.users.id ?= @request.auth.id", + "updateRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id || created_by.id ?= @request.auth.id", + "deleteRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id || created_by.id ?= @request.auth.id", + "name": "event_series", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": true, + "collectionId": "pbc_1568971955", + "help": "", + "hidden": false, + "id": "relation_series_team", + "maxSelect": 1, + "minSelect": 0, + "name": "team", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text_series_name", + "max": 200, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text_series_location", + "max": 200, + "min": 0, + "name": "location", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text_series_description", + "max": 2000, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "select_series_rule", + "maxSelect": 1, + "name": "rule", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "woechentlich", + "zweiwoechentlich", + "monatlich" + ] + }, + { + "hidden": false, + "id": "date_series_starts", + "max": "", + "min": "", + "name": "starts", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "date_series_until", + "max": "", + "min": "", + "name": "until", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "bool_series_timing", + "name": "timing", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "select_series_participation", + "maxSelect": 1, + "name": "participation", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "offen", + "bestaetigung", + "geschlossen" + ] + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation_series_created_by", + "maxSelect": 1, + "minSelect": 0, + "name": "created_by", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate_created", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate_updated", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false } - }, - { - "id": "pbc_1568971955", - "listRule": "users.id ?= @request.auth.id", - "viewRule": "users.id ?= @request.auth.id", - "createRule": "@request.auth.id != \"\"", - "updateRule": "owner.id ?= @request.auth.id || admins.id ?= @request.auth.id", - "deleteRule": "owner.id ?= @request.auth.id", - "name": "teams", - "type": "base", - "fields": [ - { - "autogeneratePattern": "[a-z0-9]{15}", - "help": "", - "hidden": false, - "id": "text3208210256", - "max": 15, - "min": 15, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "text1579384326", - "max": 100, - "min": 1, - "name": "name", - "pattern": "", - "presentable": true, - "primaryKey": false, - "required": true, - "system": false, - "type": "text" - }, - { - "cascadeDelete": false, - "collectionId": "_pb_users_auth_", - "help": "", - "hidden": false, - "id": "relation3479234172", - "maxSelect": 1, - "minSelect": 1, - "name": "owner", - "presentable": false, - "required": true, - "system": false, - "type": "relation" - }, - { - "cascadeDelete": false, - "collectionId": "_pb_users_auth_", - "help": "", - "hidden": false, - "id": "relation2732594447", - "maxSelect": 999, - "minSelect": 0, - "name": "admins", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "cascadeDelete": false, - "collectionId": "_pb_users_auth_", - "help": "", - "hidden": false, - "id": "relation344172009", - "maxSelect": 999, - "minSelect": 0, - "name": "users", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "help": "", - "hidden": false, - "id": "file_team_logo", - "maxSelect": 1, - "maxSize": 5242880, - "mimeTypes": [ - "image/jpeg", - "image/png", - "image/svg+xml", - "image/gif", - "image/webp" - ], - "name": "logo", - "presentable": false, - "protected": false, - "required": false, - "system": false, - "thumbs": null, - "type": "file" - }, - { - "hidden": false, - "id": "autodate2990389176", - "name": "created", - "onCreate": true, - "onUpdate": false, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "hidden": false, - "id": "autodate3332085495", - "name": "updated", - "onCreate": true, - "onUpdate": true, - "presentable": false, - "system": false, - "type": "autodate" - } - ], - "indexes": [], - "created": "2026-08-06 12:06:09.035Z", - "updated": "2026-08-06 12:06:09.035Z", - "system": false - }, - { - "id": "xiqlzggqxg1e2y7", - "listRule": "team.users.id ?= @request.auth.id", - "viewRule": "team.users.id ?= @request.auth.id", - "createRule": "team.users.id ?= @request.auth.id", - "updateRule": "team.users.id ?= @request.auth.id", - "deleteRule": "team.users.id ?= @request.auth.id", - "name": "events", - "type": "base", - "fields": [ - { - "autogeneratePattern": "[a-z0-9]{15}", - "help": "", - "hidden": false, - "id": "text3208210256", - "max": 15, - "min": 15, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "g7nkasyb", - "max": 0, - "min": 0, - "name": "description", - "pattern": "", - "presentable": true, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "7n5qjiax", - "max": 0, - "min": 0, - "name": "location", - "pattern": "", - "presentable": true, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "hdp9vbeh", - "max": 0, - "min": 0, - "name": "gps", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "hidden": false, - "id": "autodate2990389176", - "name": "created", - "onCreate": true, - "onUpdate": false, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "hidden": false, - "id": "autodate3332085495", - "name": "updated", - "onCreate": true, - "onUpdate": true, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "text1579384326", - "max": 0, - "min": 0, - "name": "name", - "pattern": "", - "presentable": true, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "hidden": false, - "id": "date_event_starts", - "max": "", - "min": "", - "name": "starts", - "presentable": false, - "required": false, - "system": false, - "type": "date" - }, - { - "hidden": false, - "id": "date_event_ends", - "max": "", - "min": "", - "name": "ends", - "presentable": false, - "required": false, - "system": false, - "type": "date" - }, - { - "cascadeDelete": false, - "collectionId": "pbc_1568971955", - "help": "", - "hidden": false, - "id": "relation3303056927", - "maxSelect": 1, - "minSelect": 0, - "name": "team", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - } - ], - "indexes": [], - "created": "2026-08-06 12:06:09.033Z", - "updated": "2026-08-06 12:06:09.033Z", - "system": false - }, - { - "id": "jo49lycyfs2sz2c", - "listRule": "team.users.id ?= @request.auth.id", - "viewRule": "team.users.id ?= @request.auth.id", - "createRule": "team.users.id ?= @request.auth.id", - "updateRule": "team.users.id ?= @request.auth.id", - "deleteRule": "team.users.id ?= @request.auth.id", - "name": "runs", - "type": "base", - "fields": [ - { - "autogeneratePattern": "[a-z0-9]{15}", - "help": "", - "hidden": false, - "id": "text3208210256", - "max": 15, - "min": 15, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "lnfys3xe", - "max": 0, - "min": 0, - "name": "description", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "cascadeDelete": false, - "collectionId": "xiqlzggqxg1e2y7", - "help": "", - "hidden": false, - "id": "fqqevsrf", - "maxSelect": 1, - "minSelect": 0, - "name": "event", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "hidden": false, - "id": "autodate2990389176", - "name": "created", - "onCreate": true, - "onUpdate": false, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "hidden": false, - "id": "autodate3332085495", - "name": "updated", - "onCreate": true, - "onUpdate": true, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "cascadeDelete": false, - "collectionId": "pbc_1568971955", - "help": "", - "hidden": false, - "id": "relation3303056927", - "maxSelect": 1, - "minSelect": 0, - "name": "team", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - } - ], - "indexes": [], - "created": "2026-08-06 12:06:09.034Z", - "updated": "2026-08-06 12:06:09.034Z", - "system": false - }, - { - "id": "spgs7e4anufen9u", - "listRule": "team.users.id ?= @request.auth.id", - "viewRule": "team.users.id ?= @request.auth.id", - "createRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", - "updateRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", - "deleteRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", - "name": "riders", - "type": "base", - "fields": [ - { - "autogeneratePattern": "[a-z0-9]{15}", - "help": "", - "hidden": false, - "id": "text3208210256", - "max": 15, - "min": 15, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "hcc93nbj", - "max": 0, - "min": 0, - "name": "name", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "help": "", - "hidden": false, - "id": "hc38dfyc", - "maxSelect": 1, - "maxSize": 5242880, - "mimeTypes": [], - "name": "avatar", - "presentable": false, - "protected": false, - "required": false, - "system": false, - "thumbs": [], - "type": "file" - }, - { - "cascadeDelete": false, - "collectionId": "xiqlzggqxg1e2y7", - "help": "", - "hidden": false, - "id": "anbugepo", - "maxSelect": 1, - "minSelect": 0, - "name": "event", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "cascadeDelete": false, - "collectionId": "_pb_users_auth_", - "help": "", - "hidden": false, - "id": "relation_rider_user", - "maxSelect": 1, - "minSelect": 0, - "name": "user", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "hidden": false, - "id": "autodate2990389176", - "name": "created", - "onCreate": true, - "onUpdate": false, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "hidden": false, - "id": "autodate3332085495", - "name": "updated", - "onCreate": true, - "onUpdate": true, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "text2526027604", - "max": 0, - "min": 0, - "name": "number", - "pattern": "", - "presentable": true, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "text2208304744", - "max": 0, - "min": 0, - "name": "firstname", - "pattern": "", - "presentable": true, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "text824489398", - "max": 0, - "min": 0, - "name": "lastname", - "pattern": "", - "presentable": true, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "cascadeDelete": false, - "collectionId": "pbc_1568971955", - "help": "", - "hidden": false, - "id": "relation3303056927", - "maxSelect": 1, - "minSelect": 0, - "name": "team", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - } - ], - "indexes": [], - "created": "2026-08-06 12:06:09.031Z", - "updated": "2026-08-06 12:06:09.031Z", - "system": false - }, - { - "id": "iomx9mcd1ko22ma", - "listRule": "team.users.id ?= @request.auth.id", - "viewRule": "team.users.id ?= @request.auth.id", - "createRule": "team.users.id ?= @request.auth.id", - "updateRule": "team.users.id ?= @request.auth.id", - "deleteRule": "team.users.id ?= @request.auth.id", - "name": "times", - "type": "base", - "fields": [ - { - "autogeneratePattern": "[a-z0-9]{15}", - "help": "", - "hidden": false, - "id": "text3208210256", - "max": 15, - "min": 15, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" - }, - { - "help": "", - "hidden": false, - "id": "3k55v0ld", - "maxSelect": 1, - "name": "status", - "presentable": false, - "required": false, - "system": false, - "type": "select", - "values": [ - "active", - "finished", - "dnf", - "dns", - "dsq" - ] - }, - { - "cascadeDelete": false, - "collectionId": "spgs7e4anufen9u", - "help": "", - "hidden": false, - "id": "r3ixummt", - "maxSelect": 1, - "minSelect": 0, - "name": "rider", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "help": "", - "hidden": false, - "id": "mfhbdhhe", - "max": "", - "min": "", - "name": "start", - "presentable": false, - "required": false, - "system": false, - "type": "date" - }, - { - "cascadeDelete": false, - "collectionId": "_pb_users_auth_", - "help": "", - "hidden": false, - "id": "ybjm5n0k", - "maxSelect": 1, - "minSelect": 0, - "name": "startedBy", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "help": "", - "hidden": false, - "id": "nkmjfeqt", - "max": "", - "min": "", - "name": "stop", - "presentable": false, - "required": false, - "system": false, - "type": "date" - }, - { - "cascadeDelete": false, - "collectionId": "_pb_users_auth_", - "help": "", - "hidden": false, - "id": "o2fp9hvv", - "maxSelect": 1, - "minSelect": 0, - "name": "stoppedBy", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "dky8o1vw", - "max": 0, - "min": 0, - "name": "comment", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "cascadeDelete": false, - "collectionId": "jo49lycyfs2sz2c", - "help": "", - "hidden": false, - "id": "ltfephwe", - "maxSelect": 1, - "minSelect": 0, - "name": "run", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "hidden": false, - "id": "autodate2990389176", - "name": "created", - "onCreate": true, - "onUpdate": false, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "hidden": false, - "id": "autodate3332085495", - "name": "updated", - "onCreate": true, - "onUpdate": true, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "help": "", - "hidden": false, - "id": "number2728239544", - "max": null, - "min": null, - "name": "correction", - "onlyInt": false, - "presentable": false, - "required": false, - "system": false, - "type": "number" - }, - { - "cascadeDelete": false, - "collectionId": "pbc_1568971955", - "help": "", - "hidden": false, - "id": "relation3303056927", - "maxSelect": 1, - "minSelect": 0, - "name": "team", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - } - ], - "indexes": [], - "created": "2026-08-06 12:06:09.032Z", - "updated": "2026-08-06 12:06:09.032Z", - "system": false - }, - { - "id": "pbc_1369982065", - "listRule": "team.users.id ?= @request.auth.id", - "viewRule": "team.users.id ?= @request.auth.id", - "createRule": "team.users.id ?= @request.auth.id", - "updateRule": "stewards.id ?= @request.auth.id || team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", - "deleteRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", - "name": "trails", - "type": "base", - "fields": [ - { - "autogeneratePattern": "[a-z0-9]{15}", - "help": "", - "hidden": false, - "id": "text3208210256", - "max": 15, - "min": 15, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" - }, - { - "cascadeDelete": true, - "collectionId": "pbc_1568971955", - "help": "", - "hidden": false, - "id": "relation3303056927", - "maxSelect": 1, - "minSelect": 0, - "name": "team", - "presentable": false, - "required": true, - "system": false, - "type": "relation" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "text1579384326", - "max": 200, - "min": 0, - "name": "name", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": true, - "system": false, - "type": "text" - }, - { - "convertURLs": false, - "help": "", - "hidden": false, - "id": "editor1843675174", - "maxSize": 0, - "name": "description", - "presentable": false, - "required": false, - "system": false, - "type": "editor" - }, - { - "help": "", - "hidden": false, - "id": "select2063623452", - "maxSelect": 1, - "name": "status", - "presentable": false, - "required": false, - "system": false, - "type": "select", - "values": [ - "offen", - "eingeschraenkt", - "gesperrt" - ] - }, - { - "hidden": false, - "id": "date_trail_status_changed", - "max": "", - "min": "", - "name": "status_changed", - "presentable": false, - "required": false, - "system": false, - "type": "date" - }, - { - "cascadeDelete": false, - "collectionId": "_pb_users_auth_", - "help": "", - "hidden": false, - "id": "relation_trail_status_by", - "maxSelect": 1, - "minSelect": 0, - "name": "status_by", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "cascadeDelete": false, - "collectionId": "_pb_users_auth_", - "help": "", - "hidden": false, - "id": "relation1348713833", - "maxSelect": 0, - "minSelect": 0, - "name": "stewards", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "cascadeDelete": false, - "collectionId": "xiqlzggqxg1e2y7", - "help": "", - "hidden": false, - "id": "relation1001261735", - "maxSelect": 1, - "minSelect": 0, - "name": "event", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "cascadeDelete": false, - "collectionId": "_pb_users_auth_", - "help": "", - "hidden": false, - "id": "relation3725765462", - "maxSelect": 1, - "minSelect": 0, - "name": "created_by", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "hidden": false, - "id": "autodate2990389176", - "name": "created", - "onCreate": true, - "onUpdate": false, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "hidden": false, - "id": "autodate3332085495", - "name": "updated", - "onCreate": true, - "onUpdate": true, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "cascadeDelete": false, - "collectionId": "pbc_1570473209", - "help": "", - "hidden": false, - "id": "relation3706926091", - "maxSelect": 1, - "minSelect": 0, - "name": "current", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - } - ], - "indexes": [], - "created": "2026-08-06 14:55:19.498Z", - "updated": "2026-08-06 14:55:19.503Z", - "system": false - }, - { - "id": "pbc_1570473209", - "listRule": "trail.team.users.id ?= @request.auth.id", - "viewRule": "trail.team.users.id ?= @request.auth.id", - "createRule": "trail.stewards.id ?= @request.auth.id || trail.team.owner.id ?= @request.auth.id || trail.team.admins.id ?= @request.auth.id", - "updateRule": null, - "deleteRule": "trail.team.owner.id ?= @request.auth.id || trail.team.admins.id ?= @request.auth.id", - "name": "trail_versions", - "type": "base", - "fields": [ - { - "autogeneratePattern": "[a-z0-9]{15}", - "help": "", - "hidden": false, - "id": "text3208210256", - "max": 15, - "min": 15, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" - }, - { - "cascadeDelete": true, - "collectionId": "pbc_1369982065", - "help": "", - "hidden": false, - "id": "relation2993194383", - "maxSelect": 1, - "minSelect": 0, - "name": "trail", - "presentable": false, - "required": true, - "system": false, - "type": "relation" - }, - { - "help": "", - "hidden": false, - "id": "file3275261007", - "maxSelect": 1, - "maxSize": 10485760, - "mimeTypes": [ - "application/gpx+xml", - "application/xml", - "text/xml" - ], - "name": "gpx", - "presentable": false, - "protected": false, - "required": false, - "system": false, - "thumbs": null, - "type": "file" - }, - { - "help": "", - "hidden": false, - "id": "json3664062777", - "maxSize": 5000000, - "name": "geojson", - "presentable": false, - "required": false, - "system": false, - "type": "json" - }, - { - "help": "", - "hidden": false, - "id": "json1009404545", - "maxSize": 2000000, - "name": "elevation", - "presentable": false, - "required": false, - "system": false, - "type": "json" - }, - { - "help": "", - "hidden": false, - "id": "json967432143", - "maxSize": 1000, - "name": "bounds", - "presentable": false, - "required": false, - "system": false, - "type": "json" - }, - { - "help": "", - "hidden": false, - "id": "number2109440323", - "max": null, - "min": null, - "name": "distance_m", - "onlyInt": false, - "presentable": false, - "required": false, - "system": false, - "type": "number" - }, - { - "help": "", - "hidden": false, - "id": "number4251987185", - "max": null, - "min": null, - "name": "ascent_m", - "onlyInt": false, - "presentable": false, - "required": false, - "system": false, - "type": "number" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "text3485334036", - "max": 500, - "min": 0, - "name": "note", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "cascadeDelete": false, - "collectionId": "_pb_users_auth_", - "help": "", - "hidden": false, - "id": "relation3823579430", - "maxSelect": 1, - "minSelect": 0, - "name": "uploaded_by", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "hidden": false, - "id": "autodate2990389176", - "name": "created", - "onCreate": true, - "onUpdate": false, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "hidden": false, - "id": "autodate3332085495", - "name": "updated", - "onCreate": true, - "onUpdate": true, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "help": "", - "hidden": false, - "id": "json1889387860", - "maxSize": 2000000, - "name": "coord_distances", - "presentable": false, - "required": false, - "system": false, - "type": "json" - } - ], - "indexes": [], - "created": "2026-08-06 14:55:19.501Z", - "updated": "2026-08-06 17:20:30.496Z", - "system": false - }, - { - "id": "pbc_3747930671", - "listRule": "team.users.id ?= @request.auth.id", - "viewRule": "team.users.id ?= @request.auth.id", - "createRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", - "updateRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", - "deleteRule": "(team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id) && @collection.trail_markers.flag ?!= id", - "name": "trail_flags", - "type": "base", - "fields": [ - { - "autogeneratePattern": "[a-z0-9]{15}", - "help": "", - "hidden": false, - "id": "text3208210256", - "max": 15, - "min": 15, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" - }, - { - "cascadeDelete": true, - "collectionId": "pbc_1568971955", - "help": "", - "hidden": false, - "id": "relation3303056927", - "maxSelect": 1, - "minSelect": 0, - "name": "team", - "presentable": false, - "required": true, - "system": false, - "type": "relation" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "text245846248", - "max": 60, - "min": 0, - "name": "label", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": true, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "text1704208859", - "max": 40, - "min": 0, - "name": "icon", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "text1716930793", - "max": 9, - "min": 0, - "name": "color", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "help": "", - "hidden": false, - "id": "select4133540203", - "maxSelect": 1, - "name": "severity", - "presentable": false, - "required": false, - "system": false, - "type": "select", - "values": [ - "info", - "warnung", - "kritisch" - ] - }, - { - "hidden": false, - "id": "autodate2990389176", - "name": "created", - "onCreate": true, - "onUpdate": false, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "hidden": false, - "id": "autodate3332085495", - "name": "updated", - "onCreate": true, - "onUpdate": true, - "presentable": false, - "system": false, - "type": "autodate" - } - ], - "indexes": [], - "created": "2026-08-06 14:55:19.508Z", - "updated": "2026-08-06 15:11:28.425Z", - "system": false - }, - { - "id": "pbc_39880540", - "listRule": "team.users.id ?= @request.auth.id", - "viewRule": "team.users.id ?= @request.auth.id", - "createRule": "team.users.id ?= @request.auth.id", - "updateRule": "created_by.id ?= @request.auth.id || trail.stewards.id ?= @request.auth.id || team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", - "deleteRule": "created_by.id ?= @request.auth.id || trail.stewards.id ?= @request.auth.id || team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", - "name": "trail_markers", - "type": "base", - "fields": [ - { - "autogeneratePattern": "[a-z0-9]{15}", - "help": "", - "hidden": false, - "id": "text3208210256", - "max": 15, - "min": 15, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" - }, - { - "cascadeDelete": true, - "collectionId": "pbc_1369982065", - "help": "", - "hidden": false, - "id": "relation2993194383", - "maxSelect": 1, - "minSelect": 0, - "name": "trail", - "presentable": false, - "required": true, - "system": false, - "type": "relation" - }, - { - "cascadeDelete": true, - "collectionId": "pbc_1568971955", - "help": "", - "hidden": false, - "id": "relation3303056927", - "maxSelect": 1, - "minSelect": 0, - "name": "team", - "presentable": false, - "required": true, - "system": false, - "type": "relation" - }, - { - "cascadeDelete": false, - "collectionId": "pbc_3747930671", - "help": "", - "hidden": false, - "id": "relation3522489242", - "maxSelect": 1, - "minSelect": 0, - "name": "flag", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "text_marker_label", - "max": 120, - "min": 0, - "name": "label", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "text_marker_icon", - "max": 60, - "min": 0, - "name": "icon", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "help": "", - "hidden": false, - "id": "number2499937429", - "max": null, - "min": null, - "name": "lat", - "onlyInt": false, - "presentable": false, - "required": true, - "system": false, - "type": "number" - }, - { - "help": "", - "hidden": false, - "id": "number2518964612", - "max": null, - "min": null, - "name": "lng", - "onlyInt": false, - "presentable": false, - "required": true, - "system": false, - "type": "number" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "text3485334036", - "max": 500, - "min": 0, - "name": "note", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "help": "", - "hidden": false, - "id": "bool3084178383", - "name": "resolved", - "presentable": false, - "required": false, - "system": false, - "type": "bool" - }, - { - "cascadeDelete": false, - "collectionId": "_pb_users_auth_", - "help": "", - "hidden": false, - "id": "relation3725765462", - "maxSelect": 1, - "minSelect": 0, - "name": "created_by", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "hidden": false, - "id": "autodate2990389176", - "name": "created", - "onCreate": true, - "onUpdate": false, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "hidden": false, - "id": "autodate3332085495", - "name": "updated", - "onCreate": true, - "onUpdate": true, - "presentable": false, - "system": false, - "type": "autodate" - } - ], - "indexes": [], - "created": "2026-08-06 14:55:19.510Z", - "updated": "2026-08-06 14:55:19.510Z", - "system": false - }, - { - "id": "pbc_467227137", - "listRule": "team.users.id ?= @request.auth.id", - "viewRule": "team.users.id ?= @request.auth.id", - "createRule": "team.users.id ?= @request.auth.id", - "updateRule": "created_by.id ?= @request.auth.id || trail.stewards.id ?= @request.auth.id || team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", - "deleteRule": "created_by.id ?= @request.auth.id || trail.stewards.id ?= @request.auth.id || team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", - "name": "trail_comments", - "type": "base", - "fields": [ - { - "autogeneratePattern": "[a-z0-9]{15}", - "help": "", - "hidden": false, - "id": "text3208210256", - "max": 15, - "min": 15, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" - }, - { - "cascadeDelete": true, - "collectionId": "pbc_1369982065", - "help": "", - "hidden": false, - "id": "relation2993194383", - "maxSelect": 1, - "minSelect": 0, - "name": "trail", - "presentable": false, - "required": true, - "system": false, - "type": "relation" - }, - { - "cascadeDelete": true, - "collectionId": "pbc_1568971955", - "help": "", - "hidden": false, - "id": "relation3303056927", - "maxSelect": 1, - "minSelect": 0, - "name": "team", - "presentable": false, - "required": true, - "system": false, - "type": "relation" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "text999008199", - "max": 2000, - "min": 0, - "name": "text", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": true, - "system": false, - "type": "text" - }, - { - "cascadeDelete": false, - "collectionId": "_pb_users_auth_", - "help": "", - "hidden": false, - "id": "relation3725765462", - "maxSelect": 1, - "minSelect": 0, - "name": "created_by", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "hidden": false, - "id": "autodate2990389176", - "name": "created", - "onCreate": true, - "onUpdate": false, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "hidden": false, - "id": "autodate3332085495", - "name": "updated", - "onCreate": true, - "onUpdate": true, - "presentable": false, - "system": false, - "type": "autodate" - } - ], - "indexes": [], - "created": "2026-08-06 14:55:19.513Z", - "updated": "2026-08-06 14:55:19.513Z", - "system": false - } -] + ] // importCollections erwartet das Array selbst, keinen JSON-String — die // String-Variante heißt importCollectionsByMarshaledJSON. diff --git a/backend/pb_migrations/1754501100_trails_status_report.js b/backend/pb_migrations/1754501100_trails_status_report.js new file mode 100644 index 0000000..5c27c84 --- /dev/null +++ b/backend/pb_migrations/1754501100_trails_status_report.js @@ -0,0 +1,39 @@ +/// + +// Jedes Teammitglied darf den Zustand eines Trails melden. +// +// Bisher durften das nur Paten und Team-Admins. Genau die sind aber selten +// die, die vor dem umgestürzten Baum stehen. Gemeldet werden darf deshalb von +// allen — geändert werden darf dabei aber ausschließlich der Zustand. +// +// PocketBase kennt keine Rechte je Feld, also wird jedes andere Feld +// ausdrücklich ausgeschlossen. Das ist lang und muss mitwachsen, wenn ein +// Feld dazukommt; die Alternative wäre, Mitgliedern das ganze Objekt +// freizugeben. `status_by = @request.auth.id` verhindert außerdem, eine +// Meldung unter fremdem Namen abzusetzen. + +const STEWARD_OR_ADMIN = + 'stewards.id ?= @request.auth.id' + + ' || team.owner.id ?= @request.auth.id' + + ' || team.admins.id ?= @request.auth.id' + +const MEMBER_REPORTS_STATUS = + 'team.users.id ?= @request.auth.id' + + ' && @request.body.team:isset = false' + + ' && @request.body.name:isset = false' + + ' && @request.body.description:isset = false' + + ' && @request.body.stewards:isset = false' + + ' && @request.body.event:isset = false' + + ' && @request.body.current:isset = false' + + ' && @request.body.created_by:isset = false' + + ' && @request.body.status_by = @request.auth.id' + +migrate((app) => { + const trails = app.findCollectionByNameOrId('trails') + trails.updateRule = `${STEWARD_OR_ADMIN} || (${MEMBER_REPORTS_STATUS})` + app.save(trails) +}, (app) => { + const trails = app.findCollectionByNameOrId('trails') + trails.updateRule = STEWARD_OR_ADMIN + app.save(trails) +}) diff --git a/backend/pb_migrations/1754501200_users_list_for_invites.js b/backend/pb_migrations/1754501200_users_list_for_invites.js new file mode 100644 index 0000000..b6e6846 --- /dev/null +++ b/backend/pb_migrations/1754501200_users_list_for_invites.js @@ -0,0 +1,22 @@ +/// + +// Konten lassen sich wieder über ihre E-Mail-Adresse finden. +// +// Die Einladung eines bestehenden Kontos in ein Team braucht genau das: eine +// Suche. Mit `id = @request.auth.id` fand die Suche immer nur einen selbst, +// weshalb die Funktion faktisch nie funktioniert hat. +// +// Damit kann jeder Eingeloggte die Konten der Instanz auflisten. Für einen +// Verein mit eingeladenen Mitgliedern ist das vertretbar; E-Mail-Adressen +// bleiben über emailVisibility weiterhin verborgen, und anlegen kann Konten +// ohnehin nur, wer eingeloggt ist. + +migrate((app) => { + const users = app.findCollectionByNameOrId('users') + users.listRule = '@request.auth.id != ""' + app.save(users) +}, (app) => { + const users = app.findCollectionByNameOrId('users') + users.listRule = 'id = @request.auth.id' + app.save(users) +}) diff --git a/backend/pb_migrations/1754501300_event_participants.js b/backend/pb_migrations/1754501300_event_participants.js new file mode 100644 index 0000000..7961b76 --- /dev/null +++ b/backend/pb_migrations/1754501300_event_participants.js @@ -0,0 +1,146 @@ +/// + +// Teilnehmer eines Events — und damit die Auflösung der Frage, ob Fahrer zum +// Team oder zum Event gehören: zum Team. +// +// `riders` ist ab hier der Kader des Teams. Wer bei einem Event dabei ist, +// steht in `event_participants`; dort hängt auch die Startnummer, denn die +// gilt je Event und nicht je Person. Ein Fahrer kann so über Jahre an vielen +// Events teilnehmen, ohne dass er mehrfach angelegt werden muss. +// +// Die vorhandenen Daten wandern mit: Jeder Fahrer mit Event wird zu einem +// Teilnehmer samt seiner bisherigen Nummer. Danach entfallen `riders.event` +// und `riders.number`. +// +// ACHTUNG: Eine Nummer an einem Fahrer OHNE Event hat danach keinen Platz +// mehr und geht verloren. Sie war ohnehin nirgends sichtbar, weil alle +// Listen nach Event gefiltert haben. + +migrate((app) => { + const events = app.findCollectionByNameOrId('events') + const riders = app.findCollectionByNameOrId('riders') + const teams = app.findCollectionByNameOrId('teams') + + // Wer das Event angelegt hat, darf seine Teilnehmer verwalten — ebenso + // die Teamleitung. Das Feld kommt in derselben Migration dazu, damit die + // Regeln unten darauf zeigen können. + if (!events.fields.getByName('created_by')) { + events.fields.add(new Field({ + id: 'relation_event_created_by', + name: 'created_by', + type: 'relation', + collectionId: app.findCollectionByNameOrId('users').id, + cascadeDelete: false, + maxSelect: 1, + minSelect: 0, + required: false, + })) + app.save(events) + } + + let participants + try { + participants = app.findCollectionByNameOrId('event_participants') + } catch { + // Verwaltet wird die Teilnehmerliste vom Ersteller des Events und von + // der Teamleitung. Wer sich selbst einträgt, hängt am + // Teilnahmemodus — das regelt 1754501400, sobald es das Feld gibt. + const manager = + 'event.created_by.id ?= @request.auth.id' + + ' || event.team.owner.id ?= @request.auth.id' + + ' || event.team.admins.id ?= @request.auth.id' + + participants = new Collection({ + type: 'base', + name: 'event_participants', + listRule: 'event.team.users.id ?= @request.auth.id', + viewRule: 'event.team.users.id ?= @request.auth.id', + createRule: manager, + updateRule: manager, + deleteRule: manager, + fields: [ + { + name: 'event', + type: 'relation', + required: true, + maxSelect: 1, + collectionId: events.id, + cascadeDelete: true, + }, + { + name: 'rider', + type: 'relation', + required: true, + maxSelect: 1, + collectionId: riders.id, + cascadeDelete: true, + }, + { name: 'number', type: 'text', max: 20 }, + { + name: 'status', + type: 'select', + maxSelect: 1, + values: ['angefragt', 'zugesagt', 'abgesagt'], + }, + { + name: 'created_by', + type: 'relation', + maxSelect: 1, + collectionId: app.findCollectionByNameOrId('users').id, + cascadeDelete: false, + }, + { name: 'created', type: 'autodate', onCreate: true, onUpdate: false }, + { name: 'updated', type: 'autodate', onCreate: true, onUpdate: true }, + ], + }) + app.save(participants) + } + + // --- Bestand übernehmen --------------------------------------------- + const existing = app.findRecordsByFilter('riders', 'event != ""', '', 0, 0) + for (const rider of existing) { + const record = new Record(participants) + record.set('event', rider.get('event')) + record.set('rider', rider.id) + record.set('number', rider.get('number')) + record.set('status', 'zugesagt') + app.save(record) + } + + riders.fields.removeByName('event') + riders.fields.removeByName('number') + app.save(riders) +}, (app) => { + const riders = app.findCollectionByNameOrId('riders') + const events = app.findCollectionByNameOrId('events') + + riders.fields.add(new Field({ + name: 'event', + type: 'relation', + collectionId: events.id, + cascadeDelete: false, + maxSelect: 1, + })) + riders.fields.add(new Field({ name: 'number', type: 'text' })) + app.save(riders) + + // Zurück wandert nur das Offensichtliche: das erste Event je Fahrer. + const participants = app.findRecordsByFilter('event_participants', 'id != ""', '', 0, 0) + for (const p of participants) { + try { + const rider = app.findRecordById('riders', p.get('rider')) + if (!rider.get('event')) { + rider.set('event', p.get('event')) + rider.set('number', p.get('number')) + app.save(rider) + } + } catch { + // Fahrer inzwischen gelöscht — nichts zu tun. + } + } + + app.delete(app.findCollectionByNameOrId('event_participants')) + + events.fields.removeByName('created_by') + app.save(events) +}) diff --git a/backend/pb_migrations/1754501400_events_timing_participation.js b/backend/pb_migrations/1754501400_events_timing_participation.js new file mode 100644 index 0000000..34e97b2 --- /dev/null +++ b/backend/pb_migrations/1754501400_events_timing_participation.js @@ -0,0 +1,93 @@ +/// + +// Events werden zu Ausfahrten, Rennen, Stammtischen — je nach Schaltern. +// +// `timing` blendet Runs und Zeitnahme ein. Der wöchentliche Stammtisch +// braucht das nicht, das Rennen schon; ein zweites Konzept neben Events wäre +// dafür zu viel gewesen. +// +// `participation` legt fest, wie man teilnimmt: +// offen — jedes Teammitglied trägt sich selbst ein +// bestaetigung — Anmeldung möglich, Ersteller oder Team-Admin bestätigt +// geschlossen — nur Ersteller oder Team-Admin trägt ein +// +// Bestehende Events bekommen `timing = true` und `geschlossen`: Genau so +// wurden sie bisher benutzt. + +const MANAGER = + 'event.created_by.id ?= @request.auth.id' + + ' || event.team.owner.id ?= @request.auth.id' + + ' || event.team.admins.id ?= @request.auth.id' + +// Sich selbst eintragen: nur den eigenen Datensatz, nur mit dem Status, den +// der Modus vorsieht. Ohne die Statusprüfung könnte man sich im +// Bestätigungsmodus gleich selbst bestätigen. +// +// Geprüft wird auf den Feldern des entstehenden Datensatzes statt über +// @request.body: Eine Relation aus dem Request-Body heraus weiterzuverfolgen +// (@request.body.rider.user) ist nicht verlässlich. +const SELF_SIGNUP = + 'event.team.users.id ?= @request.auth.id' + + ' && rider.user.id ?= @request.auth.id' + + ' && (' + + '(event.participation = "offen" && status = "zugesagt")' + + ' || (event.participation = "bestaetigung" && status = "angefragt")' + + ')' + +// Die eigene Teilnahme darf man ändern — aber nicht in eine Bestätigung. +const SELF_UPDATE = + 'rider.user.id ?= @request.auth.id' + + ' && @request.body.rider:isset = false' + + ' && @request.body.event:isset = false' + + ' && @request.body.number:isset = false' + + ' && @request.body.status != "zugesagt"' + +migrate((app) => { + const events = app.findCollectionByNameOrId('events') + + if (!events.fields.getByName('timing')) { + events.fields.add(new Field({ + id: 'bool_event_timing', + name: 'timing', + type: 'bool', + })) + } + + if (!events.fields.getByName('participation')) { + events.fields.add(new Field({ + id: 'select_event_participation', + name: 'participation', + type: 'select', + maxSelect: 1, + values: ['offen', 'bestaetigung', 'geschlossen'], + })) + } + + app.save(events) + + for (const event of app.findRecordsByFilter('events', 'id != ""', '', 0, 0)) { + event.set('timing', true) + event.set('participation', 'geschlossen') + app.save(event) + } + + const participants = app.findCollectionByNameOrId('event_participants') + participants.createRule = `${MANAGER} || (${SELF_SIGNUP})` + participants.updateRule = `${MANAGER} || (${SELF_UPDATE})` + // Absagen heißt Status ändern, nicht löschen — sonst verschwindet die + // Historie einer Anmeldung. + participants.deleteRule = MANAGER + app.save(participants) +}, (app) => { + const events = app.findCollectionByNameOrId('events') + + events.fields.removeByName('timing') + events.fields.removeByName('participation') + app.save(events) + + const participants = app.findCollectionByNameOrId('event_participants') + participants.createRule = MANAGER + participants.updateRule = MANAGER + participants.deleteRule = MANAGER + app.save(participants) +}) diff --git a/backend/pb_migrations/1754501500_event_series.js b/backend/pb_migrations/1754501500_event_series.js new file mode 100644 index 0000000..3555b41 --- /dev/null +++ b/backend/pb_migrations/1754501500_event_series.js @@ -0,0 +1,100 @@ +/// + +// Wiederkehrende Termine — vom wöchentlichen Stammtisch bis zur monatlichen +// Feierabendrunde. +// +// Eine Serie beschreibt die Regel und erzeugt daraus echte Einzeltermine; +// sie ist keine Wiederholungsregel, aus der Termine nur gerechnet werden. +// Der Unterschied zählt, sobald jemand fragt „wer kommt am 12.?" oder +// „diese Woche fällt aus": Beides braucht einen Datensatz je Termin, den man +// absagen, verschieben und bespielen kann. +// +// Die Serie trägt dafür die Vorlage (Ort, Beschreibung, Schalter), aus der +// neue Termine entstehen. Ändert sich die Vorlage, gilt das für künftige +// Termine — bereits erzeugte bleiben, wie sie sind. + +migrate((app) => { + const teams = app.findCollectionByNameOrId('teams') + const users = app.findCollectionByNameOrId('users') + const events = app.findCollectionByNameOrId('events') + + let series + try { + series = app.findCollectionByNameOrId('event_series') + } catch { + const admin = + 'team.owner.id ?= @request.auth.id' + + ' || team.admins.id ?= @request.auth.id' + + ' || created_by.id ?= @request.auth.id' + + series = new Collection({ + type: 'base', + name: 'event_series', + listRule: 'team.users.id ?= @request.auth.id', + viewRule: 'team.users.id ?= @request.auth.id', + createRule: 'team.users.id ?= @request.auth.id', + updateRule: admin, + deleteRule: admin, + fields: [ + { + name: 'team', + type: 'relation', + required: true, + maxSelect: 1, + collectionId: teams.id, + cascadeDelete: true, + }, + { name: 'name', type: 'text', required: true, max: 200 }, + { name: 'location', type: 'text', max: 200 }, + { name: 'description', type: 'text', max: 2000 }, + { + name: 'rule', + type: 'select', + maxSelect: 1, + values: ['woechentlich', 'zweiwoechentlich', 'monatlich'], + }, + { name: 'starts', type: 'date' }, + { name: 'until', type: 'date' }, + { name: 'timing', type: 'bool' }, + { + name: 'participation', + type: 'select', + maxSelect: 1, + values: ['offen', 'bestaetigung', 'geschlossen'], + }, + { + name: 'created_by', + type: 'relation', + maxSelect: 1, + collectionId: users.id, + cascadeDelete: false, + }, + { name: 'created', type: 'autodate', onCreate: true, onUpdate: false }, + { name: 'updated', type: 'autodate', onCreate: true, onUpdate: true }, + ], + }) + app.save(series) + } + + // cascadeDelete bleibt aus: Wer eine Serie beendet, will selten alle + // gelaufenen Termine samt Zeiten und Teilnehmern mitlöschen. + if (!events.fields.getByName('series')) { + events.fields.add(new Field({ + id: 'relation_event_series', + name: 'series', + type: 'relation', + collectionId: series.id, + cascadeDelete: false, + maxSelect: 1, + minSelect: 0, + required: false, + })) + app.save(events) + } +}, (app) => { + const events = app.findCollectionByNameOrId('events') + events.fields.removeByName('series') + app.save(events) + + app.delete(app.findCollectionByNameOrId('event_series')) +}) diff --git a/frontend/src/lib/components/RunningTimesToast.svelte b/frontend/src/lib/components/RunningTimesToast.svelte index 8771cb2..331aa55 100644 --- a/frontend/src/lib/components/RunningTimesToast.svelte +++ b/frontend/src/lib/components/RunningTimesToast.svelte @@ -14,6 +14,7 @@ import { getTimeContext } from '$lib/stores/times.svelte' import { fullName, getRiderContext } from '$lib/stores/riders.svelte' import { getRunContext } from '$lib/stores/runs.svelte' + import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte' import { ticker } from '$lib/stores/ticker.svelte' import TimeDialog from './TimeDialog.svelte' import { Button } from '@/components/ui/button' @@ -22,6 +23,7 @@ const times = getTimeContext() const riders = getRiderContext() const runs = getRunContext() + const participants = getEventParticipantContext() const clock = ticker() const running = $derived(times.allRunning(clock.now)) @@ -49,10 +51,14 @@ // Nutzer. Fällt die Anzahl wieder auf eine, ist der Zustand egal. const open = $derived(single || expanded) - function riderLabel(riderId: string | undefined) { - const r = riderId ? riders.getById(riderId) : undefined - if (!r) return 'Unbekannter Fahrer' - return `#${r.number || '—'} ${fullName(r)}`.trim() + /** Die Startnummer gilt je Event, deshalb der Umweg über den Run. */ + function riderLabel(riderId: string | undefined, runId: string | undefined) { + const eventId = runId ? runs.getById(runId)?.event : undefined + if (!eventId) { + const r = riderId ? riders.getById(riderId) : undefined + return r ? fullName(r) : 'Unbekannter Fahrer' + } + return participants.riderLabel(eventId, riderId) } function runPath(runId: string | undefined) { @@ -110,7 +116,7 @@ {@const path = runPath(t.run)}
  • -

    {riderLabel(t.rider)}

    +

    {riderLabel(t.rider, t.run)}

    {#if path} {runLabel(t.run)} diff --git a/frontend/src/lib/components/TimeDialog.svelte b/frontend/src/lib/components/TimeDialog.svelte index 8733c21..ef859eb 100644 --- a/frontend/src/lib/components/TimeDialog.svelte +++ b/frontend/src/lib/components/TimeDialog.svelte @@ -9,6 +9,7 @@ import { getTimeContext, type TimeStatus } from '$lib/stores/times.svelte' import { fullName, getRiderContext } from '$lib/stores/riders.svelte' import { getRunContext } from '$lib/stores/runs.svelte' + import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte' import { ticker } from '$lib/stores/ticker.svelte' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' @@ -33,6 +34,7 @@ const times = getTimeContext() const riders = getRiderContext() const runs = getRunContext() + const participants = getEventParticipantContext() const clock = ticker() let selectedRider = $state('') @@ -52,16 +54,19 @@ confirmDnf = false }) - const eventRiders = $derived(eventId ? riders.getByEvent(eventId) : []) + // Zur Auswahl steht, wer beim Event zugesagt hat — nicht der ganze Kader. + const eventRiders = $derived( + eventId + ? participants + .confirmedByEvent(eventId) + .map((p) => riders.getById(p.rider)) + .filter((r) => !!r) + : [], + ) const eventRuns = $derived(eventId ? runs.getByEvent(eventId) : []) const riderLabel = $derived( - selectedRider - ? (() => { - const r = riders.getById(selectedRider) - return r ? `#${r.number || '—'} ${fullName(r)}`.trim() : 'Fahrer wählen…' - })() - : 'Fahrer wählen…', + selectedRider ? participants.riderLabel(eventId, selectedRider) : 'Fahrer wählen…', ) const runLabel = $derived( selectedRun ? runs.getById(selectedRun)?.description ?? 'Run wählen…' : 'Run wählen…', @@ -135,7 +140,7 @@ {#each eventRiders as r (r.id)} - #{r.number || '—'} {fullName(r)} + {participants.riderLabel(eventId, r.id)} {/each} diff --git a/frontend/src/lib/stores/eventParticipants.svelte.ts b/frontend/src/lib/stores/eventParticipants.svelte.ts new file mode 100644 index 0000000..3f4a54b --- /dev/null +++ b/frontend/src/lib/stores/eventParticipants.svelte.ts @@ -0,0 +1,184 @@ +import { getContext, setContext } from 'svelte' +import { api, auth } from './pocketbase.svelte' +import { getEventContext } from './events.svelte' +import { getRiderContext } from './riders.svelte' +import type { EventParticipantsResponse, RidersResponse } from '$lib/types' + +const KEY = Symbol('eventParticipant') + +export type ParticipantStatus = 'angefragt' | 'zugesagt' | 'abgesagt' + +export const PARTICIPANT_STATUS_LABEL: Record = { + angefragt: 'Angefragt', + zugesagt: 'Dabei', + abgesagt: 'Abgesagt', +} + +/** + * Teilnehmer eines Events. Fahrer gehören dem Team, nicht dem Event — wer bei + * einem Termin dabei ist, steht hier. Die Startnummer hängt ebenfalls hier, + * denn sie gilt je Event: Derselbe Fahrer kann in zwei Rennen zwei Nummern + * haben, und beim Stammtisch gar keine. + */ +export class EventParticipantStore { + records = $state([]) + loading = $state(false) + error = $state(null) + + private unsubscribe: (() => void) | null = null + private events = getEventContext() + private riders = getRiderContext() + + /** Nur Teilnehmer von Events des aktiven Teams. */ + get scoped(): EventParticipantsResponse[] { + const eventIds = new Set(this.events.scoped.map((e) => e.id)) + return this.records.filter((r) => eventIds.has(r.event)) + } + + /** Teilnehmer eines Events, nach Startnummer und dann nach Name. */ + byEvent(eventId: string): EventParticipantsResponse[] { + return this.records + .filter((r) => r.event === eventId) + .sort((a, b) => { + const na = parseInt(a.number ?? '', 10) + const nb = parseInt(b.number ?? '', 10) + if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb + if (Number.isFinite(na)) return -1 + if (Number.isFinite(nb)) return 1 + return this.riderName(a.rider).localeCompare(this.riderName(b.rider)) + }) + } + + /** Nur die Zugesagten — wer wirklich am Start steht. */ + confirmedByEvent(eventId: string): EventParticipantsResponse[] { + return this.byEvent(eventId).filter((r) => r.status === 'zugesagt') + } + + byRider(riderId: string): EventParticipantsResponse[] { + return this.scoped.filter((r) => r.rider === riderId) + } + + get(eventId: string, riderId: string): EventParticipantsResponse | undefined { + return this.records.find((r) => r.event === eventId && r.rider === riderId) + } + + /** Startnummer eines Fahrers bei einem Event, falls vergeben. */ + numberOf(eventId: string, riderId: string | undefined): string { + if (!riderId) return '' + return this.get(eventId, riderId)?.number ?? '' + } + + /** + * Startnummern sind je Event eindeutig — zwei gleiche Nummern in einem + * Rennen wären an der Strecke nicht auseinanderzuhalten. + */ + isNumberTaken(eventId: string, number: string, excludeId?: string): boolean { + const wanted = number.trim() + if (!wanted) return false + + return this.byEvent(eventId).some( + (r) => (r.number ?? '').trim() === wanted && r.id !== excludeId, + ) + } + + /** Teilnahme des eingeloggten Nutzers, sofern sein Konto an einem Fahrer hängt. */ + own(eventId: string): EventParticipantsResponse | undefined { + const uid = auth.user?.id + if (!uid) return undefined + + const mine = new Set(this.riders.getByUser(uid).map((r) => r.id)) + return this.byEvent(eventId).find((p) => mine.has(p.rider)) + } + + /** „#12 Max Mustermann" — ohne Nummer nur der Name. */ + riderLabel(eventId: string, riderId: string | undefined): string { + if (!riderId) return 'Unbekannter Fahrer' + + const name = this.riderName(riderId) || 'Unbekannter Fahrer' + const number = this.numberOf(eventId, riderId) + + return number ? `#${number} ${name}` : name + } + + private riderName(riderId: string): string { + const r = this.riders.getById(riderId) + if (!r) return '' + return [r.firstname, r.lastname].filter(Boolean).join(' ') || r.name || '' + } + + async load() { + this.loading = true + this.error = null + try { + this.records = await api.collection('event_participants').getFullList({ + sort: '+created', + requestKey: null, + }) + } catch (e: any) { + this.error = e.message ?? 'Fehler beim Laden der Teilnehmer' + console.error(e) + } finally { + this.loading = false + } + } + + subscribe() { + if (this.unsubscribe) return + api.collection('event_participants').subscribe('*', (e) => { + const idx = this.records.findIndex((r) => r.id === e.record.id) + if (e.action === 'create' && idx === -1) this.records = [...this.records, e.record] + else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record + else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id) + }).then((unsub) => { + this.unsubscribe = unsub + }).catch((e) => console.warn('event_participants subscribe failed:', e)) + } + + async add(data: { + event: string + rider: string + number?: string + status?: ParticipantStatus + }) { + return await api.collection('event_participants').create({ + status: 'zugesagt' as ParticipantStatus, + ...data, + created_by: auth.user?.id, + }) + } + + async edit(id: string, data: Partial) { + return await api.collection('event_participants').update(id, data) + } + + async setStatus(id: string, status: ParticipantStatus) { + return await this.edit(id, { status } as Partial) + } + + async remove(id: string) { + await api.collection('event_participants').delete(id) + } + + /** Fahrer des Teams, die bei diesem Event noch fehlen. */ + available(eventId: string): RidersResponse[] { + const taken = new Set(this.byEvent(eventId).map((r) => r.rider)) + return this.riders.scoped.filter((r) => !taken.has(r.id)) + } + + destroy() { + if (this.unsubscribe) { + this.unsubscribe() + this.unsubscribe = null + } + } +} + +export function setEventParticipantContext() { + const store = new EventParticipantStore() + setContext(KEY, store) + return store +} + +export function getEventParticipantContext(): EventParticipantStore { + return getContext(KEY) +} diff --git a/frontend/src/lib/stores/eventSeries.svelte.ts b/frontend/src/lib/stores/eventSeries.svelte.ts new file mode 100644 index 0000000..980b827 --- /dev/null +++ b/frontend/src/lib/stores/eventSeries.svelte.ts @@ -0,0 +1,183 @@ +import { getContext, setContext } from 'svelte' +import { api, auth } from './pocketbase.svelte' +import { getTeamContext } from './teams.svelte' +import { getEventContext } from './events.svelte' +import { dayOf, todayISO } from '$lib/time' +import type { EventSeriesResponse } from '$lib/types' + +const KEY = Symbol('eventSeries') + +export type SeriesRule = 'woechentlich' | 'zweiwoechentlich' | 'monatlich' + +export const SERIES_RULE_LABEL: Record = { + woechentlich: 'wöchentlich', + zweiwoechentlich: 'alle zwei Wochen', + monatlich: 'monatlich', +} + +/** Wie viele Termine ein Erzeugen-Lauf höchstens anlegt. */ +const BATCH = 12 + +/** + * Nächste Termine einer Regel ab einem Startdatum. + * + * Gerechnet wird auf Kalendertagen, nicht auf Millisekunden: Über Sommer- und + * Winterzeit hinweg verschiebt sich eine Woche sonst um eine Stunde und ein + * Termin kippt auf den Vortag. + */ +export function occurrences( + startDay: string, + rule: SeriesRule, + count: number, + untilDay?: string, +): string[] { + if (!startDay) return [] + + const [y, m, d] = startDay.split('-').map(Number) + const out: string[] = [] + let cursor = new Date(Date.UTC(y, m - 1, d)) + + for (let i = 0; i < count; i++) { + const iso = cursor.toISOString().slice(0, 10) + if (untilDay && iso > untilDay) break + out.push(iso) + + if (rule === 'monatlich') { + cursor = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, cursor.getUTCDate())) + } else { + const step = rule === 'zweiwoechentlich' ? 14 : 7 + cursor = new Date(cursor.getTime() + step * 24 * 60 * 60 * 1000) + } + } + + return out +} + +export class EventSeriesStore { + records = $state([]) + loading = $state(false) + error = $state(null) + + private unsubscribe: (() => void) | null = null + private teams = getTeamContext() + private events = getEventContext() + + get scoped(): EventSeriesResponse[] { + const teamId = this.teams.activeId + if (!teamId) return [] + return this.records.filter((r) => r.team === teamId) + } + + getById(id: string) { + return this.records.find((r) => r.id === id) + } + + /** Termine, die aus dieser Serie entstanden sind. */ + eventsOf(seriesId: string) { + return this.events.scoped.filter((e) => e.series === seriesId) + } + + async load() { + this.loading = true + this.error = null + try { + this.records = await api.collection('event_series').getFullList({ + sort: '+name', + requestKey: null, + }) + } catch (e: any) { + this.error = e.message ?? 'Fehler beim Laden der Serien' + console.error(e) + } finally { + this.loading = false + } + } + + subscribe() { + if (this.unsubscribe) return + api.collection('event_series').subscribe('*', (e) => { + const idx = this.records.findIndex((r) => r.id === e.record.id) + if (e.action === 'create' && idx === -1) this.records = [...this.records, e.record] + else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record + else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id) + }).then((unsub) => { + this.unsubscribe = unsub + }).catch((e) => console.warn('event_series subscribe failed:', e)) + } + + async create(data: Partial) { + const teamId = this.teams.activeId + if (!teamId) throw new Error('Kein aktives Team') + + return await api.collection('event_series').create({ + ...data, + team: teamId, + created_by: auth.user?.id, + }) + } + + async edit(id: string, data: Partial) { + return await api.collection('event_series').update(id, data) + } + + async remove(id: string) { + await api.collection('event_series').delete(id) + } + + /** + * Legt die nächsten fälligen Termine an. Vorhandene bleiben unberührt — + * erzeugt wird nur, was fehlt, damit ein zweiter Klick nichts verdoppelt. + * Zurück kommt die Zahl der neuen Termine. + */ + async generate(seriesId: string): Promise { + const series = this.getById(seriesId) + if (!series) throw new Error('Serie nicht gefunden') + if (!series.starts) throw new Error('Die Serie hat kein Startdatum.') + + const existing = new Set(this.eventsOf(seriesId).map((e) => dayOf(e.starts))) + const today = todayISO() + + // Vergangene Termine nachträglich anzulegen hilft niemandem. + const dates = occurrences( + dayOf(series.starts), + (series.rule || 'woechentlich') as SeriesRule, + BATCH * 4, + dayOf(series.until) || undefined, + ).filter((d) => d >= today && !existing.has(d)) + + let created = 0 + for (const day of dates.slice(0, BATCH)) { + await api.collection('events').create({ + name: series.name, + description: series.description, + location: series.location, + starts: day, + timing: !!series.timing, + participation: series.participation || 'offen', + series: series.id, + team: series.team, + created_by: auth.user?.id, + }) + created++ + } + + return created + } + + destroy() { + if (this.unsubscribe) { + this.unsubscribe() + this.unsubscribe = null + } + } +} + +export function setEventSeriesContext() { + const store = new EventSeriesStore() + setContext(KEY, store) + return store +} + +export function getEventSeriesContext(): EventSeriesStore { + return getContext(KEY) +} diff --git a/frontend/src/lib/stores/eventSeries.test.ts b/frontend/src/lib/stores/eventSeries.test.ts new file mode 100644 index 0000000..27d0d46 --- /dev/null +++ b/frontend/src/lib/stores/eventSeries.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest' +import { occurrences } from './eventSeries.svelte' + +describe('occurrences', () => { + it('zählt wöchentlich weiter', () => { + expect(occurrences('2026-09-02', 'woechentlich', 3)).toEqual([ + '2026-09-02', + '2026-09-09', + '2026-09-16', + ]) + }) + + it('zählt zweiwöchentlich weiter', () => { + expect(occurrences('2026-09-02', 'zweiwoechentlich', 3)).toEqual([ + '2026-09-02', + '2026-09-16', + '2026-09-30', + ]) + }) + + it('zählt monatlich weiter', () => { + expect(occurrences('2026-01-15', 'monatlich', 3)).toEqual([ + '2026-01-15', + '2026-02-15', + '2026-03-15', + ]) + }) + + it('bleibt über die Zeitumstellung hinweg auf demselben Wochentag', () => { + // Ende Oktober 2026 wird in Europa die Uhr zurückgestellt; über + // Millisekunden gerechnet kippte der Termin sonst auf den Vortag. + const days = occurrences('2026-10-21', 'woechentlich', 3) + expect(days).toEqual(['2026-10-21', '2026-10-28', '2026-11-04']) + + const weekdays = days.map((d) => new Date(`${d}T12:00:00Z`).getUTCDay()) + expect(new Set(weekdays).size).toBe(1) + }) + + it('hört beim Enddatum auf', () => { + expect(occurrences('2026-09-02', 'woechentlich', 10, '2026-09-20')).toEqual([ + '2026-09-02', + '2026-09-09', + '2026-09-16', + ]) + }) + + it('liefert nichts ohne Startdatum', () => { + expect(occurrences('', 'woechentlich', 5)).toEqual([]) + }) +}) diff --git a/frontend/src/lib/stores/events.svelte.ts b/frontend/src/lib/stores/events.svelte.ts index 554c340..8633e2c 100644 --- a/frontend/src/lib/stores/events.svelte.ts +++ b/frontend/src/lib/stores/events.svelte.ts @@ -7,6 +7,13 @@ import type { EventsResponse } from '$lib/types' const KEY = Symbol('event') +/** Wie man zu einem Event dazukommt. */ +export const PARTICIPATION_LABEL: Record = { + offen: 'Offen', + bestaetigung: 'Mit Bestätigung', + geschlossen: 'Geschlossen', +} + export class EventStore { records = $state([]) loading = $state(false) diff --git a/frontend/src/lib/stores/riders.svelte.ts b/frontend/src/lib/stores/riders.svelte.ts index fed8b33..654e6bc 100644 --- a/frontend/src/lib/stores/riders.svelte.ts +++ b/frontend/src/lib/stores/riders.svelte.ts @@ -6,12 +6,12 @@ import type { RidersResponse, UsersResponse } from '$lib/types' const KEY = Symbol('rider') -function sortByNumber(records: RidersResponse[]) { - return [...records].sort((a, b) => { - const na = parseInt(a.number ?? '0', 10) - const nb = parseInt(b.number ?? '0', 10) - return na - nb - }) +/** + * Der Kader wird nach Namen sortiert. Nach Startnummern ginge es nicht mehr: + * Die gelten seit 1754501300 je Event und stehen am Teilnehmer. + */ +function sortByName(records: RidersResponse[]) { + return [...records].sort((a, b) => fullName(a).localeCompare(fullName(b))) } export function fullName(r: RidersResponse) { @@ -48,7 +48,7 @@ export class RiderStore { this.error = null try { const raw = await api.collection('riders').getFullList({ requestKey: null }) - this.records = sortByNumber(raw) + this.records = sortByName(raw) } catch (e: any) { this.error = e.message ?? 'Fehler beim Laden der Fahrer' console.error(e) @@ -62,10 +62,10 @@ export class RiderStore { api.collection('riders').subscribe('*', (e) => { const idx = this.records.findIndex((r) => r.id === e.record.id) if (e.action === 'create' && idx === -1) { - this.records = sortByNumber([...this.records, e.record]) + this.records = sortByName([...this.records, e.record]) } else if (e.action === 'update' && idx !== -1) { this.records[idx] = e.record - this.records = sortByNumber(this.records) + this.records = sortByName(this.records) } else if (e.action === 'delete' && idx !== -1) { this.records = this.records.filter((r) => r.id !== e.record.id) } @@ -88,7 +88,7 @@ export class RiderStore { return new Promise((resolve, reject) => { app.confirm.request({ title: 'Fahrer löschen?', - text: `Möchten Sie „#${record.number} ${fullName(record)}" wirklich löschen? Alle zugehörigen Zeiten werden ebenfalls gelöscht.`, + text: `Möchten Sie „${fullName(record)}" wirklich löschen? Alle Teilnahmen und Zeiten dieses Fahrers werden ebenfalls gelöscht.`, yes: async () => { try { await api.collection('riders').delete(record.id) @@ -139,22 +139,11 @@ export class RiderStore { return user } - getByEvent(eventId: string) { - return this.scoped.filter((r) => r.event === eventId) - } - - isNumberTaken(number: string, eventId: string, excludeId?: string) { - return this.scoped.some( - (r) => r.number === number && r.event === eventId && r.id !== excludeId, - ) - } - get filtered() { const q = this.searchQuery.toLowerCase().trim() if (!q) return this.scoped return this.scoped.filter((r) => { return ( - (r.number ?? '').toLowerCase().includes(q) || (r.firstname ?? '').toLowerCase().includes(q) || (r.lastname ?? '').toLowerCase().includes(q) || (r.name ?? '').toLowerCase().includes(q) diff --git a/frontend/src/lib/types.d.ts b/frontend/src/lib/types.d.ts index 6e488be..ee302d0 100644 --- a/frontend/src/lib/types.d.ts +++ b/frontend/src/lib/types.d.ts @@ -6,6 +6,8 @@ import type PocketBase from 'pocketbase' import type { RecordService } from 'pocketbase' export enum Collections { + EventParticipants = "event_participants", + EventSeries = "event_series", Events = "events", Riders = "riders", Runs = "runs", @@ -46,28 +48,78 @@ export type AuthSystemFields = { // Record types for each collection +export enum EventParticipantsStatusOptions { + "angefragt" = "angefragt", + "zugesagt" = "zugesagt", + "abgesagt" = "abgesagt", +} +export type EventParticipantsRecord = { + created?: IsoDateString + created_by?: RecordIdString + event: RecordIdString + id: string + number?: string + rider: RecordIdString + status?: EventParticipantsStatusOptions + updated?: IsoDateString +} + +export enum EventSeriesRuleOptions { + "woechentlich" = "woechentlich", + "zweiwoechentlich" = "zweiwoechentlich", + "monatlich" = "monatlich", +} + +export enum EventSeriesParticipationOptions { + "offen" = "offen", + "bestaetigung" = "bestaetigung", + "geschlossen" = "geschlossen", +} +export type EventSeriesRecord = { + created?: IsoDateString + created_by?: RecordIdString + description?: string + id: string + location?: string + name: string + participation?: EventSeriesParticipationOptions + rule?: EventSeriesRuleOptions + starts?: IsoDateString + team: RecordIdString + timing?: boolean + until?: IsoDateString + updated?: IsoDateString +} + +export enum EventsParticipationOptions { + "offen" = "offen", + "bestaetigung" = "bestaetigung", + "geschlossen" = "geschlossen", +} export type EventsRecord = { created?: IsoDateString + created_by?: RecordIdString description?: string ends?: IsoDateString gps?: string id: string location?: string name?: string + participation?: EventsParticipationOptions + series?: RecordIdString starts?: IsoDateString team?: RecordIdString + timing?: boolean updated?: IsoDateString } export type RidersRecord = { avatar?: string created?: IsoDateString - event?: RecordIdString firstname?: string id: string lastname?: string name?: string - number?: string team?: RecordIdString updated?: IsoDateString user?: RecordIdString @@ -211,6 +263,8 @@ export type UsersRecord = { } // Response types include system fields and match responses from the PocketBase API +export type EventParticipantsResponse = Required & BaseSystemFields +export type EventSeriesResponse = Required & BaseSystemFields export type EventsResponse = Required & BaseSystemFields export type RidersResponse = Required & BaseSystemFields export type RunsResponse = Required & BaseSystemFields @@ -226,6 +280,8 @@ export type UsersResponse = Required & AuthSyste // Types containing all Records and Responses, useful for creating typing helper functions export type CollectionRecords = { + event_participants: EventParticipantsRecord + event_series: EventSeriesRecord events: EventsRecord riders: RidersRecord runs: RunsRecord @@ -240,6 +296,8 @@ export type CollectionRecords = { } export type CollectionResponses = { + event_participants: EventParticipantsResponse + event_series: EventSeriesResponse events: EventsResponse riders: RidersResponse runs: RunsResponse @@ -257,6 +315,8 @@ export type CollectionResponses = { // https://github.com/pocketbase/js-sdk#specify-typescript-definitions export type TypedPocketBase = PocketBase & { + collection(idOrName: 'event_participants'): RecordService + collection(idOrName: 'event_series'): RecordService collection(idOrName: 'events'): RecordService collection(idOrName: 'riders'): RecordService collection(idOrName: 'runs'): RecordService diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 121c58d..a451e9d 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -7,6 +7,8 @@ import { setRunContext } from '$lib/stores/runs.svelte' import { setRiderContext } from '$lib/stores/riders.svelte' import { setTimeContext } from '$lib/stores/times.svelte' + import { setEventParticipantContext } from '$lib/stores/eventParticipants.svelte' + import { setEventSeriesContext } from '$lib/stores/eventSeries.svelte' import { setTrailContext } from '$lib/stores/trails.svelte' import { setTrailVersionContext } from '$lib/stores/trailVersions.svelte' import { setTrailFlagContext } from '$lib/stores/trailFlags.svelte' @@ -31,6 +33,9 @@ const runs = setRunContext() const riders = setRiderContext() const times = setTimeContext() + // Nach events und riders: Der Store greift im Constructor auf beide zu. + const participants = setEventParticipantContext() + const series = setEventSeriesContext() const trails = setTrailContext() const trailVersions = setTrailVersionContext() const trailFlags = setTrailFlagContext() @@ -48,7 +53,7 @@ await teams.load() teams.subscribe() await Promise.all([ - events.load(), runs.load(), riders.load(), times.load(), + events.load(), runs.load(), riders.load(), times.load(), participants.load(), series.load(), trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(), trailComments.load(), ]) @@ -56,6 +61,8 @@ runs.subscribe() riders.subscribe() times.subscribe() + participants.subscribe() + series.subscribe() trails.subscribe() trailVersions.subscribe() trailFlags.subscribe() @@ -69,6 +76,8 @@ runs.destroy() riders.destroy() times.destroy() + participants.destroy() + series.destroy() trails.destroy() trailVersions.destroy() trailFlags.destroy() diff --git a/frontend/src/routes/dashboard/events/+page.svelte b/frontend/src/routes/dashboard/events/+page.svelte index ded6452..09d0d58 100644 --- a/frontend/src/routes/dashboard/events/+page.svelte +++ b/frontend/src/routes/dashboard/events/+page.svelte @@ -1,28 +1,56 @@ @@ -69,7 +64,7 @@

    - +
    {#if riders.scoped.length === 0} @@ -96,20 +91,16 @@ - # Name - Event + Events Zeiten Konto {#each roster as r (r.id)} - {@const path = riderPath(r)} + {@const path = riderPath(r.id)} - - {r.number || '—'} - {#if path} {fullName(r) || '— ohne Name —'} @@ -118,7 +109,7 @@ {/if} - {eventName(r.event) ?? '—'} + {participants.byRider(r.id).length} {times.getByRider(r.id).length} diff --git a/frontend/src/routes/dashboard/settings/riders/+page.svelte b/frontend/src/routes/dashboard/settings/riders/+page.svelte index 10fa7d3..8abe386 100644 --- a/frontend/src/routes/dashboard/settings/riders/+page.svelte +++ b/frontend/src/routes/dashboard/settings/riders/+page.svelte @@ -6,7 +6,7 @@ */ import { getTeamContext } from '$lib/stores/teams.svelte' import { fullName, getRiderContext } from '$lib/stores/riders.svelte' - import { getEventContext } from '$lib/stores/events.svelte' + import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' @@ -20,7 +20,7 @@ const teams = getTeamContext() const riders = getRiderContext() - const events = getEventContext() + const participants = getEventParticipantContext() const canManage = $derived(riders.canManage) const roster = $derived(riders.scoped) @@ -29,20 +29,15 @@ teams.loadMembers() }) - function eventName(id: string | undefined) { - if (!id) return null - return events.getById(id)?.name ?? 'Unbekanntes Event' - } - // --- Fahrer anlegen und ändern --------------------------------------- let riderDialog = $state(false) let editing = $state(null) - let form = $state({ number: '', firstname: '', lastname: '', event: '' }) + let form = $state({ firstname: '', lastname: '' }) let riderError = $state(null) function openCreate() { editing = null - form = { number: '', firstname: '', lastname: '', event: '' } + form = { firstname: '', lastname: '' } riderError = null riderDialog = true } @@ -50,34 +45,24 @@ function openEdit(r: RidersResponse) { editing = r form = { - number: r.number ?? '', firstname: r.firstname ?? '', lastname: r.lastname ?? '', - event: r.event ?? '', } riderError = null riderDialog = true } async function saveRider() { - if (!form.number.trim()) { - riderError = 'Die Startnummer ist Pflicht.' - return - } - // Startnummern sind je Event eindeutig; Fahrer ohne Event teilen sich - // den gemeinsamen Topf des Teams. - if (riders.isNumberTaken(form.number.trim(), form.event, editing?.id)) { - riderError = `Startnummer ${form.number} ist dort bereits vergeben.` + if (!form.firstname.trim() && !form.lastname.trim()) { + riderError = 'Bitte wenigstens einen Namen angeben.' return } riderError = null try { const data = { - number: form.number.trim(), firstname: form.firstname.trim(), lastname: form.lastname.trim(), - event: form.event, } if (editing) await riders.edit(editing.id, data) else await riders.create(data) @@ -172,8 +157,9 @@

    - Fahrer des Teams. Ein verknüpftes Konto erlaubt der Person, sich selbst - anzumelden. + Der Kader des Teams. Wer bei einem Event dabei ist und mit welcher + Startnummer, steht am Event selbst. Ein verknüpftes Konto erlaubt der + Person, sich anzumelden.

    + +
    +
    -
    - - -
    -
    - - -
    -
    - - -

    - Gib es der Person weiter; sie kann es unter „Profil" ändern. -

    -
    + {#if memberMode === 'suchen'} +
    + +
    + e.key === 'Enter' && searchMember()} + /> + +
    +
    + + {#if found} +
    + +
    +

    {found.name || found.email}

    +

    Konto gefunden

    +
    +
    + {/if} + {:else} +
    + + +
    +
    + + +
    +
    + + +

    + Gib es der Person weiter; sie kann es unter „Profil" ändern. +

    +
    + {/if} + {#if memberError}

    {memberError}

    {/if}
    + - + {#if memberMode === 'suchen'} + + {:else} + + {/if}