stammtisch-hersbruck/backend/pb_migrations/1754501300_event_participants.js
Daniel Michelberger fae50d3209 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
2026-09-02 16:03:17 +02:00

146 lines
5.4 KiB
JavaScript

/// <reference path="../pb_data/types.d.ts" />
// 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)
})