import { getContext, setContext } from 'svelte' import { api, auth } from './pocketbase.svelte' import { getTeamContext } from './teams.svelte' import type { TeamInvitesResponse } from '$lib/types' const KEY = Symbol('teamInvite') /** * Einladungslinks eines Teams. * * Zwei Seiten, zwei Wege: Die Teamleitung legt Links über die Collection an * und sieht sie dort — das regeln die Regeln in * 1754501600_team_invites.js. Wer einen Link einlöst, ist noch nicht * eingeloggt und läuft über die öffentlichen Routen aus * backend/pb_hooks/team_invites.pb.js; die stehen unten als `invites.public`. */ export class TeamInviteStore { records = $state([]) loading = $state(false) error = $state(null) private unsubscribe: (() => void) | null = null private teams = getTeamContext() get scoped(): TeamInvitesResponse[] { const teamId = this.teams.activeId if (!teamId) return [] return this.records.filter((r) => r.team === teamId) } /** * Einladungen verwaltet nur die Teamleitung — dieselbe Regel wie in * list/create/deleteRule der Collection. Die Prüfung hier blendet nur UI * aus; durchgesetzt wird sie serverseitig. */ get canManage(): boolean { return this.teams.isAdmin() } /** Gilt der Link noch? Abgelaufen und aufgebraucht sehen gleich aus. */ isValid(invite: TeamInvitesResponse): boolean { if (invite.expires && new Date(invite.expires).getTime() < Date.now()) return false const max = invite.max_uses ?? 0 return !(max > 0 && (invite.uses ?? 0) >= max) } /** Der Link zum Weitergeben. Ohne Fenster (SSR) gibt es keinen Host. */ url(invite: TeamInvitesResponse): string { const origin = typeof location !== 'undefined' ? location.origin : '' return `${origin}/invite/${invite.token}` } async load() { // Ein einfaches Mitglied bekäme hier ohnehin eine leere Liste; der // Aufruf entfällt trotzdem, damit das Dashboard beim Start eine // Anfrage weniger stellt. if (!auth.user) return this.loading = true this.error = null try { this.records = await api.collection('team_invites').getFullList({ sort: '-created', requestKey: null, }) } catch (e: any) { this.error = e.message ?? 'Fehler beim Laden der Einladungen' console.error(e) } finally { this.loading = false } } subscribe() { if (this.unsubscribe) return api.collection('team_invites').subscribe('*', (e) => { const idx = this.records.findIndex((r) => r.id === e.record.id) if (e.action === 'create' && idx === -1) this.records = [e.record, ...this.records] 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) } }).then((unsub) => { this.unsubscribe = unsub }).catch((e) => console.warn('team_invites subscribe failed:', e)) } /** * Neuen Link erzeugen. Den Token setzt PocketBase selbst — ihn * mitzuschicken lehnt die createRule ab, damit sich niemand einen leicht * zu ratenden aussuchen kann. * * `days` 0 heißt: läuft nicht ab, `maxUses` 0: beliebig oft einlösbar. */ async create({ days = 0, maxUses = 0 } = {}, teamId = this.teams.activeId) { if (!teamId) throw new Error('Kein aktives Team') const data: Record = { team: teamId, max_uses: maxUses, uses: 0, created_by: auth.user?.id ?? '', } if (days > 0) { const until = new Date() until.setDate(until.getDate() + days) data.expires = until.toISOString() } const invite = await api.collection('team_invites').create(data) this.records = [invite, ...this.records] return invite as TeamInvitesResponse } /** * Link zurückziehen. Ohne Rückfrage: Ein Einladungslink ist nichts, was * verloren gehen kann — wer einen braucht, erzeugt den nächsten. */ async remove(id: string) { await api.collection('team_invites').delete(id) this.records = this.records.filter((r) => r.id !== id) } destroy() { if (this.unsubscribe) { this.unsubscribe() this.unsubscribe = null } } } /** * Die öffentliche Seite einer Einladung — ohne Login, ohne Store, ohne Team. * Sie läuft gegen die Routen aus backend/pb_hooks/team_invites.pb.js, nicht * gegen eine Collection: Ein Nicht-Eingeloggter darf `team_invites` nicht * einmal sehen. */ export type InviteTeam = { id: string collectionId: string collectionName: string name: string logo: string } async function send(path: string, body?: unknown) { const res = await fetch(`${api.baseURL.replace(/\/$/, '')}${path}`, { method: body === undefined ? 'GET' : 'POST', headers: { 'Content-Type': 'application/json', // Für /join muss der Server wissen, wer beitritt. ...(api.authStore.token ? { Authorization: api.authStore.token } : {}), }, body: body === undefined ? undefined : JSON.stringify(body), }) const data = await res.json().catch(() => ({})) if (!res.ok) throw new Error(data?.message || 'Die Einladung konnte nicht geöffnet werden.') return data } export const publicInvite = { /** Zu welchem Team gehört der Link? Wirft, wenn er nicht (mehr) gilt. */ async load(token: string): Promise { const data = await send(`/api/team-invites/${encodeURIComponent(token)}`) return data.team as InviteTeam }, /** Konto anlegen und beitreten. */ async accept( token: string, data: { email: string; password: string; passwordConfirm: string; name: string }, ) { return await send(`/api/team-invites/${encodeURIComponent(token)}/accept`, data) }, /** Als bereits angemeldeter Nutzer beitreten. */ async join(token: string, data: { name?: string } = {}) { return await send(`/api/team-invites/${encodeURIComponent(token)}/join`, data) }, } export function setTeamInviteContext() { const store = new TeamInviteStore() setContext(KEY, store) return store } export function getTeamInviteContext(): TeamInviteStore { return getContext(KEY) }