stammtisch-hersbruck/frontend/src/lib/stores/teams.svelte.ts
Daniel Michelberger 1061a8b9ad chore: Repo-Struktur mit frontend/ und backend/ aufsetzen
Die SvelteKit-App liegt unter frontend/, das Backend unter backend/ als
eigenständige PocketBase-Instanz mit Dockerfile, docker-compose und
versioniertem Schema.

- PocketBase-URL über PUBLIC_PB_URL konfigurierbar, Default bleibt die
  produktive Instanz https://api.stammtisch-hersbruck.de
- Schema als Snapshot-Migration der sechs fachlichen Collections
  (users, teams, events, runs, riders, times), abgezogen von der
  produktiven Instanz. Verifiziert: ein Erststart gegen leere pb_data
  legt alle sechs an, Felder und API-Rules stimmen überein.
- pb_data und pb_migrations als Bind-Mounts, damit Daten persistieren und
  im Admin-UI erzeugte Migrationen im Repo landen
- Veraltete Dokumentation entfernt: pocketbase_schema.json nannte
  Collections (stages, results, organizers), die es nicht gibt

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 13:16:43 +02:00

200 lines
6.9 KiB
TypeScript

import { getContext, setContext } from 'svelte'
import { api, auth } from './pocketbase.svelte'
import { app } from './app.svelte'
import type { TeamsResponse, UsersResponse } from '$lib/types'
const KEY = Symbol('team')
const LS_KEY = 'activeTeamId'
export class TeamStore {
records = $state<TeamsResponse[]>([])
activeId = $state<string | null>(null)
loading = $state(false)
error = $state<string | null>(null)
private unsubscribe: (() => void) | null = null
get active(): TeamsResponse | null {
return this.records.find((t) => t.id === this.activeId) ?? null
}
/** ID des aktuell eingeloggten Users */
private get me(): string | null {
return api.authStore.record?.id ?? null
}
isOwner(team: TeamsResponse | null = this.active): boolean {
if (!team || !this.me) return false
return team.owner === this.me
}
isAdmin(team: TeamsResponse | null = this.active): boolean {
if (!team || !this.me) return false
if (this.isOwner(team)) return true
return (team.admins ?? []).includes(this.me)
}
isMember(team: TeamsResponse | null = this.active): boolean {
if (!team || !this.me) return false
return (team.users ?? []).includes(this.me)
}
async load() {
this.loading = true
this.error = null
try {
this.records = await api.collection('teams').getFullList({
sort: '+name',
requestKey: null,
})
// Aktives Team aus localStorage wiederherstellen, sonst erstes nehmen
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(LS_KEY) : null
if (stored && this.records.some((t) => t.id === stored)) {
this.activeId = stored
} else if (this.records.length > 0) {
this.activeId = this.records[0].id
} else {
this.activeId = null
}
} catch (e: any) {
this.error = e.message ?? 'Fehler beim Laden der Teams'
console.error(e)
} finally {
this.loading = false
}
}
subscribe() {
if (this.unsubscribe) return
api.collection('teams').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].sort((a, b) => a.name.localeCompare(b.name))
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)
if (this.activeId === e.record.id) {
this.activeId = this.records[0]?.id ?? null
}
}
}).then((unsub) => {
this.unsubscribe = unsub
}).catch((e) => console.warn('teams subscribe failed:', e))
}
setActive(id: string) {
if (!this.records.some((t) => t.id === id)) return
this.activeId = id
if (typeof localStorage !== 'undefined') localStorage.setItem(LS_KEY, id)
}
async create(name: string) {
if (!this.me) throw new Error('Nicht eingeloggt')
const team = await api.collection('teams').create({
name,
owner: this.me,
users: [this.me],
admins: [],
})
// sofort als aktiv setzen
setTimeout(() => this.setActive(team.id), 0)
return team
}
async rename(id: string, name: string) {
return await api.collection('teams').update(id, { name })
}
/** Mitglied hinzufügen (Owner/Admin only) */
async addMember(teamId: string, userId: string) {
const team = this.records.find((t) => t.id === teamId)
if (!team) throw new Error('Team nicht gefunden')
const users = [...(team.users ?? [])]
if (!users.includes(userId)) users.push(userId)
return await api.collection('teams').update(teamId, { users })
}
/** Mitglied entfernen */
async removeMember(teamId: string, userId: string) {
const team = this.records.find((t) => t.id === teamId)
if (!team) throw new Error('Team nicht gefunden')
const users = (team.users ?? []).filter((u) => u !== userId)
const admins = (team.admins ?? []).filter((u) => u !== userId)
return await api.collection('teams').update(teamId, { users, admins })
}
async promoteToAdmin(teamId: string, userId: string) {
const team = this.records.find((t) => t.id === teamId)
if (!team) throw new Error('Team nicht gefunden')
const admins = [...(team.admins ?? [])]
if (!admins.includes(userId)) admins.push(userId)
return await api.collection('teams').update(teamId, { admins })
}
async demoteFromAdmin(teamId: string, userId: string) {
const team = this.records.find((t) => t.id === teamId)
if (!team) throw new Error('Team nicht gefunden')
const admins = (team.admins ?? []).filter((u) => u !== userId)
return await api.collection('teams').update(teamId, { admins })
}
/** Verlassen — entfernt sich selbst aus users + admins */
async leave(teamId: string) {
if (!this.me) return
return await this.removeMember(teamId, this.me)
}
/** Team löschen (nur Owner) */
remove(team: TeamsResponse) {
return new Promise<void>((resolve, reject) => {
app.confirm.request({
title: 'Team löschen?',
text: `Möchtest du das Team „${team.name}" wirklich löschen? Achtung: zugehörige Events, Fahrer und Zeiten werden NICHT gelöscht, sind aber für niemanden mehr sichtbar.`,
yes: async () => {
try {
await api.collection('teams').delete(team.id)
app.confirm.close()
resolve()
} catch (e) {
app.confirm.close()
reject(e)
}
},
no: () => {
app.confirm.close()
resolve()
},
})
})
}
/** User per E-Mail suchen (für Einladungs-UI) */
async findUserByEmail(email: string): Promise<UsersResponse | null> {
try {
const res = await api.collection('users').getFirstListItem(
`email = "${email.replaceAll('"', '')}"`,
{ requestKey: null },
)
return res
} catch {
return null
}
}
destroy() {
if (this.unsubscribe) {
this.unsubscribe()
this.unsubscribe = null
}
}
}
export function setTeamContext() {
const store = new TeamStore()
setContext(KEY, store)
return store
}
export function getTeamContext(): TeamStore {
return getContext<TeamStore>(KEY)
}