Cleaned up old Attendees model. Updated tests.

This commit is contained in:
Pen Anderson 2026-03-04 15:27:03 -06:00
parent 64ce97c74d
commit 260e017f79
14 changed files with 90 additions and 584 deletions

View file

@ -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' }),

View file

@ -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')
})

View file

@ -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 ?? ''

View file

@ -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',
])
})
})

View file

@ -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>

View file

@ -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)
}

View file

@ -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: [],