2026-03-03 11:27:07 -06:00
|
|
|
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()
|
2026-03-03 19:55:35 -06:00
|
|
|
window.location.pathname = '/login'
|
2026-03-03 11:27:07 -06:00
|
|
|
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) }),
|
|
|
|
|
},
|
2026-03-04 10:53:42 -06:00
|
|
|
participants: {
|
|
|
|
|
list: (params = {}) => apiJSON('/api/participants?' + new URLSearchParams(params)),
|
|
|
|
|
get: (id) => apiJSON(`/api/participants/${id}`),
|
|
|
|
|
create: (data) => apiJSON('/api/participants', { method: 'POST', body: JSON.stringify(data) }),
|
|
|
|
|
update: (id, data) => apiJSON(`/api/participants/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
|
|
|
delete: (id) => apiFetch(`/api/participants/${id}`, { method: 'DELETE' }),
|
|
|
|
|
merge: (id, otherId) => apiJSON(`/api/participants/${id}/merge/${otherId}`, { method: 'POST' }),
|
|
|
|
|
},
|
|
|
|
|
tickets: {
|
|
|
|
|
list: () => apiJSON('/api/tickets'),
|
2026-03-04 14:19:51 -06:00
|
|
|
create: (data) => apiJSON('/api/tickets', { method: 'POST', body: JSON.stringify(data) }),
|
2026-03-04 10:53:42 -06:00
|
|
|
checkIn: (id) => apiJSON(`/api/tickets/${id}/checkin`, { method: 'POST' }),
|
|
|
|
|
generateCodes: () => apiJSON('/api/tickets/generate-codes', { method: 'POST' }),
|
|
|
|
|
emailCode: (id) => apiJSON(`/api/tickets/${id}/email-code`, { method: 'POST' }),
|
|
|
|
|
emailAllCodes: () => apiJSON('/api/tickets/email-codes', { method: 'POST' }),
|
|
|
|
|
},
|
2026-03-03 11:27:07 -06:00
|
|
|
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 }) }),
|
2026-03-03 17:59:35 -06:00
|
|
|
toggleShiftSignups: (open) => apiJSON('/api/settings/shift-signups', { method: 'POST', body: JSON.stringify({ open }) }),
|
2026-03-04 10:53:42 -06:00
|
|
|
resetTickets: () => apiJSON('/api/settings/reset-tickets', { method: 'POST' }),
|
2026-03-03 19:55:35 -06:00
|
|
|
resetVolunteers: () => apiJSON('/api/settings/reset-volunteers', { method: 'POST' }),
|
|
|
|
|
resetShifts: () => apiJSON('/api/settings/reset-shifts', { method: 'POST' }),
|
|
|
|
|
resetDepartments: () => apiJSON('/api/settings/reset-departments', { method: 'POST' }),
|
|
|
|
|
resetVolunteerShifts: () => apiJSON('/api/settings/reset-volunteer-shifts', { method: 'POST' }),
|
2026-03-03 17:59:35 -06:00
|
|
|
},
|
|
|
|
|
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 }) }),
|
2026-03-03 11:27:07 -06:00
|
|
|
},
|
|
|
|
|
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' }),
|
|
|
|
|
},
|
|
|
|
|
}
|