Renamed and updated roles and privileges.

This commit is contained in:
Pen Anderson 2026-03-04 12:00:36 -06:00
parent cd8e1e3b3b
commit d30ee18e77
13 changed files with 112 additions and 72 deletions

View file

@ -95,7 +95,7 @@ func TestAuthMiddlewareRoleEnforcement(t *testing.T) {
mux := testMux(app) mux := testMux(app)
// Create a gate user — should not be able to access /api/users (admin only) // Create a gate user — should not be able to access /api/users (admin only)
gate := testUserWithRole(t, app, "gateuser", "gate", []int{}) gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{})
token := testToken(t, app, gate) token := testToken(t, app, gate)
req := testAuthRequest("GET", "/api/users", nil, token) req := testAuthRequest("GET", "/api/users", nil, token)

42
db.go
View file

@ -44,7 +44,7 @@ func migrate(db *sql.DB) error {
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin','coordinator','gate','ticketing','volunteer_lead')), role TEXT NOT NULL CHECK(role IN ('admin','ticketing','staffing','colead','gatekeeper')),
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
@ -260,6 +260,46 @@ func migrateV3(db *sql.DB) error {
AND v.deleted_at IS NULL AND v.deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM tickets t WHERE t.participant_id = v.participant_id AND t.deleted_at IS NULL)`) AND NOT EXISTS (SELECT 1 FROM tickets t WHERE t.participant_id = v.participant_id AND t.deleted_at IS NULL)`)
return migrateV4(db)
}
// migrateV4 renames roles: volunteer_lead→colead, coordinator→staffing, gate→gatekeeper.
func migrateV4(db *sql.DB) error {
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE role IN ('volunteer_lead','coordinator','gate')`).Scan(&count); err != nil || count == 0 {
return nil
}
if _, err := db.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
return err
}
stmts := []string{
`CREATE TABLE users_v4 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin','ticketing','staffing','colead','gatekeeper')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`INSERT INTO users_v4 (id, username, password_hash, role, created_at)
SELECT id, username, password_hash,
CASE role
WHEN 'volunteer_lead' THEN 'colead'
WHEN 'coordinator' THEN 'staffing'
WHEN 'gate' THEN 'gatekeeper'
ELSE role
END,
created_at
FROM users`,
`DROP TABLE users`,
`ALTER TABLE users_v4 RENAME TO users`,
`PRAGMA foreign_keys = ON`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
db.Exec(`PRAGMA foreign_keys = ON`)
return fmt.Errorf("migrateV4: %w", err)
}
}
return nil return nil
} }

View file

@ -4,7 +4,6 @@
import { syncPull, startSSE, startSyncLoop } from './sync.js' import { syncPull, startSSE, startSyncLoop } from './sync.js'
import Login from './pages/Login.svelte' import Login from './pages/Login.svelte'
import Dashboard from './pages/Dashboard.svelte' import Dashboard from './pages/Dashboard.svelte'
import Attendees from './pages/Attendees.svelte'
import Participants from './pages/Participants.svelte' import Participants from './pages/Participants.svelte'
import Volunteers from './pages/Volunteers.svelte' import Volunteers from './pages/Volunteers.svelte'
import Departments from './pages/Departments.svelte' import Departments from './pages/Departments.svelte'
@ -104,7 +103,7 @@
<ConfirmEmail /> <ConfirmEmail />
{:else if !session} {:else if !session}
<Login onlogin={onLogin} /> <Login onlogin={onLogin} />
{:else if role === 'gate'} {:else if role === 'gatekeeper'}
<!-- Gate users get the full-screen GateUI instead of the standard layout --> <!-- Gate users get the full-screen GateUI instead of the standard layout -->
<GateUI {session} {onLogout} /> <GateUI {session} {onLogout} />
{:else} {:else}
@ -122,13 +121,11 @@
<span class="mobile-brand">Turn<span class="accent">pike</span></span> <span class="mobile-brand">Turn<span class="accent">pike</span></span>
</header> </header>
{#if path === '/' || path === ''} {#if path === '/' || path === ''}
{#if role === 'volunteer_lead'} {#if role === 'colead'}
<ScheduleBoard {session} /> <ScheduleBoard {session} />
{:else} {:else}
<Dashboard {session} /> <Dashboard {session} />
{/if} {/if}
{:else if path.startsWith('/attendees')}
<Attendees {session} />
{:else if path.startsWith('/participants')} {:else if path.startsWith('/participants')}
<Participants {session} /> <Participants {session} />
{:else if path.startsWith('/volunteers')} {:else if path.startsWith('/volunteers')}

View file

@ -1,5 +1,5 @@
<script> <script>
import { LayoutDashboard, ClipboardCheck, Heart, Hexagon, CalendarDays, Upload, Users, Settings, LogOut, Ticket } from 'lucide-svelte' import { LayoutDashboard, Heart, Hexagon, CalendarDays, Upload, Users, Settings, LogOut, Ticket } from 'lucide-svelte'
let { session, active, onLogout, navigate, open = false } = $props() let { session, active, onLogout, navigate, open = false } = $props()
@ -8,17 +8,12 @@
const iconProps = { size: 18, strokeWidth: 1.75 } const iconProps = { size: 18, strokeWidth: 1.75 }
const links = $derived.by(() => { const links = $derived.by(() => {
if (role === 'ticketing') return [ if (role === 'colead') return [
{ href: '/participants', label: 'Participants', icon: Ticket },
{ href: '/attendees', label: 'Attendees', icon: ClipboardCheck },
{ href: '/import', label: 'Import', icon: Upload },
]
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 === 'staffing') 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 },
@ -27,7 +22,6 @@
return [ return [
{ href: '/', label: 'Dashboard', icon: LayoutDashboard }, { href: '/', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/participants', label: 'Participants', icon: Ticket }, { href: '/participants', label: 'Participants', icon: Ticket },
{ 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: '/schedule', label: 'Schedule', icon: CalendarDays }, { href: '/schedule', label: 'Schedule', icon: CalendarDays },

View file

@ -19,8 +19,8 @@
let saving = $state(false) let saving = $state(false)
const role = $derived(session?.user?.role ?? '') const role = $derived(session?.user?.role ?? '')
const canCreate = $derived(['admin', 'coordinator'].includes(role)) const canCreate = $derived(['admin', 'ticketing', 'staffing'].includes(role))
const canDelete = $derived(role === 'admin') const canDelete = $derived(['admin', 'ticketing'].includes(role))
const allDepts = liveQuery(() => const allDepts = liveQuery(() =>
db.departments.filter(d => !d.deleted_at).toArray() db.departments.filter(d => !d.deleted_at).toArray()

View file

@ -26,7 +26,7 @@
let assigning = $state(false) let assigning = $state(false)
const role = $derived(session?.user?.role ?? '') const role = $derived(session?.user?.role ?? '')
const canManage = $derived(['admin', 'coordinator', 'volunteer_lead'].includes(role)) const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role))
const myDeptIDs = $derived(session?.user?.department_ids ?? []) const myDeptIDs = $derived(session?.user?.department_ids ?? [])
const allDepts = liveQuery(() => const allDepts = liveQuery(() =>
@ -54,7 +54,7 @@
// Departments visible to this user // Departments visible to this user
const visibleDepts = $derived.by(() => { const visibleDepts = $derived.by(() => {
const depts = $allDepts ?? [] const depts = $allDepts ?? []
if (role === 'volunteer_lead') return depts.filter(d => myDeptIDs.includes(d.id)) if (role === 'colead') return depts.filter(d => myDeptIDs.includes(d.id))
return depts return depts
}) })

View file

@ -28,7 +28,7 @@
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name))) .then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
) )
const roles = ['admin', 'coordinator', 'ticketing', 'gate', 'volunteer_lead'] const roles = ['admin', 'ticketing', 'staffing', 'colead', 'gatekeeper']
const me = $derived(session?.user?.id) const me = $derived(session?.user?.id)

View file

@ -20,7 +20,7 @@
let newNote = $state('') let newNote = $state('')
const role = $derived(session?.user?.role ?? '') const role = $derived(session?.user?.role ?? '')
const canManage = $derived(['admin', 'coordinator', 'volunteer_lead'].includes(role)) const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role))
const allVolunteers = liveQuery(() => const allVolunteers = liveQuery(() =>
db.volunteers.filter(v => !v.deleted_at).toArray() db.volunteers.filter(v => !v.deleted_at).toArray()

View file

@ -78,7 +78,7 @@ func TestCheckInAttendeeHandler(t *testing.T) {
func TestGateRoleCanCheckIn(t *testing.T) { func TestGateRoleCanCheckIn(t *testing.T) {
app := testApp(t) app := testApp(t)
gate := testUserWithRole(t, app, "gateuser", "gate", []int{}) gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{})
token := testToken(t, app, gate) token := testToken(t, app, gate)
mux := testMux(app) mux := testMux(app)
@ -94,7 +94,7 @@ func TestGateRoleCanCheckIn(t *testing.T) {
func TestGateRoleCannotDelete(t *testing.T) { func TestGateRoleCannotDelete(t *testing.T) {
app := testApp(t) app := testApp(t)
gate := testUserWithRole(t, app, "gateuser", "gate", []int{}) gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{})
token := testToken(t, app, gate) token := testToken(t, app, gate)
mux := testMux(app) mux := testMux(app)

View file

@ -83,7 +83,7 @@ func TestResetAttendees(t *testing.T) {
func TestResetAttendeesRequiresAdmin(t *testing.T) { func TestResetAttendeesRequiresAdmin(t *testing.T) {
app := testApp(t) app := testApp(t)
gate := testUserWithRole(t, app, "gate1", "gate", []int{}) gate := testUserWithRole(t, app, "gate1", "gatekeeper", []int{})
token := testToken(t, app, gate) token := testToken(t, app, gate)
mux := testMux(app) mux := testMux(app)
@ -129,7 +129,7 @@ func TestResetDepartmentsCascadesShifts(t *testing.T) {
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", "gatekeeper", []int{})
token := testToken(t, app, gate) token := testToken(t, app, gate)
mux := testMux(app) mux := testMux(app)

View file

@ -17,7 +17,7 @@ func (app *App) handleListShifts(w http.ResponseWriter, r *http.Request) {
} }
claims := claimsFromContext(r) claims := claimsFromContext(r)
if claims.Role == "volunteer_lead" && deptID == nil && len(claims.DeptIDs) > 0 { if claims.Role == "colead" && deptID == nil && len(claims.DeptIDs) > 0 {
deptID = &claims.DeptIDs[0] deptID = &claims.DeptIDs[0]
} }
@ -40,7 +40,7 @@ func (app *App) handleCreateShift(w http.ResponseWriter, r *http.Request) {
return return
} }
claims := claimsFromContext(r) claims := claimsFromContext(r)
if claims.Role == "volunteer_lead" && !inSlice(s.DepartmentID, claims.DeptIDs) { if claims.Role == "colead" && !inSlice(s.DepartmentID, claims.DeptIDs) {
writeError(w, "forbidden: outside your department", http.StatusForbidden) writeError(w, "forbidden: outside your department", http.StatusForbidden)
return return
} }
@ -65,7 +65,7 @@ func (app *App) handleUpdateShift(w http.ResponseWriter, r *http.Request) {
return return
} }
claims := claimsFromContext(r) claims := claimsFromContext(r)
if claims.Role == "volunteer_lead" { if claims.Role == "colead" {
existing, _ := app.getShift(id) existing, _ := app.getShift(id)
if existing == nil || !inSlice(existing.DepartmentID, claims.DeptIDs) { if existing == nil || !inSlice(existing.DepartmentID, claims.DeptIDs) {
writeError(w, "forbidden: outside your department", http.StatusForbidden) writeError(w, "forbidden: outside your department", http.StatusForbidden)

View file

@ -20,7 +20,7 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) {
} }
claims := claimsFromContext(r) claims := claimsFromContext(r)
if claims.Role == "volunteer_lead" && deptID == nil && len(claims.DeptIDs) > 0 { if claims.Role == "colead" && deptID == nil && len(claims.DeptIDs) > 0 {
deptID = &claims.DeptIDs[0] deptID = &claims.DeptIDs[0]
} }
@ -43,12 +43,21 @@ func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) {
return return
} }
claims := claimsFromContext(r) claims := claimsFromContext(r)
if claims.Role == "volunteer_lead" { if claims.Role == "colead" {
if v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { if v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
writeError(w, "forbidden: outside your department", http.StatusForbidden) writeError(w, "forbidden: outside your department", http.StatusForbidden)
return return
} }
} }
if v.Email != "" && v.ParticipantID == nil {
p, _ := app.getParticipantByEmail(v.Email)
if p == nil {
p, _ = app.createParticipant(Participant{PreferredName: v.Name, Email: v.Email})
}
if p != nil {
v.ParticipantID = &p.ID
}
}
created, err := app.createVolunteer(v) created, err := app.createVolunteer(v)
if err != nil { if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
@ -88,7 +97,7 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) {
return return
} }
claims := claimsFromContext(r) claims := claimsFromContext(r)
if claims.Role == "volunteer_lead" { if claims.Role == "colead" {
existing, _ := app.getVolunteer(id) existing, _ := app.getVolunteer(id)
if existing == nil || existing.DepartmentID == nil || !inSlice(*existing.DepartmentID, claims.DeptIDs) { if existing == nil || existing.DepartmentID == nil || !inSlice(*existing.DepartmentID, claims.DeptIDs) {
writeError(w, "forbidden: outside your department", http.StatusForbidden) writeError(w, "forbidden: outside your department", http.StatusForbidden)

80
main.go
View file

@ -97,71 +97,71 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/me", auth(app.handleMe)) mux.HandleFunc("GET /api/me", auth(app.handleMe))
mux.HandleFunc("GET /api/event", auth(app.handleGetEvent)) mux.HandleFunc("GET /api/event", auth(app.handleGetEvent))
mux.HandleFunc("PUT /api/event", auth(app.handleUpdateEvent, "admin")) mux.HandleFunc("PUT /api/event", auth(app.handleUpdateEvent, "admin", "ticketing"))
mux.HandleFunc("GET /api/attendees", auth(app.handleListAttendees, "admin", "ticketing", "gate")) mux.HandleFunc("GET /api/attendees", auth(app.handleListAttendees, "admin", "ticketing", "gatekeeper"))
mux.HandleFunc("POST /api/attendees", auth(app.handleCreateAttendee, "admin", "ticketing")) mux.HandleFunc("POST /api/attendees", auth(app.handleCreateAttendee, "admin", "ticketing"))
mux.HandleFunc("GET /api/attendees/export", auth(app.handleExportAttendees, "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("POST /api/attendees/generate-tokens", auth(app.handleGenerateTokens, "admin", "ticketing"))
mux.HandleFunc("GET /api/attendees/export-tokens", auth(app.handleExportTokenLinks, "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("POST /api/attendees/email-tokens", auth(app.handleEmailAllTokens, "admin", "ticketing"))
mux.HandleFunc("GET /api/attendees/{id}", auth(app.handleGetAttendee, "admin", "ticketing", "gate")) 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("PUT /api/attendees/{id}", auth(app.handleUpdateAttendee, "admin", "ticketing"))
mux.HandleFunc("DELETE /api/attendees/{id}", auth(app.handleDeleteAttendee, "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", "gate")) 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("POST /api/attendees/{id}/email-token", auth(app.handleEmailToken, "admin", "ticketing"))
mux.HandleFunc("GET /api/participants", auth(app.handleListParticipants, "admin", "ticketing", "gate")) mux.HandleFunc("GET /api/participants", auth(app.handleListParticipants, "admin", "ticketing", "gatekeeper"))
mux.HandleFunc("POST /api/participants", auth(app.handleCreateParticipant, "admin", "ticketing")) mux.HandleFunc("POST /api/participants", auth(app.handleCreateParticipant, "admin", "ticketing"))
mux.HandleFunc("GET /api/participants/export", auth(app.handleExportParticipants, "admin", "ticketing")) mux.HandleFunc("GET /api/participants/export", auth(app.handleExportParticipants, "admin", "ticketing"))
mux.HandleFunc("GET /api/participants/{id}", auth(app.handleGetParticipant, "admin", "ticketing", "gate")) mux.HandleFunc("GET /api/participants/{id}", auth(app.handleGetParticipant, "admin", "ticketing", "gatekeeper"))
mux.HandleFunc("PUT /api/participants/{id}", auth(app.handleUpdateParticipant, "admin", "ticketing")) mux.HandleFunc("PUT /api/participants/{id}", auth(app.handleUpdateParticipant, "admin", "ticketing"))
mux.HandleFunc("DELETE /api/participants/{id}", auth(app.handleDeleteParticipant, "admin", "ticketing")) mux.HandleFunc("DELETE /api/participants/{id}", auth(app.handleDeleteParticipant, "admin", "ticketing"))
mux.HandleFunc("POST /api/participants/{id}/merge/{other_id}", auth(app.handleMergeParticipants, "admin", "ticketing")) mux.HandleFunc("POST /api/participants/{id}/merge/{other_id}", auth(app.handleMergeParticipants, "admin", "ticketing"))
mux.HandleFunc("GET /api/tickets", auth(app.handleListTickets, "admin", "ticketing", "gate")) mux.HandleFunc("GET /api/tickets", auth(app.handleListTickets, "admin", "ticketing", "gatekeeper"))
mux.HandleFunc("POST /api/tickets/{id}/checkin", auth(app.handleCheckInTicket, "admin", "ticketing", "gate")) mux.HandleFunc("POST /api/tickets/{id}/checkin", auth(app.handleCheckInTicket, "admin", "ticketing", "gatekeeper"))
mux.HandleFunc("POST /api/tickets/generate-codes", auth(app.handleGenerateTokens, "admin", "ticketing")) mux.HandleFunc("POST /api/tickets/generate-codes", auth(app.handleGenerateTokens, "admin", "ticketing"))
mux.HandleFunc("GET /api/tickets/export-links", auth(app.handleExportTokenLinks, "admin", "ticketing")) mux.HandleFunc("GET /api/tickets/export-links", auth(app.handleExportTokenLinks, "admin", "ticketing"))
mux.HandleFunc("POST /api/tickets/email-codes", auth(app.handleEmailAllTokens, "admin", "ticketing")) mux.HandleFunc("POST /api/tickets/email-codes", auth(app.handleEmailAllTokens, "admin", "ticketing"))
mux.HandleFunc("POST /api/tickets/{id}/email-code", auth(app.handleEmailToken, "admin", "ticketing")) mux.HandleFunc("POST /api/tickets/{id}/email-code", auth(app.handleEmailToken, "admin", "ticketing"))
mux.HandleFunc("GET /api/departments", auth(app.handleListDepartments)) mux.HandleFunc("GET /api/departments", auth(app.handleListDepartments))
mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "coordinator")) mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "ticketing", "staffing"))
mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "coordinator")) mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "ticketing", "staffing"))
mux.HandleFunc("DELETE /api/departments/{id}", auth(app.handleDeleteDepartment, "admin")) mux.HandleFunc("DELETE /api/departments/{id}", auth(app.handleDeleteDepartment, "admin", "ticketing"))
mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "coordinator", "volunteer_lead")) mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "ticketing", "staffing", "colead"))
mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "coordinator", "volunteer_lead")) mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "ticketing", "staffing", "colead"))
mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "coordinator", "volunteer_lead")) mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "ticketing", "staffing", "colead"))
mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "coordinator", "volunteer_lead")) mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "ticketing", "staffing", "colead"))
mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "coordinator", "volunteer_lead")) mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "ticketing", "staffing", "colead"))
mux.HandleFunc("POST /api/volunteers/{id}/checkin", auth(app.handleCheckInVolunteer, "admin", "coordinator", "volunteer_lead")) mux.HandleFunc("POST /api/volunteers/{id}/checkin", auth(app.handleCheckInVolunteer, "admin", "ticketing", "staffing", "colead"))
mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "coordinator", "volunteer_lead")) mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "ticketing", "staffing", "colead"))
mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "coordinator", "volunteer_lead")) mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "ticketing", "staffing", "colead"))
mux.HandleFunc("GET /api/shifts", auth(app.handleListShifts, "admin", "coordinator", "volunteer_lead")) mux.HandleFunc("GET /api/shifts", auth(app.handleListShifts, "admin", "ticketing", "staffing", "colead"))
mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "coordinator", "volunteer_lead")) mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "ticketing", "staffing", "colead"))
mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "coordinator", "volunteer_lead")) mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "ticketing", "staffing", "colead"))
mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "coordinator", "volunteer_lead")) mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "ticketing", "staffing", "colead"))
mux.HandleFunc("DELETE /api/shifts/{id}", auth(app.handleDeleteShift, "admin", "coordinator", "volunteer_lead")) mux.HandleFunc("DELETE /api/shifts/{id}", auth(app.handleDeleteShift, "admin", "ticketing", "staffing", "colead"))
mux.HandleFunc("POST /api/shifts/{id}/volunteers", auth(app.handleAssignShiftVolunteer, "admin", "coordinator", "volunteer_lead")) mux.HandleFunc("POST /api/shifts/{id}/volunteers", auth(app.handleAssignShiftVolunteer, "admin", "ticketing", "staffing", "colead"))
mux.HandleFunc("DELETE /api/shifts/{id}/volunteers/{volunteer_id}", auth(app.handleUnassignShiftVolunteer, "admin", "coordinator", "volunteer_lead")) mux.HandleFunc("DELETE /api/shifts/{id}/volunteers/{volunteer_id}", auth(app.handleUnassignShiftVolunteer, "admin", "ticketing", "staffing", "colead"))
mux.HandleFunc("GET /api/users", auth(app.handleListUsers, "admin")) mux.HandleFunc("GET /api/users", auth(app.handleListUsers, "admin", "ticketing"))
mux.HandleFunc("POST /api/users", auth(app.handleCreateUser, "admin")) mux.HandleFunc("POST /api/users", auth(app.handleCreateUser, "admin", "ticketing"))
mux.HandleFunc("PUT /api/users/{id}", auth(app.handleUpdateUser, "admin")) mux.HandleFunc("PUT /api/users/{id}", auth(app.handleUpdateUser, "admin", "ticketing"))
mux.HandleFunc("DELETE /api/users/{id}", auth(app.handleDeleteUser, "admin")) mux.HandleFunc("DELETE /api/users/{id}", auth(app.handleDeleteUser, "admin", "ticketing"))
mux.HandleFunc("GET /api/settings", auth(app.handleGetSettings, "admin")) mux.HandleFunc("GET /api/settings", auth(app.handleGetSettings, "admin", "ticketing"))
mux.HandleFunc("PUT /api/settings", auth(app.handleUpdateSettings, "admin")) mux.HandleFunc("PUT /api/settings", auth(app.handleUpdateSettings, "admin", "ticketing"))
mux.HandleFunc("POST /api/settings/test-email", auth(app.handleTestEmail, "admin")) mux.HandleFunc("POST /api/settings/test-email", auth(app.handleTestEmail, "admin", "ticketing"))
mux.HandleFunc("POST /api/settings/reset-attendees", auth(app.handleResetAttendees, "admin")) mux.HandleFunc("POST /api/settings/reset-attendees", auth(app.handleResetAttendees, "admin", "ticketing"))
mux.HandleFunc("POST /api/settings/reset-tickets", auth(app.handleResetTickets, "admin")) mux.HandleFunc("POST /api/settings/reset-tickets", auth(app.handleResetTickets, "admin", "ticketing"))
mux.HandleFunc("POST /api/settings/reset-volunteers", auth(app.handleResetVolunteers, "admin")) mux.HandleFunc("POST /api/settings/reset-volunteers", auth(app.handleResetVolunteers, "admin", "ticketing"))
mux.HandleFunc("POST /api/settings/reset-shifts", auth(app.handleResetShifts, "admin")) mux.HandleFunc("POST /api/settings/reset-shifts", auth(app.handleResetShifts, "admin", "ticketing"))
mux.HandleFunc("POST /api/settings/reset-departments", auth(app.handleResetDepartments, "admin")) mux.HandleFunc("POST /api/settings/reset-departments", auth(app.handleResetDepartments, "admin", "ticketing"))
mux.HandleFunc("POST /api/settings/reset-volunteer-shifts", auth(app.handleResetVolunteerShifts, "admin")) mux.HandleFunc("POST /api/settings/reset-volunteer-shifts", auth(app.handleResetVolunteerShifts, "admin", "ticketing"))
mux.HandleFunc("POST /api/import", auth(app.handleImport, "admin", "ticketing")) mux.HandleFunc("POST /api/import", auth(app.handleImport, "admin", "ticketing"))
@ -172,7 +172,7 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
writeJSON(w, map[string]string{"build": buildID}) writeJSON(w, map[string]string{"build": buildID})
}) })
mux.HandleFunc("POST /api/settings/shift-signups", auth(app.handleToggleShiftSignups, "admin", "volunteer_lead")) mux.HandleFunc("POST /api/settings/shift-signups", auth(app.handleToggleShiftSignups, "admin", "ticketing", "staffing"))
// Public endpoints — no JWT required. // Public endpoints — no JWT required.
mux.HandleFunc("GET /api/public/signup-config", app.handlePublicSignupConfig) mux.HandleFunc("GET /api/public/signup-config", app.handlePublicSignupConfig)