import { db } from './db.js' async function getToken() { const session = await db.session.get(1) return session?.token ?? null } export async function apiFetch(path, options = {}) { const token = await getToken() const headers = {} // Don't set Content-Type for FormData — browser sets it with correct boundary if (!(options.body instanceof FormData)) { headers['Content-Type'] = 'application/json' } Object.assign(headers, options.headers) if (token) headers['Authorization'] = `Bearer ${token}` const res = await fetch(path, { ...options, headers }) if (res.status === 401) { await db.session.clear() window.location.hash = '#/login' throw new Error('unauthorized') } return res } export async function apiJSON(path, options = {}) { const res = await apiFetch(path, options) if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })) throw new Error(err.error || res.statusText) } return res.json() } // Unauthenticated fetch for the kiosk (no JWT, no redirect on 401) async function kioskFetch(path, options = {}) { const headers = { 'Content-Type': 'application/json', ...options.headers } const res = await fetch(path, { ...options, headers }) if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })) const e = new Error(err.error || res.statusText) e.status = res.status e.body = err throw e } return res.json() } export const api = { login: (username, password) => apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ username, password }) }), logout: () => apiFetch('/api/logout', { method: 'POST' }), me: () => apiJSON('/api/me'), event: { get: () => apiJSON('/api/event'), update: (data) => apiJSON('/api/event', { method: 'PUT', body: JSON.stringify(data) }), }, attendees: { list: (params = {}) => apiJSON('/api/attendees?' + new URLSearchParams(params)), get: (id) => apiJSON(`/api/attendees/${id}`), create: (data) => apiJSON('/api/attendees', { method: 'POST', body: JSON.stringify(data) }), update: (id, data) => apiJSON(`/api/attendees/${id}`, { method: 'PUT', body: JSON.stringify(data) }), delete: (id) => apiFetch(`/api/attendees/${id}`, { method: 'DELETE' }), checkIn: (id, opts = {}) => apiJSON(`/api/attendees/${id}/checkin`, { method: 'POST', body: JSON.stringify(opts) }), generateTokens: () => apiJSON('/api/attendees/generate-tokens', { method: 'POST' }), emailToken: (id) => apiJSON(`/api/attendees/${id}/email-token`, { method: 'POST' }), emailAllTokens: () => apiJSON('/api/attendees/email-tokens', { method: 'POST' }), }, volunteers: { list: (params = {}) => apiJSON('/api/volunteers?' + new URLSearchParams(params)), get: (id) => apiJSON(`/api/volunteers/${id}`), create: (data) => apiJSON('/api/volunteers', { method: 'POST', body: JSON.stringify(data) }), update: (id, data) => apiJSON(`/api/volunteers/${id}`, { method: 'PUT', body: JSON.stringify(data) }), delete: (id) => apiFetch(`/api/volunteers/${id}`, { method: 'DELETE' }), checkIn: (id) => apiJSON(`/api/volunteers/${id}/checkin`, { method: 'POST' }), assignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts`, { method: 'POST', body: JSON.stringify({ shift_id: shiftId }) }), unassignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts/${shiftId}`, { method: 'DELETE' }), }, departments: { list: () => apiJSON('/api/departments'), create: (data) => apiJSON('/api/departments', { method: 'POST', body: JSON.stringify(data) }), update: (id, data) => apiJSON(`/api/departments/${id}`, { method: 'PUT', body: JSON.stringify(data) }), delete: (id) => apiFetch(`/api/departments/${id}`, { method: 'DELETE' }), }, shifts: { list: (params = {}) => apiJSON('/api/shifts?' + new URLSearchParams(params)), create: (data) => apiJSON('/api/shifts', { method: 'POST', body: JSON.stringify(data) }), update: (id, data) => apiJSON(`/api/shifts/${id}`, { method: 'PUT', body: JSON.stringify(data) }), delete: (id) => apiFetch(`/api/shifts/${id}`, { method: 'DELETE' }), assignVolunteer: (shiftId, volunteerId, force = false) => apiFetch(`/api/shifts/${shiftId}/volunteers`, { method: 'POST', body: JSON.stringify({ volunteer_id: volunteerId, force }) }), unassignVolunteer: (shiftId, volunteerId) => apiFetch(`/api/shifts/${shiftId}/volunteers/${volunteerId}`, { method: 'DELETE' }), reorder: (positions) => apiFetch('/api/shifts/reorder', { method: 'POST', body: JSON.stringify(positions) }), }, users: { list: () => apiJSON('/api/users'), create: (data) => apiJSON('/api/users', { method: 'POST', body: JSON.stringify(data) }), update: (id, data) => apiJSON(`/api/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }), delete: (id) => apiFetch(`/api/users/${id}`, { method: 'DELETE' }), }, settings: { get: () => apiJSON('/api/settings'), update: (data) => apiJSON('/api/settings', { method: 'PUT', body: JSON.stringify(data) }), testEmail: (to) => apiJSON('/api/settings/test-email', { method: 'POST', body: JSON.stringify({ to }) }), }, import: async (formData) => { const res = await apiFetch('/api/import', { method: 'POST', body: formData }) if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })) throw new Error(err.error || res.statusText) } return res.json() }, sync: { pull: (since) => apiJSON('/api/sync/pull' + (since ? `?since=${encodeURIComponent(since)}` : '')), }, kiosk: { get: (token) => kioskFetch(`/api/v/${token}`), // claim returns {conflict: true, conflicting_shifts: [...]} on 409, or the updated kiosk state on success. claim: async (token, shiftId, force = false) => { const res = await fetch(`/api/v/${token}/shifts/${shiftId}${force ? '?force=true' : ''}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }) const body = await res.json().catch(() => ({})) if (res.status === 409) return { conflict: true, ...body } if (!res.ok) throw new Error(body.error || res.statusText) return body }, unclaim: (token, shiftId) => kioskFetch(`/api/v/${token}/shifts/${shiftId}`, { method: 'DELETE' }), }, }