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([]) activeId = $state(null) loading = $state(false) error = $state(null) /** * Aufgelöste User-Datensätze, nach ID. Ein Team kennt nur die IDs seiner * Mitglieder; die Namen holt sich die Oberfläche einzeln nach. Cache, * damit dieselbe ID nicht auf jeder Seite erneut geladen wird. */ users = $state>({}) 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 }) } /** * Logo setzen oder entfernen. Mit Datei als FormData — ein leeres Feld im * FormData würde ein vorhandenes Bild löschen, deshalb der zweite Weg. */ async setLogo(id: string, file: File | null) { if (!file) { return await api.collection('teams').update(id, { logo: null }) } const data = new FormData() data.append('logo', file) return await api.collection('teams').update(id, data) } /** 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((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() }, }) }) } /** * Einen User nachladen. Sichtbar ist ein fremder Datensatz nur über seine * ID (users.viewRule); durchblättern lässt sich die Collection nicht. */ async loadUser(id: string): Promise { if (this.users[id]) return this.users[id] try { const u = await api.collection('users').getOne(id, { requestKey: null }) this.users = { ...this.users, [id]: u } return u } catch { return null } } /** Alle Mitglieder eines Teams in den Cache holen. */ async loadMembers(team: TeamsResponse | null = this.active) { if (!team) return const ids = [...(team.users ?? []), team.owner].filter(Boolean) as string[] await Promise.all(ids.filter((id) => !this.users[id]).map((id) => this.loadUser(id))) } /** Anzeigename zu einer User-ID; fällt auf eine gekürzte ID zurück. */ userLabel(id: string): string { const u = this.users[id] if (!u) return id.slice(0, 6) + '…' return u.name || u.email || id.slice(0, 6) + '…' } /** * Legt ein Login an und nimmt es ins Team auf. Zwei Schritte, weil die * REST-API keine Transaktion kennt: Scheitert die Aufnahme, existiert das * Konto bereits — der Aufrufer meldet den Fehler, statt still * weiterzumachen. * * Erlaubt ist das seit 1754500500_riders_user_and_admin_rules.js jedem * eingeloggten Nutzer (users.createRule); eine öffentliche Registrierung * gibt es weiterhin nicht. */ async createMember(data: { email: string; password: string; name?: string }, teamId = this.activeId) { if (!teamId) throw new Error('Kein aktives Team') const user = (await api.collection('users').create({ email: data.email.trim(), emailVisibility: false, password: data.password, passwordConfirm: data.password, name: data.name?.trim() || data.email.trim(), })) as UsersResponse this.users = { ...this.users, [user.id]: user } await this.addMember(teamId, user.id) return user } 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(KEY) }