Cleaned up old Attendees model. Updated tests.
This commit is contained in:
parent
64ce97c74d
commit
260e017f79
14 changed files with 90 additions and 584 deletions
|
|
@ -72,21 +72,6 @@ export const api = {
|
|||
emailCode: (id) => apiJSON(`/api/tickets/${id}/email-code`, { method: 'POST' }),
|
||||
emailAllCodes: () => apiJSON('/api/tickets/email-codes', { method: 'POST' }),
|
||||
},
|
||||
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}`),
|
||||
|
|
@ -126,7 +111,6 @@ export const api = {
|
|||
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 }) }),
|
||||
resetAttendees: () => apiJSON('/api/settings/reset-attendees', { method: 'POST' }),
|
||||
resetTickets: () => apiJSON('/api/settings/reset-tickets', { method: 'POST' }),
|
||||
resetVolunteers: () => apiJSON('/api/settings/reset-volunteers', { method: 'POST' }),
|
||||
resetShifts: () => apiJSON('/api/settings/reset-shifts', { method: 'POST' }),
|
||||
|
|
|
|||
|
|
@ -71,16 +71,16 @@ describe('api methods', () => {
|
|||
expect(JSON.parse(opts.body)).toEqual({ username: 'admin', password: 'pass' })
|
||||
})
|
||||
|
||||
it('attendees.list calls correct endpoint', async () => {
|
||||
const f = mockFetch({ attendees: [] })
|
||||
await api.attendees.list({ search: 'test' })
|
||||
expect(f.mock.calls[0][0]).toBe('/api/attendees?search=test')
|
||||
it('participants.list calls correct endpoint', async () => {
|
||||
const f = mockFetch({ participants: [] })
|
||||
await api.participants.list({ search: 'test' })
|
||||
expect(f.mock.calls[0][0]).toBe('/api/participants?search=test')
|
||||
})
|
||||
|
||||
it('attendees.delete uses DELETE method', async () => {
|
||||
it('participants.delete uses DELETE method', async () => {
|
||||
const f = mockFetch({}, 204)
|
||||
await api.attendees.delete(5)
|
||||
expect(f.mock.calls[0][0]).toBe('/api/attendees/5')
|
||||
await api.participants.delete(5)
|
||||
expect(f.mock.calls[0][0]).toBe('/api/participants/5')
|
||||
expect(f.mock.calls[0][1].method).toBe('DELETE')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,12 @@ db.version(3).stores({
|
|||
outbox: '++id, table, op, synced_at',
|
||||
})
|
||||
|
||||
db.version(4).stores({
|
||||
attendees: null,
|
||||
outbox: null,
|
||||
volunteers: 'id, name, department_id, checked_in, participant_id, deleted_at',
|
||||
})
|
||||
|
||||
export async function getLastSync() {
|
||||
const m = await db.meta.get('last_sync')
|
||||
return m?.value ?? ''
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ describe('db schema', () => {
|
|||
it('has expected tables', () => {
|
||||
const names = db.tables.map(t => t.name).sort()
|
||||
expect(names).toEqual([
|
||||
'attendees', 'departments', 'event', 'meta',
|
||||
'outbox', 'participants', 'session', 'shifts', 'tickets', 'volunteer_shifts', 'volunteers',
|
||||
'departments', 'event', 'meta',
|
||||
'participants', 'session', 'shifts', 'tickets', 'volunteer_shifts', 'volunteers',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,274 +0,0 @@
|
|||
<script>
|
||||
import { liveQuery } from 'dexie'
|
||||
import { db } from '../db.js'
|
||||
import { api } from '../api.js'
|
||||
import CheckInButton from '../components/CheckInButton.svelte'
|
||||
|
||||
let { session } = $props()
|
||||
|
||||
let search = $state('')
|
||||
let filterType = $state('')
|
||||
let filterChecked = $state('')
|
||||
let error = $state('')
|
||||
let success = $state('')
|
||||
let showAdd = $state(false)
|
||||
let newName = $state('')
|
||||
let newEmail = $state('')
|
||||
let newPhone = $state('')
|
||||
let newTicketID = $state('')
|
||||
let newTicketType = $state('')
|
||||
let newNote = $state('')
|
||||
let adding = $state(false)
|
||||
let generating = $state(false)
|
||||
let emailing = $state(false)
|
||||
|
||||
const role = $derived(session?.user?.role ?? '')
|
||||
const canManage = $derived(['admin', 'ticketing'].includes(role))
|
||||
const canCheckIn = $derived(['admin', 'ticketing', 'gate'].includes(role))
|
||||
|
||||
const allAttendees = liveQuery(() => db.attendees.toArray())
|
||||
const ticketTypes = liveQuery(() =>
|
||||
db.attendees.orderBy('ticket_type').uniqueKeys()
|
||||
)
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const list = $allAttendees ?? []
|
||||
const s = search.toLowerCase()
|
||||
return list
|
||||
.filter(a => {
|
||||
if (filterType && a.ticket_type !== filterType) return false
|
||||
if (filterChecked === 'true' && !a.checked_in) return false
|
||||
if (filterChecked === 'false' && a.checked_in) return false
|
||||
if (s && !a.name.toLowerCase().includes(s) &&
|
||||
!a.email.toLowerCase().includes(s) &&
|
||||
!a.ticket_id.toLowerCase().includes(s)) return false
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
|
||||
async function checkIn(attendee) {
|
||||
try {
|
||||
const result = await api.attendees.checkIn(attendee.id)
|
||||
if (result.attendee) await db.attendees.put(result.attendee)
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
}
|
||||
}
|
||||
|
||||
async function addAttendee(e) {
|
||||
e.preventDefault()
|
||||
adding = true
|
||||
error = ''
|
||||
try {
|
||||
const a = await api.attendees.create({
|
||||
name: newName, email: newEmail, phone: newPhone,
|
||||
ticket_id: newTicketID, ticket_type: newTicketType, note: newNote,
|
||||
})
|
||||
await db.attendees.put(a)
|
||||
showAdd = false
|
||||
newName = newEmail = newPhone = newTicketID = newTicketType = newNote = ''
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
adding = false
|
||||
}
|
||||
}
|
||||
|
||||
async function generateTokens() {
|
||||
generating = true
|
||||
error = ''
|
||||
success = ''
|
||||
try {
|
||||
const result = await api.attendees.generateTokens()
|
||||
success = `Generated ${result.generated} token${result.generated !== 1 ? 's' : ''}.`
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
generating = false
|
||||
}
|
||||
}
|
||||
|
||||
async function emailAll() {
|
||||
if (!confirm('Send token emails to all attendees with a token and email address?')) return
|
||||
emailing = true
|
||||
error = ''
|
||||
success = ''
|
||||
try {
|
||||
const result = await api.attendees.emailAllTokens()
|
||||
success = `Sent ${result.sent} email${result.sent !== 1 ? 's' : ''}${result.skipped ? `, skipped ${result.skipped}` : ''}.`
|
||||
if (result.errors?.length) error = result.errors.join('; ')
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
emailing = false
|
||||
}
|
||||
}
|
||||
|
||||
async function emailToken(attendee) {
|
||||
error = ''
|
||||
success = ''
|
||||
try {
|
||||
await api.attendees.emailToken(attendee.id)
|
||||
success = `Token email sent to ${attendee.name}.`
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Attendees</h1>
|
||||
<div class="actions">
|
||||
{#if canManage}
|
||||
<button class="btn btn-primary btn-sm" onclick={() => showAdd = !showAdd}>+ Add</button>
|
||||
<a href="/api/attendees/export" class="btn btn-ghost btn-sm">Export CSV</a>
|
||||
<button class="btn btn-ghost btn-sm" onclick={generateTokens} disabled={generating}>
|
||||
{generating ? '…' : '⚿ Tokens'}
|
||||
</button>
|
||||
<a href="/api/attendees/export-tokens" class="btn btn-ghost btn-sm">Export Links</a>
|
||||
<button class="btn btn-ghost btn-sm" onclick={emailAll} disabled={emailing}>
|
||||
{emailing ? '…' : '✉ Email All'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error">{error}</div>
|
||||
{/if}
|
||||
{#if success}
|
||||
<div class="alert alert-success">{success}</div>
|
||||
{/if}
|
||||
|
||||
{#if showAdd && canManage}
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<form onsubmit={addAttendee}>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||
<div class="form-group">
|
||||
<label for="new-name">Name *</label>
|
||||
<input id="new-name" bind:value={newName} required placeholder="Full name" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-email">Email</label>
|
||||
<input id="new-email" type="email" bind:value={newEmail} placeholder="email@example.com" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-ticket-id">Ticket ID</label>
|
||||
<input id="new-ticket-id" bind:value={newTicketID} placeholder="From ticketing system" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-ticket-type">Ticket type</label>
|
||||
<input id="new-ticket-type" bind:value={newTicketType} placeholder="e.g. General, VIP" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-note">Note</label>
|
||||
<input id="new-note" bind:value={newNote} placeholder="Optional note" />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary" disabled={adding}>
|
||||
{adding ? 'Adding…' : 'Add attendee'}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" onclick={() => showAdd = false}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="search-bar">
|
||||
<input placeholder="Search name, email, ticket ID…" bind:value={search} />
|
||||
{#if ($ticketTypes ?? []).length > 0}
|
||||
<select bind:value={filterType} style="width:auto">
|
||||
<option value="">All types</option>
|
||||
{#each $ticketTypes ?? [] as t}
|
||||
<option value={t}>{t}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<select bind:value={filterChecked} style="width:auto">
|
||||
<option value="">All</option>
|
||||
<option value="false">Not checked in</option>
|
||||
<option value="true">Checked in</option>
|
||||
</select>
|
||||
<span class="text-muted" style="font-size:0.85rem;white-space:nowrap">
|
||||
{filtered.length} shown
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if ($allAttendees ?? []).length === 0}
|
||||
<div class="empty">
|
||||
<strong>No attendees yet</strong>
|
||||
<p>Import a CSV or add attendees manually.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Ticket type</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
{#if canCheckIn}<th></th>{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filtered as a (a.id)}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{a.name}</strong>
|
||||
{#if a.ticket_id}
|
||||
<span class="text-muted" style="font-size:0.8rem"> · {a.ticket_id}</span>
|
||||
{/if}
|
||||
{#if (a.party_size ?? 1) > 1}
|
||||
<span class="badge badge-lead" style="margin-left:0.3rem">×{a.party_size}</span>
|
||||
{/if}
|
||||
{#if a.note}
|
||||
<div class="text-muted" style="font-size:0.78rem">{a.note}</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="text-muted">{a.ticket_type || '—'}</td>
|
||||
<td>
|
||||
<div>{a.email || '—'}</div>
|
||||
{#if a.volunteer_token && canManage}
|
||||
<div style="font-size:0.75rem;margin-top:0.15rem">
|
||||
<code style="color:var(--c-accent-h)">{a.volunteer_token}</code>
|
||||
{#if a.email}
|
||||
<button class="btn btn-ghost btn-sm" style="padding:0.1rem 0.4rem;margin-left:0.25rem"
|
||||
onclick={() => emailToken(a)}>✉</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if (a.party_size ?? 1) > 1}
|
||||
<span class="badge {a.checked_in ? 'badge-checked' : 'badge-unchecked'}">
|
||||
{a.checked_in_count ?? 0}/{a.party_size} in
|
||||
</span>
|
||||
{:else}
|
||||
<span class="badge {a.checked_in ? 'badge-checked' : 'badge-unchecked'}">
|
||||
{a.checked_in ? 'Checked in' : 'Pending'}
|
||||
</span>
|
||||
{/if}
|
||||
{#if a.checked_in_at}
|
||||
<div class="text-muted" style="font-size:0.75rem">
|
||||
{new Date(a.checked_in_at).toLocaleTimeString()}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
{#if canCheckIn}
|
||||
<td>
|
||||
{#if (a.checked_in_count ?? 0) < (a.party_size ?? 1)}
|
||||
<CheckInButton onclick={() => checkIn(a)} />
|
||||
{/if}
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -12,17 +12,11 @@ export async function syncPull() {
|
|||
const data = await api.sync.pull(since)
|
||||
|
||||
await db.transaction('rw',
|
||||
[db.event, db.attendees, db.participants, db.tickets, db.departments, db.volunteers, db.shifts, db.volunteer_shifts],
|
||||
[db.event, db.participants, db.tickets, db.departments, db.volunteers, db.shifts, db.volunteer_shifts],
|
||||
async () => {
|
||||
if (data.event) {
|
||||
await db.event.put(data.event)
|
||||
}
|
||||
if (data.attendees?.length) {
|
||||
await db.attendees.bulkPut(data.attendees)
|
||||
// Purge hard-deleted records from Dexie
|
||||
const deleted = data.attendees.filter(a => a.deleted_at).map(a => a.id)
|
||||
if (deleted.length) await db.attendees.bulkDelete(deleted)
|
||||
}
|
||||
if (data.participants?.length) {
|
||||
await db.participants.bulkPut(data.participants)
|
||||
const deleted = data.participants.filter(p => p.deleted_at).map(p => p.id)
|
||||
|
|
@ -82,9 +76,6 @@ export function startSSE(onEvent) {
|
|||
try {
|
||||
const payload = JSON.parse(e.data)
|
||||
if (payload.event === 'checkin') {
|
||||
if (payload.data?.type === 'attendee' && payload.data?.attendee) {
|
||||
await db.attendees.put(payload.data.attendee)
|
||||
}
|
||||
if (payload.data?.type === 'ticket' && payload.data?.ticket) {
|
||||
await db.tickets.put(payload.data.ticket)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,30 +18,31 @@ function mockFetch(body = {}, status = 200) {
|
|||
}
|
||||
|
||||
describe('syncPull', () => {
|
||||
it('writes attendees to Dexie', async () => {
|
||||
it('writes participants to Dexie', async () => {
|
||||
mockFetch({
|
||||
server_time: '2026-03-01T12:00:00Z',
|
||||
attendees: [{ id: 1, name: 'Titania' }],
|
||||
participants: [{ id: 1, preferred_name: 'Titania', email: 'titania@example.com' }],
|
||||
tickets: [],
|
||||
departments: [],
|
||||
volunteers: [],
|
||||
shifts: [],
|
||||
volunteer_shifts: [],
|
||||
})
|
||||
// Import fresh to reset syncing guard
|
||||
const { syncPull } = await import('./sync.js')
|
||||
await syncPull()
|
||||
|
||||
const a = await db.attendees.get(1)
|
||||
expect(a.name).toBe('Titania')
|
||||
const p = await db.participants.get(1)
|
||||
expect(p.preferred_name).toBe('Titania')
|
||||
expect(await getLastSync()).toBe('2026-03-01T12:00:00Z')
|
||||
})
|
||||
|
||||
it('deletes soft-deleted attendees from Dexie', async () => {
|
||||
await db.attendees.put({ id: 1, name: 'Titania' })
|
||||
it('deletes soft-deleted participants from Dexie', async () => {
|
||||
await db.participants.put({ id: 1, preferred_name: 'Titania', email: 'titania@example.com' })
|
||||
|
||||
mockFetch({
|
||||
server_time: '2026-03-01T13:00:00Z',
|
||||
attendees: [{ id: 1, name: 'Titania', deleted_at: '2026-03-01T12:30:00Z' }],
|
||||
participants: [{ id: 1, preferred_name: 'Titania', deleted_at: '2026-03-01T12:30:00Z' }],
|
||||
tickets: [],
|
||||
departments: [],
|
||||
volunteers: [],
|
||||
shifts: [],
|
||||
|
|
@ -50,8 +51,8 @@ describe('syncPull', () => {
|
|||
const { syncPull } = await import('./sync.js')
|
||||
await syncPull()
|
||||
|
||||
const a = await db.attendees.get(1)
|
||||
expect(a).toBeUndefined()
|
||||
const p = await db.participants.get(1)
|
||||
expect(p).toBeUndefined()
|
||||
})
|
||||
|
||||
it('deletes soft-deleted volunteer_shifts from Dexie', async () => {
|
||||
|
|
@ -59,7 +60,8 @@ describe('syncPull', () => {
|
|||
|
||||
mockFetch({
|
||||
server_time: '2026-03-01T13:00:00Z',
|
||||
attendees: [],
|
||||
participants: [],
|
||||
tickets: [],
|
||||
departments: [],
|
||||
volunteers: [],
|
||||
shifts: [],
|
||||
|
|
@ -75,7 +77,8 @@ describe('syncPull', () => {
|
|||
it('sets lastSync timestamp', async () => {
|
||||
mockFetch({
|
||||
server_time: '2026-03-02T00:00:00Z',
|
||||
attendees: [],
|
||||
participants: [],
|
||||
tickets: [],
|
||||
departments: [],
|
||||
volunteers: [],
|
||||
shifts: [],
|
||||
|
|
|
|||
|
|
@ -1,177 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func (app *App) handleListAttendees(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
attendees, err := app.listAttendees(q.Get("search"), q.Get("ticket_type"), q.Get("checked_in"))
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
types, _ := app.attendeeTicketTypes()
|
||||
total, checkedIn, _ := app.attendeeCounts()
|
||||
writeJSON(w, map[string]any{
|
||||
"attendees": attendees,
|
||||
"ticket_types": types,
|
||||
"total": total,
|
||||
"checked_in": checkedIn,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleCreateAttendee(w http.ResponseWriter, r *http.Request) {
|
||||
var a Attendee
|
||||
if err := json.NewDecoder(r.Body).Decode(&a); err != nil {
|
||||
writeError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if a.Name == "" {
|
||||
writeError(w, "name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
created, err := app.createAttendee(a)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
writeJSON(w, created)
|
||||
}
|
||||
|
||||
func (app *App) handleGetAttendee(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
a, err := app.getAttendee(id)
|
||||
if err != nil || a == nil {
|
||||
writeError(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, a)
|
||||
}
|
||||
|
||||
func (app *App) handleUpdateAttendee(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var a Attendee
|
||||
if err := json.NewDecoder(r.Body).Decode(&a); err != nil {
|
||||
writeError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if a.Name == "" {
|
||||
writeError(w, "name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
a.ID = id
|
||||
if err := app.updateAttendee(a); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
updated, _ := app.getAttendee(id)
|
||||
writeJSON(w, updated)
|
||||
}
|
||||
|
||||
func (app *App) handleDeleteAttendee(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := app.deleteAttendee(id); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleCheckInAttendee handles POST /api/attendees/:id/checkin.
|
||||
// Optional body: {"count": N, "also_volunteer": true}
|
||||
// Returns {"attendee": ..., "volunteer": ...} — volunteer is included if also_volunteer=true
|
||||
// and the attendee has a linked volunteer record.
|
||||
func (app *App) handleCheckInAttendee(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Count int `json:"count"`
|
||||
AlsoVolunteer bool `json:"also_volunteer"`
|
||||
}
|
||||
body.Count = 1
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body.Count < 1 {
|
||||
body.Count = 1
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r)
|
||||
a, err := app.checkInAttendee(id, claims.UserID, body.Count)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
result := map[string]any{"attendee": a}
|
||||
|
||||
if body.AlsoVolunteer {
|
||||
// Try to find volunteer via participant_id first (new model), fall back to attendee_id (legacy).
|
||||
var v *Volunteer
|
||||
if a != nil {
|
||||
p, _ := app.getParticipantByEmail(a.Email)
|
||||
if p != nil {
|
||||
v, _ = app.getVolunteerByParticipantID(p.ID)
|
||||
}
|
||||
}
|
||||
if v == nil {
|
||||
v, _ = app.getVolunteerByAttendeeID(id)
|
||||
}
|
||||
if v != nil {
|
||||
if !v.CheckedIn {
|
||||
if v2, err := app.checkInVolunteer(v.ID, claims.UserID); err == nil {
|
||||
result["volunteer"] = v2
|
||||
app.broker.publish("checkin", map[string]any{"type": "volunteer", "volunteer": v2})
|
||||
}
|
||||
} else {
|
||||
result["volunteer"] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.broker.publish("checkin", map[string]any{"type": "attendee", "attendee": a})
|
||||
writeJSON(w, result)
|
||||
}
|
||||
|
||||
func (app *App) handleExportAttendees(w http.ResponseWriter, r *http.Request) {
|
||||
attendees, err := app.listAttendees("", "", "")
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="attendees.csv"`)
|
||||
wr := csv.NewWriter(w)
|
||||
wr.Write([]string{"name", "email", "phone", "ticket_id", "ticket_type", "party_size", "checked_in_count", "note", "checked_in"})
|
||||
for _, a := range attendees {
|
||||
ci := "no"
|
||||
if a.CheckedIn {
|
||||
ci = "yes"
|
||||
}
|
||||
wr.Write([]string{
|
||||
a.Name, a.Email, a.Phone, a.TicketID, a.TicketType,
|
||||
strconv.Itoa(a.PartySize), strconv.Itoa(a.CheckedInCount),
|
||||
a.Note, ci,
|
||||
})
|
||||
}
|
||||
wr.Flush()
|
||||
}
|
||||
|
|
@ -6,14 +6,14 @@ import (
|
|||
"testing"
|
||||
)
|
||||
|
||||
func TestAttendeesListCreateDelete(t *testing.T) {
|
||||
func TestParticipantsListCreateDelete(t *testing.T) {
|
||||
app := testApp(t)
|
||||
admin := testAdminUser(t, app)
|
||||
token := testToken(t, app, admin)
|
||||
mux := testMux(app)
|
||||
|
||||
// Create
|
||||
req := testAuthRequest("POST", "/api/attendees", map[string]string{"name": "Titania"}, token)
|
||||
req := testAuthRequest("POST", "/api/participants", map[string]string{"preferred_name": "Titania", "email": "titania@example.com"}, token)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
|
|
@ -23,20 +23,20 @@ func TestAttendeesListCreateDelete(t *testing.T) {
|
|||
id := created["id"].(float64)
|
||||
|
||||
// List
|
||||
req = testAuthRequest("GET", "/api/attendees", nil, token)
|
||||
req = testAuthRequest("GET", "/api/participants", nil, token)
|
||||
w = httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list: status = %d", w.Code)
|
||||
}
|
||||
list := parseJSON(t, w)
|
||||
attendees := list["attendees"].([]any)
|
||||
if len(attendees) != 1 {
|
||||
t.Errorf("list: got %d, want 1", len(attendees))
|
||||
participants := list["participants"].([]any)
|
||||
if len(participants) != 1 {
|
||||
t.Errorf("list: got %d, want 1", len(participants))
|
||||
}
|
||||
|
||||
// Delete
|
||||
req = testAuthRequest("DELETE", "/api/attendees/"+itoa(int(id)), nil, token)
|
||||
req = testAuthRequest("DELETE", "/api/participants/"+itoa(int(id)), nil, token)
|
||||
w = httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNoContent {
|
||||
|
|
@ -44,66 +44,66 @@ func TestAttendeesListCreateDelete(t *testing.T) {
|
|||
}
|
||||
|
||||
// List again — should be empty
|
||||
req = testAuthRequest("GET", "/api/attendees", nil, token)
|
||||
req = testAuthRequest("GET", "/api/participants", nil, token)
|
||||
w = httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
list = parseJSON(t, w)
|
||||
if a2, ok := list["attendees"].([]any); ok && len(a2) != 0 {
|
||||
t.Errorf("after delete: got %d, want 0", len(a2))
|
||||
if ps, ok := list["participants"].([]any); ok && len(ps) != 0 {
|
||||
t.Errorf("after delete: got %d, want 0", len(ps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckInAttendeeHandler(t *testing.T) {
|
||||
func TestCheckInTicketHandler(t *testing.T) {
|
||||
app := testApp(t)
|
||||
admin := testAdminUser(t, app)
|
||||
token := testToken(t, app, admin)
|
||||
mux := testMux(app)
|
||||
|
||||
app.createAttendee(Attendee{Name: "Oberon"})
|
||||
app.db.Exec(`UPDATE attendees SET party_size = 3 WHERE id = 1`)
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Oberon", Email: "oberon@example.com"})
|
||||
tk, _ := app.createTicket(Ticket{ParticipantID: &p.ID, Name: "Oberon", Source: "manual"})
|
||||
|
||||
// Check in 1
|
||||
req := testAuthRequest("POST", "/api/attendees/1/checkin", map[string]int{"count": 1}, token)
|
||||
req := testAuthRequest("POST", "/api/tickets/"+itoa(tk.ID)+"/checkin", nil, token)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("checkin: status = %d\nbody: %s", w.Code, w.Body.String())
|
||||
}
|
||||
result := parseJSON(t, w)
|
||||
attendee := result["attendee"].(map[string]any)
|
||||
if attendee["checked_in_count"] != float64(1) {
|
||||
t.Errorf("checked_in_count = %v, want 1", attendee["checked_in_count"])
|
||||
ticket := result["ticket"].(map[string]any)
|
||||
if ticket["checked_in_at"] == nil {
|
||||
t.Error("checked_in_at should be set after check-in")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateRoleCanCheckIn(t *testing.T) {
|
||||
func TestGatekeeperRoleCanCheckIn(t *testing.T) {
|
||||
app := testApp(t)
|
||||
gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{})
|
||||
token := testToken(t, app, gate)
|
||||
mux := testMux(app)
|
||||
|
||||
app.createAttendee(Attendee{Name: "Puck"})
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@example.com"})
|
||||
tk, _ := app.createTicket(Ticket{ParticipantID: &p.ID, Name: "Puck", Source: "manual"})
|
||||
|
||||
req := testAuthRequest("POST", "/api/attendees/1/checkin", nil, token)
|
||||
req := testAuthRequest("POST", "/api/tickets/"+itoa(tk.ID)+"/checkin", nil, token)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("gate checkin: status = %d", w.Code)
|
||||
t.Errorf("gatekeeper checkin: status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateRoleCannotDelete(t *testing.T) {
|
||||
func TestGatekeeperRoleCannotDelete(t *testing.T) {
|
||||
app := testApp(t)
|
||||
gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{})
|
||||
token := testToken(t, app, gate)
|
||||
mux := testMux(app)
|
||||
|
||||
app.createAttendee(Attendee{Name: "Puck"})
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@example.com"})
|
||||
|
||||
req := testAuthRequest("DELETE", "/api/attendees/1", nil, token)
|
||||
req := testAuthRequest("DELETE", "/api/participants/"+itoa(p.ID), nil, token)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("gate delete: status = %d, want 403", w.Code)
|
||||
t.Errorf("gatekeeper delete: status = %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,17 +79,6 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
|||
app.handleGetSettings(w, r)
|
||||
}
|
||||
|
||||
func (app *App) handleResetAttendees(w http.ResponseWriter, r *http.Request) {
|
||||
ts := now()
|
||||
result, err := app.db.Exec(`UPDATE attendees SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL`, ts, ts)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
writeJSON(w, map[string]any{"deleted": n})
|
||||
}
|
||||
|
||||
func (app *App) handleResetTickets(w http.ResponseWriter, r *http.Request) {
|
||||
ts := now()
|
||||
result, err := app.db.Exec(`UPDATE tickets SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL`, ts, ts)
|
||||
|
|
|
|||
|
|
@ -55,17 +55,19 @@ func TestUpdateSettings(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestResetAttendees(t *testing.T) {
|
||||
func TestResetTickets(t *testing.T) {
|
||||
app := testApp(t)
|
||||
admin := testAdminUser(t, app)
|
||||
token := testToken(t, app, admin)
|
||||
mux := testMux(app)
|
||||
|
||||
app.createAttendee(Attendee{Name: "Titania", Email: "titania@example.com"})
|
||||
app.createAttendee(Attendee{Name: "Oberon", Email: "oberon@example.com"})
|
||||
p1, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"})
|
||||
p2, _ := app.createParticipant(Participant{PreferredName: "Oberon", Email: "oberon@example.com"})
|
||||
app.createTicket(Ticket{ParticipantID: &p1.ID, Name: "Titania", Source: "manual"})
|
||||
app.createTicket(Ticket{ParticipantID: &p2.ID, Name: "Oberon", Source: "manual"})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/reset-attendees", nil, token))
|
||||
mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/reset-tickets", nil, token))
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
|
||||
|
|
@ -75,20 +77,20 @@ func TestResetAttendees(t *testing.T) {
|
|||
t.Fatalf("deleted = %v, want 2", result["deleted"])
|
||||
}
|
||||
|
||||
attendees, _ := app.listAttendees("", "", "")
|
||||
if len(attendees) != 0 {
|
||||
t.Fatalf("attendees remaining = %d, want 0", len(attendees))
|
||||
tickets, _ := app.listTickets(nil, "")
|
||||
if len(tickets) != 0 {
|
||||
t.Fatalf("tickets remaining = %d, want 0", len(tickets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetAttendeesRequiresAdmin(t *testing.T) {
|
||||
func TestResetTicketsRequiresAdmin(t *testing.T) {
|
||||
app := testApp(t)
|
||||
gate := testUserWithRole(t, app, "gate1", "gatekeeper", []int{})
|
||||
token := testToken(t, app, gate)
|
||||
mux := testMux(app)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/reset-attendees", nil, token))
|
||||
mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/reset-tickets", nil, token))
|
||||
|
||||
if w.Code != 403 {
|
||||
t.Fatalf("status = %d, want 403", w.Code)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ func (app *App) handleSyncPull(w http.ResponseWriter, r *http.Request) {
|
|||
since := r.URL.Query().Get("since")
|
||||
|
||||
event, _ := app.getEvent()
|
||||
attendees, _ := app.attendeesSince(since)
|
||||
participants, _ := app.listParticipants("", since)
|
||||
tickets, _ := app.listTickets(nil, since)
|
||||
departments, _ := app.listDepartments(since)
|
||||
|
|
@ -20,9 +19,6 @@ func (app *App) handleSyncPull(w http.ResponseWriter, r *http.Request) {
|
|||
shifts, _ := app.listShifts(nil, "", since)
|
||||
volunteerShifts, _ := app.listVolunteerShifts(since)
|
||||
|
||||
if attendees == nil {
|
||||
attendees = []Attendee{}
|
||||
}
|
||||
if participants == nil {
|
||||
participants = []Participant{}
|
||||
}
|
||||
|
|
@ -45,7 +41,6 @@ func (app *App) handleSyncPull(w http.ResponseWriter, r *http.Request) {
|
|||
writeJSON(w, map[string]any{
|
||||
"server_time": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
"event": event,
|
||||
"attendees": attendees,
|
||||
"participants": participants,
|
||||
"tickets": tickets,
|
||||
"departments": departments,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ func TestSyncPullFull(t *testing.T) {
|
|||
token := testToken(t, app, admin)
|
||||
mux := testMux(app)
|
||||
|
||||
app.createAttendee(Attendee{Name: "Titania"})
|
||||
app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"})
|
||||
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
||||
deptID := dept.ID
|
||||
app.createVolunteer(Volunteer{Name: "Titania", DepartmentID: &deptID})
|
||||
|
|
@ -31,9 +31,9 @@ func TestSyncPullFull(t *testing.T) {
|
|||
if result["server_time"] == nil {
|
||||
t.Error("missing server_time")
|
||||
}
|
||||
attendees := result["attendees"].([]any)
|
||||
if len(attendees) != 1 {
|
||||
t.Errorf("attendees = %d, want 1", len(attendees))
|
||||
participants := result["participants"].([]any)
|
||||
if len(participants) != 1 {
|
||||
t.Errorf("participants = %d, want 1", len(participants))
|
||||
}
|
||||
depts := result["departments"].([]any)
|
||||
if len(depts) != 1 {
|
||||
|
|
@ -47,29 +47,29 @@ func TestSyncPullIncremental(t *testing.T) {
|
|||
token := testToken(t, app, admin)
|
||||
mux := testMux(app)
|
||||
|
||||
app.createAttendee(Attendee{Name: "Titania"})
|
||||
p1, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"})
|
||||
// Backdate Titania so she falls before the "since" cutoff
|
||||
app.db.Exec(`UPDATE attendees SET updated_at = '2026-01-01T00:00:00Z' WHERE name = 'Titania'`)
|
||||
app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, p1.ID)
|
||||
|
||||
since := "2026-01-01T12:00:00Z"
|
||||
|
||||
// Oberon created with default updated_at (now), which is after our since
|
||||
app.createAttendee(Attendee{Name: "Oberon"})
|
||||
app.createParticipant(Participant{PreferredName: "Oberon", Email: "oberon@example.com"})
|
||||
|
||||
req := testAuthRequest("GET", "/api/sync/pull?since="+since, nil, token)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
result := parseJSON(t, w)
|
||||
attendees := result["attendees"].([]any)
|
||||
participants := result["participants"].([]any)
|
||||
// Should only include Oberon (created after `since`)
|
||||
if len(attendees) != 1 {
|
||||
t.Errorf("incremental: got %d attendees, want 1", len(attendees))
|
||||
if len(participants) != 1 {
|
||||
t.Errorf("incremental: got %d participants, want 1", len(participants))
|
||||
}
|
||||
if len(attendees) == 1 {
|
||||
a := attendees[0].(map[string]any)
|
||||
if a["name"] != "Oberon" {
|
||||
t.Errorf("name = %v, want Oberon", a["name"])
|
||||
if len(participants) == 1 {
|
||||
p := participants[0].(map[string]any)
|
||||
if p["preferred_name"] != "Oberon" {
|
||||
t.Errorf("preferred_name = %v, want Oberon", p["preferred_name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -80,31 +80,31 @@ func TestSyncPullIncludesSoftDeleted(t *testing.T) {
|
|||
token := testToken(t, app, admin)
|
||||
mux := testMux(app)
|
||||
|
||||
a, _ := app.createAttendee(Attendee{Name: "Titania"})
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"})
|
||||
// Backdate Titania's creation so the since cutoff is between creation and deletion
|
||||
app.db.Exec(`UPDATE attendees SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, a.ID)
|
||||
app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, p.ID)
|
||||
|
||||
since := "2026-01-01T12:00:00Z"
|
||||
|
||||
// Delete updates updated_at to now(), which is after our since
|
||||
app.deleteAttendee(a.ID)
|
||||
app.deleteParticipant(p.ID)
|
||||
|
||||
req := testAuthRequest("GET", "/api/sync/pull?since="+since, nil, token)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
var result struct {
|
||||
Attendees []struct {
|
||||
Participants []struct {
|
||||
ID int `json:"id"`
|
||||
DeletedAt *string `json:"deleted_at"`
|
||||
} `json:"attendees"`
|
||||
} `json:"participants"`
|
||||
}
|
||||
json.Unmarshal(w.Body.Bytes(), &result)
|
||||
|
||||
if len(result.Attendees) != 1 {
|
||||
t.Fatalf("got %d attendees, want 1", len(result.Attendees))
|
||||
if len(result.Participants) != 1 {
|
||||
t.Fatalf("got %d participants, want 1", len(result.Participants))
|
||||
}
|
||||
if result.Attendees[0].DeletedAt == nil {
|
||||
if result.Participants[0].DeletedAt == nil {
|
||||
t.Error("deleted_at should be set for soft-deleted record")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
main.go
13
main.go
|
|
@ -99,18 +99,6 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
|
|||
mux.HandleFunc("GET /api/event", auth(app.handleGetEvent))
|
||||
mux.HandleFunc("PUT /api/event", auth(app.handleUpdateEvent, "admin", "ticketing"))
|
||||
|
||||
mux.HandleFunc("GET /api/attendees", auth(app.handleListAttendees, "admin", "ticketing", "gatekeeper"))
|
||||
mux.HandleFunc("POST /api/attendees", auth(app.handleCreateAttendee, "admin", "ticketing"))
|
||||
mux.HandleFunc("GET /api/attendees/export", auth(app.handleExportAttendees, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/attendees/generate-tokens", auth(app.handleGenerateTokens, "admin", "ticketing"))
|
||||
mux.HandleFunc("GET /api/attendees/export-tokens", auth(app.handleExportTokenLinks, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/attendees/email-tokens", auth(app.handleEmailAllTokens, "admin", "ticketing"))
|
||||
mux.HandleFunc("GET /api/attendees/{id}", auth(app.handleGetAttendee, "admin", "ticketing", "gatekeeper"))
|
||||
mux.HandleFunc("PUT /api/attendees/{id}", auth(app.handleUpdateAttendee, "admin", "ticketing"))
|
||||
mux.HandleFunc("DELETE /api/attendees/{id}", auth(app.handleDeleteAttendee, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/attendees/{id}/checkin", auth(app.handleCheckInAttendee, "admin", "ticketing", "gatekeeper"))
|
||||
mux.HandleFunc("POST /api/attendees/{id}/email-token", auth(app.handleEmailToken, "admin", "ticketing"))
|
||||
|
||||
mux.HandleFunc("GET /api/participants", auth(app.handleListParticipants, "admin", "ticketing", "gatekeeper"))
|
||||
mux.HandleFunc("POST /api/participants", auth(app.handleCreateParticipant, "admin", "ticketing"))
|
||||
mux.HandleFunc("GET /api/participants/export", auth(app.handleExportParticipants, "admin", "ticketing"))
|
||||
|
|
@ -157,7 +145,6 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
|
|||
mux.HandleFunc("GET /api/settings", auth(app.handleGetSettings, "admin", "ticketing"))
|
||||
mux.HandleFunc("PUT /api/settings", auth(app.handleUpdateSettings, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/settings/test-email", auth(app.handleTestEmail, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/settings/reset-attendees", auth(app.handleResetAttendees, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/settings/reset-tickets", auth(app.handleResetTickets, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/settings/reset-volunteers", auth(app.handleResetVolunteers, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/settings/reset-shifts", auth(app.handleResetShifts, "admin", "ticketing"))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue