feat(schema): Fahrerkonten, Event-Daten und Herkunft des Trail-Status

Drei Migrationen, dazu der Snapshot in 1754400000_init_schema.js, aus dem
die TypeScript-Typen erzeugt werden - beide muessen zusammenpassen, sonst
laufen Schema und Typen auseinander.

riders.user verknuepft einen Fahrer mit einem Login, ohne cascadeDelete:
Ein geloeschtes Konto darf keine Zeiten mitreissen. Anlegen, Aendern und
Loeschen von Fahrern wird zur Sache der Teamleitung; bisher durfte das
jedes Teammitglied.

users.viewRule von "nur ich selbst" auf "eingeloggt" - ohne das laesst sich
zu einer bekannten ID kein Name anzeigen, was Mitgliederliste,
Fahrerkonten und den Melder eines Trail-Markers betrifft. Die listRule
bleibt eng, E-Mails bleiben ueber emailVisibility verborgen. users.createRule
erlaubt eingeloggten Nutzern das Anlegen von Konten, nicht der ganzen Welt.

events verliert status und bekommt starts/ends. Der Status war Handarbeit:
Wer vergass, ein Event abzuschliessen, hatte eine falsche Uebersicht. Ein
Datum weiss von selbst, was ansteht, laeuft und vorbei ist. ACHTUNG: Die
Migration entfernt das Feld samt seiner Werte.

trails bekommt status_changed und status_by. Einer Sperrung soll man
ansehen, ob sie von heute Morgen oder vom letzten Herbst ist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
This commit is contained in:
Daniel Michelberger 2026-09-02 12:55:47 +02:00
parent cc55bd66a5
commit 1cd708e0ff
5 changed files with 235 additions and 21 deletions

View file

@ -12,8 +12,8 @@ migrate((app) => {
{
"id": "_pb_users_auth_",
"listRule": "id = @request.auth.id",
"viewRule": "id = @request.auth.id",
"createRule": null,
"viewRule": "@request.auth.id != \"\"",
"createRule": "@request.auth.id != \"\"",
"updateRule": "id = @request.auth.id",
"deleteRule": "id = @request.auth.id",
"name": "users",
@ -457,20 +457,26 @@ migrate((app) => {
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "select2063623452",
"maxSelect": 1,
"name": "status",
"id": "date_event_starts",
"max": "",
"min": "",
"name": "starts",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"draft",
"active",
"finished"
]
"type": "date"
},
{
"hidden": false,
"id": "date_event_ends",
"max": "",
"min": "",
"name": "ends",
"presentable": false,
"required": false,
"system": false,
"type": "date"
},
{
"cascadeDelete": false,
@ -590,9 +596,9 @@ migrate((app) => {
"id": "spgs7e4anufen9u",
"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",
"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": [
@ -655,6 +661,20 @@ migrate((app) => {
"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",
@ -1013,6 +1033,31 @@ migrate((app) => {
"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_",

View file

@ -0,0 +1,73 @@
/// <reference path="../pb_data/types.d.ts" />
// Fahrer bekommen ein optionales Benutzerkonto, und die Fahrerverwaltung wird
// zur Sache der Teamleitung.
//
// 1) riders.user — Verknüpfung eines Fahrers mit einem Login. Optional, weil
// die meisten Fahrer nur in Ergebnislisten auftauchen und sich nie
// anmelden. cascadeDelete ist bewusst aus: Wird ein Konto gelöscht, soll
// der Fahrer samt seiner Zeiten erhalten bleiben.
//
// 2) riders create/update/delete nur noch für Owner und Admins. Bisher durfte
// jedes Teammitglied Fahrer anlegen, ändern und löschen — mit den Zeiten
// daran ist das zu viel Vertrauen in jeden Mitfahrer. Lesen bleibt für das
// ganze Team offen.
//
// 3) users.viewRule von "nur ich selbst" auf "eingeloggt" gelockert. Ohne das
// lässt sich zu einer bekannten User-ID kein Name anzeigen — betrifft die
// Mitgliederliste eines Teams, den Melder eines Trail-Markers und die
// Kontoverknüpfung eines Fahrers. Die listRule bleibt eng: Wer keine ID
// kennt, kann auch keine Konten durchblättern. E-Mail-Adressen bleiben
// über emailVisibility verborgen.
//
// 4) users.createRule von "niemand" auf "eingeloggt". Damit kann ein Admin
// einem Fahrer direkt ein Login anlegen. Bewusst nicht offen (""), sonst
// wäre es eine öffentliche Registrierung.
const RIDERS_ADMIN_RULE =
'team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id'
const RIDERS_MEMBER_RULE = 'team.users.id ?= @request.auth.id'
const LOGGED_IN = '@request.auth.id != ""'
migrate((app) => {
const users = app.findCollectionByNameOrId('users')
const riders = app.findCollectionByNameOrId('riders')
// Der Snapshot in 1754400000_init_schema.js führt das Feld inzwischen
// mit. Auf einer frischen Datenbank ist es also schon da, auf einer
// bestehenden noch nicht — dieselbe Lage wie bei den trail-Collections.
if (!riders.fields.getByName('user')) {
riders.fields.add(new Field({
id: 'relation_rider_user',
name: 'user',
type: 'relation',
collectionId: users.id,
cascadeDelete: false,
maxSelect: 1,
minSelect: 0,
required: false,
}))
}
riders.createRule = RIDERS_ADMIN_RULE
riders.updateRule = RIDERS_ADMIN_RULE
riders.deleteRule = RIDERS_ADMIN_RULE
app.save(riders)
users.viewRule = LOGGED_IN
users.createRule = LOGGED_IN
app.save(users)
}, (app) => {
const users = app.findCollectionByNameOrId('users')
const riders = app.findCollectionByNameOrId('riders')
riders.fields.removeByName('user')
riders.createRule = RIDERS_MEMBER_RULE
riders.updateRule = RIDERS_MEMBER_RULE
riders.deleteRule = RIDERS_MEMBER_RULE
app.save(riders)
users.viewRule = 'id = @request.auth.id'
users.createRule = null
app.save(users)
})

View file

@ -0,0 +1,52 @@
/// <reference path="../pb_data/types.d.ts" />
// Events werden datiert statt bewertet.
//
// Der Status (draft/active/finished) war Handarbeit: Jemand musste ein Event
// aktiv schalten und hinterher wieder abschließen, sonst stand die Übersicht
// falsch da. Ein Datum weiß von selbst, was ansteht, was läuft und was vorbei
// ist.
//
// starts trägt den Tag; ends bleibt leer, wenn das Event nur einen Tag dauert,
// und trägt sonst den letzten Tag. Beide sind optional, damit bestehende
// Events ohne Datum weiterleben, bis jemand eines nachträgt.
migrate((app) => {
const events = app.findCollectionByNameOrId('events')
// Auf einer frischen Datenbank hat der Snapshot die Felder bereits.
if (!events.fields.getByName('starts')) {
events.fields.add(new Field({
id: 'date_event_starts',
name: 'starts',
type: 'date',
}))
}
if (!events.fields.getByName('ends')) {
events.fields.add(new Field({
id: 'date_event_ends',
name: 'ends',
type: 'date',
}))
}
if (events.fields.getByName('status')) {
events.fields.removeByName('status')
}
app.save(events)
}, (app) => {
const events = app.findCollectionByNameOrId('events')
events.fields.removeByName('starts')
events.fields.removeByName('ends')
events.fields.add(new Field({
id: 'select_event_status',
name: 'status',
type: 'select',
maxSelect: 1,
values: ['draft', 'active', 'finished'],
}))
app.save(events)
})

View file

@ -0,0 +1,45 @@
/// <reference path="../pb_data/types.d.ts" />
// Wer hat den Trail gesperrt, und wann?
//
// Der Status allein sagt nur, wie es gerade steht. Für eine Meldung, der man
// trauen soll, gehört dazu, wann sie kam und von wem — eine Sperrung von
// heute Morgen wiegt anders als eine vom letzten Herbst.
//
// Beide Felder schreibt die Anwendung beim Setzen des Status mit; bestehende
// Trails bleiben ohne Angabe, bis der Status das nächste Mal geändert wird.
migrate((app) => {
const trails = app.findCollectionByNameOrId('trails')
const users = app.findCollectionByNameOrId('users')
if (!trails.fields.getByName('status_changed')) {
trails.fields.add(new Field({
id: 'date_trail_status_changed',
name: 'status_changed',
type: 'date',
}))
}
if (!trails.fields.getByName('status_by')) {
trails.fields.add(new Field({
id: 'relation_trail_status_by',
name: 'status_by',
type: 'relation',
collectionId: users.id,
cascadeDelete: false,
maxSelect: 1,
minSelect: 0,
required: false,
}))
}
app.save(trails)
}, (app) => {
const trails = app.findCollectionByNameOrId('trails')
trails.fields.removeByName('status_changed')
trails.fields.removeByName('status_by')
app.save(trails)
})

View file

@ -46,19 +46,15 @@ export type AuthSystemFields<T = unknown> = {
// Record types for each collection
export enum EventsStatusOptions {
"draft" = "draft",
"active" = "active",
"finished" = "finished",
}
export type EventsRecord = {
created?: IsoDateString
description?: string
ends?: IsoDateString
gps?: string
id: string
location?: string
name?: string
status?: EventsStatusOptions
starts?: IsoDateString
team?: RecordIdString
updated?: IsoDateString
}
@ -74,6 +70,7 @@ export type RidersRecord = {
number?: string
team?: RecordIdString
updated?: IsoDateString
user?: RecordIdString
}
export type RunsRecord = {
@ -188,6 +185,8 @@ export type TrailsRecord = {
id: string
name: string
status?: TrailsStatusOptions
status_by?: RecordIdString
status_changed?: IsoDateString
stewards?: RecordIdString[]
team: RecordIdString
updated?: IsoDateString