diff --git a/backend/pb_migrations/1754400000_init_schema.js b/backend/pb_migrations/1754400000_init_schema.js index b0da36c..efe36c8 100644 --- a/backend/pb_migrations/1754400000_init_schema.js +++ b/backend/pb_migrations/1754400000_init_schema.js @@ -709,7 +709,7 @@ migrate((app) => { "min": 0, "name": "name", "pattern": "", - "presentable": false, + "presentable": true, "primaryKey": false, "required": false, "system": false, @@ -764,36 +764,6 @@ migrate((app) => { "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", diff --git a/backend/pb_migrations/1754501800_riders_single_name.js b/backend/pb_migrations/1754501800_riders_single_name.js new file mode 100644 index 0000000..20a0082 --- /dev/null +++ b/backend/pb_migrations/1754501800_riders_single_name.js @@ -0,0 +1,87 @@ +/// + +// Ein Fahrer hat einen Namen, nicht zwei. +// +// Vorname und Nachname getrennt zu führen versprach eine Ordnung, die es hier +// nie gab: sortiert wird über den ganzen Namen, angezeigt wird der ganze Name, +// gesucht wird über beides zugleich. Dafür musste jede Maske zwei Felder +// anbieten und jede Anzeige sie wieder zusammensetzen — und wer schlicht +// „Schorsch" heißt, stand vor der Frage, welches der beiden Felder das ist. +// +// Das Feld `name` gibt es an riders längst; es lag nur brach, weil die +// Anwendung firstname und lastname benutzte. Es bleibt deshalb, wie es ist, +// und bekommt nur den Inhalt der beiden anderen — kein neues Feld, keine +// zweite Spalte, nichts, was auf bestehenden Instanzen erst entstehen müsste. +// +// Beim Zusammenlegen geht nichts verloren: firstname und lastname werden mit +// einem Leerzeichen verbunden. Zurück geht es nur ungenau — die Umkehrung +// trennt am ersten Leerzeichen und rät damit bei jedem Doppelvornamen falsch. +// Das ist der Preis dafür, dass die Trennung überhaupt verschwindet. + +migrate((app) => { + const riders = app.findCollectionByNameOrId('riders') + + // Erst zusammenführen, dann die alten Felder abräumen — andersherum wären + // die Daten fort, bevor sie jemand liest. Auf einer frischen Datenbank hat + // der Snapshot die beiden Felder gar nicht erst. + if (riders.fields.getByName('firstname') || riders.fields.getByName('lastname')) { + for (const rider of app.findRecordsByFilter('riders', 'id != ""', '', 0, 0)) { + const merged = [rider.get('firstname'), rider.get('lastname')] + .map((part) => (part || '').trim()) + .filter(Boolean) + .join(' ') + + // Ein bereits gefülltes `name` gewinnt: Es wäre von Hand gesetzt + // worden und wüsste mehr als die beiden Bruchstücke. + if (merged && !(rider.get('name') || '').trim()) { + rider.set('name', merged) + app.save(rider) + } + } + } + + riders.fields.removeByName('firstname') + riders.fields.removeByName('lastname') + + // Im Admin-UI steht ein Fahrer jetzt mit Namen statt mit seiner ID da. + const name = riders.fields.getByName('name') + if (name) name.presentable = true + + app.save(riders) +}, (app) => { + const riders = app.findCollectionByNameOrId('riders') + + if (!riders.fields.getByName('firstname')) { + riders.fields.add(new Field({ + id: 'text2208304744', + name: 'firstname', + type: 'text', + presentable: true, + })) + } + if (!riders.fields.getByName('lastname')) { + riders.fields.add(new Field({ + id: 'text824489398', + name: 'lastname', + type: 'text', + presentable: true, + })) + } + + const name = riders.fields.getByName('name') + if (name) name.presentable = false + + app.save(riders) + + // Am ersten Leerzeichen trennen: davor der Vorname, danach der Rest. Aus + // „Anna Maria Huber" wird so „Anna" und „Maria Huber". + for (const rider of app.findRecordsByFilter('riders', 'id != ""', '', 0, 0)) { + const full = (rider.get('name') || '').trim() + if (!full) continue + + const cut = full.indexOf(' ') + rider.set('firstname', cut === -1 ? full : full.slice(0, cut)) + rider.set('lastname', cut === -1 ? '' : full.slice(cut + 1).trim()) + app.save(rider) + } +}) diff --git a/frontend/src/lib/components/RunningTimesToast.svelte b/frontend/src/lib/components/RunningTimesToast.svelte index 331aa55..1942c45 100644 --- a/frontend/src/lib/components/RunningTimesToast.svelte +++ b/frontend/src/lib/components/RunningTimesToast.svelte @@ -12,7 +12,7 @@ * Zeit-Dialog mit Fahrer und Run vorbelegt. */ import { getTimeContext } from '$lib/stores/times.svelte' - import { fullName, getRiderContext } from '$lib/stores/riders.svelte' + import { riderName, 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' @@ -56,7 +56,7 @@ const eventId = runId ? runs.getById(runId)?.event : undefined if (!eventId) { const r = riderId ? riders.getById(riderId) : undefined - return r ? fullName(r) : 'Unbekannter Fahrer' + return r ? riderName(r) : 'Unbekannter Fahrer' } return participants.riderLabel(eventId, riderId) } diff --git a/frontend/src/lib/components/TimeDialog.svelte b/frontend/src/lib/components/TimeDialog.svelte index ef859eb..3f2389b 100644 --- a/frontend/src/lib/components/TimeDialog.svelte +++ b/frontend/src/lib/components/TimeDialog.svelte @@ -7,7 +7,7 @@ * Tabellen noch die Toast-Leiste lösen sie direkt aus. */ import { getTimeContext, type TimeStatus } from '$lib/stores/times.svelte' - import { fullName, getRiderContext } from '$lib/stores/riders.svelte' + import { riderName, 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' diff --git a/frontend/src/lib/stores/eventParticipants.svelte.ts b/frontend/src/lib/stores/eventParticipants.svelte.ts index 3f4a54b..86ff826 100644 --- a/frontend/src/lib/stores/eventParticipants.svelte.ts +++ b/frontend/src/lib/stores/eventParticipants.svelte.ts @@ -1,7 +1,7 @@ import { getContext, setContext } from 'svelte' import { api, auth } from './pocketbase.svelte' import { getEventContext } from './events.svelte' -import { getRiderContext } from './riders.svelte' +import { getRiderContext, riderName } from './riders.svelte' import type { EventParticipantsResponse, RidersResponse } from '$lib/types' const KEY = Symbol('eventParticipant') @@ -45,7 +45,7 @@ export class EventParticipantStore { 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)) + return this.nameOf(a.rider).localeCompare(this.nameOf(b.rider)) }) } @@ -94,16 +94,15 @@ export class EventParticipantStore { riderLabel(eventId: string, riderId: string | undefined): string { if (!riderId) return 'Unbekannter Fahrer' - const name = this.riderName(riderId) || 'Unbekannter Fahrer' + const name = this.nameOf(riderId) || 'Unbekannter Fahrer' const number = this.numberOf(eventId, riderId) return number ? `#${number} ${name}` : name } - private riderName(riderId: string): string { + private nameOf(riderId: string): string { const r = this.riders.getById(riderId) - if (!r) return '' - return [r.firstname, r.lastname].filter(Boolean).join(' ') || r.name || '' + return r ? riderName(r) : '' } async load() { diff --git a/frontend/src/lib/stores/riders.svelte.ts b/frontend/src/lib/stores/riders.svelte.ts index 654e6bc..714b22b 100644 --- a/frontend/src/lib/stores/riders.svelte.ts +++ b/frontend/src/lib/stores/riders.svelte.ts @@ -11,12 +11,20 @@ const KEY = Symbol('rider') * Die gelten seit 1754501300 je Event und stehen am Teilnehmer. */ function sortByName(records: RidersResponse[]) { - return [...records].sort((a, b) => fullName(a).localeCompare(fullName(b))) + return [...records].sort((a, b) => riderName(a).localeCompare(riderName(b))) } -export function fullName(r: RidersResponse) { - const parts = [r.firstname, r.lastname].filter(Boolean) - return parts.join(' ').trim() || r.name || '' +/** + * Der Name eines Fahrers — ein Feld, ein Wert. + * + * Bis 1754501800_riders_single_name.js standen hier Vorname und Nachname + * getrennt und mussten überall wieder zusammengesetzt werden. Die Funktion + * bleibt trotzdem: Sie ist die eine Stelle, an der ein namenloser Fahrer zu + * einem leeren String wird, statt als `undefined` durch die Oberfläche zu + * geistern. + */ +export function riderName(r: RidersResponse) { + return (r.name ?? '').trim() } export class RiderStore { @@ -84,13 +92,47 @@ export class RiderStore { return await api.collection('riders').update(id, data) } + /** + * Fahrer löschen — und mit ihm die Mitgliedschaft seines Kontos. + * + * Ohne den zweiten Schritt riss jedes Löschen ein Loch: Der Fahrer war + * fort, sein Konto blieb in `teams.users` und stand danach als Zeile ohne + * Fahrer im Kader. Aus so einer Zeile führte kein Weg zurück — ein Konto + * nachträglich an einen Fahrer zu hängen gibt es bewusst nicht, weil + * dieselbe Person sonst zweimal in der Liste stehen könnte. Der Eintrag + * war damit unreparierbar. + * + * Das Konto selbst bleibt bestehen: Löschen darf es allein sein Inhaber + * (`users.deleteRule = id = @request.auth.id`), und über einen neuen + * Einladungslink kommt es zurück ins Team. + * + * Der Owner ist ausgenommen. Ihn aus `teams.users` zu nehmen, nähme ihm + * die Sicht auf sein eigenes Team — die listRule der meisten Collections + * hängt an `team.users`, nicht an `team.owner`. + */ remove(record: RidersResponse) { + const team = this.teams.records.find((t) => t.id === record.team) + const userId = record.user || null + const dropsMembership = !!userId && !!team && userId !== team.owner + + const text = dropsMembership + ? `Möchten Sie „${riderName(record)}" wirklich löschen? Alle Teilnahmen und Zeiten dieses Fahrers werden ebenfalls gelöscht. ` + + `Das zugehörige Konto verliert seine Mitgliedschaft im Team — gelöscht wird es nicht, es kann über einen Einladungslink zurückkommen.` + : `Möchten Sie „${riderName(record)}" wirklich löschen? Alle Teilnahmen und Zeiten dieses Fahrers werden ebenfalls gelöscht.` + return new Promise((resolve, reject) => { app.confirm.request({ title: 'Fahrer löschen?', - text: `Möchten Sie „${fullName(record)}" wirklich löschen? Alle Teilnahmen und Zeiten dieses Fahrers werden ebenfalls gelöscht.`, + text, yes: async () => { try { + // Erst die Mitgliedschaft, dann der Fahrer. Andersherum + // stünde bei einem Fehler im zweiten Schritt genau das + // verwaiste Konto da, das hier verhindert werden soll. + if (dropsMembership) { + await this.teams.removeMember(team!.id, userId!) + } + await api.collection('riders').delete(record.id) app.confirm.close() resolve() @@ -116,9 +158,14 @@ export class RiderStore { return this.scoped.filter((r) => r.user === userId) } - /** Bestehendes Konto zuordnen oder die Zuordnung lösen. */ - async linkUser(riderId: string, userId: string | null) { - return await this.edit(riderId, { user: userId ?? '' } as Partial) + /** + * Konto an den Fahrer hängen. Nur intern und nur in diese Richtung: Ein + * Konto entsteht am Fahrer, ein Lösen der Verbindung gibt es nicht — sonst + * stünde dieselbe Person zweimal in der Liste, einmal mit und einmal ohne + * Login. + */ + private async linkUser(riderId: string, userId: string) { + return await this.edit(riderId, { user: userId } as Partial) } /** @@ -131,7 +178,7 @@ export class RiderStore { ): Promise { const user = await this.teams.createMember({ ...data, - name: data.name?.trim() || fullName(rider), + name: data.name?.trim() || riderName(rider), }) await this.linkUser(rider.id, user.id) @@ -142,13 +189,7 @@ export class RiderStore { get filtered() { const q = this.searchQuery.toLowerCase().trim() if (!q) return this.scoped - return this.scoped.filter((r) => { - return ( - (r.firstname ?? '').toLowerCase().includes(q) || - (r.lastname ?? '').toLowerCase().includes(q) || - (r.name ?? '').toLowerCase().includes(q) - ) - }) + return this.scoped.filter((r) => riderName(r).toLowerCase().includes(q)) } destroy() { diff --git a/frontend/src/lib/types.d.ts b/frontend/src/lib/types.d.ts index ee302d0..0fab11a 100644 --- a/frontend/src/lib/types.d.ts +++ b/frontend/src/lib/types.d.ts @@ -11,6 +11,7 @@ export enum Collections { Events = "events", Riders = "riders", Runs = "runs", + TeamInvites = "team_invites", Teams = "teams", Times = "times", TrailComments = "trail_comments", @@ -116,9 +117,7 @@ export type EventsRecord = { export type RidersRecord = { avatar?: string created?: IsoDateString - firstname?: string id: string - lastname?: string name?: string team?: RecordIdString updated?: IsoDateString @@ -134,6 +133,18 @@ export type RunsRecord = { updated?: IsoDateString } +export type TeamInvitesRecord = { + created?: IsoDateString + created_by?: RecordIdString + expires?: IsoDateString + id: string + max_uses?: number + team: RecordIdString + token: string + updated?: IsoDateString + uses?: number +} + export type TeamsRecord = { admins?: RecordIdString[] created?: IsoDateString @@ -268,6 +279,7 @@ export type EventSeriesResponse = Required export type EventsResponse = Required & BaseSystemFields export type RidersResponse = Required & BaseSystemFields export type RunsResponse = Required & BaseSystemFields +export type TeamInvitesResponse = Required & BaseSystemFields export type TeamsResponse = Required & BaseSystemFields export type TimesResponse = Required & BaseSystemFields export type TrailCommentsResponse = Required & BaseSystemFields @@ -285,6 +297,7 @@ export type CollectionRecords = { events: EventsRecord riders: RidersRecord runs: RunsRecord + team_invites: TeamInvitesRecord teams: TeamsRecord times: TimesRecord trail_comments: TrailCommentsRecord @@ -301,6 +314,7 @@ export type CollectionResponses = { events: EventsResponse riders: RidersResponse runs: RunsResponse + team_invites: TeamInvitesResponse teams: TeamsResponse times: TimesResponse trail_comments: TrailCommentsResponse @@ -320,6 +334,7 @@ export type TypedPocketBase = PocketBase & { collection(idOrName: 'events'): RecordService collection(idOrName: 'riders'): RecordService collection(idOrName: 'runs'): RecordService + collection(idOrName: 'team_invites'): RecordService collection(idOrName: 'teams'): RecordService collection(idOrName: 'times'): RecordService collection(idOrName: 'trail_comments'): RecordService diff --git a/frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte b/frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte index a33905b..fa6f270 100644 --- a/frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte +++ b/frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte @@ -8,7 +8,7 @@ import { goto } from '$app/navigation' import { getEventContext } from '$lib/stores/events.svelte' import { getRunContext } from '$lib/stores/runs.svelte' - import { fullName, getRiderContext } from '$lib/stores/riders.svelte' + import { riderName, getRiderContext } from '$lib/stores/riders.svelte' import { getTimeContext } from '$lib/stores/times.svelte' import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte' import { ticker } from '$lib/stores/ticker.svelte' @@ -85,7 +85,7 @@ }) -{rider ? fullName(rider) : 'Fahrer'} – {event?.name ?? 'Event'} +{rider ? riderName(rider) : 'Fahrer'} – {event?.name ?? 'Event'} {#if !valid} @@ -117,7 +117,7 @@ {entry.number} {/if}

- {fullName(rider) || 'Ohne Name'} + {riderName(rider) || 'Ohne Name'}

diff --git a/frontend/src/routes/dashboard/events/[id]/runs/[runId]/+page.svelte b/frontend/src/routes/dashboard/events/[id]/runs/[runId]/+page.svelte index 6d25405..fb63dbb 100644 --- a/frontend/src/routes/dashboard/events/[id]/runs/[runId]/+page.svelte +++ b/frontend/src/routes/dashboard/events/[id]/runs/[runId]/+page.svelte @@ -9,7 +9,7 @@ import { page } from '$app/state' import { getEventContext } from '$lib/stores/events.svelte' import { getRunContext } from '$lib/stores/runs.svelte' - import { fullName, getRiderContext } from '$lib/stores/riders.svelte' + import { riderName, getRiderContext } from '$lib/stores/riders.svelte' import { getTimeContext } from '$lib/stores/times.svelte' import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte' import { ticker } from '$lib/stores/ticker.svelte' @@ -75,7 +75,7 @@ rows.push([ String(i + 1), event ? participants.numberOf(event.id, t.rider) : '', - r ? fullName(r) : '', + r ? riderName(r) : '', t.formattedTime ?? '', String(t.correction ?? 0), ]) @@ -195,7 +195,7 @@ {#if r} - {fullName(r) || '—'} + {riderName(r) || '—'} {:else} — {/if} @@ -233,7 +233,7 @@ {#if r} - {fullName(r) || '—'} + {riderName(r) || '—'} {:else} — {/if}