Switched to path routing. Added data management.

This commit is contained in:
Pen Anderson 2026-03-03 19:55:35 -06:00
parent 8dc5d3ed01
commit 4bba0ed3a0
14 changed files with 256 additions and 46 deletions

View file

@ -8,6 +8,7 @@ Turnpike handles gate check-in, volunteer scheduling, and department coordinatio
- **Attendee management** — CSV import (CrowdWork/Zeffy auto-detected), party-size tracking, search, check-in
- **Volunteer scheduling** — departments, shifts with capacity, conflict detection, drag-and-drop reordering
- **Public volunteer signup** — self-registration form with email confirmation, auto-attendee linking
- **Volunteer kiosk** — token-authenticated self-service shift signup, no login required
- **Gate check-in** — full-screen UI with QR scanner, party check-in ("2/3 checked in"), volunteer dual check-in
- **Schedule board** — department leads and coordinators manage shift assignments with conflict awareness
@ -90,7 +91,7 @@ The Vite dev server runs on `:5173` and proxies `/api` requests to the Go server
## Documentation
- [Usage Guide](docs/USAGE.md) — event setup, attendee import, volunteer kiosk, gate check-in, schedule board
- [Usage Guide](docs/USAGE.md) — event setup, attendee import, volunteer signup, volunteer kiosk, gate check-in, schedule board
- [Installation Guide](docs/INSTALLATION.md) — building, deploying, systemd, Docker, NixOS, reverse proxy, backup
## License

View file

@ -65,6 +65,36 @@ The import result shows `inserted` (new records), `grouped` (merged into existin
Re-importing the same CSV is safe — existing records are skipped, not duplicated.
## Volunteer Signup
Turnpike provides a public signup form for volunteers at `/#/volunteer-signup`. No login is required.
### Signup flow
1. Volunteer visits the signup form and fills in: preferred name (required), ticket name, email (required), pronouns, phone, department preference, and an optional note.
2. Turnpike creates a volunteer record and auto-links it to an existing attendee by email match, or creates a new attendee record.
3. A confirmation email is sent with a unique link (`/#/confirm/{token}`).
4. The volunteer clicks the link to confirm their email.
5. If shift signups are already open, the confirmation page includes a link to the kiosk for shift selection.
Duplicate signups with the same email silently succeed — no error is shown and no duplicate is created. This prevents email enumeration.
### Configuring the signup form
In **Settings**, the "Volunteer Signup" card controls:
- **Note field label** — customize the label shown on the form (default: "Additional note")
- **Note field required** — when checked, volunteers must fill in the note to submit
### Opening shift signups
In **Settings**, the "Shift Signups" card has an open/close toggle:
- **Opening** signups generates kiosk tokens for all confirmed volunteers and emails them their shift signup links. A confirmation dialog warns before sending.
- **Closing** signups prevents new kiosk links from being issued on confirmation, but existing links continue to work.
If a volunteer confirms their email while signups are already open, they receive their kiosk link immediately in the confirmation response and via email.
## Managing Volunteers
Under **Volunteers**, you can:

View file

@ -133,7 +133,7 @@ func (app *App) sendTokenEmail(a Attendee) error {
cfg := app.loadSMTPConfig()
eventName := app.eventName()
link := fmt.Sprintf("%s/#/v/%s", app.resolveBaseURL(), *a.VolunteerToken)
link := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), *a.VolunteerToken)
subject := fmt.Sprintf("Your volunteer link for %s", eventName)
body := fmt.Sprintf(
"Hi %s,\n\nThank you for volunteering at %s!\n\nYour volunteer token: %s\nYour signup link: %s\n\nUse this link to sign up for available shifts in your department.\n\nSee you there!\n",
@ -146,7 +146,7 @@ func (app *App) sendTokenEmail(a Attendee) error {
func (app *App) sendConfirmationEmail(to, name, confirmToken string) error {
cfg := app.loadSMTPConfig()
eventName := app.eventName()
link := fmt.Sprintf("%s/#/confirm/%s", app.resolveBaseURL(), confirmToken)
link := fmt.Sprintf("%s/confirm/%s", app.resolveBaseURL(), confirmToken)
subject := fmt.Sprintf("Please confirm your email for %s", eventName)
body := fmt.Sprintf(
"Hi %s,\n\nThank you for signing up to volunteer at %s!\n\nPlease confirm your email address by visiting:\n%s\n\nIf you did not sign up, you can safely ignore this email.\n",

View file

@ -23,16 +23,22 @@
let session = $state(null)
let loading = $state(true)
let route = $state(window.location.hash || '#/')
let route = $state(window.location.pathname)
let updateAvailable = $state(false)
let mobileNavOpen = $state(false)
// Check if this is a public page (no auth needed)
const kioskToken = $derived(window.location.hash.match(/^#\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
const isVolunteerSignup = $derived(window.location.hash.startsWith('#/volunteer-signup'))
const isConfirmEmail = $derived(window.location.hash.startsWith('#/confirm/'))
const kioskToken = $derived(route.match(/^\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
const isVolunteerSignup = $derived(route.startsWith('/volunteer-signup'))
const isConfirmEmail = $derived(route.startsWith('/confirm/'))
const isPublicPage = $derived(!!kioskToken || isVolunteerSignup || isConfirmEmail)
function navigate(path) {
history.pushState(null, '', path)
route = path
mobileNavOpen = false
}
async function checkVersion() {
try {
const res = await fetch('/api/version')
@ -56,8 +62,8 @@
startSSE()
startSyncLoop()
}
window.addEventListener('hashchange', () => {
route = window.location.hash || '#/'
window.addEventListener('popstate', () => {
route = window.location.pathname
mobileNavOpen = false
})
@ -67,17 +73,17 @@
function onLogin(s) {
session = s
window.location.hash = '#/'
navigate('/')
syncPull().then(() => { startSSE(); startSyncLoop() })
}
async function onLogout() {
await clearSession()
session = null
window.location.hash = '#/login'
navigate('/login')
}
const path = $derived(route.replace(/^#/, '') || '/')
const path = $derived(route || '/')
const role = $derived(session?.user?.role ?? '')
</script>
@ -107,7 +113,7 @@
{#if mobileNavOpen}
<div class="nav-overlay" onclick={() => mobileNavOpen = false} onkeydown={() => {}}></div>
{/if}
<Nav {session} {onLogout} active={path} open={mobileNavOpen} />
<Nav {session} {onLogout} {navigate} active={path} open={mobileNavOpen} />
<div class="main">
<header class="mobile-header">
<button class="hamburger" onclick={() => mobileNavOpen = !mobileNavOpen} aria-label="Menu">

View file

@ -18,7 +18,7 @@ export async function apiFetch(path, options = {}) {
const res = await fetch(path, { ...options, headers })
if (res.status === 401) {
await db.session.clear()
window.location.hash = '#/login'
window.location.pathname = '/login'
throw new Error('unauthorized')
}
return res
@ -110,6 +110,11 @@ 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' }),
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' }),
},
signup: {
config: () => kioskFetch('/api/public/signup-config'),

View file

@ -1,7 +1,7 @@
<script>
import { LayoutDashboard, ClipboardCheck, Heart, Hexagon, Clock, CalendarDays, Upload, Users, Settings, LogOut } from 'lucide-svelte'
let { session, active, onLogout, open = false } = $props()
let { session, active, onLogout, navigate, open = false } = $props()
const role = $derived(session?.user?.role ?? '')
@ -9,45 +9,45 @@
const links = $derived.by(() => {
if (role === 'ticketing') return [
{ href: '#/attendees', label: 'Attendees', icon: ClipboardCheck },
{ href: '#/import', label: 'Import', icon: Upload },
{ href: '/attendees', label: 'Attendees', icon: ClipboardCheck },
{ href: '/import', label: 'Import', icon: Upload },
]
if (role === 'volunteer_lead') return [
{ href: '#/', label: 'Schedule', icon: CalendarDays },
{ href: '#/volunteers', label: 'Volunteers', icon: Heart },
{ href: '#/departments', label: 'Departments', icon: Hexagon },
{ href: '/', label: 'Schedule', icon: CalendarDays },
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
{ href: '/departments', label: 'Departments', icon: Hexagon },
]
if (role === 'coordinator') return [
{ href: '#/', label: 'Dashboard', icon: LayoutDashboard },
{ href: '#/schedule', label: 'Schedule', icon: CalendarDays },
{ href: '#/volunteers', label: 'Volunteers', icon: Heart },
{ href: '#/departments', label: 'Departments', icon: Hexagon },
{ href: '#/shifts', label: 'Shifts', icon: Clock },
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/schedule', label: 'Schedule', icon: CalendarDays },
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
{ href: '/departments', label: 'Departments', icon: Hexagon },
{ href: '/shifts', label: 'Shifts', icon: Clock },
]
return [
{ href: '#/', label: 'Dashboard', icon: LayoutDashboard },
{ href: '#/attendees', label: 'Attendees', icon: ClipboardCheck },
{ href: '#/volunteers', label: 'Volunteers', icon: Heart },
{ href: '#/departments', label: 'Departments', icon: Hexagon },
{ href: '#/shifts', label: 'Shifts', icon: Clock },
{ href: '#/schedule', label: 'Schedule', icon: CalendarDays },
{ href: '#/import', label: 'Import', icon: Upload },
{ href: '#/users', label: 'Users', icon: Users },
{ href: '#/settings', label: 'Settings', icon: Settings },
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/attendees', label: 'Attendees', icon: ClipboardCheck },
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
{ href: '/departments', label: 'Departments', icon: Hexagon },
{ href: '/shifts', label: 'Shifts', icon: Clock },
{ href: '/schedule', label: 'Schedule', icon: CalendarDays },
{ href: '/import', label: 'Import', icon: Upload },
{ href: '/users', label: 'Users', icon: Users },
{ href: '/settings', label: 'Settings', icon: Settings },
]
})
function isActive(href) {
const p = href.replace(/^#/, '')
if (p === '/') return active === '/' || active === ''
return active.startsWith(p)
if (href === '/') return active === '/' || active === ''
return active.startsWith(href)
}
</script>
<nav class="sidebar" class:open>
<div class="sidebar-brand">Turn<span>pike</span></div>
{#each links as link}
<a href={link.href} class="nav-link" class:active={isActive(link.href)}>
<a href={link.href} class="nav-link" class:active={isActive(link.href)}
onclick={(e) => { e.preventDefault(); navigate(link.href) }}>
<link.icon {...iconProps} />
{link.label}
</a>

View file

@ -7,7 +7,7 @@
let error = $state('')
onMount(async () => {
const match = window.location.hash.match(/^#\/confirm\/(.+)/)
const match = window.location.pathname.match(/^\/confirm\/(.+)/)
const token = match?.[1] ?? ''
if (!token) {
status = 'invalid'

View file

@ -2,8 +2,7 @@
import { onMount } from 'svelte'
import { api } from '../api.js'
// Token comes from the URL hash: /#/v/TOKEN
const token = $derived(window.location.hash.match(/^#\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
const token = $derived(window.location.pathname.match(/^\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
let state = $state(null) // { volunteer, shifts, available }
let loading = $state(true)

View file

@ -84,6 +84,18 @@
}
}
async function resetModel(label, fn) {
if (!confirm(`PERMANENTLY DELETE all ${label}? This cannot be undone.`)) return
error = ''
success = ''
try {
const result = await fn()
success = `Deleted ${result.deleted} ${label}.`
} catch (err) {
error = err.message
}
}
async function sendTest() {
if (!testEmail) return
testing = true
@ -186,12 +198,12 @@
Note field is required
</label>
<p class="text-muted" style="font-size:0.78rem;margin-top:0.75rem">
Signup form: <a href="/#/volunteer-signup" target="_blank" style="color:var(--c-accent)">/#/volunteer-signup</a>
Signup form: <a href="/volunteer-signup" target="_blank" style="color:var(--c-accent)">/volunteer-signup</a>
</p>
</div>
<!-- Shift Signups -->
<div class="card">
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Shift Signups</h2>
<div style="display:flex;align-items:center;gap:1rem">
<span style="font-size:0.875rem">
@ -212,5 +224,30 @@
</p>
{/if}
</div>
<!-- Data Management -->
<div class="card">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:0.5rem">Data Management</h2>
<p class="text-muted" style="font-size:0.78rem;margin-bottom:1rem">
Permanently delete all records of a given type. This cannot be undone.
</p>
<div style="display:flex;flex-wrap:wrap;gap:0.5rem">
<button class="btn btn-danger" onclick={() => resetModel('attendees', api.settings.resetAttendees)}>
Delete All Attendees
</button>
<button class="btn btn-danger" onclick={() => resetModel('volunteers', api.settings.resetVolunteers)}>
Delete All Volunteers
</button>
<button class="btn btn-danger" onclick={() => resetModel('shifts', api.settings.resetShifts)}>
Delete All Shifts
</button>
<button class="btn btn-danger" onclick={() => resetModel('departments', api.settings.resetDepartments)}>
Delete All Departments
</button>
<button class="btn btn-danger" onclick={() => resetModel('volunteer shift assignments', api.settings.resetVolunteerShifts)}>
Delete All Shift Assignments
</button>
</div>
</div>
{/if}
</div>

View file

@ -79,6 +79,61 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
app.handleGetSettings(w, r)
}
func (app *App) handleResetAttendees(w http.ResponseWriter, r *http.Request) {
result, err := app.db.Exec(`DELETE FROM attendees`)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
n, _ := result.RowsAffected()
writeJSON(w, map[string]any{"deleted": n})
}
func (app *App) handleResetVolunteers(w http.ResponseWriter, r *http.Request) {
result, err := app.db.Exec(`DELETE FROM volunteers`)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
n, _ := result.RowsAffected()
writeJSON(w, map[string]any{"deleted": n})
}
func (app *App) handleResetShifts(w http.ResponseWriter, r *http.Request) {
result, err := app.db.Exec(`DELETE FROM shifts`)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
n, _ := result.RowsAffected()
writeJSON(w, map[string]any{"deleted": n})
}
func (app *App) handleResetDepartments(w http.ResponseWriter, r *http.Request) {
result, err := app.db.Exec(`DELETE FROM departments`)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
n, _ := result.RowsAffected()
writeJSON(w, map[string]any{"deleted": n})
}
func (app *App) handleResetVolunteerShifts(w http.ResponseWriter, r *http.Request) {
result, err := app.db.Exec(`DELETE FROM volunteer_shifts`)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
n, _ := result.RowsAffected()
writeJSON(w, map[string]any{"deleted": n})
}
func (app *App) handleTestEmail(w http.ResponseWriter, r *http.Request) {
var body struct {
To string `json:"to"`

View file

@ -55,6 +55,78 @@ func TestUpdateSettings(t *testing.T) {
}
}
func TestResetAttendees(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"})
w := httptest.NewRecorder()
mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/reset-attendees", nil, token))
if w.Code != 200 {
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
}
result := parseJSON(t, w)
if result["deleted"] != float64(2) {
t.Fatalf("deleted = %v, want 2", result["deleted"])
}
attendees, _ := app.listAttendees("", "", "")
if len(attendees) != 0 {
t.Fatalf("attendees remaining = %d, want 0", len(attendees))
}
}
func TestResetAttendeesRequiresAdmin(t *testing.T) {
app := testApp(t)
gate := testUserWithRole(t, app, "gate1", "gate", []int{})
token := testToken(t, app, gate)
mux := testMux(app)
w := httptest.NewRecorder()
mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/reset-attendees", nil, token))
if w.Code != 403 {
t.Fatalf("status = %d, want 403", w.Code)
}
}
func TestResetDepartmentsCascadesShifts(t *testing.T) {
app := testApp(t)
admin := testAdminUser(t, app)
token := testToken(t, app, admin)
mux := testMux(app)
dept, _ := app.createDepartment(Department{Name: "Rangers"})
app.createShift(Shift{DepartmentID: dept.ID, Day: "2026-03-01", StartTime: "09:00", EndTime: "12:00", Capacity: 5})
shifts, _ := app.listShifts(nil, "", "")
if len(shifts) != 1 {
t.Fatalf("shifts before reset = %d, want 1", len(shifts))
}
w := httptest.NewRecorder()
mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/reset-departments", nil, token))
if w.Code != 200 {
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
}
depts, _ := app.listDepartments("")
if len(depts) != 0 {
t.Fatalf("departments remaining = %d, want 0", len(depts))
}
shifts, _ = app.listShifts(nil, "", "")
if len(shifts) != 0 {
t.Fatalf("shifts should cascade-delete, remaining = %d", len(shifts))
}
}
func TestSettingsNonAdminRejected(t *testing.T) {
app := testApp(t)
gate := testUserWithRole(t, app, "gateuser", "gate", []int{})

View file

@ -169,7 +169,7 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) {
}
}
if a != nil && a.VolunteerToken != nil {
kioskLink := fmt.Sprintf("%s/#/v/%s", app.resolveBaseURL(), *a.VolunteerToken)
kioskLink := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), *a.VolunteerToken)
response["kiosk_link"] = kioskLink
go func() {
if err := app.sendShiftSignupEmail(vol.Email, vol.PreferredName, kioskLink); err != nil {
@ -232,7 +232,7 @@ func (app *App) openShiftSignups() {
if a == nil || a.VolunteerToken == nil {
continue
}
kioskLink := fmt.Sprintf("%s/#/v/%s", baseURL, *a.VolunteerToken)
kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *a.VolunteerToken)
name := v.PreferredName
if name == "" {
name = v.Name

View file

@ -45,7 +45,7 @@ func (app *App) handleExportTokenLinks(w http.ResponseWriter, r *http.Request) {
if parts := strings.Fields(a.Name); len(parts) > 0 {
firstName = parts[0]
}
link := fmt.Sprintf("%s/#/v/%s", baseURL, *a.VolunteerToken)
link := fmt.Sprintf("%s/v/%s", baseURL, *a.VolunteerToken)
wr.Write([]string{a.Email, firstName, *a.VolunteerToken, link})
}
wr.Flush()

View file

@ -141,6 +141,11 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/settings", auth(app.handleGetSettings, "admin"))
mux.HandleFunc("PUT /api/settings", auth(app.handleUpdateSettings, "admin"))
mux.HandleFunc("POST /api/settings/test-email", auth(app.handleTestEmail, "admin"))
mux.HandleFunc("POST /api/settings/reset-attendees", auth(app.handleResetAttendees, "admin"))
mux.HandleFunc("POST /api/settings/reset-volunteers", auth(app.handleResetVolunteers, "admin"))
mux.HandleFunc("POST /api/settings/reset-shifts", auth(app.handleResetShifts, "admin"))
mux.HandleFunc("POST /api/settings/reset-departments", auth(app.handleResetDepartments, "admin"))
mux.HandleFunc("POST /api/settings/reset-volunteer-shifts", auth(app.handleResetVolunteerShifts, "admin"))
mux.HandleFunc("POST /api/import", auth(app.handleImport, "admin", "ticketing"))