Turnpike/frontend/src/api.js

146 lines
6.6 KiB
JavaScript

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 }) }),
toggleShiftSignups: (open) => apiJSON('/api/settings/shift-signups', { method: 'POST', body: JSON.stringify({ open }) }),
},
signup: {
config: () => kioskFetch('/api/public/signup-config'),
submit: (data) => kioskFetch('/api/public/signup', { method: 'POST', body: JSON.stringify(data) }),
confirm: (token) => kioskFetch('/api/public/confirm', { method: 'POST', body: JSON.stringify({ token }) }),
},
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' }),
},
}