Switched to path routing. Added data management.
This commit is contained in:
parent
8dc5d3ed01
commit
4bba0ed3a0
14 changed files with 256 additions and 46 deletions
|
|
@ -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
|
- **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
|
- **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
|
- **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
|
- **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
|
- **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
|
## 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
|
- [Installation Guide](docs/INSTALLATION.md) — building, deploying, systemd, Docker, NixOS, reverse proxy, backup
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
## Managing Volunteers
|
||||||
|
|
||||||
Under **Volunteers**, you can:
|
Under **Volunteers**, you can:
|
||||||
|
|
|
||||||
4
email.go
4
email.go
|
|
@ -133,7 +133,7 @@ func (app *App) sendTokenEmail(a Attendee) error {
|
||||||
|
|
||||||
cfg := app.loadSMTPConfig()
|
cfg := app.loadSMTPConfig()
|
||||||
eventName := app.eventName()
|
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)
|
subject := fmt.Sprintf("Your volunteer link for %s", eventName)
|
||||||
body := fmt.Sprintf(
|
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",
|
"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 {
|
func (app *App) sendConfirmationEmail(to, name, confirmToken string) error {
|
||||||
cfg := app.loadSMTPConfig()
|
cfg := app.loadSMTPConfig()
|
||||||
eventName := app.eventName()
|
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)
|
subject := fmt.Sprintf("Please confirm your email for %s", eventName)
|
||||||
body := fmt.Sprintf(
|
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",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -23,16 +23,22 @@
|
||||||
|
|
||||||
let session = $state(null)
|
let session = $state(null)
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
let route = $state(window.location.hash || '#/')
|
let route = $state(window.location.pathname)
|
||||||
let updateAvailable = $state(false)
|
let updateAvailable = $state(false)
|
||||||
let mobileNavOpen = $state(false)
|
let mobileNavOpen = $state(false)
|
||||||
|
|
||||||
// Check if this is a public page (no auth needed)
|
// Check if this is a public page (no auth needed)
|
||||||
const kioskToken = $derived(window.location.hash.match(/^#\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
|
const kioskToken = $derived(route.match(/^\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
|
||||||
const isVolunteerSignup = $derived(window.location.hash.startsWith('#/volunteer-signup'))
|
const isVolunteerSignup = $derived(route.startsWith('/volunteer-signup'))
|
||||||
const isConfirmEmail = $derived(window.location.hash.startsWith('#/confirm/'))
|
const isConfirmEmail = $derived(route.startsWith('/confirm/'))
|
||||||
const isPublicPage = $derived(!!kioskToken || isVolunteerSignup || isConfirmEmail)
|
const isPublicPage = $derived(!!kioskToken || isVolunteerSignup || isConfirmEmail)
|
||||||
|
|
||||||
|
function navigate(path) {
|
||||||
|
history.pushState(null, '', path)
|
||||||
|
route = path
|
||||||
|
mobileNavOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
async function checkVersion() {
|
async function checkVersion() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/version')
|
const res = await fetch('/api/version')
|
||||||
|
|
@ -56,8 +62,8 @@
|
||||||
startSSE()
|
startSSE()
|
||||||
startSyncLoop()
|
startSyncLoop()
|
||||||
}
|
}
|
||||||
window.addEventListener('hashchange', () => {
|
window.addEventListener('popstate', () => {
|
||||||
route = window.location.hash || '#/'
|
route = window.location.pathname
|
||||||
mobileNavOpen = false
|
mobileNavOpen = false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -67,17 +73,17 @@
|
||||||
|
|
||||||
function onLogin(s) {
|
function onLogin(s) {
|
||||||
session = s
|
session = s
|
||||||
window.location.hash = '#/'
|
navigate('/')
|
||||||
syncPull().then(() => { startSSE(); startSyncLoop() })
|
syncPull().then(() => { startSSE(); startSyncLoop() })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onLogout() {
|
async function onLogout() {
|
||||||
await clearSession()
|
await clearSession()
|
||||||
session = null
|
session = null
|
||||||
window.location.hash = '#/login'
|
navigate('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = $derived(route.replace(/^#/, '') || '/')
|
const path = $derived(route || '/')
|
||||||
const role = $derived(session?.user?.role ?? '')
|
const role = $derived(session?.user?.role ?? '')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -107,7 +113,7 @@
|
||||||
{#if mobileNavOpen}
|
{#if mobileNavOpen}
|
||||||
<div class="nav-overlay" onclick={() => mobileNavOpen = false} onkeydown={() => {}}></div>
|
<div class="nav-overlay" onclick={() => mobileNavOpen = false} onkeydown={() => {}}></div>
|
||||||
{/if}
|
{/if}
|
||||||
<Nav {session} {onLogout} active={path} open={mobileNavOpen} />
|
<Nav {session} {onLogout} {navigate} active={path} open={mobileNavOpen} />
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<header class="mobile-header">
|
<header class="mobile-header">
|
||||||
<button class="hamburger" onclick={() => mobileNavOpen = !mobileNavOpen} aria-label="Menu">
|
<button class="hamburger" onclick={() => mobileNavOpen = !mobileNavOpen} aria-label="Menu">
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export async function apiFetch(path, options = {}) {
|
||||||
const res = await fetch(path, { ...options, headers })
|
const res = await fetch(path, { ...options, headers })
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
await db.session.clear()
|
await db.session.clear()
|
||||||
window.location.hash = '#/login'
|
window.location.pathname = '/login'
|
||||||
throw new Error('unauthorized')
|
throw new Error('unauthorized')
|
||||||
}
|
}
|
||||||
return res
|
return res
|
||||||
|
|
@ -110,6 +110,11 @@ export const api = {
|
||||||
update: (data) => apiJSON('/api/settings', { method: 'PUT', body: JSON.stringify(data) }),
|
update: (data) => apiJSON('/api/settings', { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
testEmail: (to) => apiJSON('/api/settings/test-email', { method: 'POST', body: JSON.stringify({ to }) }),
|
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 }) }),
|
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: {
|
signup: {
|
||||||
config: () => kioskFetch('/api/public/signup-config'),
|
config: () => kioskFetch('/api/public/signup-config'),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script>
|
<script>
|
||||||
import { LayoutDashboard, ClipboardCheck, Heart, Hexagon, Clock, CalendarDays, Upload, Users, Settings, LogOut } from 'lucide-svelte'
|
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 ?? '')
|
const role = $derived(session?.user?.role ?? '')
|
||||||
|
|
||||||
|
|
@ -9,45 +9,45 @@
|
||||||
|
|
||||||
const links = $derived.by(() => {
|
const links = $derived.by(() => {
|
||||||
if (role === 'ticketing') return [
|
if (role === 'ticketing') return [
|
||||||
{ href: '#/attendees', label: 'Attendees', icon: ClipboardCheck },
|
{ href: '/attendees', label: 'Attendees', icon: ClipboardCheck },
|
||||||
{ href: '#/import', label: 'Import', icon: Upload },
|
{ href: '/import', label: 'Import', icon: Upload },
|
||||||
]
|
]
|
||||||
if (role === 'volunteer_lead') return [
|
if (role === 'volunteer_lead') return [
|
||||||
{ href: '#/', label: 'Schedule', icon: CalendarDays },
|
{ href: '/', label: 'Schedule', icon: CalendarDays },
|
||||||
{ href: '#/volunteers', label: 'Volunteers', icon: Heart },
|
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
||||||
{ href: '#/departments', label: 'Departments', icon: Hexagon },
|
{ href: '/departments', label: 'Departments', icon: Hexagon },
|
||||||
]
|
]
|
||||||
if (role === 'coordinator') return [
|
if (role === 'coordinator') return [
|
||||||
{ href: '#/', label: 'Dashboard', icon: LayoutDashboard },
|
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ href: '#/schedule', label: 'Schedule', icon: CalendarDays },
|
{ href: '/schedule', label: 'Schedule', icon: CalendarDays },
|
||||||
{ href: '#/volunteers', label: 'Volunteers', icon: Heart },
|
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
||||||
{ href: '#/departments', label: 'Departments', icon: Hexagon },
|
{ href: '/departments', label: 'Departments', icon: Hexagon },
|
||||||
{ href: '#/shifts', label: 'Shifts', icon: Clock },
|
{ href: '/shifts', label: 'Shifts', icon: Clock },
|
||||||
]
|
]
|
||||||
return [
|
return [
|
||||||
{ href: '#/', label: 'Dashboard', icon: LayoutDashboard },
|
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ href: '#/attendees', label: 'Attendees', icon: ClipboardCheck },
|
{ href: '/attendees', label: 'Attendees', icon: ClipboardCheck },
|
||||||
{ href: '#/volunteers', label: 'Volunteers', icon: Heart },
|
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
||||||
{ href: '#/departments', label: 'Departments', icon: Hexagon },
|
{ href: '/departments', label: 'Departments', icon: Hexagon },
|
||||||
{ href: '#/shifts', label: 'Shifts', icon: Clock },
|
{ href: '/shifts', label: 'Shifts', icon: Clock },
|
||||||
{ href: '#/schedule', label: 'Schedule', icon: CalendarDays },
|
{ href: '/schedule', label: 'Schedule', icon: CalendarDays },
|
||||||
{ href: '#/import', label: 'Import', icon: Upload },
|
{ href: '/import', label: 'Import', icon: Upload },
|
||||||
{ href: '#/users', label: 'Users', icon: Users },
|
{ href: '/users', label: 'Users', icon: Users },
|
||||||
{ href: '#/settings', label: 'Settings', icon: Settings },
|
{ href: '/settings', label: 'Settings', icon: Settings },
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
function isActive(href) {
|
function isActive(href) {
|
||||||
const p = href.replace(/^#/, '')
|
if (href === '/') return active === '/' || active === ''
|
||||||
if (p === '/') return active === '/' || active === ''
|
return active.startsWith(href)
|
||||||
return active.startsWith(p)
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav class="sidebar" class:open>
|
<nav class="sidebar" class:open>
|
||||||
<div class="sidebar-brand">Turn<span>pike</span></div>
|
<div class="sidebar-brand">Turn<span>pike</span></div>
|
||||||
{#each links as link}
|
{#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.icon {...iconProps} />
|
||||||
{link.label}
|
{link.label}
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const match = window.location.hash.match(/^#\/confirm\/(.+)/)
|
const match = window.location.pathname.match(/^\/confirm\/(.+)/)
|
||||||
const token = match?.[1] ?? ''
|
const token = match?.[1] ?? ''
|
||||||
if (!token) {
|
if (!token) {
|
||||||
status = 'invalid'
|
status = 'invalid'
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { api } from '../api.js'
|
import { api } from '../api.js'
|
||||||
|
|
||||||
// Token comes from the URL hash: /#/v/TOKEN
|
const token = $derived(window.location.pathname.match(/^\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
|
||||||
const token = $derived(window.location.hash.match(/^#\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
|
|
||||||
|
|
||||||
let state = $state(null) // { volunteer, shifts, available }
|
let state = $state(null) // { volunteer, shifts, available }
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
async function sendTest() {
|
||||||
if (!testEmail) return
|
if (!testEmail) return
|
||||||
testing = true
|
testing = true
|
||||||
|
|
@ -186,12 +198,12 @@
|
||||||
Note field is required
|
Note field is required
|
||||||
</label>
|
</label>
|
||||||
<p class="text-muted" style="font-size:0.78rem;margin-top:0.75rem">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Shift Signups -->
|
<!-- 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>
|
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Shift Signups</h2>
|
||||||
<div style="display:flex;align-items:center;gap:1rem">
|
<div style="display:flex;align-items:center;gap:1rem">
|
||||||
<span style="font-size:0.875rem">
|
<span style="font-size:0.875rem">
|
||||||
|
|
@ -212,5 +224,30 @@
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,61 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
app.handleGetSettings(w, r)
|
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) {
|
func (app *App) handleTestEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
var body struct {
|
var body struct {
|
||||||
To string `json:"to"`
|
To string `json:"to"`
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestSettingsNonAdminRejected(t *testing.T) {
|
||||||
app := testApp(t)
|
app := testApp(t)
|
||||||
gate := testUserWithRole(t, app, "gateuser", "gate", []int{})
|
gate := testUserWithRole(t, app, "gateuser", "gate", []int{})
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,7 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if a != nil && a.VolunteerToken != nil {
|
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
|
response["kiosk_link"] = kioskLink
|
||||||
go func() {
|
go func() {
|
||||||
if err := app.sendShiftSignupEmail(vol.Email, vol.PreferredName, kioskLink); err != nil {
|
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 {
|
if a == nil || a.VolunteerToken == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
kioskLink := fmt.Sprintf("%s/#/v/%s", baseURL, *a.VolunteerToken)
|
kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *a.VolunteerToken)
|
||||||
name := v.PreferredName
|
name := v.PreferredName
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = v.Name
|
name = v.Name
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ func (app *App) handleExportTokenLinks(w http.ResponseWriter, r *http.Request) {
|
||||||
if parts := strings.Fields(a.Name); len(parts) > 0 {
|
if parts := strings.Fields(a.Name); len(parts) > 0 {
|
||||||
firstName = 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.Write([]string{a.Email, firstName, *a.VolunteerToken, link})
|
||||||
}
|
}
|
||||||
wr.Flush()
|
wr.Flush()
|
||||||
|
|
|
||||||
5
main.go
5
main.go
|
|
@ -141,6 +141,11 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /api/settings", auth(app.handleGetSettings, "admin"))
|
mux.HandleFunc("GET /api/settings", auth(app.handleGetSettings, "admin"))
|
||||||
mux.HandleFunc("PUT /api/settings", auth(app.handleUpdateSettings, "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/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"))
|
mux.HandleFunc("POST /api/import", auth(app.handleImport, "admin", "ticketing"))
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue