From 8dc5d3ed01378602e4396f71f5b487c8820c2916 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 3 Mar 2026 17:59:35 -0600 Subject: [PATCH 01/47] Added volunteer signup. --- db.go | 105 +++++-- email.go | 55 +++- frontend/src/App.svelte | 15 +- frontend/src/api.js | 6 + frontend/src/api.test.js | 47 +++ frontend/src/pages/ConfirmEmail.svelte | 148 ++++++++++ frontend/src/pages/Settings.svelte | 67 ++++- frontend/src/pages/VolunteerSignup.svelte | 241 ++++++++++++++++ handle_settings.go | 36 ++- handle_signup.go | 247 ++++++++++++++++ handle_signup_test.go | 333 ++++++++++++++++++++++ main.go | 7 + 12 files changed, 1258 insertions(+), 49 deletions(-) create mode 100644 frontend/src/pages/ConfirmEmail.svelte create mode 100644 frontend/src/pages/VolunteerSignup.svelte create mode 100644 handle_signup.go create mode 100644 handle_signup_test.go diff --git a/db.go b/db.go index ef3a339..bc97c91 100644 --- a/db.go +++ b/db.go @@ -135,6 +135,11 @@ func migrateV2(db *sql.DB) error { addColumnIfMissing(db, "attendees", "checked_in_count INTEGER NOT NULL DEFAULT 0") addColumnIfMissing(db, "shifts", "position INTEGER NOT NULL DEFAULT 0") addColumnIfMissing(db, "volunteer_shifts", "deleted_at TEXT") + addColumnIfMissing(db, "volunteers", "preferred_name TEXT NOT NULL DEFAULT ''") + addColumnIfMissing(db, "volunteers", "ticket_name TEXT NOT NULL DEFAULT ''") + addColumnIfMissing(db, "volunteers", "pronouns TEXT NOT NULL DEFAULT ''") + addColumnIfMissing(db, "volunteers", "email_confirmed INTEGER NOT NULL DEFAULT 0") + addColumnIfMissing(db, "volunteers", "confirmation_token TEXT") // Widen the uniqueness constraint from name-only to (name, ticket_id). db.Exec(`DROP INDEX IF EXISTS idx_attendees_name`) db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_attendees_name_ticket ON attendees(name, ticket_id) WHERE deleted_at IS NULL`) @@ -217,19 +222,24 @@ type Department struct { } type Volunteer struct { - ID int `json:"id"` - AttendeeID *int `json:"attendee_id,omitempty"` - Name string `json:"name"` - Email string `json:"email"` - Phone string `json:"phone"` - DepartmentID *int `json:"department_id,omitempty"` - IsLead bool `json:"is_lead"` - CheckedIn bool `json:"checked_in"` - CheckedInAt *string `json:"checked_in_at,omitempty"` - Note string `json:"note"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - DeletedAt *string `json:"deleted_at,omitempty"` + ID int `json:"id"` + AttendeeID *int `json:"attendee_id,omitempty"` + Name string `json:"name"` + PreferredName string `json:"preferred_name"` + TicketName string `json:"ticket_name"` + Email string `json:"email"` + Phone string `json:"phone"` + Pronouns string `json:"pronouns"` + DepartmentID *int `json:"department_id,omitempty"` + IsLead bool `json:"is_lead"` + CheckedIn bool `json:"checked_in"` + CheckedInAt *string `json:"checked_in_at,omitempty"` + EmailConfirmed bool `json:"email_confirmed"` + ConfirmationToken *string `json:"-"` + Note string `json:"note"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at,omitempty"` } type Shift struct { @@ -719,7 +729,7 @@ func queryDepartments(db *sql.DB, q string, args ...any) ([]Department, error) { // --- Volunteers --- -const volunteerCols = `id, attendee_id, name, email, phone, department_id, is_lead, checked_in, checked_in_at, note, created_at, updated_at, deleted_at` +const volunteerCols = `id, attendee_id, name, preferred_name, ticket_name, email, phone, pronouns, department_id, is_lead, checked_in, checked_in_at, email_confirmed, confirmation_token, note, created_at, updated_at, deleted_at` func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) { q := `SELECT ` + volunteerCols + ` FROM volunteers WHERE 1=1` @@ -763,9 +773,10 @@ func (app *App) getVolunteerByAttendeeID(attendeeID int) (*Volunteer, error) { func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { res, err := app.db.Exec( - `INSERT INTO volunteers (attendee_id, name, email, phone, department_id, is_lead, note, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - v.AttendeeID, v.Name, v.Email, v.Phone, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), + `INSERT INTO volunteers (attendee_id, name, preferred_name, ticket_name, email, phone, pronouns, department_id, is_lead, email_confirmed, confirmation_token, note, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + v.AttendeeID, v.Name, v.PreferredName, v.TicketName, v.Email, v.Phone, v.Pronouns, + v.DepartmentID, boolInt(v.IsLead), boolInt(v.EmailConfirmed), v.ConfirmationToken, v.Note, now(), ) if err != nil { return nil, err @@ -776,9 +787,10 @@ func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { func (app *App) updateVolunteer(v Volunteer) error { _, err := app.db.Exec( - `UPDATE volunteers SET attendee_id=?, name=?, email=?, phone=?, department_id=?, is_lead=?, note=?, updated_at=? + `UPDATE volunteers SET attendee_id=?, name=?, preferred_name=?, ticket_name=?, email=?, phone=?, pronouns=?, department_id=?, is_lead=?, note=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, - v.AttendeeID, v.Name, v.Email, v.Phone, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID, + v.AttendeeID, v.Name, v.PreferredName, v.TicketName, v.Email, v.Phone, v.Pronouns, + v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID, ) return err } @@ -822,10 +834,13 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { for rows.Next() { var v Volunteer var attendeeID, deptID sql.NullInt64 - var isLead, checkedIn int + var isLead, checkedIn, emailConfirmed int + var confirmationToken sql.NullString if err := rows.Scan( - &v.ID, &attendeeID, &v.Name, &v.Email, &v.Phone, &deptID, - &isLead, &checkedIn, &v.CheckedInAt, &v.Note, + &v.ID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName, + &v.Email, &v.Phone, &v.Pronouns, &deptID, + &isLead, &checkedIn, &v.CheckedInAt, + &emailConfirmed, &confirmationToken, &v.Note, &v.CreatedAt, &v.UpdatedAt, &v.DeletedAt, ); err != nil { return nil, err @@ -838,13 +853,59 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { id := int(deptID.Int64) v.DepartmentID = &id } + if confirmationToken.Valid { + v.ConfirmationToken = &confirmationToken.String + } v.IsLead = isLead == 1 v.CheckedIn = checkedIn == 1 + v.EmailConfirmed = emailConfirmed == 1 result = append(result, v) } return result, rows.Err() } +func (app *App) getVolunteerByEmail(email string) (*Volunteer, error) { + rows, err := queryVolunteers(app.db, + `SELECT `+volunteerCols+` FROM volunteers WHERE LOWER(email) = LOWER(?) AND deleted_at IS NULL LIMIT 1`, email) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +func (app *App) getVolunteerByConfirmationToken(token string) (*Volunteer, error) { + rows, err := queryVolunteers(app.db, + `SELECT `+volunteerCols+` FROM volunteers WHERE confirmation_token = ? AND deleted_at IS NULL LIMIT 1`, token) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +func (app *App) confirmVolunteerEmail(id int) error { + _, err := app.db.Exec( + `UPDATE volunteers SET email_confirmed = 1, confirmation_token = NULL, updated_at = ? WHERE id = ?`, + now(), id) + return err +} + +func (app *App) listConfirmedVolunteersWithoutKioskToken() ([]Volunteer, error) { + return queryVolunteers(app.db, ` + SELECT `+volunteerCols+` + FROM volunteers + WHERE email_confirmed = 1 AND deleted_at IS NULL + AND attendee_id IS NOT NULL + AND (SELECT a.volunteer_token FROM attendees a WHERE a.id = volunteers.attendee_id) IS NULL`) +} + +func generateConfirmationToken() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("read random: %w", err) + } + return fmt.Sprintf("%x", b), nil +} + // --- Shifts --- func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) { diff --git a/email.go b/email.go index 05c94f0..8d1d463 100644 --- a/email.go +++ b/email.go @@ -106,6 +106,22 @@ func sendEmail(cfg SMTPConfig, to, subject, body string) error { return smtp.SendMail(addr, auth, cfg.From, []string{to}, []byte(msg)) } +func (app *App) resolveBaseURL() string { + baseURL := app.baseURL + if baseURL == "" { + app.db.QueryRow(`SELECT value FROM config WHERE key = 'base_url'`).Scan(&baseURL) + } + return strings.TrimRight(baseURL, "/") +} + +func (app *App) eventName() string { + event, _ := app.getEvent() + if event != nil && event.Name != "" { + return event.Name + } + return "the event" +} + // sendTokenEmail sends a volunteer token link to the attendee's email address. func (app *App) sendTokenEmail(a Attendee) error { if a.Email == "" { @@ -116,20 +132,8 @@ func (app *App) sendTokenEmail(a Attendee) error { } cfg := app.loadSMTPConfig() - - baseURL := app.baseURL - if baseURL == "" { - app.db.QueryRow(`SELECT value FROM config WHERE key = 'base_url'`).Scan(&baseURL) - } - baseURL = strings.TrimRight(baseURL, "/") - - event, _ := app.getEvent() - eventName := "the event" - if event != nil && event.Name != "" { - eventName = event.Name - } - - link := fmt.Sprintf("%s/#/v/%s", baseURL, *a.VolunteerToken) + eventName := app.eventName() + 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", @@ -138,3 +142,26 @@ func (app *App) sendTokenEmail(a Attendee) error { return sendEmail(cfg, a.Email, subject, body) } + +func (app *App) sendConfirmationEmail(to, name, confirmToken string) error { + cfg := app.loadSMTPConfig() + eventName := app.eventName() + 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", + name, eventName, link, + ) + return sendEmail(cfg, to, subject, body) +} + +func (app *App) sendShiftSignupEmail(to, name, kioskLink string) error { + cfg := app.loadSMTPConfig() + eventName := app.eventName() + subject := fmt.Sprintf("Shift signups are open for %s!", eventName) + body := fmt.Sprintf( + "Hi %s,\n\nShift signups are now open for %s!\n\nUse this link to sign up for available shifts:\n%s\n\nSee you there!\n", + name, eventName, kioskLink, + ) + return sendEmail(cfg, to, subject, body) +} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 52ed392..179cc2c 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -11,6 +11,8 @@ import Users from './pages/Users.svelte' import Import from './pages/Import.svelte' import Kiosk from './pages/Kiosk.svelte' + import VolunteerSignup from './pages/VolunteerSignup.svelte' + import ConfirmEmail from './pages/ConfirmEmail.svelte' import GateUI from './pages/GateUI.svelte' import ScheduleBoard from './pages/ScheduleBoard.svelte' import Settings from './pages/Settings.svelte' @@ -25,8 +27,11 @@ let updateAvailable = $state(false) let mobileNavOpen = $state(false) - // Check if this is a kiosk token URL before doing anything else + // 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 isPublicPage = $derived(!!kioskToken || isVolunteerSignup || isConfirmEmail) async function checkVersion() { try { @@ -39,8 +44,8 @@ onMount(async () => { checkVersion() - // Kiosk pages don't need auth - if (kioskToken) { + // Public pages don't need auth + if (isPublicPage) { loading = false return } @@ -87,6 +92,10 @@ {:else if kioskToken} +{:else if isVolunteerSignup} + +{:else if isConfirmEmail} + {:else if !session} {:else if role === 'gate'} diff --git a/frontend/src/api.js b/frontend/src/api.js index c6f6e11..d7ea497 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -109,6 +109,12 @@ export const api = { get: () => apiJSON('/api/settings'), 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 }) }), + }, + signup: { + config: () => kioskFetch('/api/public/signup-config'), + submit: (data) => kioskFetch('/api/public/signup', { method: 'POST', body: JSON.stringify(data) }), + confirm: (token) => kioskFetch('/api/public/confirm', { method: 'POST', body: JSON.stringify({ token }) }), }, import: async (formData) => { const res = await apiFetch('/api/import', { method: 'POST', body: formData }) diff --git a/frontend/src/api.test.js b/frontend/src/api.test.js index 25e0dd9..f6527f5 100644 --- a/frontend/src/api.test.js +++ b/frontend/src/api.test.js @@ -96,3 +96,50 @@ describe('api methods', () => { expect(f.mock.calls[0][0]).toBe('/api/sync/pull') }) }) + +describe('signup methods', () => { + it('signup.config fetches config without auth', async () => { + const f = mockFetch({ departments: [], volunteer_note_label: 'Note' }) + await api.signup.config() + const [url, opts] = f.mock.calls[0] + expect(url).toBe('/api/public/signup-config') + expect(opts.headers['Authorization']).toBeUndefined() + }) + + it('signup.submit posts form data without auth', async () => { + const f = mockFetch({ ok: true }) + await api.signup.submit({ preferred_name: 'Titania', email: 'titania@example.com' }) + const [url, opts] = f.mock.calls[0] + expect(url).toBe('/api/public/signup') + expect(opts.method).toBe('POST') + expect(JSON.parse(opts.body)).toEqual({ preferred_name: 'Titania', email: 'titania@example.com' }) + expect(opts.headers['Authorization']).toBeUndefined() + }) + + it('signup.confirm posts token without auth', async () => { + const f = mockFetch({ status: 'confirmed' }) + await api.signup.confirm('abc123') + const [url, opts] = f.mock.calls[0] + expect(url).toBe('/api/public/confirm') + expect(opts.method).toBe('POST') + expect(JSON.parse(opts.body)).toEqual({ token: 'abc123' }) + expect(opts.headers['Authorization']).toBeUndefined() + }) + + it('signup.submit throws on 400', async () => { + mockFetch({ error: 'preferred name and email are required' }, 400) + await expect(api.signup.submit({})).rejects.toThrow('preferred name and email are required') + }) +}) + +describe('settings shift signups', () => { + it('toggleShiftSignups posts open flag', async () => { + await saveSession('tok', { id: 1 }) + const f = mockFetch({ shift_signups_open: true }) + await api.settings.toggleShiftSignups(true) + const [url, opts] = f.mock.calls[0] + expect(url).toBe('/api/settings/shift-signups') + expect(opts.method).toBe('POST') + expect(JSON.parse(opts.body)).toEqual({ open: true }) + }) +}) diff --git a/frontend/src/pages/ConfirmEmail.svelte b/frontend/src/pages/ConfirmEmail.svelte new file mode 100644 index 0000000..39babea --- /dev/null +++ b/frontend/src/pages/ConfirmEmail.svelte @@ -0,0 +1,148 @@ + + +
+
+
Turnpike  Email Confirmation
+
+ +
+ {#if status === 'loading'} +
Confirming...
+ {:else if status === 'confirmed'} +
+
+

Email Confirmed

+

+ Your email address has been verified. Thank you for signing up! +

+ {#if kioskLink} + + {/if} +
+ {:else if status === 'already_confirmed'} +
+

Already Confirmed

+

+ This email address was already confirmed. No further action needed. +

+
+ {:else if status === 'error'} +
{error}
+ {:else} +
+

Invalid Link

+

+ This confirmation link is not valid or has already been used. +

+
+ {/if} +
+
+ + diff --git a/frontend/src/pages/Settings.svelte b/frontend/src/pages/Settings.svelte index 060aefd..28193e4 100644 --- a/frontend/src/pages/Settings.svelte +++ b/frontend/src/pages/Settings.svelte @@ -16,6 +16,10 @@ let smtpFromName = $state('') let baseURL = $state('') let testEmail = $state('') + let noteLabel = $state('Additional note') + let noteRequired = $state(false) + let shiftSignupsOpen = $state(false) + let togglingSignups = $state(false) onMount(async () => { try { @@ -27,6 +31,9 @@ smtpFrom = s.smtp_from ?? '' smtpFromName = s.smtp_from_name ?? '' baseURL = s.base_url ?? '' + noteLabel = s.volunteer_note_label ?? 'Additional note' + noteRequired = s.volunteer_note_required ?? false + shiftSignupsOpen = s.shift_signups_open ?? false } catch (err) { error = err.message } finally { @@ -48,6 +55,8 @@ smtp_from: smtpFrom, smtp_from_name: smtpFromName, base_url: baseURL, + volunteer_note_label: noteLabel, + volunteer_note_required: noteRequired, }) smtpPassword = '' success = 'Settings saved.' @@ -58,6 +67,23 @@ } } + async function toggleSignups() { + const opening = !shiftSignupsOpen + if (opening && !confirm('This will email all confirmed volunteers their shift signup links. Continue?')) return + togglingSignups = true + error = '' + success = '' + try { + const result = await api.settings.toggleShiftSignups(opening) + shiftSignupsOpen = result.shift_signups_open + success = opening ? 'Shift signups opened. Emails are being sent.' : 'Shift signups closed.' + } catch (err) { + error = err.message + } finally { + togglingSignups = false + } + } + async function sendTest() { if (!testEmail) return testing = true @@ -135,7 +161,7 @@ -
+

Test Email

@@ -147,5 +173,44 @@
+ + +
+

Volunteer Signup

+
+ + +
+ +

+ Signup form: /#/volunteer-signup +

+
+ + +
+

Shift Signups

+
+ + Status: {shiftSignupsOpen ? 'Open' : 'Closed'} + + +
+ {#if !shiftSignupsOpen} +

+ Opening signups will email all confirmed volunteers their shift signup links. +

+ {/if} +
{/if}
diff --git a/frontend/src/pages/VolunteerSignup.svelte b/frontend/src/pages/VolunteerSignup.svelte new file mode 100644 index 0000000..8681265 --- /dev/null +++ b/frontend/src/pages/VolunteerSignup.svelte @@ -0,0 +1,241 @@ + + +
+
+
Turnpike  Volunteer Signup
+
+ +
+ {#if loading} +
Loading...
+ {:else if submitted} +
+

Thank you!

+

+ We've sent a confirmation email to {email}. + Please check your inbox and click the link to confirm your signup. +

+
+ {:else} + {#if config?.event_name && config.event_name !== 'the event'} + + {/if} + + {#if error} +
{error}
+ {/if} + +
+
+ + + + + + + + + {#if config?.departments?.length > 0} + + {/if} + + + + +
+
+ {/if} +
+
+ + diff --git a/handle_settings.go b/handle_settings.go index bf19d13..27420fa 100644 --- a/handle_settings.go +++ b/handle_settings.go @@ -19,14 +19,25 @@ func (app *App) handleGetSettings(w http.ResponseWriter, r *http.Request) { pass = "***" } + var noteLabel, noteRequired, signupsOpen string + app.db.QueryRow(`SELECT value FROM config WHERE key = 'volunteer_note_label'`).Scan(¬eLabel) + app.db.QueryRow(`SELECT value FROM config WHERE key = 'volunteer_note_required'`).Scan(¬eRequired) + app.db.QueryRow(`SELECT value FROM config WHERE key = 'shift_signups_open'`).Scan(&signupsOpen) + if noteLabel == "" { + noteLabel = "Additional note" + } + writeJSON(w, map[string]any{ - "smtp_host": cfg.Host, - "smtp_port": cfg.Port, - "smtp_user": cfg.User, - "smtp_password": pass, - "smtp_from": cfg.From, - "smtp_from_name": cfg.FromName, - "base_url": baseURL, + "smtp_host": cfg.Host, + "smtp_port": cfg.Port, + "smtp_user": cfg.User, + "smtp_password": pass, + "smtp_from": cfg.From, + "smtp_from_name": cfg.FromName, + "base_url": baseURL, + "volunteer_note_label": noteLabel, + "volunteer_note_required": noteRequired == "true", + "shift_signups_open": signupsOpen == "true", }) } @@ -37,7 +48,8 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) { return } - keys := []string{"smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_from_name", "base_url"} + keys := []string{"smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_from_name", "base_url", + "volunteer_note_label", "volunteer_note_required"} for _, k := range keys { v, ok := body[k] if !ok { @@ -47,11 +59,17 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) { switch vv := v.(type) { case string: if k == "smtp_password" && vv == "" { - continue // don't erase the stored password with an empty value + continue } val = vv case float64: val = strconv.Itoa(int(vv)) + case bool: + if vv { + val = "true" + } else { + val = "false" + } default: continue } diff --git a/handle_signup.go b/handle_signup.go new file mode 100644 index 0000000..62ab6c2 --- /dev/null +++ b/handle_signup.go @@ -0,0 +1,247 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strings" +) + +func (app *App) handlePublicSignupConfig(w http.ResponseWriter, r *http.Request) { + var noteLabel, noteRequired string + app.db.QueryRow(`SELECT value FROM config WHERE key = 'volunteer_note_label'`).Scan(¬eLabel) + app.db.QueryRow(`SELECT value FROM config WHERE key = 'volunteer_note_required'`).Scan(¬eRequired) + if noteLabel == "" { + noteLabel = "Additional note" + } + + depts, _ := app.listDepartments("") + deptList := []map[string]any{} + for _, d := range depts { + deptList = append(deptList, map[string]any{"id": d.ID, "name": d.Name, "color": d.Color}) + } + + eventName := app.eventName() + + writeJSON(w, map[string]any{ + "event_name": eventName, + "departments": deptList, + "volunteer_note_label": noteLabel, + "volunteer_note_required": noteRequired == "true", + }) +} + +func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) { + var body struct { + PreferredName string `json:"preferred_name"` + TicketName string `json:"ticket_name"` + Email string `json:"email"` + Pronouns string `json:"pronouns"` + Phone string `json:"phone"` + DepartmentID *int `json:"department_id"` + Note string `json:"note"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, "invalid request", http.StatusBadRequest) + return + } + + body.PreferredName = strings.TrimSpace(body.PreferredName) + body.Email = strings.TrimSpace(body.Email) + if body.PreferredName == "" || body.Email == "" { + writeError(w, "preferred name and email are required", http.StatusBadRequest) + return + } + + var noteRequired string + app.db.QueryRow(`SELECT value FROM config WHERE key = 'volunteer_note_required'`).Scan(¬eRequired) + if noteRequired == "true" && strings.TrimSpace(body.Note) == "" { + writeError(w, "note field is required", http.StatusBadRequest) + return + } + + // Don't reveal whether email is already registered + existing, _ := app.getVolunteerByEmail(body.Email) + if existing != nil { + writeJSON(w, map[string]any{"ok": true}) + return + } + + // Auto-match attendee by email or create new + var attendeeID *int + attendees, _ := app.listAttendees("", "", "") + for _, a := range attendees { + if strings.EqualFold(a.Email, body.Email) { + id := a.ID + attendeeID = &id + break + } + } + + if attendeeID == nil { + name := body.PreferredName + if body.TicketName != "" { + name = body.TicketName + } + newAttendee, err := app.createAttendee(Attendee{ + Name: name, + Email: body.Email, + Phone: body.Phone, + }) + if err != nil { + writeError(w, "internal error", http.StatusInternalServerError) + return + } + attendeeID = &newAttendee.ID + } + + confirmToken, err := generateConfirmationToken() + if err != nil { + writeError(w, "internal error", http.StatusInternalServerError) + return + } + + vol := Volunteer{ + AttendeeID: attendeeID, + Name: body.PreferredName, + PreferredName: body.PreferredName, + TicketName: body.TicketName, + Email: body.Email, + Phone: body.Phone, + Pronouns: body.Pronouns, + DepartmentID: body.DepartmentID, + Note: body.Note, + ConfirmationToken: &confirmToken, + } + + if _, err := app.createVolunteer(vol); err != nil { + writeError(w, "internal error", http.StatusInternalServerError) + return + } + + go func() { + if err := app.sendConfirmationEmail(body.Email, body.PreferredName, confirmToken); err != nil { + log.Printf("confirmation email to %s failed: %v", body.Email, err) + } + }() + + writeJSON(w, map[string]any{"ok": true}) +} + +func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) { + var body struct { + Token string `json:"token"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Token == "" { + writeError(w, "invalid request", http.StatusBadRequest) + return + } + + vol, err := app.getVolunteerByConfirmationToken(body.Token) + if err != nil || vol == nil { + writeJSON(w, map[string]any{"status": "invalid"}) + return + } + + if vol.EmailConfirmed { + writeJSON(w, map[string]any{"status": "already_confirmed"}) + return + } + + if err := app.confirmVolunteerEmail(vol.ID); err != nil { + writeError(w, "internal error", http.StatusInternalServerError) + return + } + + response := map[string]any{"status": "confirmed"} + + var signupsOpen string + app.db.QueryRow(`SELECT value FROM config WHERE key = 'shift_signups_open'`).Scan(&signupsOpen) + + if signupsOpen == "true" && vol.AttendeeID != nil { + a, _ := app.getAttendee(*vol.AttendeeID) + if a != nil && a.VolunteerToken == nil { + t, err := app.generateUniqueToken() + if err == nil { + app.db.Exec(`UPDATE attendees SET volunteer_token=?, updated_at=? WHERE id=?`, t, now(), a.ID) + a.VolunteerToken = &t + } + } + if a != nil && a.VolunteerToken != nil { + 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 { + log.Printf("shift signup email to %s failed: %v", vol.Email, err) + } + }() + } + } + + writeJSON(w, response) +} + +func (app *App) handleToggleShiftSignups(w http.ResponseWriter, r *http.Request) { + var body struct { + Open bool `json:"open"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, "invalid request", http.StatusBadRequest) + return + } + val := "false" + if body.Open { + val = "true" + } + app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', ?)`, val) + + if body.Open { + go app.openShiftSignups() + } + writeJSON(w, map[string]any{"shift_signups_open": body.Open}) +} + +func (app *App) openShiftSignups() { + // Generate kiosk tokens for confirmed volunteers whose attendees lack one + vols, _ := app.listConfirmedVolunteersWithoutKioskToken() + for _, v := range vols { + if v.AttendeeID == nil { + continue + } + t, err := app.generateUniqueToken() + if err != nil { + continue + } + app.db.Exec(`UPDATE attendees SET volunteer_token=?, updated_at=? WHERE id=?`, t, now(), *v.AttendeeID) + } + + // Email all confirmed volunteers with kiosk links + confirmed, _ := queryVolunteers(app.db, ` + SELECT `+volunteerCols+` + FROM volunteers + WHERE email_confirmed = 1 AND deleted_at IS NULL AND attendee_id IS NOT NULL`) + baseURL := app.resolveBaseURL() + sent := 0 + + for _, v := range confirmed { + if v.AttendeeID == nil || v.Email == "" { + continue + } + a, _ := app.getAttendee(*v.AttendeeID) + if a == nil || a.VolunteerToken == nil { + continue + } + kioskLink := fmt.Sprintf("%s/#/v/%s", baseURL, *a.VolunteerToken) + name := v.PreferredName + if name == "" { + name = v.Name + } + if err := app.sendShiftSignupEmail(v.Email, name, kioskLink); err == nil { + sent++ + } else { + log.Printf("shift signup email to %s failed: %v", v.Email, err) + } + } + log.Printf("Shift signups opened: sent %d emails", sent) +} diff --git a/handle_signup_test.go b/handle_signup_test.go new file mode 100644 index 0000000..a59da69 --- /dev/null +++ b/handle_signup_test.go @@ -0,0 +1,333 @@ +package main + +import ( + "net/http/httptest" + "testing" +) + +func TestPublicSignupConfig(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + app.createDepartment(Department{Name: "Setup", Color: "#ff0000"}) + app.createDepartment(Department{Name: "Teardown", Color: "#00ff00"}) + app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('volunteer_note_label', 'Who sent you?')`) + app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('volunteer_note_required', 'true')`) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testRequest("GET", "/api/public/signup-config", nil)) + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + result := parseJSON(t, w) + depts, ok := result["departments"].([]any) + if !ok || len(depts) != 2 { + t.Fatalf("expected 2 departments, got %v", result["departments"]) + } + if result["volunteer_note_label"] != "Who sent you?" { + t.Errorf("expected 'Who sent you?', got %v", result["volunteer_note_label"]) + } + if result["volunteer_note_required"] != true { + t.Errorf("expected note required true, got %v", result["volunteer_note_required"]) + } +} + +func TestPublicSignup(t *testing.T) { + app := testApp(t) + mux := testMux(app) + app.createDepartment(Department{Name: "Setup", Color: "#ff0000"}) + + w := httptest.NewRecorder() + deptID := 1 + mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{ + "preferred_name": "Titania", + "email": "titania@example.com", + "pronouns": "she/they", + "department_id": deptID, + })) + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + result := parseJSON(t, w) + if result["ok"] != true { + t.Fatalf("expected ok true, got %v", result) + } + + // Volunteer should exist + vol, err := app.getVolunteerByEmail("titania@example.com") + if err != nil || vol == nil { + t.Fatal("volunteer not created") + } + if vol.PreferredName != "Titania" { + t.Errorf("preferred_name = %q, want Titania", vol.PreferredName) + } + if vol.Pronouns != "she/they" { + t.Errorf("pronouns = %q, want she/they", vol.Pronouns) + } + if vol.ConfirmationToken == nil || *vol.ConfirmationToken == "" { + t.Error("expected confirmation token to be set") + } + if vol.EmailConfirmed { + t.Error("should not be confirmed yet") + } + + // Attendee should be auto-created and linked + if vol.AttendeeID == nil { + t.Fatal("expected attendee to be linked") + } + a, _ := app.getAttendee(*vol.AttendeeID) + if a == nil { + t.Fatal("linked attendee not found") + } + if a.Email != "titania@example.com" { + t.Errorf("attendee email = %q, want titania@example.com", a.Email) + } +} + +func TestPublicSignupAutoMatchAttendee(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + // Pre-existing attendee + existing, _ := app.createAttendee(Attendee{Name: "Titania Fairweather", Email: "titania@example.com"}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{ + "preferred_name": "Titania", + "ticket_name": "Titania Fairweather", + "email": "titania@example.com", + })) + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + + vol, _ := app.getVolunteerByEmail("titania@example.com") + if vol == nil { + t.Fatal("volunteer not created") + } + if vol.AttendeeID == nil || *vol.AttendeeID != existing.ID { + t.Errorf("expected volunteer linked to existing attendee %d, got %v", existing.ID, vol.AttendeeID) + } +} + +func TestPublicSignupDuplicateEmail(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + // First signup + w := httptest.NewRecorder() + mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{ + "preferred_name": "Titania", + "email": "titania@example.com", + })) + if w.Code != 200 { + t.Fatalf("first signup: expected 200, got %d", w.Code) + } + + // Second signup with same email — should silently succeed + w = httptest.NewRecorder() + mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{ + "preferred_name": "Puck", + "email": "titania@example.com", + })) + if w.Code != 200 { + t.Fatalf("duplicate signup: expected 200, got %d", w.Code) + } + result := parseJSON(t, w) + if result["ok"] != true { + t.Fatalf("expected ok true for duplicate, got %v", result) + } + + // Should still be only one volunteer + vols, _ := app.listVolunteers("", nil, "") + if len(vols) != 1 { + t.Errorf("expected 1 volunteer, got %d", len(vols)) + } +} + +func TestPublicSignupMissingFields(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + tests := []struct { + name string + body map[string]any + }{ + {"no name", map[string]any{"email": "a@b.com"}}, + {"no email", map[string]any{"preferred_name": "Titania"}}, + {"empty both", map[string]any{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", tt.body)) + if w.Code != 400 { + t.Errorf("expected 400, got %d", w.Code) + } + }) + } +} + +func TestPublicSignupNoteRequired(t *testing.T) { + app := testApp(t) + mux := testMux(app) + app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('volunteer_note_required', 'true')`) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{ + "preferred_name": "Titania", + "email": "titania@example.com", + "note": "", + })) + if w.Code != 400 { + t.Fatalf("expected 400 when note required but empty, got %d", w.Code) + } + + // With note provided + w = httptest.NewRecorder() + mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{ + "preferred_name": "Titania", + "email": "titania@example.com", + "note": "A friend sent me", + })) + if w.Code != 200 { + t.Fatalf("expected 200 with note, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestConfirmEmail(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + token := "abc123def456" + app.createVolunteer(Volunteer{ + Name: "Titania", + PreferredName: "Titania", + Email: "titania@example.com", + ConfirmationToken: &token, + }) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + result := parseJSON(t, w) + if result["status"] != "confirmed" { + t.Errorf("expected confirmed, got %v", result["status"]) + } + + // Verify volunteer is confirmed + vol, _ := app.getVolunteerByEmail("titania@example.com") + if vol == nil || !vol.EmailConfirmed { + t.Error("volunteer should be email confirmed") + } + if vol.ConfirmationToken != nil { + t.Error("confirmation token should be cleared after confirmation") + } +} + +func TestConfirmEmailInvalid(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": "nonexistent"})) + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + result := parseJSON(t, w) + if result["status"] != "invalid" { + t.Errorf("expected invalid, got %v", result["status"]) + } +} + +func TestConfirmEmailAlreadyConfirmed(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + token := "abc123def456" + app.createVolunteer(Volunteer{ + Name: "Titania", + PreferredName: "Titania", + Email: "titania@example.com", + ConfirmationToken: &token, + }) + + // Confirm first time + w := httptest.NewRecorder() + mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) + if parseJSON(t, w)["status"] != "confirmed" { + t.Fatal("first confirm should succeed") + } + + // Second confirm with same token should be invalid (token cleared) + w = httptest.NewRecorder() + mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) + result := parseJSON(t, w) + if result["status"] != "invalid" { + t.Errorf("expected invalid after token cleared, got %v", result["status"]) + } +} + +func TestConfirmEmailWithSignupsOpen(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`) + app.baseURL = "https://example.com" + + attendee, _ := app.createAttendee(Attendee{Name: "Titania", Email: "titania@example.com"}) + token := "abc123def456" + app.createVolunteer(Volunteer{ + Name: "Titania", + PreferredName: "Titania", + Email: "titania@example.com", + AttendeeID: &attendee.ID, + ConfirmationToken: &token, + }) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + result := parseJSON(t, w) + if result["status"] != "confirmed" { + t.Fatalf("expected confirmed, got %v", result["status"]) + } + kioskLink, ok := result["kiosk_link"].(string) + if !ok || kioskLink == "" { + t.Error("expected kiosk_link when signups are open") + } + + // Attendee should now have a kiosk token + a, _ := app.getAttendee(attendee.ID) + if a.VolunteerToken == nil { + t.Error("attendee should have kiosk token after confirm with signups open") + } +} + +func TestToggleShiftSignups(t *testing.T) { + app := testApp(t) + mux := testMux(app) + admin := testAdminUser(t, app) + tok := testToken(t, app, admin) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/shift-signups", map[string]any{"open": true}, tok)) + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + result := parseJSON(t, w) + if result["shift_signups_open"] != true { + t.Errorf("expected shift_signups_open true, got %v", result) + } + + // Check config stored + var val string + app.db.QueryRow(`SELECT value FROM config WHERE key = 'shift_signups_open'`).Scan(&val) + if val != "true" { + t.Errorf("config not stored, got %q", val) + } +} diff --git a/main.go b/main.go index aca8086..fa05ac6 100644 --- a/main.go +++ b/main.go @@ -151,6 +151,13 @@ func (app *App) registerRoutes(mux *http.ServeMux) { writeJSON(w, map[string]string{"build": buildID}) }) + mux.HandleFunc("POST /api/settings/shift-signups", auth(app.handleToggleShiftSignups, "admin", "volunteer_lead")) + + // Public endpoints — no JWT required. + mux.HandleFunc("GET /api/public/signup-config", app.handlePublicSignupConfig) + mux.HandleFunc("POST /api/public/signup", app.handlePublicSignup) + mux.HandleFunc("POST /api/public/confirm", app.handleConfirmEmail) + // Kiosk — authenticated by volunteer token, no JWT required. mux.HandleFunc("GET /api/v/{token}", app.handleKioskGet) mux.HandleFunc("POST /api/v/{token}/shifts/{id}", app.handleKioskClaim) From 4bba0ed3a079bfdd73f5dddfc07b503697230136 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 3 Mar 2026 19:55:35 -0600 Subject: [PATCH 02/47] Switched to path routing. Added data management. --- README.md | 3 +- docs/USAGE.md | 30 +++++++++++ email.go | 4 +- frontend/src/App.svelte | 26 ++++++---- frontend/src/api.js | 7 ++- frontend/src/components/Nav.svelte | 48 ++++++++--------- frontend/src/pages/ConfirmEmail.svelte | 2 +- frontend/src/pages/Kiosk.svelte | 3 +- frontend/src/pages/Settings.svelte | 41 ++++++++++++++- handle_settings.go | 55 ++++++++++++++++++++ handle_settings_test.go | 72 ++++++++++++++++++++++++++ handle_signup.go | 4 +- handle_tokens.go | 2 +- main.go | 5 ++ 14 files changed, 256 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index b526fbe..cd596c4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/USAGE.md b/docs/USAGE.md index 80b25f0..8e0d174 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -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: diff --git a/email.go b/email.go index 8d1d463..0a1c65d 100644 --- a/email.go +++ b/email.go @@ -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", diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 179cc2c..e9c0638 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -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 ?? '') @@ -107,7 +113,7 @@ {#if mobileNavOpen} {/if} -
- {/if} - - - - {#if ($allAttendees ?? []).length === 0} -
- No attendees yet -

Import a CSV or add attendees manually.

-
- {:else} -
- - - - - - - - {#if canCheckIn}{/if} - - - - {#each filtered as a (a.id)} - - - - - - {#if canCheckIn} - - {/if} - - {/each} - -
NameTicket typeEmailStatus
- {a.name} - {#if a.ticket_id} - · {a.ticket_id} - {/if} - {#if (a.party_size ?? 1) > 1} - ×{a.party_size} - {/if} - {#if a.note} -
{a.note}
- {/if} -
{a.ticket_type || '—'} -
{a.email || '—'}
- {#if a.volunteer_token && canManage} -
- {a.volunteer_token} - {#if a.email} - - {/if} -
- {/if} -
- {#if (a.party_size ?? 1) > 1} - - {a.checked_in_count ?? 0}/{a.party_size} in - - {:else} - - {a.checked_in ? 'Checked in' : 'Pending'} - - {/if} - {#if a.checked_in_at} -
- {new Date(a.checked_in_at).toLocaleTimeString()} -
- {/if} -
- {#if (a.checked_in_count ?? 0) < (a.party_size ?? 1)} - checkIn(a)} /> - {/if} -
-
- {/if} - diff --git a/frontend/src/sync.js b/frontend/src/sync.js index e8f2d1a..fa3b641 100644 --- a/frontend/src/sync.js +++ b/frontend/src/sync.js @@ -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) } diff --git a/frontend/src/sync.test.js b/frontend/src/sync.test.js index 2c8915e..21c213e 100644 --- a/frontend/src/sync.test.js +++ b/frontend/src/sync.test.js @@ -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: [], diff --git a/handle_attendees.go b/handle_attendees.go deleted file mode 100644 index 0ee5628..0000000 --- a/handle_attendees.go +++ /dev/null @@ -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() -} diff --git a/handle_attendees_test.go b/handle_attendees_test.go index ff2a196..c5e6adb 100644 --- a/handle_attendees_test.go +++ b/handle_attendees_test.go @@ -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) } } diff --git a/handle_settings.go b/handle_settings.go index f812134..d4ed01c 100644 --- a/handle_settings.go +++ b/handle_settings.go @@ -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) diff --git a/handle_settings_test.go b/handle_settings_test.go index c588c88..cbc53fb 100644 --- a/handle_settings_test.go +++ b/handle_settings_test.go @@ -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) diff --git a/handle_sync.go b/handle_sync.go index a11d414..f2171ce 100644 --- a/handle_sync.go +++ b/handle_sync.go @@ -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, diff --git a/handle_sync_test.go b/handle_sync_test.go index c5d759a..e4aa2af 100644 --- a/handle_sync_test.go +++ b/handle_sync_test.go @@ -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") } } diff --git a/main.go b/main.go index ee2db53..2eedcd0 100644 --- a/main.go +++ b/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")) From e7b25ea0c6044103b4f0b0c178f3cd7a996e4832 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Wed, 4 Mar 2026 20:52:12 -0600 Subject: [PATCH 11/47] Updated Dashboard and clarified default states. --- frontend/src/pages/Dashboard.svelte | 177 ++++++++++++++++++++---- frontend/src/pages/Departments.svelte | 2 +- frontend/src/pages/ScheduleBoard.svelte | 4 +- frontend/src/pages/Users.svelte | 14 +- frontend/src/pages/Volunteers.svelte | 10 +- 5 files changed, 172 insertions(+), 35 deletions(-) diff --git a/frontend/src/pages/Dashboard.svelte b/frontend/src/pages/Dashboard.svelte index c1ae495..9a69d10 100644 --- a/frontend/src/pages/Dashboard.svelte +++ b/frontend/src/pages/Dashboard.svelte @@ -4,13 +4,54 @@ let { session } = $props() - const attendees = liveQuery(() => db.attendees.toArray()) - const event = liveQuery(() => db.event.get(1)) + const role = $derived(session?.user?.role ?? '') + const myDeptIDs = $derived(session?.user?.department_ids ?? []) + const isTicketing = $derived(['admin', 'ticketing'].includes(role)) + const isStaffing = $derived(['admin', 'ticketing', 'staffing'].includes(role)) + const isColead = $derived(role === 'colead') - const total = $derived(($attendees ?? []).length) - const checkedIn = $derived(($attendees ?? []).filter(a => a.checked_in).length) - const remaining = $derived(total - checkedIn) - const pct = $derived(total > 0 ? Math.round((checkedIn / total) * 100) : 0) + const event = liveQuery(() => db.event.get(1)) + const allTickets = liveQuery(() => db.tickets.toArray()) + const allVolunteers = liveQuery(() => db.volunteers.filter(v => !v.deleted_at).toArray()) + const allShifts = liveQuery(() => db.shifts.filter(s => !s.deleted_at).toArray()) + const allDepts = liveQuery(() => db.departments.filter(d => !d.deleted_at).toArray()) + const allVS = liveQuery(() => db.volunteer_shifts.toArray()) + + // Ticket stats + const tickets = $derived($allTickets ?? []) + const ticketTotal = $derived(tickets.length) + const ticketCheckedIn = $derived(tickets.filter(t => t.checked_in_at).length) + const ticketRemaining = $derived(ticketTotal - ticketCheckedIn) + const ticketPct = $derived(ticketTotal > 0 ? Math.round((ticketCheckedIn / ticketTotal) * 100) : 0) + + // Volunteer stats (scoped for colead) + const volunteers = $derived.by(() => { + const vols = $allVolunteers ?? [] + if (isColead) return vols.filter(v => myDeptIDs.includes(v.department_id)) + return vols + }) + const volTotal = $derived(volunteers.length) + const volCheckedIn = $derived(volunteers.filter(v => v.checked_in).length) + const volLeads = $derived(volunteers.filter(v => v.is_lead).length) + + // Shift stats (scoped for colead) + const shifts = $derived.by(() => { + const all = $allShifts ?? [] + if (isColead) return all.filter(s => myDeptIDs.includes(s.department_id)) + return all + }) + const shiftTotal = $derived(shifts.length) + const shiftsFilled = $derived.by(() => { + const vs = $allVS ?? [] + return shifts.filter(s => vs.some(a => a.shift_id === s.id)).length + }) + const shiftFillPct = $derived(shiftTotal > 0 ? Math.round((shiftsFilled / shiftTotal) * 100) : 0) + + // Department names for colead header + const myDeptNames = $derived.by(() => { + const depts = $allDepts ?? [] + return myDeptIDs.map(id => depts.find(d => d.id === id)?.name).filter(Boolean) + })
@@ -28,35 +69,113 @@

{/if} -
-
-
Total
-
{total}
-
-
-
Checked in
-
{checkedIn}
-
-
-
Remaining
-
{remaining}
-
-
-
Progress
-
{pct}%
-
-
+ {#if isColead && myDeptNames.length > 0} +

+ Your department{myDeptNames.length > 1 ? 's' : ''}: + {myDeptNames.join(', ')} +

+ {/if} - {#if total > 0} -
-
-
+ + {#if isTicketing} +

Ticket Check-in

+
+
+
Total tickets
+
{ticketTotal}
+
+
+
Checked in
+
{ticketCheckedIn}
+
+
+
Remaining
+
{ticketRemaining}
+
+
+
Progress
+
{ticketPct}%
+
+
+ + {#if ticketTotal > 0} +
+
+
+ {/if} + {/if} + + + {#if isStaffing || isColead} +

{isColead ? 'My Volunteers' : 'Volunteers'}

+
+
+
Total
+
{volTotal}
+
+
+
Checked in
+
{volCheckedIn}
+
+
+
Leads
+
{volLeads}
{/if} -

+ + {#if isStaffing || isColead} +

{isColead ? 'My Shifts' : 'Shift Coverage'}

+
+
+
Total shifts
+
{shiftTotal}
+
+
+
With volunteers
+
{shiftsFilled}
+
+
+
Fill rate
+
{shiftFillPct}%
+
+
+ {/if} + + + {#if isTicketing} + + {:else if isStaffing || isColead} + + {/if} + +

Welcome, {session?.user?.username} · {session?.user?.role}

+ + diff --git a/frontend/src/pages/Departments.svelte b/frontend/src/pages/Departments.svelte index b50fde4..b7fd627 100644 --- a/frontend/src/pages/Departments.svelte +++ b/frontend/src/pages/Departments.svelte @@ -127,7 +127,7 @@ {#if ($allDepts ?? []).length === 0}
No departments yet -

Add departments to organize your volunteer teams.

+

Create departments to organize shifts and volunteer teams. Coleads are assigned to specific departments.

{:else}
diff --git a/frontend/src/pages/ScheduleBoard.svelte b/frontend/src/pages/ScheduleBoard.svelte index 5d9d265..2d0b555 100644 --- a/frontend/src/pages/ScheduleBoard.svelte +++ b/frontend/src/pages/ScheduleBoard.svelte @@ -315,8 +315,8 @@ {#if ($allShifts ?? []).length === 0 && !showAdd}
- No shifts yet -

Add shifts to schedule your volunteers.

+ No shifts scheduled yet +

Create departments first, then add shifts here. Volunteers can self-select shifts via the kiosk.

{:else} {#each board as { dept, days }} diff --git a/frontend/src/pages/Users.svelte b/frontend/src/pages/Users.svelte index 237617b..c683fb9 100644 --- a/frontend/src/pages/Users.svelte +++ b/frontend/src/pages/Users.svelte @@ -117,7 +117,7 @@ } function roleLabel(r) { - return { admin: 'Admin', coordinator: 'Coordinator', ticketing: 'Ticketing', gate: 'Gate', volunteer_lead: 'Vol. Lead' }[r] || r + return { admin: 'Admin', ticketing: 'Ticketing', staffing: 'Staffing', colead: 'Colead', gatekeeper: 'Gatekeeper' }[r] || r } @@ -129,6 +129,15 @@
+

+ Roles: + admin — full access · + ticketing — participants, tickets, import · + staffing — volunteers, shifts, departments · + colead — manage assigned departments only · + gatekeeper — check-in only +

+ {#if loadError}
{loadError}
{/if} @@ -187,7 +196,8 @@
Loading…
{:else if users.length === 0}
- No users yet + No additional users +

The admin account was created at setup. Add users above to delegate access.

{:else}
diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index 39bc98b..e5df71a 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -20,6 +20,14 @@ const role = $derived(session?.user?.role ?? '') const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role)) + const myDeptIDs = $derived(session?.user?.department_ids ?? []) + + // Auto-filter coleads to their department on mount + $effect(() => { + if (role === 'colead' && myDeptIDs.length > 0 && !filterDept) { + filterDept = String(myDeptIDs[0]) + } + }) const allVolunteers = liveQuery(() => db.volunteers.filter(v => !v.deleted_at).toArray() @@ -177,7 +185,7 @@ {#if ($allVolunteers ?? []).length === 0}
No volunteers yet -

Add volunteers manually.

+

Add volunteers manually above, or enable public signup in Settings.

{:else}
From ecfbfcd53ec0d96c0c077d0409e6b25ea27c9cb6 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Wed, 4 Mar 2026 21:06:00 -0600 Subject: [PATCH 12/47] Used preferred name in volunteer signup. --- handle_signup.go | 16 ++++--- handle_signup_test.go | 102 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 5 deletions(-) diff --git a/handle_signup.go b/handle_signup.go index 31c796b..2373e9c 100644 --- a/handle_signup.go +++ b/handle_signup.go @@ -69,11 +69,7 @@ func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) { } // Find or create participant by email. - name := body.PreferredName - if body.TicketName != "" { - name = body.TicketName - } - participant, _, err := app.upsertParticipant(body.Email, name) + participant, _, err := app.upsertParticipant(body.Email, body.PreferredName) if err != nil { writeError(w, "internal error", http.StatusInternalServerError) return @@ -166,8 +162,13 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) { if len(tickets) > 0 { ticketID = tickets[0].ID } else { + tkName := vol.TicketName + if tkName == "" { + tkName = vol.PreferredName + } stub, err := app.createTicket(Ticket{ ParticipantID: vol.ParticipantID, + Name: tkName, Source: "manual", }) if err == nil { @@ -228,8 +229,13 @@ func (app *App) openShiftSignups() { if len(tickets) > 0 { ticketID = tickets[0].ID } else { + tkName := v.TicketName + if tkName == "" { + tkName = v.PreferredName + } stub, err := app.createTicket(Ticket{ ParticipantID: v.ParticipantID, + Name: tkName, Source: "manual", }) if err != nil { diff --git a/handle_signup_test.go b/handle_signup_test.go index a9bdab0..86bcfe5 100644 --- a/handle_signup_test.go +++ b/handle_signup_test.go @@ -315,6 +315,108 @@ func TestConfirmEmailWithSignupsOpen(t *testing.T) { } } +func TestPublicSignupTicketNameDoesNotOverwritePreferredName(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{ + "preferred_name": "Titania", + "ticket_name": "Titania Fairweather", + "email": "titania@example.com", + })) + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + vol, _ := app.getVolunteerByEmail("titania@example.com") + if vol == nil || vol.ParticipantID == nil { + t.Fatal("volunteer/participant not created") + } + p, _ := app.getParticipant(*vol.ParticipantID) + if p == nil { + t.Fatal("participant not found") + } + if p.PreferredName != "Titania" { + t.Errorf("participant preferred_name = %q, want %q (not ticket_name)", p.PreferredName, "Titania") + } + if vol.TicketName != "Titania Fairweather" { + t.Errorf("vol.TicketName = %q, want %q", vol.TicketName, "Titania Fairweather") + } +} + +func TestConfirmEmailStubTicketHasName(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`) + app.baseURL = "https://example.com" + + // Volunteer with a ticket_name but no pre-existing ticket + participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) + token := "abc123def456" + app.createVolunteer(Volunteer{ + Name: "Titania", + PreferredName: "Titania", + TicketName: "Titania Fairweather", + Email: "titania@example.com", + ParticipantID: &participant.ID, + ConfirmationToken: &token, + }) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + result := parseJSON(t, w) + if result["status"] != "confirmed" { + t.Fatalf("expected confirmed, got %v", result["status"]) + } + + // Stub ticket should have been created with TicketName as its name + tickets, _ := app.listTickets(&participant.ID, "") + if len(tickets) == 0 { + t.Fatal("expected stub ticket to be created") + } + if tickets[0].Name != "Titania Fairweather" { + t.Errorf("stub ticket name = %q, want %q", tickets[0].Name, "Titania Fairweather") + } +} + +func TestConfirmEmailStubTicketFallsBackToPreferredName(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`) + app.baseURL = "https://example.com" + + // Volunteer with no ticket_name — stub should use preferred_name + participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) + token := "abc123def456" + app.createVolunteer(Volunteer{ + Name: "Titania", + PreferredName: "Titania", + Email: "titania@example.com", + ParticipantID: &participant.ID, + ConfirmationToken: &token, + }) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + + tickets, _ := app.listTickets(&participant.ID, "") + if len(tickets) == 0 { + t.Fatal("expected stub ticket to be created") + } + if tickets[0].Name != "Titania" { + t.Errorf("stub ticket name = %q, want %q (preferred_name fallback)", tickets[0].Name, "Titania") + } +} + func TestToggleShiftSignups(t *testing.T) { app := testApp(t) mux := testMux(app) From 940cf29d04ee67b53c663026b467bb33a982a3c8 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Wed, 4 Mar 2026 22:25:31 -0600 Subject: [PATCH 13/47] Added ability to set colead. --- frontend/src/pages/Volunteers.svelte | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index e5df71a..1bf13c4 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -87,6 +87,15 @@ } } + async function toggleLead(v) { + try { + const updated = await api.volunteers.update(v.id, { ...v, is_lead: !v.is_lead }) + await db.volunteers.put(updated) + } catch (err) { + error = err.message + } + } + async function deleteVolunteer(v) { if (!confirm(`Delete volunteer "${v.name}"?`)) return try { @@ -243,6 +252,10 @@ {#if canManage} + {/if} From 6eb72c50918cba2616e2d69eefb930da3c2c4abc Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Wed, 4 Mar 2026 22:32:10 -0600 Subject: [PATCH 14/47] Clarified Co-Lead badging. --- frontend/src/pages/Kiosk.svelte | 2 +- frontend/src/pages/ScheduleBoard.svelte | 11 +++++++++++ frontend/src/pages/Volunteers.svelte | 6 +++--- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/Kiosk.svelte b/frontend/src/pages/Kiosk.svelte index 983e039..c9eb58a 100644 --- a/frontend/src/pages/Kiosk.svelte +++ b/frontend/src/pages/Kiosk.svelte @@ -149,7 +149,7 @@
{state.volunteer.name}
{state.volunteer.email || ''} - {state.volunteer.is_lead ? ' · Department Lead' : ''} + {state.volunteer.is_lead ? ' · Co-Lead' : ''}
Token: {token}
diff --git a/frontend/src/pages/ScheduleBoard.svelte b/frontend/src/pages/ScheduleBoard.svelte index 2d0b555..65391e0 100644 --- a/frontend/src/pages/ScheduleBoard.svelte +++ b/frontend/src/pages/ScheduleBoard.svelte @@ -397,6 +397,9 @@ {#each assigned as { vs, volunteer }}
{volunteer.name} + {#if volunteer.is_lead} + Co-Lead + {/if} {#if checkConflict(volunteer.id, shift.id, $allVolunteerShifts ?? [], $allShifts ?? [])} {/if} @@ -514,6 +517,14 @@ font-size: 0.78rem; font-weight: 500; } + .chip-lead { + font-size: 0.68rem; + font-weight: 600; + background: rgba(245,158,11,0.2); + color: var(--c-warn); + padding: 0.05rem 0.3rem; + border-radius: 99px; + } .board-vol-remove { background: none; border: none; diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index 1bf13c4..e028389 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -216,7 +216,7 @@ {v.name} {#if v.is_lead} - Lead + Co-Lead {/if} {#if !v.participant_id} No ticket @@ -253,8 +253,8 @@ {#if canManage} From a60ef7d25b4397a08dbb1a084a652bfd2e70c4c9 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Wed, 4 Mar 2026 23:02:35 -0600 Subject: [PATCH 15/47] Updated docs. --- README.md | 25 ++++++------- docs/INSTALLATION.md | 22 +++++++----- docs/USAGE.md | 86 +++++++++++++++++++++----------------------- 3 files changed, 66 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 88b09ef..80a43e5 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,21 @@ # Turnpike -Self-hosted event attendee and volunteer management. One instance, one event. +Self-hosted event ticketing and volunteer management. One instance, one event. Turnpike handles gate check-in, volunteer scheduling, and department coordination for events ranging from a single evening to a multi-day festival. It works offline — gate volunteers can check people in without a network connection and sync when connectivity returns. ## Features -- **Attendee management** — CSV import (CrowdWork/Zeffy auto-detected), party-size tracking, search, check-in +- **Participant & ticket management** — CSV import (CrowdWork/Zeffy auto-detected), search, check-in - **Volunteer scheduling** — departments, shifts with capacity, conflict detection, 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 +- **Public volunteer signup** — self-registration form with email confirmation, auto-participant linking +- **Volunteer kiosk** — code-authenticated self-service shift signup, no login required +- **Gate check-in** — full-screen UI with QR scanner, volunteer dual check-in - **Schedule** — create shifts, assign volunteers, manage assignments with conflict awareness -- **Role-based access** — admin, coordinator, volunteer lead (department-scoped), gate +- **Role-based access** — admin, ticketing, staffing, colead (department-scoped), gatekeeper - **Offline-first PWA** — installs on phones/tablets, full offline check-in with background sync - **Real-time** — check-ins and changes broadcast live via SSE -- **SMTP email** — send volunteer token links directly or export CSV for bulk email platforms +- **SMTP email** — volunteer confirmation emails, kiosk link distribution when shift signups open - **Single binary** — Go backend embeds the frontend; no runtime dependencies ## Tech Stack @@ -60,10 +60,11 @@ See [docs/INSTALLATION.md](docs/INSTALLATION.md) for systemd, Docker, NixOS, and | Role | Access | |------|--------| -| `admin` | Full access: attendee import, user management, SMTP settings, all departments and shifts | -| `coordinator` | All departments: volunteers, shifts, schedule. No user management or settings | -| `volunteer_lead` | Own department only: volunteers and shifts scoped to assigned department | -| `gate` | Full-screen check-in UI with QR scanner. No access to other pages | +| `admin` | Full access: participant import, user management, SMTP settings, all departments and shifts | +| `ticketing` | Participants, tickets, import. No user management | +| `staffing` | All departments: volunteers, shifts, schedule. No user management or settings | +| `colead` | Own department only: volunteers and shifts scoped to assigned department(s) | +| `gatekeeper` | Full-screen check-in UI with QR scanner. No access to other pages | See [docs/USAGE.md](docs/USAGE.md) for detailed workflow documentation. @@ -91,7 +92,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 signup, volunteer kiosk, gate check-in, schedule +- [Usage Guide](docs/USAGE.md) — event setup, participant import, volunteer signup, volunteer kiosk, gate check-in, schedule - [Installation Guide](docs/INSTALLATION.md) — building, deploying, systemd, Docker, NixOS, reverse proxy, backup ## License diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 1f9a967..9bc0dbc 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -105,23 +105,27 @@ docker run -p 8180:8180 \ ## NixOS -Turnpike builds with `buildGoModule` using the pure-Go SQLite driver (no CGO): +Turnpike builds with `buildGoModule` + `buildNpmPackage` (pure-Go SQLite, no CGO). The frontend is built separately and copied into the Go build: ```nix +frontendDist = pkgs.buildNpmPackage { + pname = "turnpike-frontend"; + src = "${src}/frontend"; + npmDepsHash = "sha256-..."; + buildPhase = "npm run build"; + installPhase = "cp -r dist $out"; +}; + turnpike = pkgs.buildGoModule { pname = "turnpike"; - version = "0.1.0"; - src = ./path/to/turnpike; # must include vendor/ and frontend/dist/ - vendorHash = null; + src = fetchgit { url = "..."; rev = "v2.0.0"; hash = "sha256-..."; }; + vendorHash = "sha256-..."; env.CGO_ENABLED = 0; + preBuild = "cp -r ${frontendDist} frontend/dist"; }; ``` -The source directory must contain: -- Go source files and `vendor/` (run `go mod vendor`) -- Pre-built frontend at `frontend/dist/` (run `cd frontend && npm run build`) - -A complete NixOS module example with `DynamicUser`, `StateDirectory`, and agenix secrets is in the project's `homelab/turnpike.nix`. +A complete NixOS module with `DynamicUser`, `StateDirectory`, and secrets is in the project's `homelab/turnpike.nix`. ## Reverse Proxy diff --git a/docs/USAGE.md b/docs/USAGE.md index de4e765..11684d4 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -12,23 +12,22 @@ After logging in, create accounts for your team under **Users**. Each user gets | Role | What they see | What they can do | |------|--------------|------------------| -| **admin** | All pages + Settings | Everything: attendee import, user management, SMTP config, departments, shifts, volunteers | -| **coordinator** | Dashboard, Schedule, Volunteers, Departments | Manage volunteers, departments, and shifts across all departments. Cannot manage users or settings | -| **volunteer_lead** | Schedule, Volunteers, Departments | Manage volunteers and shifts within their assigned department only | -| **gate** | Full-screen Gate UI | Check in attendees (search + QR scan). No access to other pages | +| **admin** | All pages + Settings | Everything: participant import, user management, SMTP config, departments, shifts, volunteers | +| **ticketing** | Participants, Tickets, Import | Manage participants and tickets, run CSV imports | +| **staffing** | Dashboard, Schedule, Volunteers, Departments | Manage volunteers, departments, and shifts across all departments. No user management or settings | +| **colead** | Dashboard, Schedule, Volunteers | Manage volunteers and shifts within their assigned department(s) only | +| **gatekeeper** | Full-screen Gate UI | Check in ticket holders (search + QR scan). No access to other pages | -Ticketing and ops staff should use the **admin** role. The `ticketing` role exists in the codebase but is effectively unused — admin covers all ticketing functions. - -Volunteer leads are scoped to a single department. When creating a volunteer_lead user, assign their department. +Coleads are scoped to one or more departments. When creating a colead user, assign their department(s). ## Event Setup -1. **Configure your event** — go to the Dashboard and set the event name and dates. +1. **Configure your event** — set the event name, venue, dates, and timezone via the API (`PUT /api/event`). These appear on the Dashboard. 2. **Create departments** — under Departments, add each department your event needs (e.g., Gate, Greeters, Rangers, Build, LNT). -3. **Import attendees** — see next section. +3. **Import participants** — see next section. 4. **Create shifts** — under Schedule, create shifts for each department with day, start/end time, and capacity. -## Importing Attendees +## Importing Participants Go to **Import** and upload a CSV file. Turnpike auto-detects two formats: @@ -36,7 +35,7 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats: | Column | Maps to | |--------|---------| -| `Patron Name` | Name | +| `Patron Name` | Ticket name | | `Patron Email` | Email | | `Order Number` | Ticket ID | | `Tier Name` | Ticket type | @@ -45,7 +44,7 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats: | Column | Maps to | |--------|---------| -| `name` (required) | Name | +| `name` (required) | Ticket name | | `email` | Email | | `ticket_id` | Ticket ID | | `ticket_type` | Ticket type | @@ -53,27 +52,21 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats: Column matching is case-insensitive. Extra columns are ignored. BOM-encoded files (Windows Excel exports) are handled automatically. -### Party-size dedup +### Participants and tickets -CrowdWork exports one row per ticket, even when the same person bought multiple tickets in one order. Turnpike handles this automatically: +Each row in the CSV creates one **ticket**. Participants are deduplicated by email — multiple tickets with the same email address are linked to a single participant record. The import result shows `inserted` (new tickets) and `skipped` (exact duplicates). -- First row for "Titania Fairweather" (order 1234) creates a record with `party_size=1` -- Subsequent rows with the same name + order number increment `party_size` (no duplicate record) -- Result: one attendee record, `party_size=3` if three tickets were purchased - -The import result shows `inserted` (new records), `grouped` (merged into existing party), and `skipped` (exact duplicates). - -Re-importing the same CSV is safe — existing records are skipped, not duplicated. +Re-importing the same CSV is safe — exact duplicates are skipped, not duplicated. ## Volunteer Signup -Turnpike provides a public signup form for volunteers at `/#/volunteer-signup`. No login is required. +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}`). +2. Turnpike creates a volunteer record and auto-links it to an existing participant by email match, or creates a new participant 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. @@ -90,7 +83,7 @@ In **Settings**, the "Volunteer Signup" card controls: 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. +- **Opening** signups generates kiosk codes 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. @@ -100,11 +93,11 @@ If a volunteer confirms their email while signups are already open, they receive Under **Volunteers**, you can: - Create volunteers manually (name, email, department) -- Link a volunteer to an existing attendee record (for dual check-in at the gate) - Assign volunteers to departments +- Mark volunteers as co-leads - Check in volunteers -Volunteers are separate from attendees. A person can be both an attendee (ticket holder) and a volunteer (shift worker). Linking them enables the gate team to check in both records simultaneously. +Volunteers are separate from participants. A person can be both a ticket holder and a volunteer. When a volunteer signs up via the public form, they are automatically linked to their participant record by email. ## Shift Scheduling @@ -128,15 +121,17 @@ The kiosk lets volunteers self-select shifts without logging in. ### Setup -1. **Generate tokens** — on the Attendees page, click "Generate Tokens." This creates a unique 8-character code for every attendee that doesn't have one. -2. **Distribute tokens** — two options: - - **Export CSV** — downloads a file with columns `Email Address`, `First Name`, `Token`, `Signup Link`. Import this into MailChimp, Zeffy, or any email platform. - - **Email directly** — if SMTP is configured (see below), use "Email All" to send token links, or email individually per attendee. -3. **Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Token links use this URL. +Kiosk links are generated and distributed automatically through the volunteer signup flow: + +1. Volunteers sign up via the public signup form (`/volunteer-signup`) and confirm their email. +2. In **Settings**, open shift signups. This generates kiosk codes for all confirmed volunteers and emails them their links. A confirmation dialog warns before sending. +3. If a volunteer confirms their email while signups are already open, they receive their kiosk link immediately. + +**Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Kiosk links use this URL. ### Volunteer experience -Each volunteer receives a link like `https://turnpike.example.com/#/v/ABC12345`. This opens a mobile-friendly page showing: +Each volunteer receives a link like `https://turnpike.example.com/v/ABC12345`. This opens a mobile-friendly page showing: - Their name and department - Currently assigned shifts @@ -144,20 +139,19 @@ Each volunteer receives a link like `https://turnpike.example.com/#/v/ABC12345`. Claiming a shift checks for time conflicts. If a conflict exists, the volunteer sees which shifts overlap and can confirm to proceed anyway. -No login is required. The 8-character token authenticates the request. +No login is required. The kiosk code authenticates the request. -### Token format +### Code format -Tokens use the character set `A-Z, 2-9` (excluding 0/O, 1/I/L to avoid ambiguity when reading aloud or on printed badges). +Kiosk codes use the character set `A-Z, 2-9` (excluding 0/O, 1/I/L to avoid ambiguity when reading aloud or on printed badges). ## Gate Check-In -Users with the **gate** role see a dedicated full-screen UI: +Users with the **gatekeeper** role see a dedicated full-screen UI: - **QR scanner** — uses the device camera via the BarcodeDetector API. Scanned codes populate the search field. -- **Search** — type a name to filter attendees in real-time (searches local IndexedDB, works offline). -- **Party check-in** — for attendees with `party_size > 1`, the gate UI shows progress ("2/3 checked in") and offers "Check in 1" or "Check in all remaining." -- **Volunteer dual check-in** — if an attendee is linked to a volunteer record, the gate UI shows their volunteer status and offers to check in both simultaneously. +- **Search** — type a name to filter tickets in real-time (searches local IndexedDB, works offline). +- **Volunteer dual check-in** — if a ticket holder is also a volunteer, the gate UI shows their volunteer status and offers to check in both simultaneously. - **Recent check-ins** — the last 10 check-ins are shown for quick reference. Gate devices should install Turnpike as a PWA (Add to Home Screen) for the best experience. Check-ins are stored locally and sync when connectivity is available. @@ -170,7 +164,7 @@ The Schedule page is the primary UI for managing shifts and volunteer assignment - Each shift card shows: name, time, capacity (used/total), assigned volunteers - Conflict badges when a volunteer has overlapping shifts on the same day -**Coordinators and admins** see all departments. **Volunteer leads** see only their assigned department. +**Admins and staffing** see all departments. **Coleads** see only their assigned department(s). Actions available: - Create new shifts (+ Add shift button) @@ -182,7 +176,7 @@ Actions available: ## SMTP Configuration -SMTP enables token email distribution and test emails. Configure in **Settings** (admin only): +SMTP enables volunteer confirmation emails, kiosk link distribution, and test emails. Configure in **Settings** (admin only): | Field | Description | |-------|-------------| @@ -203,13 +197,13 @@ Turnpike is a Progressive Web App (PWA). After the first load, it works offline: - **Gate check-ins** are stored in the browser's IndexedDB and sync when connectivity returns. - **Real-time updates** use Server-Sent Events (SSE). When the connection drops, the client reconnects automatically. -- **Sync** pulls all changes from the server on startup and periodically thereafter. Local changes are queued in an outbox and flushed in order. +- **Sync** pulls all changes from the server on startup and periodically thereafter. Install Turnpike as a PWA (Add to Home Screen on mobile, or Install App in desktop Chrome) for the best offline experience. ## CSV Exports -Two CSV exports are available from the Attendees page: +CSV exports are available from the Participants page: -- **Attendee export** — all attendee records with check-in status -- **Token link export** — columns: `Email Address`, `First Name`, `Token`, `Signup Link`. Only includes attendees with tokens. Compatible with MailChimp and Zeffy for bulk email campaigns. +- **Participant export** — all participant records with check-in status +- **Ticket export** — all ticket records with codes and check-in status From 4d3da023fcbfc38a1f668d7af840ae9c0b626842 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Wed, 4 Mar 2026 23:06:03 -0600 Subject: [PATCH 16/47] Added event edit. --- docs/USAGE.md | 2 +- frontend/src/pages/Settings.svelte | 72 +++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index 11684d4..6c5b9b7 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -22,7 +22,7 @@ Coleads are scoped to one or more departments. When creating a colead user, assi ## Event Setup -1. **Configure your event** — set the event name, venue, dates, and timezone via the API (`PUT /api/event`). These appear on the Dashboard. +1. **Configure your event** — go to **Settings** and set the event name, venue, dates, and timezone. These appear on the Dashboard and volunteer signup page. 2. **Create departments** — under Departments, add each department your event needs (e.g., Gate, Greeters, Rangers, Build, LNT). 3. **Import participants** — see next section. 4. **Create shifts** — under Schedule, create shifts for each department with day, start/end time, and capacity. diff --git a/frontend/src/pages/Settings.svelte b/frontend/src/pages/Settings.svelte index 5caf2ee..138af08 100644 --- a/frontend/src/pages/Settings.svelte +++ b/frontend/src/pages/Settings.svelte @@ -1,9 +1,11 @@ diff --git a/frontend/src/pages/GateUI.svelte b/frontend/src/pages/GateKiosk.svelte similarity index 100% rename from frontend/src/pages/GateUI.svelte rename to frontend/src/pages/GateKiosk.svelte diff --git a/frontend/src/pages/Participants.svelte b/frontend/src/pages/Participants.svelte index 2495958..5eb7610 100644 --- a/frontend/src/pages/Participants.svelte +++ b/frontend/src/pages/Participants.svelte @@ -424,9 +424,9 @@
{#if tk.checked_in_at} - In {fmtTime(tk.checked_in_at)} + Checked in {fmtTime(tk.checked_in_at)} {:else} - Pending + Not checked in {/if}
{tk.source}
diff --git a/frontend/src/pages/Kiosk.svelte b/frontend/src/pages/VolunteerKiosk.svelte similarity index 100% rename from frontend/src/pages/Kiosk.svelte rename to frontend/src/pages/VolunteerKiosk.svelte diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index e028389..1e6eea4 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -183,8 +183,8 @@ {/if} {filtered.length} shown @@ -236,8 +236,8 @@ {/if} - - {v.checked_in ? 'Checked in' : 'Pending'} + + {v.checked_in ? 'Ready' : v.email_confirmed ? 'Confirmed' : 'Unconfirmed'} {#if v.checked_in_at}
From 2b409c65c1c00954b4e252e5b73849c4616f47e1 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 09:35:35 -0600 Subject: [PATCH 19/47] Added check-in to admin Participants view. --- frontend/src/pages/Participants.svelte | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/Participants.svelte b/frontend/src/pages/Participants.svelte index 5eb7610..26f5c63 100644 --- a/frontend/src/pages/Participants.svelte +++ b/frontend/src/pages/Participants.svelte @@ -129,6 +129,16 @@ } } + async function checkInTicket(tk) { + error = '' + try { + const result = await api.tickets.checkIn(tk.id) + if (result.ticket) await db.tickets.put(result.ticket) + } catch (err) { + error = err.message + } + } + function fmtTime(ts) { if (!ts) return '' return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) @@ -426,7 +436,7 @@ {#if tk.checked_in_at} Checked in {fmtTime(tk.checked_in_at)} {:else} - Not checked in + {/if}
{tk.source}
From 87da9cf97f55bb1fc4243fda8acc219f4f57e5e9 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 09:51:40 -0600 Subject: [PATCH 20/47] Updated docs. --- README.md | 6 +++--- docs/USAGE.md | 21 +++++++++++++++------ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 80a43e5..71132c4 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ Turnpike handles gate check-in, volunteer scheduling, and department coordinatio - **Participant & ticket management** — CSV import (CrowdWork/Zeffy auto-detected), search, check-in - **Volunteer scheduling** — departments, shifts with capacity, conflict detection, reordering - **Public volunteer signup** — self-registration form with email confirmation, auto-participant linking -- **Volunteer kiosk** — code-authenticated self-service shift signup, no login required -- **Gate check-in** — full-screen UI with QR scanner, volunteer dual check-in +- **Volunteer kiosk** — public volunteer flow: signup, email confirmation, code-authenticated shift self-scheduling +- **Gate kiosk** — full-screen check-in UI with QR scanner for gatekeepers - **Schedule** — create shifts, assign volunteers, manage assignments with conflict awareness - **Role-based access** — admin, ticketing, staffing, colead (department-scoped), gatekeeper - **Offline-first PWA** — installs on phones/tablets, full offline check-in with background sync @@ -64,7 +64,7 @@ See [docs/INSTALLATION.md](docs/INSTALLATION.md) for systemd, Docker, NixOS, and | `ticketing` | Participants, tickets, import. No user management | | `staffing` | All departments: volunteers, shifts, schedule. No user management or settings | | `colead` | Own department only: volunteers and shifts scoped to assigned department(s) | -| `gatekeeper` | Full-screen check-in UI with QR scanner. No access to other pages | +| `gatekeeper` | Full-screen Gate Kiosk with QR scanner. No access to other pages | See [docs/USAGE.md](docs/USAGE.md) for detailed workflow documentation. diff --git a/docs/USAGE.md b/docs/USAGE.md index 6c5b9b7..23572eb 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -16,7 +16,7 @@ After logging in, create accounts for your team under **Users**. Each user gets | **ticketing** | Participants, Tickets, Import | Manage participants and tickets, run CSV imports | | **staffing** | Dashboard, Schedule, Volunteers, Departments | Manage volunteers, departments, and shifts across all departments. No user management or settings | | **colead** | Dashboard, Schedule, Volunteers | Manage volunteers and shifts within their assigned department(s) only | -| **gatekeeper** | Full-screen Gate UI | Check in ticket holders (search + QR scan). No access to other pages | +| **gatekeeper** | Full-screen Gate Kiosk | Check in ticket holders (search + QR scan). No access to other pages | Coleads are scoped to one or more departments. When creating a colead user, assign their department(s). @@ -95,7 +95,15 @@ Under **Volunteers**, you can: - Create volunteers manually (name, email, department) - Assign volunteers to departments - Mark volunteers as co-leads -- Check in volunteers +- Mark volunteers as ready (briefed at the volunteer station) + +### Volunteer statuses + +| Status | Meaning | +|--------|---------| +| **Unconfirmed** | Signed up but hasn't confirmed their email | +| **Confirmed** | Email confirmed, not yet briefed | +| **Ready** | Briefed at the volunteer station, has what they need to report for shifts | Volunteers are separate from participants. A person can be both a ticket holder and a volunteer. When a volunteer signs up via the public form, they are automatically linked to their participant record by email. @@ -117,7 +125,7 @@ Shifts can be reordered within a department to reflect priority or sequence usin ## Volunteer Kiosk -The kiosk lets volunteers self-select shifts without logging in. +The Volunteer Kiosk is the public-facing flow for volunteers: signup, email confirmation, and shift self-scheduling. The shift scheduling page lets volunteers self-select shifts without logging in. ### Setup @@ -145,15 +153,16 @@ No login is required. The kiosk code authenticates the request. Kiosk codes use the character set `A-Z, 2-9` (excluding 0/O, 1/I/L to avoid ambiguity when reading aloud or on printed badges). -## Gate Check-In +## Gate Kiosk -Users with the **gatekeeper** role see a dedicated full-screen UI: +Users with the **gatekeeper** role see a dedicated full-screen Gate Kiosk: - **QR scanner** — uses the device camera via the BarcodeDetector API. Scanned codes populate the search field. - **Search** — type a name to filter tickets in real-time (searches local IndexedDB, works offline). -- **Volunteer dual check-in** — if a ticket holder is also a volunteer, the gate UI shows their volunteer status and offers to check in both simultaneously. - **Recent check-ins** — the last 10 check-ins are shown for quick reference. +Admins and ticketing leads can also check in tickets directly from the **Participants** page by expanding a participant's tickets. + Gate devices should install Turnpike as a PWA (Add to Home Screen) for the best experience. Check-ins are stored locally and sync when connectivity is available. ## Schedule From 07f7d3d245cebb3e112647076e0c4f4258833fc8 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 09:51:58 -0600 Subject: [PATCH 21/47] Revised views for mobile. --- frontend/src/app.css | 28 ++++++++++++++++++++++++++ frontend/src/pages/Participants.svelte | 16 ++++++++++++--- frontend/src/pages/Users.svelte | 20 ++++++++++++------ frontend/src/pages/Volunteers.svelte | 16 +++++++++++---- 4 files changed, 67 insertions(+), 13 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index c5de1fe..d36f2f7 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -207,4 +207,32 @@ tr:hover td { background: rgba(255,255,255,0.02); } } .page { padding: 1rem; } .stats { grid-template-columns: repeat(2, 1fr); } + + /* Touch targets */ + .btn { min-height: 44px; padding: 0.6rem 1rem; } + .btn-sm { min-height: 44px; padding: 0.5rem 0.75rem; font-size: 0.85rem; } + + /* Page header & actions */ + .page-header { flex-wrap: wrap; gap: 0.75rem; } + .page-title { width: 100%; } + .actions { flex-wrap: wrap; } + + /* Search bar */ + .search-bar { flex-wrap: wrap; } + .search-bar input { max-width: none; flex: 1 1 100%; } + + /* Table → card layout */ + .table-wrap { overflow-x: visible; } + table { display: block; } + thead { display: none; } + tbody { display: flex; flex-direction: column; gap: 0.5rem; } + tr { display: flex; flex-wrap: wrap; gap: 0.25rem 0.75rem; align-items: center; + padding: 0.75rem; border: 1px solid var(--c-border); border-radius: var(--radius-lg); + background: var(--c-surface); } + tr:hover td { background: transparent; } + td { display: inline; padding: 0; border: none; } + td:empty { display: none; } + + /* Forms */ + .form-grid { grid-template-columns: 1fr !important; } } diff --git a/frontend/src/pages/Participants.svelte b/frontend/src/pages/Participants.svelte index 26f5c63..ef2274f 100644 --- a/frontend/src/pages/Participants.svelte +++ b/frontend/src/pages/Participants.svelte @@ -243,7 +243,7 @@ {#if showAdd && canManage}
-
+
@@ -362,7 +362,7 @@ onclick={mergeMode && mergeSource?.id !== p.id ? () => { mergeTarget = p } : null} style={mergeMode && mergeSource?.id !== p.id ? 'cursor:pointer' : ''} > - + {p.preferred_name || '—'} {#if p.pronouns} · {p.pronouns} @@ -397,7 +397,7 @@ {/if} {#if canManage} - + {#if !mergeMode} @@ -504,4 +504,14 @@ .edit-fields { display: flex; gap: 0.4rem; flex-wrap: wrap; } .edit-fields input { flex: 1; min-width: 120px; font-size: 0.825rem; padding: 0.3rem 0.5rem; width: auto; } + @media (max-width: 640px) { + .td-name { width: 100%; } + .td-actions { width: 100%; } + .ticket-rows { padding: 0; border: none; border-radius: 0; margin-top: -0.5rem; } + .ticket-rows td { width: 100%; } + .ticket-row { flex-direction: column; gap: 0.35rem; } + .ticket-row div:last-child { text-align: left; } + .edit-row { padding: 0.75rem; } + .edit-row td { width: 100%; } + } diff --git a/frontend/src/pages/Users.svelte b/frontend/src/pages/Users.svelte index c683fb9..c649964 100644 --- a/frontend/src/pages/Users.svelte +++ b/frontend/src/pages/Users.svelte @@ -148,7 +148,7 @@ {#if showAdd}
-
+
@@ -213,8 +213,8 @@ {#each users as u (u.id)} {#if editID === u.id} - - {u.username} {#if u.id === me}you{/if} + + {u.username} {#if u.id === me}you{/if} @@ -213,7 +213,7 @@ {@const dept = deptFor(v.department_id)} {@const participant = participantFor(v.participant_id)} - + {v.name} {#if v.is_lead} Co-Lead @@ -245,13 +245,13 @@
{/if} - + {#if !v.checked_in} checkIn(v)} /> {/if} {#if canManage} - +
{/if}
+ + From d439306657bbc2edc6d6ee1a93606c1bc52eb342 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 09:56:54 -0600 Subject: [PATCH 22/47] Moved action buttons on mobile cards. --- frontend/src/pages/Departments.svelte | 23 ++++++++++++++++------- frontend/src/pages/Participants.svelte | 4 +--- frontend/src/pages/Users.svelte | 2 +- frontend/src/pages/Volunteers.svelte | 12 +++++++----- 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/frontend/src/pages/Departments.svelte b/frontend/src/pages/Departments.svelte index b7fd627..c2cf82b 100644 --- a/frontend/src/pages/Departments.svelte +++ b/frontend/src/pages/Departments.svelte @@ -100,7 +100,7 @@ {#if showAdd && canCreate}
-
+
@@ -142,8 +142,8 @@ {#each $allDepts ?? [] as d (d.id)} {#if editID === d.id} - - + +
@@ -153,7 +153,7 @@ {#if canCreate} - +
{#if canDelete} @@ -188,3 +188,12 @@
{/if}
+ + diff --git a/frontend/src/pages/Participants.svelte b/frontend/src/pages/Participants.svelte index ef2274f..55714a6 100644 --- a/frontend/src/pages/Participants.svelte +++ b/frontend/src/pages/Participants.svelte @@ -506,11 +506,9 @@ @media (max-width: 640px) { .td-name { width: 100%; } - .td-actions { width: 100%; } + .td-actions { width: 100%; display: flex; justify-content: flex-end; } .ticket-rows { padding: 0; border: none; border-radius: 0; margin-top: -0.5rem; } .ticket-rows td { width: 100%; } - .ticket-row { flex-direction: column; gap: 0.35rem; } - .ticket-row div:last-child { text-align: left; } .edit-row { padding: 0.75rem; } .edit-row td { width: 100%; } } diff --git a/frontend/src/pages/Users.svelte b/frontend/src/pages/Users.svelte index c649964..ee6b18a 100644 --- a/frontend/src/pages/Users.svelte +++ b/frontend/src/pages/Users.svelte @@ -278,7 +278,7 @@ diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index 8e5827a..ade758b 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -228,14 +228,14 @@
{v.note}
{/if} - + {#if dept} {dept.name} {:else} — {/if} - + {v.checked_in ? 'Ready' : v.email_confirmed ? 'Confirmed' : 'Unconfirmed'} @@ -269,8 +269,10 @@ From 62b3dece84b27f14989e07ca6322626dc1037598 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 10:35:27 -0600 Subject: [PATCH 23/47] Added Participant list to Gate kiosk. --- frontend/src/pages/GateKiosk.svelte | 67 ++++++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/GateKiosk.svelte b/frontend/src/pages/GateKiosk.svelte index 6c7dc71..d1eeaa1 100644 --- a/frontend/src/pages/GateKiosk.svelte +++ b/frontend/src/pages/GateKiosk.svelte @@ -7,6 +7,8 @@ let { session, onLogout } = $props() let search = $state('') + let manuallySelectedId = $state(null) + let showAll = $state(false) let error = $state('') let scannerMsg = $state('') let qrSupported = $state(false) @@ -44,22 +46,44 @@ return ($tickets ?? []).find(t => t.external_id?.toLowerCase() === sl) ?? null }) - // Name/email search across participants + const allParticipantsSorted = $derived.by(() => + ($participants ?? []) + .filter(p => !p.deleted_at) + .sort((a, b) => (a.preferred_name || a.email || '').localeCompare(b.preferred_name || b.email || '')) + ) + + // Clear manual selection whenever search text changes + $effect(() => { + search + manuallySelectedId = null + }) + + // Name/email/ticket-name search across participants const filteredParticipants = $derived.by(() => { if (matchedTicket) return [] const s = search.trim().toLowerCase() if (!s || s.length < 2) return [] + const byTicketName = new Set( + ($tickets ?? []) + .filter(t => t.name?.toLowerCase().includes(s)) + .map(t => t.participant_id) + .filter(Boolean) + ) return ($participants ?? []) .filter(p => p.preferred_name?.toLowerCase().includes(s) || - p.email?.toLowerCase().includes(s) + p.email?.toLowerCase().includes(s) || + byTicketName.has(p.id) ) .sort((a, b) => (a.preferred_name || '').localeCompare(b.preferred_name || '')) .slice(0, 8) }) - // Auto-select when exactly one participant matches + // Manual selection takes priority; fall back to auto-select on single match const selectedParticipant = $derived.by(() => { + if (manuallySelectedId) { + return ($participants ?? []).find(p => p.id === manuallySelectedId) ?? null + } if (filteredParticipants.length === 1) return filteredParticipants[0] return null }) @@ -165,6 +189,9 @@ {scanning ? '■ Stop' : '⊡ Scan QR'} {/if} +
{#if scanning} @@ -211,7 +238,13 @@ {:else if selectedParticipant} {@const pts = ticketsFor(selectedParticipant.id)}
-
{selectedParticipant.preferred_name || selectedParticipant.email || '(unknown)'}
+
+
{selectedParticipant.preferred_name || selectedParticipant.email || '(unknown)'}
+ {#if manuallySelectedId} + + {/if} +
{#if selectedParticipant.email}
{selectedParticipant.email}
{/if} @@ -243,7 +276,7 @@ {#each filteredParticipants as p} {@const pts = ticketsFor(p.id)} {@const ci = pts.filter(t => t.checked_in_at).length} - + {/each} +
+ {/if} +
Recent Check-ins
@@ -385,7 +439,8 @@ padding: 1.25rem; margin-bottom: 1rem; } - .gate-match-name { font-size: 1.4rem; font-weight: 700; margin-bottom: 0.2rem; } + .gate-match-name-row { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.2rem; } + .gate-match-name { font-size: 1.4rem; font-weight: 700; } .gate-match-sub { color: var(--c-muted); font-size: 0.875rem; } .gate-party { margin: 0.5rem 0; From 72b245d6d6e01b3a29b921f8ec4d8cda553448e4 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 15:52:40 -0600 Subject: [PATCH 24/47] Set up Unconfirmed -> Registered -> Confirmed -> Ready flow for Volunteers --- db.go | 26 +++++++++++++- docs/USAGE.md | 13 ++++--- frontend/src/api.js | 1 + frontend/src/app.css | 7 ++-- frontend/src/pages/Volunteers.svelte | 53 ++++++++++++++++++++++------ handle_volunteers.go | 14 ++++++++ main.go | 1 + 7 files changed, 95 insertions(+), 20 deletions(-) diff --git a/db.go b/db.go index d93807d..be830db 100644 --- a/db.go +++ b/db.go @@ -174,6 +174,8 @@ func migrateV2(db *sql.DB) error { addColumnIfMissing(db, "volunteers", "pronouns TEXT NOT NULL DEFAULT ''") addColumnIfMissing(db, "volunteers", "email_confirmed INTEGER NOT NULL DEFAULT 0") addColumnIfMissing(db, "volunteers", "confirmation_token TEXT") + addColumnIfMissing(db, "volunteers", "confirmed INTEGER NOT NULL DEFAULT 0") + addColumnIfMissing(db, "volunteers", "confirmed_at TEXT") // Widen the uniqueness constraint from name-only to (name, ticket_id). db.Exec(`DROP INDEX IF EXISTS idx_attendees_name`) db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_attendees_name_ticket ON attendees(name, ticket_id) WHERE deleted_at IS NULL`) @@ -392,6 +394,8 @@ type Volunteer struct { IsLead bool `json:"is_lead"` CheckedIn bool `json:"checked_in"` CheckedInAt *string `json:"checked_in_at,omitempty"` + Confirmed bool `json:"confirmed"` + ConfirmedAt *string `json:"confirmed_at,omitempty"` EmailConfirmed bool `json:"email_confirmed"` ConfirmationToken *string `json:"-"` Note string `json:"note"` @@ -1184,6 +1188,7 @@ const volunteerSelect = `v.id, v.participant_id, v.attendee_id, COALESCE(NULLIF(p.phone,''), v.phone), COALESCE(NULLIF(p.pronouns,''), v.pronouns), v.department_id, v.is_lead, v.checked_in, v.checked_in_at, + v.confirmed, v.confirmed_at, v.email_confirmed, v.confirmation_token, v.note, v.created_at, v.updated_at, v.deleted_at` const volunteerFrom = `FROM volunteers v LEFT JOIN participants p ON p.id = v.participant_id` @@ -1293,6 +1298,19 @@ func (app *App) checkInVolunteer(id, userID int) (*Volunteer, error) { return v, nil } +func (app *App) confirmVolunteer(id int) (*Volunteer, error) { + t := now() + _, err := app.db.Exec( + `UPDATE volunteers SET confirmed=1, confirmed_at=?, updated_at=? + WHERE id=? AND deleted_at IS NULL AND confirmed=0`, + t, t, id, + ) + if err != nil { + return nil, err + } + return app.getVolunteer(id) +} + func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { rows, err := db.Query(q, args...) if err != nil { @@ -1303,12 +1321,14 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { for rows.Next() { var v Volunteer var participantID, attendeeID, deptID sql.NullInt64 - var isLead, checkedIn, emailConfirmed int + var isLead, checkedIn, confirmed, emailConfirmed int var confirmationToken sql.NullString + var confirmedAt sql.NullString if err := rows.Scan( &v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName, &v.Email, &v.Phone, &v.Pronouns, &deptID, &isLead, &checkedIn, &v.CheckedInAt, + &confirmed, &confirmedAt, &emailConfirmed, &confirmationToken, &v.Note, &v.CreatedAt, &v.UpdatedAt, &v.DeletedAt, ); err != nil { @@ -1329,8 +1349,12 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { if confirmationToken.Valid { v.ConfirmationToken = &confirmationToken.String } + if confirmedAt.Valid { + v.ConfirmedAt = &confirmedAt.String + } v.IsLead = isLead == 1 v.CheckedIn = checkedIn == 1 + v.Confirmed = confirmed == 1 v.EmailConfirmed = emailConfirmed == 1 result = append(result, v) } diff --git a/docs/USAGE.md b/docs/USAGE.md index 23572eb..949f71b 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -99,11 +99,14 @@ Under **Volunteers**, you can: ### Volunteer statuses -| Status | Meaning | -|--------|---------| -| **Unconfirmed** | Signed up but hasn't confirmed their email | -| **Confirmed** | Email confirmed, not yet briefed | -| **Ready** | Briefed at the volunteer station, has what they need to report for shifts | +| Status | Meaning | Who sets it | +|--------|---------|-------------| +| **Unconfirmed** | Signed up but hasn't confirmed their email | Automatic (not yet done) | +| **Registered** | Email confirmed — volunteer is in the system | Automatic (email link) | +| **Confirmed** | Staff has verified the volunteer is assigned and ready to be scheduled | Admin, staffing, or co-lead | +| **Ready** | Briefed at the volunteer station, cleared to report for shifts | Gate staff at check-in | + +**Confirmation** is a deliberate staff action — it signals that a volunteer has been assigned to a department and you're expecting them. Only volunteers who have been assigned a department can be confirmed. Use the **Confirm** button on a registered volunteer's row. Volunteers are separate from participants. A person can be both a ticket holder and a volunteer. When a volunteer signs up via the public form, they are automatically linked to their participant record by email. diff --git a/frontend/src/api.js b/frontend/src/api.js index b0767e6..6700d4b 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -79,6 +79,7 @@ export const api = { update: (id, data) => apiJSON(`/api/volunteers/${id}`, { method: 'PUT', body: JSON.stringify(data) }), delete: (id) => apiFetch(`/api/volunteers/${id}`, { method: 'DELETE' }), checkIn: (id) => apiJSON(`/api/volunteers/${id}/checkin`, { method: 'POST' }), + confirm: (id) => apiJSON(`/api/volunteers/${id}/confirm`, { method: 'POST' }), assignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts`, { method: 'POST', body: JSON.stringify({ shift_id: shiftId }) }), unassignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts/${shiftId}`, { method: 'DELETE' }), }, diff --git a/frontend/src/app.css b/frontend/src/app.css index d36f2f7..0cd0ee8 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -129,9 +129,10 @@ tr:hover td { background: rgba(255,255,255,0.02); } font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; } -.badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); } -.badge-confirmed { background: rgba(99,102,241,0.15); color: var(--c-accent-h); } -.badge-unchecked { background: rgba(122,127,150,0.15); color: var(--c-muted); } +.badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); } +.badge-confirmed { background: rgba(99,102,241,0.15); color: var(--c-accent-h); } +.badge-registered { background: rgba(14,165,233,0.15); color: #0ea5e9; } +.badge-unchecked { background: rgba(122,127,150,0.15); color: var(--c-muted); } .badge-partial { background: rgba(245,158,11,0.15); color: var(--c-warn); } .badge-role { background: rgba(99,102,241,0.15); color: var(--c-accent-h); } .badge-lead { background: rgba(245,158,11,0.15); color: var(--c-warn); } diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index ade758b..e26ee6a 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -8,7 +8,7 @@ let search = $state('') let filterDept = $state('') - let filterChecked = $state('') + let filterStatus = $state('') let error = $state('') let showAdd = $state(false) let adding = $state(false) @@ -20,6 +20,7 @@ const role = $derived(session?.user?.role ?? '') const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role)) + const canConfirm = $derived(['admin', 'staffing', 'colead'].includes(role)) const myDeptIDs = $derived(session?.user?.department_ids ?? []) // Auto-filter coleads to their department on mount @@ -33,6 +34,7 @@ db.volunteers.filter(v => !v.deleted_at).toArray() ) const allParticipants = liveQuery(() => db.participants.toArray()) + const allTickets = liveQuery(() => db.tickets.filter(t => !t.deleted_at).toArray()) const allDepts = liveQuery(() => db.departments.filter(d => !d.deleted_at).toArray() .then(arr => arr.sort((a, b) => a.name.localeCompare(b.name))) @@ -44,8 +46,10 @@ return list .filter(v => { if (filterDept && v.department_id !== parseInt(filterDept)) return false - if (filterChecked === 'true' && !v.checked_in) return false - if (filterChecked === 'false' && v.checked_in) return false + if (filterStatus === 'unconfirmed' && v.email_confirmed) return false + if (filterStatus === 'registered' && (!v.email_confirmed || v.confirmed)) return false + if (filterStatus === 'confirmed' && (!v.confirmed || v.checked_in)) return false + if (filterStatus === 'ready' && !v.checked_in) return false if (s && !v.name.toLowerCase().includes(s) && !(v.email || '').toLowerCase().includes(s)) return false return true @@ -62,6 +66,15 @@ } } + async function confirmVolunteer(v) { + try { + const updated = await api.volunteers.confirm(v.id) + await db.volunteers.put(updated) + } catch (err) { + error = err.message + } + } + async function addVolunteer(e) { e.preventDefault() adding = true @@ -110,6 +123,11 @@ return ($allDepts ?? []).find(d => d.id === id) } + function participantHasTickets(participantId) { + if (!participantId) return false + return ($allTickets ?? []).some(t => t.participant_id === participantId) + } + function participantFor(id) { return ($allParticipants ?? []).find(p => p.id === id) ?? null } @@ -181,10 +199,12 @@ {/each} {/if} - + + + + + {filtered.length} shown @@ -219,7 +239,9 @@ Co-Lead {/if} {#if !v.participant_id} - No ticket + No ticket + {:else if !participantHasTickets(v.participant_id)} + No ticket {/if} {#if v.email}
{v.email}
@@ -236,9 +258,15 @@ {/if} - - {v.checked_in ? 'Ready' : v.email_confirmed ? 'Confirmed' : 'Unconfirmed'} - + {#if v.checked_in} + Ready + {:else if v.confirmed} + Confirmed + {:else if v.email_confirmed} + Registered + {:else} + Unconfirmed + {/if} {#if v.checked_in_at}
{new Date(v.checked_in_at).toLocaleTimeString()} @@ -252,6 +280,9 @@ {#if canManage} + {#if canConfirm && v.email_confirmed && !v.confirmed && v.department_id} + + {/if} - {/if} - - + {#if editID === v.id} + + + {v.name} + {#if v.email}
{v.email}
{/if} - {/if} - + + + + + + + + + + + + + + + {:else} + + + {v.name} + {#if v.is_lead} + Co-Lead + {/if} + {#if !v.participant_id} + No ticket + {:else if !participantHasTickets(v.participant_id)} + No ticket + {/if} + {#if v.ticket_name && v.ticket_name !== v.name} +
Ticket: {v.ticket_name}
+ {/if} + {#if v.email} +
{v.email}
+ {/if} + {#if v.note} +
{v.note}
+ {/if} + + + {#if dept} + {dept.name} + {:else} + — + {/if} + + + {#if v.checked_in} + Ready + {:else if v.confirmed} + Confirmed + {:else if v.email_confirmed} + Registered + {:else} + Unconfirmed + {/if} + {#if v.checked_in_at} +
+ {new Date(v.checked_in_at).toLocaleTimeString()} +
+ {/if} + + + {#if !v.checked_in} + checkIn(v)} /> + {/if} + + {#if canManage} + + {#if canConfirm && v.email_confirmed && !v.confirmed} + + {/if} + + + + {/if} + + {/if} {/each} @@ -305,5 +362,7 @@ .td-dept { width: 100%; order: 3; } .td-status { width: 100%; order: 4; } .td-actions { width: 100%; order: 5; display: flex; justify-content: flex-end; } + .edit-row td { width: 100%; } + .td-edit-dept, .td-edit-checks, .td-edit-note { width: 100%; } } From cc4dd7643850606c4bcd867544f3c9c60a143cf0 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 16:51:39 -0600 Subject: [PATCH 27/47] Set co-leads to confirmed automatically, and updated test and documents. --- docs/USAGE.md | 12 ++-- handle_volunteers.go | 4 ++ handle_volunteers_test.go | 141 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 handle_volunteers_test.go diff --git a/docs/USAGE.md b/docs/USAGE.md index 949f71b..c08ec50 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -83,7 +83,7 @@ In **Settings**, the "Volunteer Signup" card controls: In **Settings**, the "Shift Signups" card has an open/close toggle: -- **Opening** signups generates kiosk codes for all confirmed volunteers and emails them their shift signup links. A confirmation dialog warns before sending. +- **Opening** signups generates kiosk codes for all registered (email-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. @@ -92,9 +92,9 @@ If a volunteer confirms their email while signups are already open, they receive Under **Volunteers**, you can: -- Create volunteers manually (name, email, department) -- Assign volunteers to departments -- Mark volunteers as co-leads +- Create volunteers manually (name, email, department, co-lead, note) +- Edit existing volunteers (department, co-lead, note) via the inline Edit button +- Confirm registered volunteers (admin, staffing, colead) - Mark volunteers as ready (briefed at the volunteer station) ### Volunteer statuses @@ -106,7 +106,7 @@ Under **Volunteers**, you can: | **Confirmed** | Staff has verified the volunteer is assigned and ready to be scheduled | Admin, staffing, or co-lead | | **Ready** | Briefed at the volunteer station, cleared to report for shifts | Gate staff at check-in | -**Confirmation** is a deliberate staff action — it signals that a volunteer has been assigned to a department and you're expecting them. Only volunteers who have been assigned a department can be confirmed. Use the **Confirm** button on a registered volunteer's row. +**Confirmation** is a deliberate staff action — it signals that you're expecting the volunteer for shifts. Use the **Confirm** button on a registered volunteer's row. Marking a volunteer as a co-lead (`is_lead`) automatically confirms them. Volunteers are separate from participants. A person can be both a ticket holder and a volunteer. When a volunteer signs up via the public form, they are automatically linked to their participant record by email. @@ -135,7 +135,7 @@ The Volunteer Kiosk is the public-facing flow for volunteers: signup, email conf Kiosk links are generated and distributed automatically through the volunteer signup flow: 1. Volunteers sign up via the public signup form (`/volunteer-signup`) and confirm their email. -2. In **Settings**, open shift signups. This generates kiosk codes for all confirmed volunteers and emails them their links. A confirmation dialog warns before sending. +2. In **Settings**, open shift signups. This generates kiosk codes for all registered (email-confirmed) volunteers and emails them their links. A confirmation dialog warns before sending. 3. If a volunteer confirms their email while signups are already open, they receive their kiosk link immediately. **Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Kiosk links use this URL. diff --git a/handle_volunteers.go b/handle_volunteers.go index 15f976f..584927b 100644 --- a/handle_volunteers.go +++ b/handle_volunteers.go @@ -109,6 +109,10 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) { writeError(w, err.Error(), http.StatusInternalServerError) return } + + if v.IsLead { + app.confirmVolunteer(id) + } updated, _ := app.getVolunteer(id) writeJSON(w, updated) } diff --git a/handle_volunteers_test.go b/handle_volunteers_test.go new file mode 100644 index 0000000..e10815c --- /dev/null +++ b/handle_volunteers_test.go @@ -0,0 +1,141 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestConfirmVolunteer(t *testing.T) { + app := testApp(t) + mux := testMux(app) + admin := testAdminUser(t, app) + tok := testToken(t, app, admin) + + dept, _ := app.createDepartment(Department{Name: "Gate"}) + deptID := dept.ID + v, _ := app.createVolunteer(Volunteer{ + Name: "Titania", Email: "titania@test.com", + DepartmentID: &deptID, EmailConfirmed: true, + }) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + result := parseJSON(t, w) + vol := result["confirmed"] + if vol != true { + t.Error("expected confirmed=true in response") + } + + got, _ := app.getVolunteer(v.ID) + if got == nil || !got.Confirmed { + t.Error("volunteer should be confirmed in DB") + } + if got.ConfirmedAt == nil { + t.Error("confirmed_at should be set") + } +} + +func TestConfirmVolunteerIdempotent(t *testing.T) { + app := testApp(t) + mux := testMux(app) + admin := testAdminUser(t, app) + tok := testToken(t, app, admin) + + v, _ := app.createVolunteer(Volunteer{Name: "Puck", Email: "puck@test.com", EmailConfirmed: true}) + + // Confirm twice — second should be a no-op, not an error. + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) + if w.Code != 200 { + t.Fatalf("first confirm: %d", w.Code) + } + + w = httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) + if w.Code != 200 { + t.Fatalf("second confirm: %d", w.Code) + } +} + +func TestConfirmVolunteerRequiresRole(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + // Ticketing role should NOT be able to confirm volunteers. + ticketing := testUserWithRole(t, app, "ticket_lead", "ticketing", nil) + tok := testToken(t, app, ticketing) + + v, _ := app.createVolunteer(Volunteer{Name: "Helena", EmailConfirmed: true}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for ticketing role, got %d", w.Code) + } +} + +func TestUpdateVolunteerDepartment(t *testing.T) { + app := testApp(t) + mux := testMux(app) + admin := testAdminUser(t, app) + tok := testToken(t, app, admin) + + dept, _ := app.createDepartment(Department{Name: "Gate"}) + v, _ := app.createVolunteer(Volunteer{Name: "Hermia"}) + + // Assign department via update. + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{ + "name": "Hermia", "department_id": dept.ID, + }, tok)) + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + got, _ := app.getVolunteer(v.ID) + if got.DepartmentID == nil || *got.DepartmentID != dept.ID { + t.Errorf("department_id = %v, want %d", got.DepartmentID, dept.ID) + } +} + +func TestUpdateVolunteerCoLeadAutoConfirms(t *testing.T) { + app := testApp(t) + mux := testMux(app) + admin := testAdminUser(t, app) + tok := testToken(t, app, admin) + + dept, _ := app.createDepartment(Department{Name: "Build"}) + deptID := dept.ID + v, _ := app.createVolunteer(Volunteer{ + Name: "Lysander", Email: "lys@test.com", + DepartmentID: &deptID, EmailConfirmed: true, + }) + + // Verify not confirmed before update. + got, _ := app.getVolunteer(v.ID) + if got.Confirmed { + t.Fatal("should not be confirmed before update") + } + + // Update is_lead=true should auto-confirm. + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{ + "name": "Lysander", "department_id": deptID, "is_lead": true, + }, tok)) + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + got, _ = app.getVolunteer(v.ID) + if !got.IsLead { + t.Error("expected is_lead=true") + } + if !got.Confirmed { + t.Error("co-lead should be auto-confirmed") + } +} From e722ef055edd0ba7de3fe1db37397fe00f391151 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 17:15:41 -0600 Subject: [PATCH 28/47] Move Ticket Name to the Participant model. --- db.go | 30 +++++++++++++------------- frontend/src/pages/Participants.svelte | 4 ++++ frontend/src/pages/Volunteers.svelte | 3 --- handle_signup.go | 10 ++++----- handle_signup_test.go | 7 +++--- 5 files changed, 27 insertions(+), 27 deletions(-) diff --git a/db.go b/db.go index 9129489..c7a1089 100644 --- a/db.go +++ b/db.go @@ -201,6 +201,7 @@ func migrateV2(db *sql.DB) error { // and links volunteers to participants via participant_id. func migrateV3(db *sql.DB) error { addColumnIfMissing(db, "volunteers", "participant_id INTEGER REFERENCES participants(id)") + addColumnIfMissing(db, "participants", "ticket_name TEXT NOT NULL DEFAULT ''") // Seed participants from volunteers first (better name data: preferred_name). db.Exec(` @@ -401,7 +402,6 @@ type Volunteer struct { AttendeeID *int `json:"attendee_id,omitempty"` // deprecated; kept for migration compat Name string `json:"name"` PreferredName string `json:"preferred_name"` - TicketName string `json:"ticket_name"` Email string `json:"email"` Phone string `json:"phone"` Pronouns string `json:"pronouns"` @@ -424,6 +424,7 @@ type Participant struct { ID int `json:"id"` Email string `json:"email"` PreferredName string `json:"preferred_name"` + TicketName string `json:"ticket_name"` Phone string `json:"phone"` Pronouns string `json:"pronouns"` Note string `json:"note"` @@ -860,7 +861,7 @@ func (app *App) attendeeCounts() (total, checkedIn int, err error) { // --- Participants --- -const participantCols = `id, email, preferred_name, phone, pronouns, note, created_at, updated_at, deleted_at` +const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, created_at, updated_at, deleted_at` func (app *App) listParticipants(search, since string) ([]Participant, error) { var q string @@ -900,8 +901,8 @@ func (app *App) getParticipantByEmail(email string) (*Participant, error) { func (app *App) createParticipant(p Participant) (*Participant, error) { res, err := app.db.Exec( - `INSERT INTO participants (email, preferred_name, phone, pronouns, note, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, - strings.ToLower(p.Email), p.PreferredName, p.Phone, p.Pronouns, p.Note, now(), + `INSERT INTO participants (email, preferred_name, ticket_name, phone, pronouns, note, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + strings.ToLower(p.Email), p.PreferredName, p.TicketName, p.Phone, p.Pronouns, p.Note, now(), ) if err != nil { return nil, err @@ -912,9 +913,9 @@ func (app *App) createParticipant(p Participant) (*Participant, error) { func (app *App) updateParticipant(p Participant) error { _, err := app.db.Exec( - `UPDATE participants SET email=?, preferred_name=?, phone=?, pronouns=?, note=?, updated_at=? + `UPDATE participants SET email=?, preferred_name=?, ticket_name=?, phone=?, pronouns=?, note=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, - strings.ToLower(p.Email), p.PreferredName, p.Phone, p.Pronouns, p.Note, now(), p.ID, + strings.ToLower(p.Email), p.PreferredName, p.TicketName, p.Phone, p.Pronouns, p.Note, now(), p.ID, ) return err } @@ -957,7 +958,7 @@ func queryParticipants(db *sql.DB, q string, args ...any) ([]Participant, error) for rows.Next() { var p Participant if err := rows.Scan( - &p.ID, &p.Email, &p.PreferredName, &p.Phone, &p.Pronouns, &p.Note, + &p.ID, &p.Email, &p.PreferredName, &p.TicketName, &p.Phone, &p.Pronouns, &p.Note, &p.CreatedAt, &p.UpdatedAt, &p.DeletedAt, ); err != nil { return nil, err @@ -1199,7 +1200,6 @@ func queryDepartments(db *sql.DB, q string, args ...any) ([]Department, error) { const volunteerSelect = `v.id, v.participant_id, v.attendee_id, COALESCE(NULLIF(p.preferred_name,''), NULLIF(v.preferred_name,''), v.name), COALESCE(NULLIF(p.preferred_name,''), NULLIF(v.preferred_name,''), v.name), - v.ticket_name, COALESCE(NULLIF(p.email,''), v.email), COALESCE(NULLIF(p.phone,''), v.phone), COALESCE(NULLIF(p.pronouns,''), v.pronouns), @@ -1210,7 +1210,7 @@ const volunteerSelect = `v.id, v.participant_id, v.attendee_id, const volunteerFrom = `FROM volunteers v LEFT JOIN participants p ON p.id = v.participant_id` // volunteerCols is kept for backward-compat references that expect unqualified column names. -const volunteerCols = `id, attendee_id, name, preferred_name, ticket_name, email, phone, pronouns, department_id, is_lead, checked_in, checked_in_at, email_confirmed, confirmation_token, note, created_at, updated_at, deleted_at` +const volunteerCols = `id, attendee_id, name, preferred_name, email, phone, pronouns, department_id, is_lead, checked_in, checked_in_at, email_confirmed, confirmation_token, note, created_at, updated_at, deleted_at` func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) { q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1` @@ -1263,9 +1263,9 @@ func (app *App) getVolunteerByParticipantID(participantID int) (*Volunteer, erro func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { res, err := app.db.Exec( - `INSERT INTO volunteers (participant_id, attendee_id, name, preferred_name, ticket_name, email, phone, pronouns, department_id, is_lead, email_confirmed, confirmation_token, note, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - v.ParticipantID, v.AttendeeID, v.Name, v.PreferredName, v.TicketName, v.Email, v.Phone, v.Pronouns, + `INSERT INTO volunteers (participant_id, attendee_id, name, preferred_name, email, phone, pronouns, department_id, is_lead, email_confirmed, confirmation_token, note, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + v.ParticipantID, v.AttendeeID, v.Name, v.PreferredName, v.Email, v.Phone, v.Pronouns, v.DepartmentID, boolInt(v.IsLead), boolInt(v.EmailConfirmed), v.ConfirmationToken, v.Note, now(), ) if err != nil { @@ -1277,9 +1277,9 @@ func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { func (app *App) updateVolunteer(v Volunteer) error { _, err := app.db.Exec( - `UPDATE volunteers SET participant_id=?, attendee_id=?, name=?, preferred_name=?, ticket_name=?, email=?, phone=?, pronouns=?, department_id=?, is_lead=?, note=?, updated_at=? + `UPDATE volunteers SET participant_id=?, attendee_id=?, name=?, preferred_name=?, email=?, phone=?, pronouns=?, department_id=?, is_lead=?, note=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, - v.ParticipantID, v.AttendeeID, v.Name, v.PreferredName, v.TicketName, v.Email, v.Phone, v.Pronouns, + v.ParticipantID, v.AttendeeID, v.Name, v.PreferredName, v.Email, v.Phone, v.Pronouns, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID, ) return err @@ -1340,7 +1340,7 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { var isLead, checkedIn, confirmed, emailConfirmed int var confirmationToken, confirmedAt, kioskCode sql.NullString if err := rows.Scan( - &v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName, + &v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.Email, &v.Phone, &v.Pronouns, &deptID, &isLead, &checkedIn, &v.CheckedInAt, &confirmed, &confirmedAt, diff --git a/frontend/src/pages/Participants.svelte b/frontend/src/pages/Participants.svelte index 55714a6..2867958 100644 --- a/frontend/src/pages/Participants.svelte +++ b/frontend/src/pages/Participants.svelte @@ -63,6 +63,7 @@ return ($allTickets ?? []).filter(t => t.participant_id === participantId) } + function checkedInCount(participantId) { return ticketsFor(participantId).filter(t => t.checked_in_at).length } @@ -367,6 +368,9 @@ {#if p.pronouns} · {p.pronouns} {/if} + {#if p.ticket_name && p.ticket_name !== p.preferred_name} +
Ticket: {p.ticket_name}
+ {/if} {#if p.note}
{p.note}
{/if} diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index c8521e1..441e415 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -299,9 +299,6 @@ {:else if !participantHasTickets(v.participant_id)} No ticket {/if} - {#if v.ticket_name && v.ticket_name !== v.name} -
Ticket: {v.ticket_name}
- {/if} {#if v.email}
{v.email}
{/if} diff --git a/handle_signup.go b/handle_signup.go index bd9f091..77a7c63 100644 --- a/handle_signup.go +++ b/handle_signup.go @@ -75,12 +75,13 @@ func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) { return } // Update participant's personal details if they signed up with more info. - if body.Phone != "" || body.Pronouns != "" { + if body.Phone != "" || body.Pronouns != "" || body.TicketName != "" { app.db.Exec(`UPDATE participants SET - phone = CASE WHEN phone = '' THEN ? ELSE phone END, - pronouns = CASE WHEN pronouns = '' THEN ? ELSE pronouns END, + phone = CASE WHEN phone = '' THEN ? ELSE phone END, + pronouns = CASE WHEN pronouns = '' THEN ? ELSE pronouns END, + ticket_name = CASE WHEN ticket_name = '' THEN ? ELSE ticket_name END, updated_at = ? - WHERE id = ?`, body.Phone, body.Pronouns, now(), participant.ID) + WHERE id = ?`, body.Phone, body.Pronouns, body.TicketName, now(), participant.ID) } confirmToken, err := generateConfirmationToken() @@ -93,7 +94,6 @@ func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) { ParticipantID: &participant.ID, Name: body.PreferredName, PreferredName: body.PreferredName, - TicketName: body.TicketName, Email: body.Email, Phone: body.Phone, Pronouns: body.Pronouns, diff --git a/handle_signup_test.go b/handle_signup_test.go index c240596..2b63c16 100644 --- a/handle_signup_test.go +++ b/handle_signup_test.go @@ -337,8 +337,8 @@ func TestPublicSignupTicketNameDoesNotOverwritePreferredName(t *testing.T) { if p.PreferredName != "Titania" { t.Errorf("participant preferred_name = %q, want %q (not ticket_name)", p.PreferredName, "Titania") } - if vol.TicketName != "Titania Fairweather" { - t.Errorf("vol.TicketName = %q, want %q", vol.TicketName, "Titania Fairweather") + if p.TicketName != "Titania Fairweather" { + t.Errorf("participant.TicketName = %q, want %q", p.TicketName, "Titania Fairweather") } } @@ -349,12 +349,11 @@ func TestConfirmEmailAssignsKioskCode(t *testing.T) { app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`) app.baseURL = "https://example.com" - participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) + participant, _ := app.createParticipant(Participant{PreferredName: "Titania", TicketName: "Titania Fairweather", Email: "titania@example.com"}) token := "abc123def456" app.createVolunteer(Volunteer{ Name: "Titania", PreferredName: "Titania", - TicketName: "Titania Fairweather", Email: "titania@example.com", ParticipantID: &participant.ID, ConfirmationToken: &token, From 4c462c9d4757c70d5e7694e0b93ee49a99021f38 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 17:34:50 -0600 Subject: [PATCH 29/47] Refactored volunteer check_in as ready status. --- db.go | 49 ++++++++++++++------ frontend/src/api.js | 2 +- frontend/src/components/CheckInButton.svelte | 2 +- frontend/src/pages/Dashboard.svelte | 2 +- frontend/src/pages/Volunteers.svelte | 18 +++---- handle_volunteers.go | 4 +- main.go | 2 +- 7 files changed, 51 insertions(+), 28 deletions(-) diff --git a/db.go b/db.go index c7a1089..514460e 100644 --- a/db.go +++ b/db.go @@ -93,8 +93,8 @@ func migrate(db *sql.DB) error { phone TEXT NOT NULL DEFAULT '', department_id INTEGER REFERENCES departments(id) ON DELETE SET NULL, is_lead INTEGER NOT NULL DEFAULT 0, - checked_in INTEGER NOT NULL DEFAULT 0, - checked_in_at TEXT, + ready INTEGER NOT NULL DEFAULT 0, + ready_at TEXT, note TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), @@ -177,6 +177,8 @@ func migrateV2(db *sql.DB) error { addColumnIfMissing(db, "volunteers", "confirmed INTEGER NOT NULL DEFAULT 0") addColumnIfMissing(db, "volunteers", "confirmed_at TEXT") addColumnIfMissing(db, "volunteers", "kiosk_code TEXT") + renameColumnIfExists(db, "volunteers", "checked_in", "ready") + renameColumnIfExists(db, "volunteers", "checked_in_at", "ready_at") db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_volunteers_kiosk_code ON volunteers(kiosk_code) WHERE kiosk_code IS NOT NULL`) // Migrate kiosk codes from tickets to volunteers (idempotent). db.Exec(` @@ -340,6 +342,27 @@ func addColumnIfMissing(db *sql.DB, table, colDef string) { db.Exec(`ALTER TABLE "` + table + `" ADD COLUMN ` + colDef) } +func renameColumnIfExists(db *sql.DB, table, oldName, newName string) { + found := false + rows, err := db.Query(`PRAGMA table_info("` + table + `")`) + if err != nil { + return + } + for rows.Next() { + var cid, notNull, pk int + var name, typ string + var dflt sql.NullString + rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk) + if name == oldName { + found = true + } + } + rows.Close() + if found { + db.Exec(`ALTER TABLE "` + table + `" RENAME COLUMN "` + oldName + `" TO "` + newName + `"`) + } +} + // --- Types --- const attendeeCols = `id, name, email, phone, ticket_id, ticket_type, volunteer_token, @@ -407,8 +430,8 @@ type Volunteer struct { Pronouns string `json:"pronouns"` DepartmentID *int `json:"department_id,omitempty"` IsLead bool `json:"is_lead"` - CheckedIn bool `json:"checked_in"` - CheckedInAt *string `json:"checked_in_at,omitempty"` + Ready bool `json:"ready"` + ReadyAt *string `json:"ready_at,omitempty"` Confirmed bool `json:"confirmed"` ConfirmedAt *string `json:"confirmed_at,omitempty"` EmailConfirmed bool `json:"email_confirmed"` @@ -1203,14 +1226,14 @@ const volunteerSelect = `v.id, v.participant_id, v.attendee_id, COALESCE(NULLIF(p.email,''), v.email), COALESCE(NULLIF(p.phone,''), v.phone), COALESCE(NULLIF(p.pronouns,''), v.pronouns), - v.department_id, v.is_lead, v.checked_in, v.checked_in_at, + v.department_id, v.is_lead, v.ready, v.ready_at, v.confirmed, v.confirmed_at, v.email_confirmed, v.confirmation_token, v.kiosk_code, v.note, v.created_at, v.updated_at, v.deleted_at` const volunteerFrom = `FROM volunteers v LEFT JOIN participants p ON p.id = v.participant_id` // volunteerCols is kept for backward-compat references that expect unqualified column names. -const volunteerCols = `id, attendee_id, name, preferred_name, email, phone, pronouns, department_id, is_lead, checked_in, checked_in_at, email_confirmed, confirmation_token, note, created_at, updated_at, deleted_at` +const volunteerCols = `id, attendee_id, name, preferred_name, email, phone, pronouns, department_id, is_lead, ready, ready_at, email_confirmed, confirmation_token, note, created_at, updated_at, deleted_at` func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) { q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1` @@ -1292,13 +1315,13 @@ func (app *App) deleteVolunteer(id int) error { return err } -// checkInVolunteer marks the volunteer as checked in and, if linked to an attendee, +// markVolunteerReady marks the volunteer as ready and, if linked to an attendee, // also increments the attendee's checked_in_count. -func (app *App) checkInVolunteer(id, userID int) (*Volunteer, error) { +func (app *App) markVolunteerReady(id, userID int) (*Volunteer, error) { t := now() _, err := app.db.Exec( - `UPDATE volunteers SET checked_in=1, checked_in_at=?, updated_at=? - WHERE id=? AND deleted_at IS NULL AND checked_in=0`, + `UPDATE volunteers SET ready=1, ready_at=?, updated_at=? + WHERE id=? AND deleted_at IS NULL AND ready=0`, t, t, id, ) if err != nil { @@ -1337,12 +1360,12 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { for rows.Next() { var v Volunteer var participantID, attendeeID, deptID sql.NullInt64 - var isLead, checkedIn, confirmed, emailConfirmed int + var isLead, ready, confirmed, emailConfirmed int var confirmationToken, confirmedAt, kioskCode sql.NullString if err := rows.Scan( &v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.Email, &v.Phone, &v.Pronouns, &deptID, - &isLead, &checkedIn, &v.CheckedInAt, + &isLead, &ready, &v.ReadyAt, &confirmed, &confirmedAt, &emailConfirmed, &confirmationToken, &kioskCode, &v.Note, &v.CreatedAt, &v.UpdatedAt, &v.DeletedAt, @@ -1371,7 +1394,7 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { v.KioskCode = &kioskCode.String } v.IsLead = isLead == 1 - v.CheckedIn = checkedIn == 1 + v.Ready = ready == 1 v.Confirmed = confirmed == 1 v.EmailConfirmed = emailConfirmed == 1 result = append(result, v) diff --git a/frontend/src/api.js b/frontend/src/api.js index 6700d4b..686faa8 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -78,7 +78,7 @@ export const api = { create: (data) => apiJSON('/api/volunteers', { method: 'POST', body: JSON.stringify(data) }), update: (id, data) => apiJSON(`/api/volunteers/${id}`, { method: 'PUT', body: JSON.stringify(data) }), delete: (id) => apiFetch(`/api/volunteers/${id}`, { method: 'DELETE' }), - checkIn: (id) => apiJSON(`/api/volunteers/${id}/checkin`, { method: 'POST' }), + markReady: (id) => apiJSON(`/api/volunteers/${id}/ready`, { method: 'POST' }), confirm: (id) => apiJSON(`/api/volunteers/${id}/confirm`, { method: 'POST' }), assignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts`, { method: 'POST', body: JSON.stringify({ shift_id: shiftId }) }), unassignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts/${shiftId}`, { method: 'DELETE' }), diff --git a/frontend/src/components/CheckInButton.svelte b/frontend/src/components/CheckInButton.svelte index f0af073..cbd4bd2 100644 --- a/frontend/src/components/CheckInButton.svelte +++ b/frontend/src/components/CheckInButton.svelte @@ -9,5 +9,5 @@ diff --git a/frontend/src/pages/Dashboard.svelte b/frontend/src/pages/Dashboard.svelte index 9a69d10..e5a26de 100644 --- a/frontend/src/pages/Dashboard.svelte +++ b/frontend/src/pages/Dashboard.svelte @@ -31,7 +31,7 @@ return vols }) const volTotal = $derived(volunteers.length) - const volCheckedIn = $derived(volunteers.filter(v => v.checked_in).length) + const volCheckedIn = $derived(volunteers.filter(v => v.ready).length) const volLeads = $derived(volunteers.filter(v => v.is_lead).length) // Shift stats (scoped for colead) diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index 441e415..f477e64 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -54,8 +54,8 @@ if (filterDept && v.department_id !== parseInt(filterDept)) return false if (filterStatus === 'unconfirmed' && v.email_confirmed) return false if (filterStatus === 'registered' && (!v.email_confirmed || v.confirmed)) return false - if (filterStatus === 'confirmed' && (!v.confirmed || v.checked_in)) return false - if (filterStatus === 'ready' && !v.checked_in) return false + if (filterStatus === 'confirmed' && (!v.confirmed || v.ready)) return false + if (filterStatus === 'ready' && !v.ready) return false if (s && !v.name.toLowerCase().includes(s) && !(v.email || '').toLowerCase().includes(s)) return false return true @@ -63,9 +63,9 @@ .sort((a, b) => a.name.localeCompare(b.name)) }) - async function checkIn(v) { + async function markReady(v) { try { - const updated = await api.volunteers.checkIn(v.id) + const updated = await api.volunteers.markReady(v.id) await db.volunteers.put(updated) } catch (err) { error = err.message @@ -314,7 +314,7 @@ {/if} - {#if v.checked_in} + {#if v.ready} Ready {:else if v.confirmed} Confirmed @@ -323,15 +323,15 @@ {:else} Unconfirmed {/if} - {#if v.checked_in_at} + {#if v.ready_at}
- {new Date(v.checked_in_at).toLocaleTimeString()} + {new Date(v.ready_at).toLocaleTimeString()}
{/if} - {#if !v.checked_in} - checkIn(v)} /> + {#if v.confirmed && !v.ready} + markReady(v)} /> {/if} {#if canManage} diff --git a/handle_volunteers.go b/handle_volunteers.go index 584927b..845bddd 100644 --- a/handle_volunteers.go +++ b/handle_volunteers.go @@ -130,14 +130,14 @@ func (app *App) handleDeleteVolunteer(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -func (app *App) handleCheckInVolunteer(w http.ResponseWriter, r *http.Request) { +func (app *App) handleMarkVolunteerReady(w http.ResponseWriter, r *http.Request) { id, err := strconv.Atoi(r.PathValue("id")) if err != nil { writeError(w, "invalid id", http.StatusBadRequest) return } claims := claimsFromContext(r) - v, err := app.checkInVolunteer(id, claims.UserID) + v, err := app.markVolunteerReady(id, claims.UserID) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return diff --git a/main.go b/main.go index ea79877..be9fc53 100644 --- a/main.go +++ b/main.go @@ -125,7 +125,7 @@ func (app *App) registerRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "ticketing", "staffing", "colead")) mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "ticketing", "staffing", "colead")) mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("POST /api/volunteers/{id}/checkin", auth(app.handleCheckInVolunteer, "admin", "ticketing", "staffing", "colead")) + mux.HandleFunc("POST /api/volunteers/{id}/ready", auth(app.handleMarkVolunteerReady, "admin", "ticketing", "staffing", "colead")) mux.HandleFunc("POST /api/volunteers/{id}/confirm", auth(app.handleConfirmVolunteer, "admin", "staffing", "colead")) 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", "ticketing", "staffing", "colead")) From c03498b59e8cf492db6c05b68f1c4e55205f7488 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 19:11:58 -0600 Subject: [PATCH 30/47] Fixed volunteer filters. --- frontend/src/pages/ScheduleBoard.svelte | 5 ++++- frontend/src/pages/Volunteers.svelte | 9 +++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/ScheduleBoard.svelte b/frontend/src/pages/ScheduleBoard.svelte index 65391e0..6755588 100644 --- a/frontend/src/pages/ScheduleBoard.svelte +++ b/frontend/src/pages/ScheduleBoard.svelte @@ -414,7 +414,10 @@
diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index f477e64..5f5f3c8 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -29,10 +29,11 @@ const canConfirm = $derived(['admin', 'staffing', 'colead'].includes(role)) const myDeptIDs = $derived(session?.user?.department_ids ?? []) - // Auto-filter coleads to their department on mount + let deptInitialized = $state(false) $effect(() => { - if (role === 'colead' && myDeptIDs.length > 0 && !filterDept) { + if (!deptInitialized && role === 'colead' && myDeptIDs.length > 0) { filterDept = String(myDeptIDs[0]) + deptInitialized = true } }) @@ -228,7 +229,7 @@ {/if} + + +
+
+ +
diff --git a/handle_volunteers.go b/handle_volunteers.go index 845bddd..6832995 100644 --- a/handle_volunteers.go +++ b/handle_volunteers.go @@ -33,11 +33,15 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) { } func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) { - var v Volunteer - if err := json.NewDecoder(r.Body).Decode(&v); err != nil { + var body struct { + Volunteer + TicketName string `json:"ticket_name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, "invalid request", http.StatusBadRequest) return } + v := body.Volunteer if v.Name == "" { writeError(w, "name is required", http.StatusBadRequest) return @@ -52,7 +56,9 @@ func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) { if v.Email != "" && v.ParticipantID == nil { p, _ := app.getParticipantByEmail(v.Email) if p == nil { - p, _ = app.createParticipant(Participant{PreferredName: v.Name, Email: v.Email}) + p, _ = app.createParticipant(Participant{PreferredName: v.Name, Email: v.Email, TicketName: body.TicketName}) + } else if body.TicketName != "" && p.TicketName == "" { + app.db.Exec(`UPDATE participants SET ticket_name = ?, updated_at = ? WHERE id = ?`, body.TicketName, now(), p.ID) } if p != nil { v.ParticipantID = &p.ID From e7e542c03cc55dba0fa29a8443830fbc283f9b3e Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 20:17:25 -0600 Subject: [PATCH 32/47] Require email on Volunteer entry. --- frontend/src/pages/Volunteers.svelte | 4 ++-- handle_volunteers.go | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index 06f6aac..e90bf80 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -190,8 +190,8 @@
- - + +
diff --git a/handle_volunteers.go b/handle_volunteers.go index 6832995..b7e385a 100644 --- a/handle_volunteers.go +++ b/handle_volunteers.go @@ -46,6 +46,10 @@ func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) { writeError(w, "name is required", http.StatusBadRequest) return } + if v.Email == "" { + writeError(w, "email is required", http.StatusBadRequest) + return + } claims := claimsFromContext(r) if claims.Role == "colead" { if v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { From fcf5bf1f34369da659490193fd39e04964c2df55 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 20:28:21 -0600 Subject: [PATCH 33/47] Generate confirmation email for manually-entered volunteers. --- handle_volunteers.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/handle_volunteers.go b/handle_volunteers.go index b7e385a..0a9fec0 100644 --- a/handle_volunteers.go +++ b/handle_volunteers.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "log" "net/http" "strconv" ) @@ -68,11 +69,22 @@ func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) { v.ParticipantID = &p.ID } } + confirmToken, err := generateConfirmationToken() + if err != nil { + writeError(w, "internal error", http.StatusInternalServerError) + return + } + v.ConfirmationToken = &confirmToken created, err := app.createVolunteer(v) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } + go func() { + if err := app.sendConfirmationEmail(v.Email, v.Name, confirmToken); err != nil { + log.Printf("confirmation email to %s failed: %v", v.Email, err) + } + }() w.WriteHeader(http.StatusCreated) writeJSON(w, created) } From 7d56ef2f339f2a8d0c43dc474a8232dc9645fac7 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Fri, 6 Mar 2026 07:11:19 -0600 Subject: [PATCH 34/47] Moved properties from Volunteer to Participant. --- db.go | 422 +++++++++----------------------------- db_test.go | 9 +- frontend/src/db.js | 5 + handle_kiosk_test.go | 5 +- handle_shifts_test.go | 6 +- handle_signup.go | 25 +-- handle_signup_test.go | 65 +++--- handle_volunteers.go | 66 +++--- handle_volunteers_test.go | 25 ++- 9 files changed, 200 insertions(+), 428 deletions(-) diff --git a/db.go b/db.go index 514460e..bda44ed 100644 --- a/db.go +++ b/db.go @@ -86,21 +86,24 @@ func migrate(db *sql.DB) error { ON attendees(name, ticket_id) WHERE deleted_at IS NULL; CREATE TABLE IF NOT EXISTS volunteers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - attendee_id INTEGER REFERENCES attendees(id) ON DELETE SET NULL, - name TEXT NOT NULL, - email TEXT NOT NULL DEFAULT '', - phone TEXT NOT NULL DEFAULT '', - department_id INTEGER REFERENCES departments(id) ON DELETE SET NULL, - is_lead INTEGER NOT NULL DEFAULT 0, - ready INTEGER NOT NULL DEFAULT 0, - ready_at TEXT, - note TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - deleted_at TEXT + id INTEGER PRIMARY KEY AUTOINCREMENT, + participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE, + department_id INTEGER REFERENCES departments(id) ON DELETE SET NULL, + is_lead INTEGER NOT NULL DEFAULT 0, + ready INTEGER NOT NULL DEFAULT 0, + ready_at TEXT, + confirmed INTEGER NOT NULL DEFAULT 0, + confirmed_at TEXT, + kiosk_code TEXT, + note TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_volunteers_kiosk_code + ON volunteers(kiosk_code) WHERE kiosk_code IS NOT NULL; + CREATE TABLE IF NOT EXISTS shifts ( id INTEGER PRIMARY KEY AUTOINCREMENT, department_id INTEGER NOT NULL REFERENCES departments(id) ON DELETE CASCADE, @@ -119,19 +122,23 @@ func migrate(db *sql.DB) error { shift_id INTEGER NOT NULL REFERENCES shifts(id) ON DELETE CASCADE, confirmed INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, PRIMARY KEY (volunteer_id, shift_id) ); CREATE TABLE IF NOT EXISTS participants ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - email TEXT NOT NULL DEFAULT '', - preferred_name TEXT NOT NULL DEFAULT '', - phone TEXT NOT NULL DEFAULT '', - pronouns TEXT NOT NULL DEFAULT '', - note TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - deleted_at TEXT + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL DEFAULT '', + preferred_name TEXT NOT NULL DEFAULT '', + ticket_name TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '', + pronouns TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + email_confirmed INTEGER NOT NULL DEFAULT 0, + confirmation_token TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT ); CREATE UNIQUE INDEX IF NOT EXISTS idx_participants_email @@ -159,210 +166,9 @@ func migrate(db *sql.DB) error { if err != nil { return err } - return migrateV2(db) -} - -// migrateV2 adds new columns to existing databases without data loss. -func migrateV2(db *sql.DB) error { - addColumnIfMissing(db, "attendees", "volunteer_token TEXT UNIQUE") - addColumnIfMissing(db, "attendees", "party_size INTEGER NOT NULL DEFAULT 1") - addColumnIfMissing(db, "attendees", "checked_in_count INTEGER NOT NULL DEFAULT 0") - addColumnIfMissing(db, "shifts", "position INTEGER NOT NULL DEFAULT 0") - addColumnIfMissing(db, "volunteer_shifts", "deleted_at TEXT") - addColumnIfMissing(db, "volunteers", "preferred_name TEXT NOT NULL DEFAULT ''") - addColumnIfMissing(db, "volunteers", "ticket_name TEXT NOT NULL DEFAULT ''") - addColumnIfMissing(db, "volunteers", "pronouns TEXT NOT NULL DEFAULT ''") - addColumnIfMissing(db, "volunteers", "email_confirmed INTEGER NOT NULL DEFAULT 0") - addColumnIfMissing(db, "volunteers", "confirmation_token TEXT") - addColumnIfMissing(db, "volunteers", "confirmed INTEGER NOT NULL DEFAULT 0") - addColumnIfMissing(db, "volunteers", "confirmed_at TEXT") - addColumnIfMissing(db, "volunteers", "kiosk_code TEXT") - renameColumnIfExists(db, "volunteers", "checked_in", "ready") - renameColumnIfExists(db, "volunteers", "checked_in_at", "ready_at") - db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_volunteers_kiosk_code ON volunteers(kiosk_code) WHERE kiosk_code IS NOT NULL`) - // Migrate kiosk codes from tickets to volunteers (idempotent). - db.Exec(` - UPDATE volunteers SET kiosk_code = ( - SELECT t.code FROM tickets t - WHERE t.participant_id = volunteers.participant_id - AND t.code IS NOT NULL AND t.deleted_at IS NULL - LIMIT 1 - ) WHERE kiosk_code IS NULL AND participant_id IS NOT NULL`) - // Delete stub tickets whose code has been migrated to the volunteer. - db.Exec(` - DELETE FROM tickets - WHERE source = 'manual' AND external_id = '' AND code IS NOT NULL - AND participant_id IN (SELECT id FROM volunteers WHERE kiosk_code IS NOT NULL)`) - // Widen the uniqueness constraint from name-only to (name, ticket_id). - db.Exec(`DROP INDEX IF EXISTS idx_attendees_name`) - db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_attendees_name_ticket ON attendees(name, ticket_id) WHERE deleted_at IS NULL`) - return migrateV3(db) -} - -// migrateV3 populates participants + tickets from attendees/volunteers, -// and links volunteers to participants via participant_id. -func migrateV3(db *sql.DB) error { - addColumnIfMissing(db, "volunteers", "participant_id INTEGER REFERENCES participants(id)") - addColumnIfMissing(db, "participants", "ticket_name TEXT NOT NULL DEFAULT ''") - - // Seed participants from volunteers first (better name data: preferred_name). - db.Exec(` - INSERT OR IGNORE INTO participants (email, preferred_name, phone, pronouns, created_at, updated_at) - SELECT - LOWER(email), - CASE WHEN preferred_name != '' THEN preferred_name ELSE name END, - phone, - pronouns, - created_at, - created_at - FROM volunteers - WHERE email != '' AND deleted_at IS NULL`) - - // Fill in from attendees for emails not yet in participants. - db.Exec(` - INSERT OR IGNORE INTO participants (email, preferred_name, phone, created_at, updated_at) - SELECT LOWER(email), name, phone, created_at, created_at - FROM attendees - WHERE email != '' AND deleted_at IS NULL`) - - // Attendees with no email: create a placeholder participant so tickets aren't orphaned. - rows, _ := db.Query(`SELECT id, name, created_at FROM attendees WHERE email = '' AND deleted_at IS NULL`) - if rows != nil { - type stub struct { - id, name, createdAt string - } - var stubs []stub - for rows.Next() { - var s stub - rows.Scan(&s.id, &s.name, &s.createdAt) - stubs = append(stubs, s) - } - rows.Close() - for _, s := range stubs { - placeholder := fmt.Sprintf("ticket-%s@unknown", s.id) - db.Exec(`INSERT OR IGNORE INTO participants (email, preferred_name, created_at, updated_at) VALUES (?, ?, ?, ?)`, - placeholder, s.name, s.createdAt, s.createdAt) - } - } - - // Link volunteers to participants via email. - db.Exec(` - UPDATE volunteers SET participant_id = ( - SELECT p.id FROM participants p WHERE LOWER(p.email) = LOWER(volunteers.email) - ) - WHERE participant_id IS NULL AND email != ''`) - - // Seed tickets from attendees (1 ticket per attendee row). - db.Exec(` - INSERT OR IGNORE INTO tickets (participant_id, name, ticket_type, source, external_id, order_id, code, checked_in_at, checked_in_by, created_at, updated_at, deleted_at) - SELECT - p.id, - a.name, - a.ticket_type, - CASE WHEN a.ticket_id != '' THEN 'crowdwork' ELSE 'manual' END, - a.ticket_id, - a.ticket_id, - a.volunteer_token, - a.checked_in_at, - a.checked_in_by, - a.created_at, - a.updated_at, - a.deleted_at - FROM attendees a - JOIN participants p ON LOWER(p.email) = LOWER(a.email) OR p.email = 'ticket-' || a.id || '@unknown'`) - - // Volunteers whose participant has no ticket: create a stub ticket so they can get a kiosk code. - db.Exec(` - INSERT OR IGNORE INTO tickets (participant_id, source, created_at, updated_at) - SELECT DISTINCT v.participant_id, 'manual', v.created_at, v.created_at - FROM volunteers v - WHERE v.participant_id IS NOT 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)`) - - 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 } -func addColumnIfMissing(db *sql.DB, table, colDef string) { - colName := strings.Fields(colDef)[0] - rows, err := db.Query(`PRAGMA table_info("` + table + `")`) - if err != nil { - return - } - defer rows.Close() - for rows.Next() { - var cid, notNull, pk int - var name, typ string - var dflt sql.NullString - rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk) - if name == colName { - return - } - } - db.Exec(`ALTER TABLE "` + table + `" ADD COLUMN ` + colDef) -} - -func renameColumnIfExists(db *sql.DB, table, oldName, newName string) { - found := false - rows, err := db.Query(`PRAGMA table_info("` + table + `")`) - if err != nil { - return - } - for rows.Next() { - var cid, notNull, pk int - var name, typ string - var dflt sql.NullString - rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk) - if name == oldName { - found = true - } - } - rows.Close() - if found { - db.Exec(`ALTER TABLE "` + table + `" RENAME COLUMN "` + oldName + `" TO "` + newName + `"`) - } -} - // --- Types --- const attendeeCols = `id, name, email, phone, ticket_id, ticket_type, volunteer_token, @@ -420,40 +226,40 @@ type Department struct { } type Volunteer struct { - ID int `json:"id"` - ParticipantID *int `json:"participant_id,omitempty"` - AttendeeID *int `json:"attendee_id,omitempty"` // deprecated; kept for migration compat - Name string `json:"name"` - PreferredName string `json:"preferred_name"` - Email string `json:"email"` - Phone string `json:"phone"` - Pronouns string `json:"pronouns"` - DepartmentID *int `json:"department_id,omitempty"` - IsLead bool `json:"is_lead"` - Ready bool `json:"ready"` - ReadyAt *string `json:"ready_at,omitempty"` - Confirmed bool `json:"confirmed"` - ConfirmedAt *string `json:"confirmed_at,omitempty"` - EmailConfirmed bool `json:"email_confirmed"` - ConfirmationToken *string `json:"-"` - KioskCode *string `json:"kiosk_code,omitempty"` - Note string `json:"note"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - DeletedAt *string `json:"deleted_at,omitempty"` -} - -type Participant struct { ID int `json:"id"` - Email string `json:"email"` - PreferredName string `json:"preferred_name"` - TicketName string `json:"ticket_name"` - Phone string `json:"phone"` - Pronouns string `json:"pronouns"` + ParticipantID int `json:"participant_id"` + DepartmentID *int `json:"department_id,omitempty"` + IsLead bool `json:"is_lead"` + Ready bool `json:"ready"` + ReadyAt *string `json:"ready_at,omitempty"` + Confirmed bool `json:"confirmed"` + ConfirmedAt *string `json:"confirmed_at,omitempty"` + KioskCode *string `json:"kiosk_code,omitempty"` Note string `json:"note"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` DeletedAt *string `json:"deleted_at,omitempty"` + // Populated via JOIN from participant, not stored on volunteers table: + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` + Pronouns string `json:"pronouns"` + EmailConfirmed bool `json:"email_confirmed"` +} + +type Participant struct { + ID int `json:"id"` + Email string `json:"email"` + PreferredName string `json:"preferred_name"` + TicketName string `json:"ticket_name"` + Phone string `json:"phone"` + Pronouns string `json:"pronouns"` + Note string `json:"note"` + EmailConfirmed bool `json:"email_confirmed"` + ConfirmationToken *string `json:"-"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at,omitempty"` } type Ticket struct { @@ -884,7 +690,7 @@ func (app *App) attendeeCounts() (total, checkedIn int, err error) { // --- Participants --- -const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, created_at, updated_at, deleted_at` +const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, email_confirmed, confirmation_token, created_at, updated_at, deleted_at` func (app *App) listParticipants(search, since string) ([]Participant, error) { var q string @@ -924,8 +730,8 @@ func (app *App) getParticipantByEmail(email string) (*Participant, error) { func (app *App) createParticipant(p Participant) (*Participant, error) { res, err := app.db.Exec( - `INSERT INTO participants (email, preferred_name, ticket_name, phone, pronouns, note, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, - strings.ToLower(p.Email), p.PreferredName, p.TicketName, p.Phone, p.Pronouns, p.Note, now(), + `INSERT INTO participants (email, preferred_name, ticket_name, phone, pronouns, note, email_confirmed, confirmation_token, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + strings.ToLower(p.Email), p.PreferredName, p.TicketName, p.Phone, p.Pronouns, p.Note, boolInt(p.EmailConfirmed), p.ConfirmationToken, now(), ) if err != nil { return nil, err @@ -980,12 +786,19 @@ func queryParticipants(db *sql.DB, q string, args ...any) ([]Participant, error) var result []Participant for rows.Next() { var p Participant + var emailConfirmed int + var confirmationToken sql.NullString if err := rows.Scan( &p.ID, &p.Email, &p.PreferredName, &p.TicketName, &p.Phone, &p.Pronouns, &p.Note, + &emailConfirmed, &confirmationToken, &p.CreatedAt, &p.UpdatedAt, &p.DeletedAt, ); err != nil { return nil, err } + p.EmailConfirmed = emailConfirmed == 1 + if confirmationToken.Valid { + p.ConfirmationToken = &confirmationToken.String + } result = append(result, p) } return result, rows.Err() @@ -1217,23 +1030,13 @@ func queryDepartments(db *sql.DB, q string, args ...any) ([]Department, error) { // --- Volunteers --- -// volunteerSelect / volunteerFrom are used together for all volunteer queries. -// Personal fields (name, email, phone, pronouns) come from the joined participant when available, -// falling back to the volunteer's own columns for legacy rows. -const volunteerSelect = `v.id, v.participant_id, v.attendee_id, - COALESCE(NULLIF(p.preferred_name,''), NULLIF(v.preferred_name,''), v.name), - COALESCE(NULLIF(p.preferred_name,''), NULLIF(v.preferred_name,''), v.name), - COALESCE(NULLIF(p.email,''), v.email), - COALESCE(NULLIF(p.phone,''), v.phone), - COALESCE(NULLIF(p.pronouns,''), v.pronouns), +const volunteerSelect = `v.id, v.participant_id, + p.preferred_name, p.email, p.phone, p.pronouns, v.department_id, v.is_lead, v.ready, v.ready_at, v.confirmed, v.confirmed_at, - v.email_confirmed, v.confirmation_token, v.kiosk_code, v.note, + p.email_confirmed, v.kiosk_code, v.note, v.created_at, v.updated_at, v.deleted_at` -const volunteerFrom = `FROM volunteers v LEFT JOIN participants p ON p.id = v.participant_id` - -// volunteerCols is kept for backward-compat references that expect unqualified column names. -const volunteerCols = `id, attendee_id, name, preferred_name, email, phone, pronouns, department_id, is_lead, ready, ready_at, email_confirmed, confirmation_token, note, created_at, updated_at, deleted_at` +const volunteerFrom = `FROM volunteers v INNER JOIN participants p ON p.id = v.participant_id` func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) { q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1` @@ -1245,15 +1048,15 @@ func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volu q += ` AND v.deleted_at IS NULL` } if search != "" { - q += ` AND (v.name LIKE ? OR v.email LIKE ? OR p.preferred_name LIKE ? OR p.email LIKE ?)` + q += ` AND (p.preferred_name LIKE ? OR p.email LIKE ?)` s := "%" + search + "%" - args = append(args, s, s, s, s) + args = append(args, s, s) } if deptID != nil { q += ` AND v.department_id = ?` args = append(args, *deptID) } - q += ` ORDER BY COALESCE(NULLIF(p.preferred_name,''), v.name)` + q += ` ORDER BY p.preferred_name` return queryVolunteers(app.db, q, args...) } @@ -1266,15 +1069,6 @@ func (app *App) getVolunteer(id int) (*Volunteer, error) { return &rows[0], nil } -func (app *App) getVolunteerByAttendeeID(attendeeID int) (*Volunteer, error) { - rows, err := queryVolunteers(app.db, - `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.attendee_id = ? AND v.deleted_at IS NULL LIMIT 1`, attendeeID) - if err != nil || len(rows) == 0 { - return nil, err - } - return &rows[0], nil -} - func (app *App) getVolunteerByParticipantID(participantID int) (*Volunteer, error) { rows, err := queryVolunteers(app.db, `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.participant_id = ? AND v.deleted_at IS NULL LIMIT 1`, participantID) @@ -1286,10 +1080,9 @@ func (app *App) getVolunteerByParticipantID(participantID int) (*Volunteer, erro func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { res, err := app.db.Exec( - `INSERT INTO volunteers (participant_id, attendee_id, name, preferred_name, email, phone, pronouns, department_id, is_lead, email_confirmed, confirmation_token, note, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - v.ParticipantID, v.AttendeeID, v.Name, v.PreferredName, v.Email, v.Phone, v.Pronouns, - v.DepartmentID, boolInt(v.IsLead), boolInt(v.EmailConfirmed), v.ConfirmationToken, v.Note, now(), + `INSERT INTO volunteers (participant_id, department_id, is_lead, note, updated_at) + VALUES (?, ?, ?, ?, ?)`, + v.ParticipantID, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), ) if err != nil { return nil, err @@ -1300,9 +1093,8 @@ func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { func (app *App) updateVolunteer(v Volunteer) error { _, err := app.db.Exec( - `UPDATE volunteers SET participant_id=?, attendee_id=?, name=?, preferred_name=?, email=?, phone=?, pronouns=?, department_id=?, is_lead=?, note=?, updated_at=? + `UPDATE volunteers SET department_id=?, is_lead=?, note=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, - v.ParticipantID, v.AttendeeID, v.Name, v.PreferredName, v.Email, v.Phone, v.Pronouns, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID, ) return err @@ -1315,8 +1107,6 @@ func (app *App) deleteVolunteer(id int) error { return err } -// markVolunteerReady marks the volunteer as ready and, if linked to an attendee, -// also increments the attendee's checked_in_count. func (app *App) markVolunteerReady(id, userID int) (*Volunteer, error) { t := now() _, err := app.db.Exec( @@ -1327,14 +1117,7 @@ func (app *App) markVolunteerReady(id, userID int) (*Volunteer, error) { if err != nil { return nil, err } - v, err := app.getVolunteer(id) - if err != nil || v == nil { - return v, err - } - if v.AttendeeID != nil { - app.checkInAttendee(*v.AttendeeID, userID, 1) - } - return v, nil + return app.getVolunteer(id) } func (app *App) confirmVolunteer(id int) (*Volunteer, error) { @@ -1359,34 +1142,23 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { var result []Volunteer for rows.Next() { var v Volunteer - var participantID, attendeeID, deptID sql.NullInt64 + var deptID sql.NullInt64 var isLead, ready, confirmed, emailConfirmed int - var confirmationToken, confirmedAt, kioskCode sql.NullString + var confirmedAt, kioskCode sql.NullString if err := rows.Scan( - &v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, - &v.Email, &v.Phone, &v.Pronouns, &deptID, - &isLead, &ready, &v.ReadyAt, + &v.ID, &v.ParticipantID, + &v.Name, &v.Email, &v.Phone, &v.Pronouns, + &deptID, &isLead, &ready, &v.ReadyAt, &confirmed, &confirmedAt, - &emailConfirmed, &confirmationToken, &kioskCode, &v.Note, + &emailConfirmed, &kioskCode, &v.Note, &v.CreatedAt, &v.UpdatedAt, &v.DeletedAt, ); err != nil { return nil, err } - if participantID.Valid { - id := int(participantID.Int64) - v.ParticipantID = &id - } - if attendeeID.Valid { - id := int(attendeeID.Int64) - v.AttendeeID = &id - } if deptID.Valid { id := int(deptID.Int64) v.DepartmentID = &id } - if confirmationToken.Valid { - v.ConfirmationToken = &confirmationToken.String - } if confirmedAt.Valid { v.ConfirmedAt = &confirmedAt.String } @@ -1404,7 +1176,7 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { func (app *App) getVolunteerByEmail(email string) (*Volunteer, error) { rows, err := queryVolunteers(app.db, - `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE LOWER(v.email) = LOWER(?) AND v.deleted_at IS NULL LIMIT 1`, email) + `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE LOWER(p.email) = LOWER(?) AND v.deleted_at IS NULL LIMIT 1`, email) if err != nil || len(rows) == 0 { return nil, err } @@ -1413,17 +1185,24 @@ func (app *App) getVolunteerByEmail(email string) (*Volunteer, error) { func (app *App) getVolunteerByConfirmationToken(token string) (*Volunteer, error) { rows, err := queryVolunteers(app.db, - `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.confirmation_token = ? AND v.deleted_at IS NULL LIMIT 1`, token) + `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE p.confirmation_token = ? AND v.deleted_at IS NULL LIMIT 1`, token) if err != nil || len(rows) == 0 { return nil, err } return &rows[0], nil } -func (app *App) confirmVolunteerEmail(id int) error { +func (app *App) confirmParticipantEmail(participantID int) error { _, err := app.db.Exec( - `UPDATE volunteers SET email_confirmed = 1, confirmation_token = NULL, updated_at = ? WHERE id = ?`, - now(), id) + `UPDATE participants SET email_confirmed = 1, confirmation_token = NULL, updated_at = ? WHERE id = ?`, + now(), participantID) + return err +} + +func (app *App) setParticipantConfirmationToken(participantID int, token string) error { + _, err := app.db.Exec( + `UPDATE participants SET confirmation_token = ?, updated_at = ? WHERE id = ?`, + token, now(), participantID) return err } @@ -1442,11 +1221,10 @@ func (app *App) assignKioskCode(id int, code string) error { return err } -// listVolunteersNeedingKioskCode returns email-confirmed volunteers without a kiosk code. func (app *App) listVolunteersNeedingKioskCode() ([]Volunteer, error) { return queryVolunteers(app.db, ` SELECT `+volunteerSelect+` `+volunteerFrom+` - WHERE v.email_confirmed = 1 AND v.kiosk_code IS NULL AND v.deleted_at IS NULL`) + WHERE p.email_confirmed = 1 AND v.kiosk_code IS NULL AND v.deleted_at IS NULL`) } func (app *App) generateVolunteerKioskCode() (string, error) { @@ -1658,7 +1436,7 @@ var errShiftFull = fmt.Errorf("shift is full") func (app *App) unassignShift(volunteerID, shiftID int) error { _, err := app.db.Exec( - `UPDATE volunteer_shifts SET deleted_at = ?, updated_at = ? WHERE volunteer_id=? AND shift_id=?`, + `UPDATE volunteer_shifts SET deleted_at=?, updated_at=? WHERE volunteer_id=? AND shift_id=?`, now(), now(), volunteerID, shiftID, ) return err diff --git a/db_test.go b/db_test.go index 0d453bb..c9e8b68 100644 --- a/db_test.go +++ b/db_test.go @@ -197,7 +197,8 @@ func TestAssignAndUnassignShift(t *testing.T) { dept, _ := app.createDepartment(Department{Name: "Gate"}) deptID := dept.ID s, _ := app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2}) - v, _ := app.createVolunteer(Volunteer{Name: "Helena", DepartmentID: &deptID}) + p, _ := app.createParticipant(Participant{PreferredName: "Helena", Email: "helena@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) if err := app.assignShift(v.ID, s.ID); err != nil { t.Fatal(err) @@ -221,7 +222,8 @@ func TestCheckShiftConflict(t *testing.T) { dept, _ := app.createDepartment(Department{Name: "Gate"}) deptID := dept.ID - v, _ := app.createVolunteer(Volunteer{Name: "Hermia", DepartmentID: &deptID}) + p, _ := app.createParticipant(Participant{PreferredName: "Hermia", Email: "hermia@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) s1, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) s2, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"}) @@ -250,7 +252,8 @@ func TestCheckShiftConflictMidnight(t *testing.T) { dept, _ := app.createDepartment(Department{Name: "Sound"}) deptID := dept.ID - v, _ := app.createVolunteer(Volunteer{Name: "Lysander", DepartmentID: &deptID}) + p, _ := app.createParticipant(Participant{PreferredName: "Lysander", Email: "lysander@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) // Night shift: 22:00-02:00 (spans midnight) night, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Night", Day: "2026-03-15", StartTime: "22:00", EndTime: "02:00"}) diff --git a/frontend/src/db.js b/frontend/src/db.js index a09f332..7e96bbc 100644 --- a/frontend/src/db.js +++ b/frontend/src/db.js @@ -46,6 +46,11 @@ db.version(4).stores({ volunteers: 'id, name, department_id, checked_in, participant_id, deleted_at', }) +db.version(5).stores({ + volunteers: 'id, participant_id, department_id, deleted_at', + participants: 'id, email, preferred_name, email_confirmed, updated_at, deleted_at', +}) + export async function getLastSync() { const m = await db.meta.get('last_sync') return m?.value ?? '' diff --git a/handle_kiosk_test.go b/handle_kiosk_test.go index e385ad3..2bac7cf 100644 --- a/handle_kiosk_test.go +++ b/handle_kiosk_test.go @@ -16,7 +16,7 @@ func setupKiosk(t *testing.T) (*App, *http.ServeMux, string) { // Create volunteer with a kiosk_code directly on the volunteer record p, _ := app.createParticipant(Participant{Email: "titania@test.com", PreferredName: "Titania"}) - v, _ := app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: &p.ID, DepartmentID: &deptID}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) token, _ := app.generateVolunteerKioskCode() app.assignKioskCode(v.ID, token) @@ -132,7 +132,8 @@ func TestKioskClaimFull(t *testing.T) { // Shift 2 has capacity 1. Fill it with another volunteer. dept, _ := app.createDepartment(Department{Name: "Build"}) deptID := dept.ID - other, _ := app.createVolunteer(Volunteer{Name: "Other", DepartmentID: &deptID}) + otherP, _ := app.createParticipant(Participant{PreferredName: "Other", Email: "other@test.com"}) + other, _ := app.createVolunteer(Volunteer{ParticipantID: otherP.ID, DepartmentID: &deptID}) app.assignShift(other.ID, 2) // fills the capacity-1 shift req := httptest.NewRequest("POST", "/api/v/"+token+"/shifts/2", nil) diff --git a/handle_shifts_test.go b/handle_shifts_test.go index 8c629b1..940164e 100644 --- a/handle_shifts_test.go +++ b/handle_shifts_test.go @@ -55,7 +55,8 @@ func TestShiftAssignVolunteer(t *testing.T) { dept, _ := app.createDepartment(Department{Name: "Gate"}) deptID := dept.ID app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) - app.createVolunteer(Volunteer{Name: "Titania", DepartmentID: &deptID}) + p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com"}) + app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) // Assign req := testAuthRequest("POST", "/api/shifts/1/volunteers", map[string]any{ @@ -86,7 +87,8 @@ func TestShiftAssignConflict(t *testing.T) { deptID := dept.ID app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"}) - app.createVolunteer(Volunteer{Name: "Titania", DepartmentID: &deptID}) + p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com"}) + app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) // Assign to first shift app.assignShift(1, 1) diff --git a/handle_signup.go b/handle_signup.go index 77a7c63..1f0fe2e 100644 --- a/handle_signup.go +++ b/handle_signup.go @@ -89,17 +89,12 @@ func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) { writeError(w, "internal error", http.StatusInternalServerError) return } + app.setParticipantConfirmationToken(participant.ID, confirmToken) vol := Volunteer{ - ParticipantID: &participant.ID, - Name: body.PreferredName, - PreferredName: body.PreferredName, - Email: body.Email, - Phone: body.Phone, - Pronouns: body.Pronouns, - DepartmentID: body.DepartmentID, - Note: body.Note, - ConfirmationToken: &confirmToken, + ParticipantID: participant.ID, + DepartmentID: body.DepartmentID, + Note: body.Note, } if _, err := app.createVolunteer(vol); err != nil { @@ -136,7 +131,7 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) { return } - if err := app.confirmVolunteerEmail(vol.ID); err != nil { + if err := app.confirmParticipantEmail(vol.ParticipantID); err != nil { writeError(w, "internal error", http.StatusInternalServerError) return } @@ -153,7 +148,7 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) { kioskLink := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), code) response["kiosk_link"] = kioskLink go func() { - if err := app.sendShiftSignupEmail(vol.Email, vol.PreferredName, kioskLink); err != nil { + if err := app.sendShiftSignupEmail(vol.Email, vol.Name, kioskLink); err != nil { log.Printf("shift signup email to %s failed: %v", vol.Email, err) } }() @@ -198,7 +193,7 @@ func (app *App) openShiftSignups() { // Email all email-confirmed volunteers that now have a kiosk code. confirmed, _ := queryVolunteers(app.db, ` SELECT `+volunteerSelect+` `+volunteerFrom+` - WHERE v.email_confirmed = 1 AND v.kiosk_code IS NOT NULL AND v.deleted_at IS NULL`) + WHERE p.email_confirmed = 1 AND v.kiosk_code IS NOT NULL AND v.deleted_at IS NULL`) baseURL := app.resolveBaseURL() sent := 0 @@ -207,11 +202,7 @@ func (app *App) openShiftSignups() { continue } kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *v.KioskCode) - name := v.PreferredName - if name == "" { - name = v.Name - } - if err := app.sendShiftSignupEmail(v.Email, name, kioskLink); err == nil { + if err := app.sendShiftSignupEmail(v.Email, v.Name, kioskLink); err == nil { sent++ } else { log.Printf("shift signup email to %s failed: %v", v.Email, err) diff --git a/handle_signup_test.go b/handle_signup_test.go index 2b63c16..62f59d1 100644 --- a/handle_signup_test.go +++ b/handle_signup_test.go @@ -58,27 +58,27 @@ func TestPublicSignup(t *testing.T) { if err != nil || vol == nil { t.Fatal("volunteer not created") } - if vol.PreferredName != "Titania" { - t.Errorf("preferred_name = %q, want Titania", vol.PreferredName) + if vol.Name != "Titania" { + t.Errorf("name = %q, want Titania", vol.Name) } if vol.Pronouns != "she/they" { t.Errorf("pronouns = %q, want she/they", vol.Pronouns) } - if vol.ConfirmationToken == nil || *vol.ConfirmationToken == "" { - t.Error("expected confirmation token to be set") - } if vol.EmailConfirmed { t.Error("should not be confirmed yet") } // Participant should be auto-created and linked - if vol.ParticipantID == nil { + if vol.ParticipantID == 0 { t.Fatal("expected participant to be linked") } - p, _ := app.getParticipant(*vol.ParticipantID) + p, _ := app.getParticipant(vol.ParticipantID) if p == nil { t.Fatal("linked participant not found") } + if p.ConfirmationToken == nil || *p.ConfirmationToken == "" { + t.Error("expected confirmation token on participant") + } if p.Email != "titania@example.com" { t.Errorf("participant email = %q, want titania@example.com", p.Email) } @@ -105,8 +105,8 @@ func TestPublicSignupAutoMatchParticipant(t *testing.T) { if vol == nil { t.Fatal("volunteer not created") } - if vol.ParticipantID == nil || *vol.ParticipantID != existing.ID { - t.Errorf("expected volunteer linked to existing participant %d, got %v", existing.ID, vol.ParticipantID) + if vol.ParticipantID == 0 || vol.ParticipantID != existing.ID { + t.Errorf("expected volunteer linked to existing participant %d, got %d", existing.ID, vol.ParticipantID) } } @@ -200,12 +200,8 @@ func TestConfirmEmail(t *testing.T) { mux := testMux(app) token := "abc123def456" - app.createVolunteer(Volunteer{ - Name: "Titania", - PreferredName: "Titania", - Email: "titania@example.com", - ConfirmationToken: &token, - }) + p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token}) + app.createVolunteer(Volunteer{ParticipantID: p.ID}) w := httptest.NewRecorder() mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) @@ -217,12 +213,13 @@ func TestConfirmEmail(t *testing.T) { t.Errorf("expected confirmed, got %v", result["status"]) } - // Verify volunteer is confirmed + // Verify participant is email confirmed vol, _ := app.getVolunteerByEmail("titania@example.com") if vol == nil || !vol.EmailConfirmed { - t.Error("volunteer should be email confirmed") + t.Error("volunteer should show email confirmed via participant") } - if vol.ConfirmationToken != nil { + updatedP, _ := app.getParticipant(p.ID) + if updatedP.ConfirmationToken != nil { t.Error("confirmation token should be cleared after confirmation") } } @@ -247,12 +244,8 @@ func TestConfirmEmailAlreadyConfirmed(t *testing.T) { mux := testMux(app) token := "abc123def456" - app.createVolunteer(Volunteer{ - Name: "Titania", - PreferredName: "Titania", - Email: "titania@example.com", - ConfirmationToken: &token, - }) + p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token}) + app.createVolunteer(Volunteer{ParticipantID: p.ID}) // Confirm first time w := httptest.NewRecorder() @@ -277,15 +270,9 @@ func TestConfirmEmailWithSignupsOpen(t *testing.T) { app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`) app.baseURL = "https://example.com" - participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) token := "abc123def456" - app.createVolunteer(Volunteer{ - Name: "Titania", - PreferredName: "Titania", - Email: "titania@example.com", - ParticipantID: &participant.ID, - ConfirmationToken: &token, - }) + participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token}) + app.createVolunteer(Volunteer{ParticipantID: participant.ID}) w := httptest.NewRecorder() mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) @@ -327,10 +314,10 @@ func TestPublicSignupTicketNameDoesNotOverwritePreferredName(t *testing.T) { } vol, _ := app.getVolunteerByEmail("titania@example.com") - if vol == nil || vol.ParticipantID == nil { + if vol == nil || vol.ParticipantID == 0 { t.Fatal("volunteer/participant not created") } - p, _ := app.getParticipant(*vol.ParticipantID) + p, _ := app.getParticipant(vol.ParticipantID) if p == nil { t.Fatal("participant not found") } @@ -349,15 +336,9 @@ func TestConfirmEmailAssignsKioskCode(t *testing.T) { app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`) app.baseURL = "https://example.com" - participant, _ := app.createParticipant(Participant{PreferredName: "Titania", TicketName: "Titania Fairweather", Email: "titania@example.com"}) token := "abc123def456" - app.createVolunteer(Volunteer{ - Name: "Titania", - PreferredName: "Titania", - Email: "titania@example.com", - ParticipantID: &participant.ID, - ConfirmationToken: &token, - }) + participant, _ := app.createParticipant(Participant{PreferredName: "Titania", TicketName: "Titania Fairweather", Email: "titania@example.com", ConfirmationToken: &token}) + app.createVolunteer(Volunteer{ParticipantID: participant.ID}) w := httptest.NewRecorder() mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) diff --git a/handle_volunteers.go b/handle_volunteers.go index 0a9fec0..5d086ad 100644 --- a/handle_volunteers.go +++ b/handle_volunteers.go @@ -35,54 +35,62 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) { func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) { var body struct { - Volunteer - TicketName string `json:"ticket_name"` + Name string `json:"name"` + TicketName string `json:"ticket_name"` + Email string `json:"email"` + DepartmentID *int `json:"department_id"` + IsLead bool `json:"is_lead"` + Note string `json:"note"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, "invalid request", http.StatusBadRequest) return } - v := body.Volunteer - if v.Name == "" { + if body.Name == "" { writeError(w, "name is required", http.StatusBadRequest) return } - if v.Email == "" { + if body.Email == "" { writeError(w, "email is required", http.StatusBadRequest) return } claims := claimsFromContext(r) if claims.Role == "colead" { - if v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { + if body.DepartmentID == nil || !inSlice(*body.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) 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, TicketName: body.TicketName}) - } else if body.TicketName != "" && p.TicketName == "" { - app.db.Exec(`UPDATE participants SET ticket_name = ?, updated_at = ? WHERE id = ?`, body.TicketName, now(), p.ID) - } - if p != nil { - v.ParticipantID = &p.ID - } + p, _ := app.getParticipantByEmail(body.Email) + if p == nil { + p, _ = app.createParticipant(Participant{PreferredName: body.Name, Email: body.Email, TicketName: body.TicketName}) + } else if body.TicketName != "" && p.TicketName == "" { + app.db.Exec(`UPDATE participants SET ticket_name = ?, updated_at = ? WHERE id = ?`, body.TicketName, now(), p.ID) + } + if p == nil { + writeError(w, "failed to create participant", http.StatusInternalServerError) + return } confirmToken, err := generateConfirmationToken() if err != nil { writeError(w, "internal error", http.StatusInternalServerError) return } - v.ConfirmationToken = &confirmToken + app.setParticipantConfirmationToken(p.ID, confirmToken) + v := Volunteer{ + ParticipantID: p.ID, + DepartmentID: body.DepartmentID, + IsLead: body.IsLead, + Note: body.Note, + } created, err := app.createVolunteer(v) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } go func() { - if err := app.sendConfirmationEmail(v.Email, v.Name, confirmToken); err != nil { - log.Printf("confirmation email to %s failed: %v", v.Email, err) + if err := app.sendConfirmationEmail(body.Email, body.Name, confirmToken); err != nil { + log.Printf("confirmation email to %s failed: %v", body.Email, err) } }() w.WriteHeader(http.StatusCreated) @@ -109,13 +117,13 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) { writeError(w, "invalid id", http.StatusBadRequest) return } - var v Volunteer - if err := json.NewDecoder(r.Body).Decode(&v); err != nil { - writeError(w, "invalid request", http.StatusBadRequest) - return + var body struct { + DepartmentID *int `json:"department_id"` + IsLead bool `json:"is_lead"` + Note string `json:"note"` } - if v.Name == "" { - writeError(w, "name is required", http.StatusBadRequest) + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, "invalid request", http.StatusBadRequest) return } claims := claimsFromContext(r) @@ -126,12 +134,16 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) { return } } - v.ID = id + v := Volunteer{ + ID: id, + DepartmentID: body.DepartmentID, + IsLead: body.IsLead, + Note: body.Note, + } if err := app.updateVolunteer(v); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } - if v.IsLead { app.confirmVolunteer(id) } diff --git a/handle_volunteers_test.go b/handle_volunteers_test.go index e10815c..ab51b9b 100644 --- a/handle_volunteers_test.go +++ b/handle_volunteers_test.go @@ -14,10 +14,8 @@ func TestConfirmVolunteer(t *testing.T) { dept, _ := app.createDepartment(Department{Name: "Gate"}) deptID := dept.ID - v, _ := app.createVolunteer(Volunteer{ - Name: "Titania", Email: "titania@test.com", - DepartmentID: &deptID, EmailConfirmed: true, - }) + p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com", EmailConfirmed: true}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) w := httptest.NewRecorder() mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) @@ -46,7 +44,8 @@ func TestConfirmVolunteerIdempotent(t *testing.T) { admin := testAdminUser(t, app) tok := testToken(t, app, admin) - v, _ := app.createVolunteer(Volunteer{Name: "Puck", Email: "puck@test.com", EmailConfirmed: true}) + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com", EmailConfirmed: true}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID}) // Confirm twice — second should be a no-op, not an error. w := httptest.NewRecorder() @@ -70,7 +69,8 @@ func TestConfirmVolunteerRequiresRole(t *testing.T) { ticketing := testUserWithRole(t, app, "ticket_lead", "ticketing", nil) tok := testToken(t, app, ticketing) - v, _ := app.createVolunteer(Volunteer{Name: "Helena", EmailConfirmed: true}) + p, _ := app.createParticipant(Participant{PreferredName: "Helena", EmailConfirmed: true}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID}) w := httptest.NewRecorder() mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) @@ -86,12 +86,13 @@ func TestUpdateVolunteerDepartment(t *testing.T) { tok := testToken(t, app, admin) dept, _ := app.createDepartment(Department{Name: "Gate"}) - v, _ := app.createVolunteer(Volunteer{Name: "Hermia"}) + p, _ := app.createParticipant(Participant{PreferredName: "Hermia"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID}) // Assign department via update. w := httptest.NewRecorder() mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{ - "name": "Hermia", "department_id": dept.ID, + "department_id": dept.ID, }, tok)) if w.Code != 200 { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) @@ -111,10 +112,8 @@ func TestUpdateVolunteerCoLeadAutoConfirms(t *testing.T) { dept, _ := app.createDepartment(Department{Name: "Build"}) deptID := dept.ID - v, _ := app.createVolunteer(Volunteer{ - Name: "Lysander", Email: "lys@test.com", - DepartmentID: &deptID, EmailConfirmed: true, - }) + p, _ := app.createParticipant(Participant{PreferredName: "Lysander", Email: "lys@test.com", EmailConfirmed: true}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) // Verify not confirmed before update. got, _ := app.getVolunteer(v.ID) @@ -125,7 +124,7 @@ func TestUpdateVolunteerCoLeadAutoConfirms(t *testing.T) { // Update is_lead=true should auto-confirm. w := httptest.NewRecorder() mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{ - "name": "Lysander", "department_id": deptID, "is_lead": true, + "department_id": deptID, "is_lead": true, }, tok)) if w.Code != 200 { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) From e640bf8bedbddd09341c79f381a4d4a268d00e15 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 10 Mar 2026 13:01:35 -0500 Subject: [PATCH 35/47] Removed `v` prefix from git tag recipes --- Makefile | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index 30bca30..a8de0d3 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ .PHONY: build frontend-build dev clean test patch minor major LAST_TAG := $(shell git tag --sort=-v:refname | head -1) -MAJOR := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f1) -MINOR := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f2) -PATCH := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f3) +MAJOR := $(shell echo $(LAST_TAG) | cut -d. -f1) +MINOR := $(shell echo $(LAST_TAG) | cut -d. -f2) +PATCH := $(shell echo $(LAST_TAG) | cut -d. -f3) build: frontend-build CGO_ENABLED=0 go build -ldflags "-X main.buildID=$$(git rev-parse --short HEAD)" -o turnpike . @@ -25,13 +25,13 @@ clean: rm -rf frontend/dist patch: - git tag v$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1))) - @echo "Tagged v$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))" + git tag $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1))) + @echo "Tagged $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))" minor: - git tag v$(MAJOR).$(shell echo $$(($(MINOR)+1))).0 - @echo "Tagged v$(MAJOR).$(shell echo $$(($(MINOR)+1))).0" + git tag $(MAJOR).$(shell echo $$(($(MINOR)+1))).0 + @echo "Tagged $(MAJOR).$(shell echo $$(($(MINOR)+1))).0" major: - git tag v$(shell echo $$(($(MAJOR)+1))).0.0 - @echo "Tagged v$(shell echo $$(($(MAJOR)+1))).0.0" + git tag $(shell echo $$(($(MAJOR)+1))).0.0 + @echo "Tagged $(shell echo $$(($(MAJOR)+1))).0.0" From 1eb6a99ff6746d03a41d88ce59198130902a2ad8 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 10 Mar 2026 14:08:00 -0500 Subject: [PATCH 36/47] Refactored user/volunteer/participant identity. --- auth.go | 30 +-- auth_test.go | 15 +- db.go | 290 +++++++++++++++++++----- db_test.go | 2 +- frontend/src/App.svelte | 9 +- frontend/src/api.js | 4 +- frontend/src/api.test.js | 4 +- frontend/src/components/Nav.svelte | 7 +- frontend/src/db.js | 2 + frontend/src/db.test.js | 4 +- frontend/src/pages/Dashboard.svelte | 23 +- frontend/src/pages/Departments.svelte | 7 +- frontend/src/pages/Login.svelte | 8 +- frontend/src/pages/Participants.svelte | 5 +- frontend/src/pages/ScheduleBoard.svelte | 7 +- frontend/src/pages/Users.svelte | 86 ++++--- frontend/src/pages/Volunteers.svelte | 9 +- handle_attendees_test.go | 12 +- handle_auth.go | 8 +- handle_participants.go | 2 +- handle_settings_test.go | 4 +- handle_shifts.go | 6 +- handle_sync_test.go | 25 +- handle_users.go | 36 +-- handle_volunteers.go | 8 +- handle_volunteers_test.go | 8 +- main.go | 102 ++++----- testutil_test.go | 11 +- 28 files changed, 469 insertions(+), 265 deletions(-) diff --git a/auth.go b/auth.go index c2d11af..0cc812a 100644 --- a/auth.go +++ b/auth.go @@ -12,10 +12,10 @@ import ( ) type Claims struct { - UserID int `json:"uid"` - Username string `json:"sub"` - Role string `json:"role"` - DeptIDs []int `json:"dept_ids,omitempty"` + ParticipantID int `json:"pid"` + Email string `json:"sub"` + Roles []string `json:"roles"` + DeptIDs []int `json:"dept_ids,omitempty"` jwt.RegisteredClaims } @@ -28,13 +28,13 @@ func checkPassword(hash, password string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil } -func (app *App) signToken(u *User) (string, error) { +func (app *App) signToken(s *User) (string, error) { expiry := time.Duration(app.tokenExpiry) * time.Hour claims := Claims{ - UserID: u.ID, - Username: u.Username, - Role: u.Role, - DeptIDs: u.DepartmentIDs, + ParticipantID: s.ID, + Email: s.Email, + Roles: s.Roles, + DeptIDs: s.DepartmentIDs, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)), IssuedAt: jwt.NewNumericDate(time.Now()), @@ -88,7 +88,7 @@ func (app *App) requireAuth(next http.HandlerFunc, roles ...string) http.Handler writeError(w, "unauthorized", http.StatusUnauthorized) return } - if len(roles) > 0 && !hasRole(claims.Role, roles) { + if len(roles) > 0 && !hasAnyRole(claims.Roles, roles) { writeError(w, "forbidden", http.StatusForbidden) return } @@ -97,10 +97,12 @@ func (app *App) requireAuth(next http.HandlerFunc, roles ...string) http.Handler } } -func hasRole(role string, allowed []string) bool { - for _, r := range allowed { - if r == role { - return true +func hasAnyRole(roles []string, allowed []string) bool { + for _, r := range roles { + for _, a := range allowed { + if r == a { + return true + } } } return false diff --git a/auth_test.go b/auth_test.go index f611bc1..602c6cf 100644 --- a/auth_test.go +++ b/auth_test.go @@ -12,7 +12,7 @@ func TestLoginValid(t *testing.T) { mux := testMux(app) req := testRequest("POST", "/api/login", map[string]string{ - "username": admin.Username, + "email": admin.Email, "password": "admin123", }) w := httptest.NewRecorder() @@ -26,7 +26,7 @@ func TestLoginValid(t *testing.T) { t.Error("missing token in response") } user, ok := result["user"].(map[string]any) - if !ok || user["username"] != "admin" { + if !ok || user["email"] != "oberon@athens.example" { t.Errorf("user = %v", result["user"]) } } @@ -37,7 +37,7 @@ func TestLoginWrongPassword(t *testing.T) { mux := testMux(app) req := testRequest("POST", "/api/login", map[string]string{ - "username": "admin", + "email": "oberon@athens.example", "password": "wrong", }) w := httptest.NewRecorder() @@ -53,7 +53,7 @@ func TestLoginNonexistentUser(t *testing.T) { mux := testMux(app) req := testRequest("POST", "/api/login", map[string]string{ - "username": "nobody", + "email": "nobody@test.com", "password": "test", }) w := httptest.NewRecorder() @@ -94,8 +94,7 @@ func TestAuthMiddlewareRoleEnforcement(t *testing.T) { app := testApp(t) mux := testMux(app) - // Create a gate user — should not be able to access /api/users (admin only) - gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{}) + gate := testUserWithRoles(t, app, "Starveling", []string{"gatekeeper"}, []int{}) token := testToken(t, app, gate) req := testAuthRequest("GET", "/api/users", nil, token) @@ -121,7 +120,7 @@ func TestMeEndpoint(t *testing.T) { t.Fatalf("status = %d", w.Code) } result := parseJSON(t, w) - if result["username"] != "admin" { - t.Errorf("username = %v", result["username"]) + if result["email"] != "oberon@athens.example" { + t.Errorf("email = %v", result["email"]) } } diff --git a/db.go b/db.go index bda44ed..99b335c 100644 --- a/db.go +++ b/db.go @@ -40,20 +40,6 @@ func migrate(db *sql.DB) error { updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); - CREATE TABLE IF NOT EXISTS users ( - 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')) - ); - - CREATE TABLE IF NOT EXISTS user_departments ( - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - department_id INTEGER NOT NULL REFERENCES departments(id) ON DELETE CASCADE, - PRIMARY KEY (user_id, department_id) - ); - CREATE TABLE IF NOT EXISTS departments ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, @@ -75,7 +61,7 @@ func migrate(db *sql.DB) error { checked_in INTEGER NOT NULL DEFAULT 0, checked_in_count INTEGER NOT NULL DEFAULT 0, checked_in_at TEXT, - checked_in_by INTEGER REFERENCES users(id), + checked_in_by INTEGER REFERENCES participants(id), note TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), @@ -154,7 +140,7 @@ func migrate(db *sql.DB) error { order_id TEXT NOT NULL DEFAULT '', code TEXT UNIQUE, checked_in_at TEXT, - checked_in_by INTEGER REFERENCES users(id), + checked_in_by INTEGER REFERENCES participants(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), deleted_at TEXT @@ -162,10 +148,99 @@ func migrate(db *sql.DB) error { CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_source_external ON tickets(source, external_id) WHERE external_id != '' AND deleted_at IS NULL; + + CREATE TABLE IF NOT EXISTS participant_roles ( + participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK(role IN ('admin','staffing','colead','gatekeeper')), + PRIMARY KEY (participant_id, role) + ); + + CREATE TABLE IF NOT EXISTS participant_departments ( + participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE, + department_id INTEGER NOT NULL REFERENCES departments(id) ON DELETE CASCADE, + PRIMARY KEY (participant_id, department_id) + ); `) if err != nil { return err } + if err := migrateAuth(db); err != nil { + return err + } + return nil +} + +func migrateAuth(db *sql.DB) error { + // Add auth columns to participants (idempotent — ignore "duplicate column" errors). + db.Exec(`ALTER TABLE participants ADD COLUMN password_hash TEXT`) + db.Exec(`ALTER TABLE participants ADD COLUMN login_enabled INTEGER NOT NULL DEFAULT 0`) + + // Migrate users → participants if the old users table exists. + var hasUsers int + if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'`).Scan(&hasUsers); err != nil || hasUsers == 0 { + return nil + } + + // Collect all users first (single connection — can't query and exec concurrently). + type oldUser struct { + id int + name string + hash string + role string + } + rows, err := db.Query(`SELECT id, username, password_hash, role FROM users`) + if err != nil { + return nil + } + var users []oldUser + for rows.Next() { + var u oldUser + if err := rows.Scan(&u.id, &u.name, &u.hash, &u.role); err != nil { + continue + } + if u.role == "ticketing" { + u.role = "admin" + } + users = append(users, u) + } + rows.Close() + + // Collect department assignments. + type deptAssign struct { + userID int + deptID int + } + deptRows, err := db.Query(`SELECT user_id, department_id FROM user_departments`) + var deptAssigns []deptAssign + if err == nil { + for deptRows.Next() { + var da deptAssign + deptRows.Scan(&da.userID, &da.deptID) + deptAssigns = append(deptAssigns, da) + } + deptRows.Close() + } + + // Now insert with the connection free. + for _, u := range users { + res, err := db.Exec( + `INSERT INTO participants (preferred_name, password_hash, login_enabled, updated_at) VALUES (?, ?, 1, ?)`, + u.name, u.hash, now(), + ) + if err != nil { + continue + } + pid, _ := res.LastInsertId() + db.Exec(`INSERT OR IGNORE INTO participant_roles (participant_id, role) VALUES (?, ?)`, pid, u.role) + for _, da := range deptAssigns { + if da.userID == u.id { + db.Exec(`INSERT OR IGNORE INTO participant_departments (participant_id, department_id) VALUES (?, ?)`, pid, da.deptID) + } + } + } + + db.Exec(`DROP TABLE IF EXISTS user_departments`) + db.Exec(`DROP TABLE IF EXISTS users`) return nil } @@ -190,11 +265,12 @@ type Event struct { } type User struct { - ID int `json:"id"` - Username string `json:"username"` - Role string `json:"role"` - DepartmentIDs []int `json:"department_ids"` - CreatedAt string `json:"created_at"` + ID int `json:"id"` + Email string `json:"email"` + PreferredName string `json:"preferred_name"` + Roles []string `json:"roles"` + DepartmentIDs []int `json:"department_ids"` + CreatedAt string `json:"created_at"` } type Attendee struct { @@ -325,11 +401,45 @@ func (app *App) upsertEvent(e Event) error { return err } -// --- Users --- +// --- Staff (participants with login_enabled) --- -func (app *App) getUserDeptIDs(userID int) ([]int, error) { +func (app *App) getParticipantRoles(participantID int) ([]string, error) { rows, err := app.db.Query( - `SELECT department_id FROM user_departments WHERE user_id = ? ORDER BY department_id`, userID, + `SELECT role FROM participant_roles WHERE participant_id = ? ORDER BY role`, participantID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var roles []string + for rows.Next() { + var r string + rows.Scan(&r) + roles = append(roles, r) + } + if roles == nil { + roles = []string{} + } + return roles, rows.Err() +} + +func (app *App) setParticipantRoles(participantID int, roles []string) error { + if _, err := app.db.Exec(`DELETE FROM participant_roles WHERE participant_id = ?`, participantID); err != nil { + return err + } + for _, role := range roles { + if _, err := app.db.Exec( + `INSERT INTO participant_roles (participant_id, role) VALUES (?, ?)`, participantID, role, + ); err != nil { + return err + } + } + return nil +} + +func (app *App) getUserDeptIDs(participantID int) ([]int, error) { + rows, err := app.db.Query( + `SELECT department_id FROM participant_departments WHERE participant_id = ? ORDER BY department_id`, participantID, ) if err != nil { return nil, err @@ -347,14 +457,13 @@ func (app *App) getUserDeptIDs(userID int) ([]int, error) { return ids, rows.Err() } -func (app *App) setUserDeptIDs(userID int, deptIDs []int) error { - _, err := app.db.Exec(`DELETE FROM user_departments WHERE user_id = ?`, userID) - if err != nil { +func (app *App) setUserDeptIDs(participantID int, deptIDs []int) error { + if _, err := app.db.Exec(`DELETE FROM participant_departments WHERE participant_id = ?`, participantID); err != nil { return err } for _, deptID := range deptIDs { if _, err := app.db.Exec( - `INSERT INTO user_departments (user_id, department_id) VALUES (?, ?)`, userID, deptID, + `INSERT INTO participant_departments (participant_id, department_id) VALUES (?, ?)`, participantID, deptID, ); err != nil { return err } @@ -362,98 +471,157 @@ func (app *App) setUserDeptIDs(userID int, deptIDs []int) error { return nil } -func (app *App) getUserByUsername(username string) (*User, string, error) { - var u User - var hash string +func (app *App) getLoginParticipant(email string) (*User, string, error) { + var s User + var hash sql.NullString err := app.db.QueryRow( - `SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?`, username, - ).Scan(&u.ID, &u.Username, &hash, &u.Role, &u.CreatedAt) + `SELECT id, email, preferred_name, password_hash, created_at + FROM participants WHERE LOWER(email) = LOWER(?) AND login_enabled = 1 AND deleted_at IS NULL`, email, + ).Scan(&s.ID, &s.Email, &s.PreferredName, &hash, &s.CreatedAt) if err == sql.ErrNoRows { return nil, "", nil } if err != nil { return nil, "", err } - u.DepartmentIDs, err = app.getUserDeptIDs(u.ID) - return &u, hash, err + var hashStr string + if hash.Valid { + hashStr = hash.String + } + s.Roles, _ = app.getParticipantRoles(s.ID) + s.DepartmentIDs, _ = app.getUserDeptIDs(s.ID) + return &s, hashStr, nil } -func (app *App) getUserByID(id int) (*User, error) { - var u User +func (app *App) getUser(id int) (*User, error) { + var s User err := app.db.QueryRow( - `SELECT id, username, role, created_at FROM users WHERE id = ?`, id, - ).Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt) + `SELECT id, email, preferred_name, created_at + FROM participants WHERE id = ? AND login_enabled = 1 AND deleted_at IS NULL`, id, + ).Scan(&s.ID, &s.Email, &s.PreferredName, &s.CreatedAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } - u.DepartmentIDs, err = app.getUserDeptIDs(u.ID) - return &u, err + s.Roles, _ = app.getParticipantRoles(s.ID) + s.DepartmentIDs, _ = app.getUserDeptIDs(s.ID) + return &s, nil } func (app *App) listUsers() ([]User, error) { rows, err := app.db.Query( - `SELECT id, username, role, created_at FROM users ORDER BY username`, + `SELECT id, email, preferred_name, created_at + FROM participants WHERE login_enabled = 1 AND deleted_at IS NULL ORDER BY preferred_name, email`, ) if err != nil { return nil, err } defer rows.Close() - var users []User + var staff []User for rows.Next() { - var u User - if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt); err != nil { + var s User + if err := rows.Scan(&s.ID, &s.Email, &s.PreferredName, &s.CreatedAt); err != nil { return nil, err } - u.DepartmentIDs = []int{} - users = append(users, u) + s.Roles = []string{} + s.DepartmentIDs = []int{} + staff = append(staff, s) } if err := rows.Err(); err != nil { return nil, err } - for i := range users { - users[i].DepartmentIDs, _ = app.getUserDeptIDs(users[i].ID) + for i := range staff { + staff[i].Roles, _ = app.getParticipantRoles(staff[i].ID) + staff[i].DepartmentIDs, _ = app.getUserDeptIDs(staff[i].ID) } - return users, nil + return staff, nil } -func (app *App) createUser(username, hash, role string, deptIDs []int) (*User, error) { +func (app *App) createUser(email, preferredName, hash string, roles []string, deptIDs []int) (*User, error) { + // Find or create participant by email. + p, err := app.getParticipantByEmail(email) + if err != nil { + return nil, err + } + if p != nil { + // Participant exists — promote to staff. + if _, err := app.db.Exec( + `UPDATE participants SET password_hash = ?, login_enabled = 1, updated_at = ? WHERE id = ?`, + hash, now(), p.ID, + ); err != nil { + return nil, err + } + if err := app.setParticipantRoles(p.ID, roles); err != nil { + return nil, err + } + if err := app.setUserDeptIDs(p.ID, deptIDs); err != nil { + return nil, err + } + return app.getUser(p.ID) + } + // Create new participant with auth. res, err := app.db.Exec( - `INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)`, - username, hash, role, + `INSERT INTO participants (email, preferred_name, password_hash, login_enabled, updated_at) + VALUES (?, ?, ?, 1, ?)`, + strings.ToLower(email), preferredName, hash, now(), ) if err != nil { return nil, err } id, _ := res.LastInsertId() + if err := app.setParticipantRoles(int(id), roles); err != nil { + return nil, err + } if err := app.setUserDeptIDs(int(id), deptIDs); err != nil { return nil, err } - return app.getUserByID(int(id)) + return app.getUser(int(id)) } -func (app *App) updateUser(id int, role string, deptIDs []int) error { - if _, err := app.db.Exec(`UPDATE users SET role = ? WHERE id = ?`, role, id); err != nil { +func (app *App) updateUserRoles(id int, roles []string, deptIDs []int) error { + var enabled int + err := app.db.QueryRow(`SELECT login_enabled FROM participants WHERE id = ? AND deleted_at IS NULL`, id).Scan(&enabled) + if err != nil || enabled != 1 { + return fmt.Errorf("participant not found or not a staff member") + } + if err := app.setParticipantRoles(id, roles); err != nil { return err } return app.setUserDeptIDs(id, deptIDs) } func (app *App) updateUserPassword(id int, hash string) error { - _, err := app.db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, hash, id) + _, err := app.db.Exec( + `UPDATE participants SET password_hash = ?, updated_at = ? WHERE id = ? AND login_enabled = 1`, hash, now(), id, + ) return err } -func (app *App) deleteUser(id int) error { - _, err := app.db.Exec(`DELETE FROM users WHERE id = ?`, id) - return err +func (app *App) removeUser(id int) error { + tx, err := app.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(`DELETE FROM participant_roles WHERE participant_id = ?`, id); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM participant_departments WHERE participant_id = ?`, id); err != nil { + return err + } + if _, err := tx.Exec( + `UPDATE participants SET login_enabled = 0, password_hash = NULL, updated_at = ? WHERE id = ?`, now(), id, + ); err != nil { + return err + } + return tx.Commit() } func (app *App) countUsers() (int, error) { var n int - err := app.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n) + err := app.db.QueryRow(`SELECT COUNT(*) FROM participants WHERE login_enabled = 1 AND deleted_at IS NULL`).Scan(&n) return n, err } diff --git a/db_test.go b/db_test.go index c9e8b68..edb2b6d 100644 --- a/db_test.go +++ b/db_test.go @@ -7,7 +7,7 @@ import ( func TestMigrate(t *testing.T) { app := testApp(t) // Verify tables exist by querying each one - tables := []string{"event", "users", "attendees", "departments", "volunteers", "shifts", "volunteer_shifts"} + tables := []string{"event", "participants", "participant_roles", "attendees", "departments", "volunteers", "shifts", "volunteer_shifts"} for _, table := range tables { var count int err := app.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 7ad6bc9..52c6741 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -83,7 +83,8 @@ } const path = $derived(route || '/') - const role = $derived(session?.user?.role ?? '') + const roles = $derived(session?.user?.roles ?? []) + function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } {#if updateAvailable} @@ -103,8 +104,8 @@ {:else if !session} -{:else if role === 'gatekeeper'} - +{:else if roles.length === 1 && roles[0] === 'gatekeeper'} + {:else}
@@ -121,7 +122,7 @@ Turnpike {#if path === '/' || path === ''} - {#if role === 'colead'} + {#if roles.length === 1 && roles[0] === 'colead'} {:else} diff --git a/frontend/src/api.js b/frontend/src/api.js index 686faa8..1b3d537 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -48,8 +48,8 @@ async function kioskFetch(path, options = {}) { } export const api = { - login: (username, password) => - apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ username, password }) }), + login: (email, password) => + apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ email, password }) }), logout: () => apiFetch('/api/logout', { method: 'POST' }), me: () => apiJSON('/api/me'), event: { diff --git a/frontend/src/api.test.js b/frontend/src/api.test.js index 974dd32..a725f32 100644 --- a/frontend/src/api.test.js +++ b/frontend/src/api.test.js @@ -64,11 +64,11 @@ describe('apiJSON', () => { describe('api methods', () => { it('login calls correct endpoint', async () => { const f = mockFetch({ token: 'tok', user: { id: 1 } }) - await api.login('admin', 'pass') + await api.login('admin@example.com', 'pass') const [url, opts] = f.mock.calls[0] expect(url).toBe('/api/login') expect(opts.method).toBe('POST') - expect(JSON.parse(opts.body)).toEqual({ username: 'admin', password: 'pass' }) + expect(JSON.parse(opts.body)).toEqual({ email: 'admin@example.com', password: 'pass' }) }) it('participants.list calls correct endpoint', async () => { diff --git a/frontend/src/components/Nav.svelte b/frontend/src/components/Nav.svelte index 545e171..61015f5 100644 --- a/frontend/src/components/Nav.svelte +++ b/frontend/src/components/Nav.svelte @@ -3,17 +3,18 @@ let { session, active, onLogout, navigate, open = false } = $props() - const role = $derived(session?.user?.role ?? '') + const roles = $derived(session?.user?.roles ?? []) + function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } const iconProps = { size: 18, strokeWidth: 1.75 } const links = $derived.by(() => { - if (role === 'colead') return [ + if (!hasRole('admin') && hasRole('colead') && !hasRole('staffing')) return [ { href: '/', label: 'Schedule', icon: CalendarDays }, { href: '/volunteers', label: 'Volunteers', icon: Heart }, { href: '/departments', label: 'Departments', icon: Hexagon }, ] - if (role === 'staffing') return [ + if (!hasRole('admin') && hasRole('staffing')) return [ { href: '/', label: 'Dashboard', icon: LayoutDashboard }, { href: '/schedule', label: 'Schedule', icon: CalendarDays }, { href: '/volunteers', label: 'Volunteers', icon: Heart }, diff --git a/frontend/src/db.js b/frontend/src/db.js index 7e96bbc..cbc0d38 100644 --- a/frontend/src/db.js +++ b/frontend/src/db.js @@ -51,6 +51,8 @@ db.version(5).stores({ participants: 'id, email, preferred_name, email_confirmed, updated_at, deleted_at', }) +db.version(6).stores({}).upgrade(tx => tx.table('session').clear()) + export async function getLastSync() { const m = await db.meta.get('last_sync') return m?.value ?? '' diff --git a/frontend/src/db.test.js b/frontend/src/db.test.js index 282b6fc..081ce1a 100644 --- a/frontend/src/db.test.js +++ b/frontend/src/db.test.js @@ -22,10 +22,10 @@ describe('session', () => { }) it('saves and retrieves session', async () => { - await saveSession('tok123', { id: 1, username: 'admin', role: 'admin' }) + await saveSession('tok123', { id: 1, email: 'admin@example.com', roles: ['admin'] }) const s = await getSession() expect(s.token).toBe('tok123') - expect(s.user.username).toBe('admin') + expect(s.user.email).toBe('admin@example.com') }) it('clears session and meta', async () => { diff --git a/frontend/src/pages/Dashboard.svelte b/frontend/src/pages/Dashboard.svelte index e5a26de..0f6decc 100644 --- a/frontend/src/pages/Dashboard.svelte +++ b/frontend/src/pages/Dashboard.svelte @@ -4,11 +4,12 @@ let { session } = $props() - const role = $derived(session?.user?.role ?? '') + const roles = $derived(session?.user?.roles ?? []) + function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } const myDeptIDs = $derived(session?.user?.department_ids ?? []) - const isTicketing = $derived(['admin', 'ticketing'].includes(role)) - const isStaffing = $derived(['admin', 'ticketing', 'staffing'].includes(role)) - const isColead = $derived(role === 'colead') + const isAdmin = $derived(hasRole('admin')) + const isStaffing = $derived(hasRole('admin', 'staffing')) + const isColead = $derived(hasRole('colead')) const event = liveQuery(() => db.event.get(1)) const allTickets = liveQuery(() => db.tickets.toArray()) @@ -76,8 +77,8 @@

{/if} - - {#if isTicketing} + + {#if isAdmin}

Ticket Check-in

@@ -105,7 +106,7 @@ {/if} {/if} - + {#if isStaffing || isColead}

{isColead ? 'My Volunteers' : 'Volunteers'}

@@ -124,7 +125,7 @@
{/if} - + {#if isStaffing || isColead}

{isColead ? 'My Shifts' : 'Shift Coverage'}

@@ -144,7 +145,7 @@ {/if} - {#if isTicketing} + {#if isAdmin}
Import CSV Manage Participants @@ -158,8 +159,8 @@ {/if}

- Welcome, {session?.user?.username} - · {session?.user?.role} + Welcome, {session?.user?.preferred_name} + · {#each roles as r}{r}{/each}

diff --git a/frontend/src/pages/Departments.svelte b/frontend/src/pages/Departments.svelte index c2cf82b..26164eb 100644 --- a/frontend/src/pages/Departments.svelte +++ b/frontend/src/pages/Departments.svelte @@ -18,9 +18,10 @@ let editDesc = $state('') let saving = $state(false) - const role = $derived(session?.user?.role ?? '') - const canCreate = $derived(['admin', 'ticketing', 'staffing'].includes(role)) - const canDelete = $derived(['admin', 'ticketing'].includes(role)) + const roles = $derived(session?.user?.roles ?? []) + function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } + const canCreate = $derived(hasRole('admin', 'staffing')) + const canDelete = $derived(hasRole('admin')) const allDepts = liveQuery(() => db.departments.filter(d => !d.deleted_at).toArray() diff --git a/frontend/src/pages/Login.svelte b/frontend/src/pages/Login.svelte index de4f6af..1512af7 100644 --- a/frontend/src/pages/Login.svelte +++ b/frontend/src/pages/Login.svelte @@ -4,7 +4,7 @@ let { onlogin } = $props() - let username = $state('') + let email = $state('') let password = $state('') let error = $state('') let loading = $state(false) @@ -14,7 +14,7 @@ error = '' loading = true try { - const { token, user } = await api.login(username, password) + const { token, user } = await api.login(email, password) await saveSession(token, user) onlogin({ token, user }) } catch (err) { @@ -34,8 +34,8 @@ {/if}
- - + +
diff --git a/frontend/src/pages/Participants.svelte b/frontend/src/pages/Participants.svelte index 2867958..fa850a3 100644 --- a/frontend/src/pages/Participants.svelte +++ b/frontend/src/pages/Participants.svelte @@ -40,8 +40,9 @@ let newTicketType = $state('') let newTicketExtId = $state('') - const role = $derived(session?.user?.role ?? '') - const canManage = $derived(['admin', 'ticketing'].includes(role)) + const roles = $derived(session?.user?.roles ?? []) + function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } + const canManage = $derived(hasRole('admin')) const allParticipants = liveQuery(() => db.participants.toArray()) const allTickets = liveQuery(() => db.tickets.toArray()) diff --git a/frontend/src/pages/ScheduleBoard.svelte b/frontend/src/pages/ScheduleBoard.svelte index 6755588..922a0b0 100644 --- a/frontend/src/pages/ScheduleBoard.svelte +++ b/frontend/src/pages/ScheduleBoard.svelte @@ -25,8 +25,9 @@ let assignVolID = $state(0) let assigning = $state(false) - const role = $derived(session?.user?.role ?? '') - const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role)) + const roles = $derived(session?.user?.roles ?? []) + function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } + const canManage = $derived(hasRole('admin', 'staffing', 'colead')) const myDeptIDs = $derived(session?.user?.department_ids ?? []) const allDepts = liveQuery(() => @@ -54,7 +55,7 @@ // Departments visible to this user const visibleDepts = $derived.by(() => { const depts = $allDepts ?? [] - if (role === 'colead') return depts.filter(d => myDeptIDs.includes(d.id)) + if (hasRole('colead') && !hasRole('admin', 'staffing')) return depts.filter(d => myDeptIDs.includes(d.id)) return depts }) diff --git a/frontend/src/pages/Users.svelte b/frontend/src/pages/Users.svelte index ee6b18a..328cc66 100644 --- a/frontend/src/pages/Users.svelte +++ b/frontend/src/pages/Users.svelte @@ -12,13 +12,14 @@ let showAdd = $state(false) let adding = $state(false) - let newUsername = $state('') + let newEmail = $state('') + let newName = $state('') let newPassword = $state('') - let newRole = $state('gate') + let newRoles = $state([]) let newDeptIDs = $state([]) let editID = $state(null) - let editRole = $state('') + let editRoles = $state([]) let editDeptIDs = $state([]) let editPassword = $state('') let saving = $state(false) @@ -28,7 +29,7 @@ .then(arr => arr.sort((a, b) => a.name.localeCompare(b.name))) ) - const roles = ['admin', 'ticketing', 'staffing', 'colead', 'gatekeeper'] + const availableRoles = ['admin', 'staffing', 'colead', 'gatekeeper'] const me = $derived(session?.user?.id) @@ -51,15 +52,16 @@ error = '' try { const u = await api.users.create({ - username: newUsername, + email: newEmail, + preferred_name: newName, password: newPassword, - role: newRole, + roles: newRoles, department_ids: newDeptIDs, }) users = [...users, u] showAdd = false - newUsername = newPassword = '' - newRole = 'gate' + newEmail = newName = newPassword = '' + newRoles = [] newDeptIDs = [] } catch (err) { error = err.message @@ -70,7 +72,7 @@ function startEdit(u) { editID = u.id - editRole = u.role + editRoles = [...(u.roles || [])] editDeptIDs = [...(u.department_ids || [])] editPassword = '' } @@ -83,7 +85,7 @@ saving = true error = '' try { - const payload = { role: editRole, department_ids: editDeptIDs } + const payload = { roles: editRoles, department_ids: editDeptIDs } if (editPassword) payload.password = editPassword const updated = await api.users.update(u.id, payload) users = users.map(x => x.id === u.id ? updated : x) @@ -96,7 +98,7 @@ } async function deleteUser(u) { - if (!confirm(`Delete user "${u.username}"? This cannot be undone.`)) return + if (!confirm(`Remove login access for "${u.preferred_name || u.email}"? Their participant record will be kept.`)) return try { await api.users.delete(u.id) users = users.filter(x => x.id !== u.id) @@ -105,7 +107,7 @@ } } - function toggleDept(id, list) { + function toggleItem(id, list) { const idx = list.indexOf(id) if (idx === -1) return [...list, id] return list.filter(x => x !== id) @@ -117,7 +119,7 @@ } function roleLabel(r) { - return { admin: 'Admin', ticketing: 'Ticketing', staffing: 'Staffing', colead: 'Colead', gatekeeper: 'Gatekeeper' }[r] || r + return { admin: 'Admin', staffing: 'Staffing', colead: 'Colead', gatekeeper: 'Gatekeeper' }[r] || r } @@ -132,7 +134,6 @@

Roles: admin — full access · - ticketing — participants, tickets, import · staffing — volunteers, shifts, departments · colead — manage assigned departments only · gatekeeper — check-in only @@ -150,20 +151,29 @@

- - + + +
+
+ +
-
- - +
+
+ Roles +
+ {#each availableRoles as r} + + {/each}
{#if ($allDepts ?? []).length > 0} @@ -174,7 +184,7 @@ @@ -204,8 +214,8 @@ - - + + @@ -214,13 +224,18 @@ {#each users as u (u.id)} {#if editID === u.id} - + - + diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index e90bf80..79681f8 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -25,14 +25,15 @@ let editNote = $state('') let saving = $state(false) - const role = $derived(session?.user?.role ?? '') - const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role)) - const canConfirm = $derived(['admin', 'staffing', 'colead'].includes(role)) + const roles = $derived(session?.user?.roles ?? []) + function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } + const canManage = $derived(hasRole('admin', 'staffing', 'colead')) + const canConfirm = $derived(hasRole('admin', 'staffing', 'colead')) const myDeptIDs = $derived(session?.user?.department_ids ?? []) let deptInitialized = $state(false) $effect(() => { - if (!deptInitialized && role === 'colead' && myDeptIDs.length > 0) { + if (!deptInitialized && hasRole('colead') && !hasRole('admin', 'staffing') && myDeptIDs.length > 0) { filterDept = String(myDeptIDs[0]) deptInitialized = true } diff --git a/handle_attendees_test.go b/handle_attendees_test.go index c5e6adb..7dd7ff8 100644 --- a/handle_attendees_test.go +++ b/handle_attendees_test.go @@ -31,8 +31,8 @@ func TestParticipantsListCreateDelete(t *testing.T) { } list := parseJSON(t, w) participants := list["participants"].([]any) - if len(participants) != 1 { - t.Errorf("list: got %d, want 1", len(participants)) + if len(participants) != 2 { // admin + Titania + t.Errorf("list: got %d, want 2", len(participants)) } // Delete @@ -48,8 +48,8 @@ func TestParticipantsListCreateDelete(t *testing.T) { w = httptest.NewRecorder() mux.ServeHTTP(w, req) list = parseJSON(t, w) - if ps, ok := list["participants"].([]any); ok && len(ps) != 0 { - t.Errorf("after delete: got %d, want 0", len(ps)) + if ps, ok := list["participants"].([]any); ok && len(ps) != 1 { // admin remains + t.Errorf("after delete: got %d, want 1", len(ps)) } } @@ -77,7 +77,7 @@ func TestCheckInTicketHandler(t *testing.T) { func TestGatekeeperRoleCanCheckIn(t *testing.T) { app := testApp(t) - gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{}) + gate := testUserWithRoles(t, app, "Philostrate", []string{"gatekeeper"}, []int{}) token := testToken(t, app, gate) mux := testMux(app) @@ -94,7 +94,7 @@ func TestGatekeeperRoleCanCheckIn(t *testing.T) { func TestGatekeeperRoleCannotDelete(t *testing.T) { app := testApp(t) - gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{}) + gate := testUserWithRoles(t, app, "Philostrate", []string{"gatekeeper"}, []int{}) token := testToken(t, app, gate) mux := testMux(app) diff --git a/handle_auth.go b/handle_auth.go index 282bd85..d75483b 100644 --- a/handle_auth.go +++ b/handle_auth.go @@ -7,7 +7,7 @@ import ( func (app *App) handleLogin(w http.ResponseWriter, r *http.Request) { var body struct { - Username string `json:"username"` + Email string `json:"email"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { @@ -15,7 +15,7 @@ func (app *App) handleLogin(w http.ResponseWriter, r *http.Request) { return } - user, hash, err := app.getUserByUsername(body.Username) + user, hash, err := app.getLoginParticipant(body.Email) if err != nil { writeError(w, "internal error", http.StatusInternalServerError) return @@ -40,9 +40,9 @@ func (app *App) handleLogout(w http.ResponseWriter, r *http.Request) { func (app *App) handleMe(w http.ResponseWriter, r *http.Request) { claims := claimsFromContext(r) - user, err := app.getUserByID(claims.UserID) + user, err := app.getUser(claims.ParticipantID) if err != nil || user == nil { - writeError(w, "not found", http.StatusNotFound) + writeError(w, "unauthorized", http.StatusUnauthorized) return } writeJSON(w, user) diff --git a/handle_participants.go b/handle_participants.go index 6277824..52624d5 100644 --- a/handle_participants.go +++ b/handle_participants.go @@ -169,7 +169,7 @@ func (app *App) handleCheckInTicket(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - tk, err := app.checkInTicket(id, claims.UserID) + tk, err := app.checkInTicket(id, claims.ParticipantID) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return diff --git a/handle_settings_test.go b/handle_settings_test.go index cbc53fb..16ef59f 100644 --- a/handle_settings_test.go +++ b/handle_settings_test.go @@ -85,7 +85,7 @@ func TestResetTickets(t *testing.T) { func TestResetTicketsRequiresAdmin(t *testing.T) { app := testApp(t) - gate := testUserWithRole(t, app, "gate1", "gatekeeper", []int{}) + gate := testUserWithRoles(t, app, "Snug", []string{"gatekeeper"}, []int{}) token := testToken(t, app, gate) mux := testMux(app) @@ -131,7 +131,7 @@ func TestResetDepartmentsCascadesShifts(t *testing.T) { func TestSettingsNonAdminRejected(t *testing.T) { app := testApp(t) - gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{}) + gate := testUserWithRoles(t, app, "Quince", []string{"gatekeeper"}, []int{}) token := testToken(t, app, gate) mux := testMux(app) diff --git a/handle_shifts.go b/handle_shifts.go index a0ceb41..67312c5 100644 --- a/handle_shifts.go +++ b/handle_shifts.go @@ -17,7 +17,7 @@ func (app *App) handleListShifts(w http.ResponseWriter, r *http.Request) { } claims := claimsFromContext(r) - if claims.Role == "colead" && deptID == nil && len(claims.DeptIDs) > 0 { + if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && deptID == nil && len(claims.DeptIDs) > 0 { deptID = &claims.DeptIDs[0] } @@ -40,7 +40,7 @@ func (app *App) handleCreateShift(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if claims.Role == "colead" && !inSlice(s.DepartmentID, claims.DeptIDs) { + if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && !inSlice(s.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) return } @@ -65,7 +65,7 @@ func (app *App) handleUpdateShift(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if claims.Role == "colead" { + if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) { existing, _ := app.getShift(id) if existing == nil || !inSlice(existing.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) diff --git a/handle_sync_test.go b/handle_sync_test.go index e4aa2af..000bc60 100644 --- a/handle_sync_test.go +++ b/handle_sync_test.go @@ -13,10 +13,10 @@ func TestSyncPullFull(t *testing.T) { token := testToken(t, app, admin) mux := testMux(app) - app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) + p, _ := 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}) + app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: p.ID, DepartmentID: &deptID}) app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) req := testAuthRequest("GET", "/api/sync/pull", nil, token) @@ -32,8 +32,8 @@ func TestSyncPullFull(t *testing.T) { t.Error("missing server_time") } participants := result["participants"].([]any) - if len(participants) != 1 { - t.Errorf("participants = %d, want 1", len(participants)) + if len(participants) != 2 { // admin + Titania + t.Errorf("participants = %d, want 2", len(participants)) } depts := result["departments"].([]any) if len(depts) != 1 { @@ -47,14 +47,16 @@ func TestSyncPullIncremental(t *testing.T) { token := testToken(t, app, admin) mux := testMux(app) + // Backdate admin participant so it falls before the "since" cutoff. + app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, admin.ID) + p1, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) - // Backdate Titania so she falls before the "since" cutoff 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.createParticipant(Participant{PreferredName: "Oberon", Email: "oberon@example.com"}) + // Lysander created with default updated_at (now), which is after our since + app.createParticipant(Participant{PreferredName: "Lysander", Email: "lysander@example.com"}) req := testAuthRequest("GET", "/api/sync/pull?since="+since, nil, token) w := httptest.NewRecorder() @@ -62,14 +64,13 @@ func TestSyncPullIncremental(t *testing.T) { result := parseJSON(t, w) participants := result["participants"].([]any) - // Should only include Oberon (created after `since`) if len(participants) != 1 { t.Errorf("incremental: got %d participants, want 1", len(participants)) } 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"]) + if p["preferred_name"] != "Lysander" { + t.Errorf("preferred_name = %v, want Lysander", p["preferred_name"]) } } } @@ -80,8 +81,10 @@ func TestSyncPullIncludesSoftDeleted(t *testing.T) { token := testToken(t, app, admin) mux := testMux(app) + // Backdate admin participant. + app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, admin.ID) + 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 participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, p.ID) since := "2026-01-01T12:00:00Z" diff --git a/handle_users.go b/handle_users.go index 386e5b0..4de6109 100644 --- a/handle_users.go +++ b/handle_users.go @@ -17,17 +17,18 @@ func (app *App) handleListUsers(w http.ResponseWriter, r *http.Request) { func (app *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { var body struct { - Username string `json:"username"` - Password string `json:"password"` - Role string `json:"role"` - DepartmentIDs []int `json:"department_ids"` + Email string `json:"email"` + PreferredName string `json:"preferred_name"` + Password string `json:"password"` + Roles []string `json:"roles"` + DepartmentIDs []int `json:"department_ids"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, "invalid request", http.StatusBadRequest) return } - if body.Username == "" || body.Password == "" || body.Role == "" { - writeError(w, "username, password, and role are required", http.StatusBadRequest) + if body.Email == "" || body.Password == "" || len(body.Roles) == 0 { + writeError(w, "email, password, and at least one role are required", http.StatusBadRequest) return } hash, err := hashPassword(body.Password) @@ -38,7 +39,7 @@ func (app *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { if body.DepartmentIDs == nil { body.DepartmentIDs = []int{} } - user, err := app.createUser(body.Username, hash, body.Role, body.DepartmentIDs) + user, err := app.createUser(body.Email, body.PreferredName, hash, body.Roles, body.DepartmentIDs) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -53,10 +54,15 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { writeError(w, "invalid id", http.StatusBadRequest) return } + target, _ := app.getUser(id) + if target == nil { + writeError(w, "not found", http.StatusNotFound) + return + } var body struct { - Role string `json:"role"` - Password string `json:"password"` - DepartmentIDs []int `json:"department_ids"` + Roles []string `json:"roles"` + Password string `json:"password"` + DepartmentIDs []int `json:"department_ids"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, "invalid request", http.StatusBadRequest) @@ -65,8 +71,8 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { if body.DepartmentIDs == nil { body.DepartmentIDs = []int{} } - if body.Role != "" { - if err := app.updateUser(id, body.Role, body.DepartmentIDs); err != nil { + if body.Roles != nil { + if err := app.updateUserRoles(id, body.Roles, body.DepartmentIDs); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } @@ -82,7 +88,7 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { return } } - user, _ := app.getUserByID(id) + user, _ := app.getUser(id) writeJSON(w, user) } @@ -93,11 +99,11 @@ func (app *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if claims.UserID == id { + if claims.ParticipantID == id { writeError(w, "cannot delete yourself", http.StatusBadRequest) return } - if err := app.deleteUser(id); err != nil { + if err := app.removeUser(id); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } diff --git a/handle_volunteers.go b/handle_volunteers.go index 5d086ad..ec3a317 100644 --- a/handle_volunteers.go +++ b/handle_volunteers.go @@ -21,7 +21,7 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) { } claims := claimsFromContext(r) - if claims.Role == "colead" && deptID == nil && len(claims.DeptIDs) > 0 { + if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && deptID == nil && len(claims.DeptIDs) > 0 { deptID = &claims.DeptIDs[0] } @@ -55,7 +55,7 @@ func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if claims.Role == "colead" { + if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) { if body.DepartmentID == nil || !inSlice(*body.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) return @@ -127,7 +127,7 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if claims.Role == "colead" { + if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) { existing, _ := app.getVolunteer(id) if existing == nil || existing.DepartmentID == nil || !inSlice(*existing.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) @@ -171,7 +171,7 @@ func (app *App) handleMarkVolunteerReady(w http.ResponseWriter, r *http.Request) return } claims := claimsFromContext(r) - v, err := app.markVolunteerReady(id, claims.UserID) + v, err := app.markVolunteerReady(id, claims.ParticipantID) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return diff --git a/handle_volunteers_test.go b/handle_volunteers_test.go index ab51b9b..19ff1b0 100644 --- a/handle_volunteers_test.go +++ b/handle_volunteers_test.go @@ -65,9 +65,9 @@ func TestConfirmVolunteerRequiresRole(t *testing.T) { app := testApp(t) mux := testMux(app) - // Ticketing role should NOT be able to confirm volunteers. - ticketing := testUserWithRole(t, app, "ticket_lead", "ticketing", nil) - tok := testToken(t, app, ticketing) + // Gatekeeper role should NOT be able to confirm volunteers. + gatekeeper := testUserWithRoles(t, app, "Egeus", []string{"gatekeeper"}, []int{}) + tok := testToken(t, app, gatekeeper) p, _ := app.createParticipant(Participant{PreferredName: "Helena", EmailConfirmed: true}) v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID}) @@ -75,7 +75,7 @@ func TestConfirmVolunteerRequiresRole(t *testing.T) { w := httptest.NewRecorder() mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) if w.Code != http.StatusForbidden { - t.Errorf("expected 403 for ticketing role, got %d", w.Code) + t.Errorf("expected 403 for gatekeeper role, got %d", w.Code) } } diff --git a/main.go b/main.go index be9fc53..f775bb6 100644 --- a/main.go +++ b/main.go @@ -97,62 +97,62 @@ func (app *App) registerRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/me", auth(app.handleMe)) mux.HandleFunc("GET /api/event", auth(app.handleGetEvent)) - mux.HandleFunc("PUT /api/event", auth(app.handleUpdateEvent, "admin", "ticketing")) + mux.HandleFunc("PUT /api/event", auth(app.handleUpdateEvent, "admin")) - 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")) - 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("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("GET /api/participants", auth(app.handleListParticipants, "admin", "gatekeeper")) + mux.HandleFunc("POST /api/participants", auth(app.handleCreateParticipant, "admin")) + mux.HandleFunc("GET /api/participants/export", auth(app.handleExportParticipants, "admin")) + mux.HandleFunc("GET /api/participants/{id}", auth(app.handleGetParticipant, "admin", "gatekeeper")) + mux.HandleFunc("PUT /api/participants/{id}", auth(app.handleUpdateParticipant, "admin")) + mux.HandleFunc("DELETE /api/participants/{id}", auth(app.handleDeleteParticipant, "admin")) + mux.HandleFunc("POST /api/participants/{id}/merge/{other_id}", auth(app.handleMergeParticipants, "admin")) - mux.HandleFunc("GET /api/tickets", auth(app.handleListTickets, "admin", "ticketing", "gatekeeper")) - mux.HandleFunc("POST /api/tickets", auth(app.handleCreateTicket, "admin", "ticketing")) - 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("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/{id}/email-code", auth(app.handleEmailToken, "admin", "ticketing")) + mux.HandleFunc("GET /api/tickets", auth(app.handleListTickets, "admin", "gatekeeper")) + mux.HandleFunc("POST /api/tickets", auth(app.handleCreateTicket, "admin")) + mux.HandleFunc("POST /api/tickets/{id}/checkin", auth(app.handleCheckInTicket, "admin", "gatekeeper")) + mux.HandleFunc("POST /api/tickets/generate-codes", auth(app.handleGenerateTokens, "admin")) + mux.HandleFunc("GET /api/tickets/export-links", auth(app.handleExportTokenLinks, "admin")) + mux.HandleFunc("POST /api/tickets/email-codes", auth(app.handleEmailAllTokens, "admin")) + mux.HandleFunc("POST /api/tickets/{id}/email-code", auth(app.handleEmailToken, "admin")) mux.HandleFunc("GET /api/departments", auth(app.handleListDepartments)) - mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "ticketing", "staffing")) - mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "ticketing", "staffing")) - mux.HandleFunc("DELETE /api/departments/{id}", auth(app.handleDeleteDepartment, "admin", "ticketing")) + mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "staffing")) + mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "staffing")) + mux.HandleFunc("DELETE /api/departments/{id}", auth(app.handleDeleteDepartment, "admin")) - mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("POST /api/volunteers/{id}/ready", auth(app.handleMarkVolunteerReady, "admin", "ticketing", "staffing", "colead")) + mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "staffing", "colead")) + mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "staffing", "colead")) + mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "staffing", "colead")) + mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "staffing", "colead")) + mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "staffing", "colead")) + mux.HandleFunc("POST /api/volunteers/{id}/ready", auth(app.handleMarkVolunteerReady, "admin", "staffing", "colead")) mux.HandleFunc("POST /api/volunteers/{id}/confirm", auth(app.handleConfirmVolunteer, "admin", "staffing", "colead")) - 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", "ticketing", "staffing", "colead")) + mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "staffing", "colead")) + mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "staffing", "colead")) - mux.HandleFunc("GET /api/shifts", auth(app.handleListShifts, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("DELETE /api/shifts/{id}", auth(app.handleDeleteShift, "admin", "ticketing", "staffing", "colead")) - 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", "ticketing", "staffing", "colead")) + mux.HandleFunc("GET /api/shifts", auth(app.handleListShifts, "admin", "staffing", "colead")) + mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "staffing", "colead")) + mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "staffing", "colead")) + mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "staffing", "colead")) + mux.HandleFunc("DELETE /api/shifts/{id}", auth(app.handleDeleteShift, "admin", "staffing", "colead")) + mux.HandleFunc("POST /api/shifts/{id}/volunteers", auth(app.handleAssignShiftVolunteer, "admin", "staffing", "colead")) + mux.HandleFunc("DELETE /api/shifts/{id}/volunteers/{volunteer_id}", auth(app.handleUnassignShiftVolunteer, "admin", "staffing", "colead")) - mux.HandleFunc("GET /api/users", auth(app.handleListUsers, "admin", "ticketing")) - mux.HandleFunc("POST /api/users", auth(app.handleCreateUser, "admin", "ticketing")) - mux.HandleFunc("PUT /api/users/{id}", auth(app.handleUpdateUser, "admin", "ticketing")) - mux.HandleFunc("DELETE /api/users/{id}", auth(app.handleDeleteUser, "admin", "ticketing")) + mux.HandleFunc("GET /api/users", auth(app.handleListUsers, "admin")) + mux.HandleFunc("POST /api/users", auth(app.handleCreateUser, "admin")) + mux.HandleFunc("PUT /api/users/{id}", auth(app.handleUpdateUser, "admin")) + mux.HandleFunc("DELETE /api/users/{id}", auth(app.handleDeleteUser, "admin")) - 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-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")) - mux.HandleFunc("POST /api/settings/reset-departments", auth(app.handleResetDepartments, "admin", "ticketing")) - mux.HandleFunc("POST /api/settings/reset-volunteer-shifts", auth(app.handleResetVolunteerShifts, "admin", "ticketing")) + 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-tickets", auth(app.handleResetTickets, "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")) mux.HandleFunc("GET /api/sync/pull", auth(app.handleSyncPull)) mux.HandleFunc("GET /api/sync/stream", auth(app.handleSyncStream)) @@ -161,7 +161,7 @@ func (app *App) registerRoutes(mux *http.ServeMux) { writeJSON(w, map[string]string{"build": buildID}) }) - mux.HandleFunc("POST /api/settings/shift-signups", auth(app.handleToggleShiftSignups, "admin", "ticketing", "staffing")) + mux.HandleFunc("POST /api/settings/shift-signups", auth(app.handleToggleShiftSignups, "admin", "staffing")) // Public endpoints — no JWT required. mux.HandleFunc("GET /api/public/signup-config", app.handlePublicSignupConfig) @@ -196,9 +196,9 @@ func (app *App) registerRoutes(mux *http.ServeMux) { } func (app *App) bootstrapAdmin() error { - adminUser := os.Getenv("TURNPIKE_ADMIN_USER") + adminEmail := os.Getenv("TURNPIKE_ADMIN_EMAIL") adminPass := os.Getenv("TURNPIKE_ADMIN_PASSWORD") - if adminUser == "" || adminPass == "" { + if adminEmail == "" || adminPass == "" { return nil } n, err := app.countUsers() @@ -209,11 +209,11 @@ func (app *App) bootstrapAdmin() error { if err != nil { return err } - _, err = app.createUser(adminUser, hash, "admin", []int{}) + _, err = app.createUser(adminEmail, "Admin", hash, []string{"admin"}, []int{}) if err != nil { return err } - log.Printf("Created admin user: %s", adminUser) + log.Printf("Created admin user: %s", adminEmail) return nil } diff --git a/testutil_test.go b/testutil_test.go index 8f58833..14351e5 100644 --- a/testutil_test.go +++ b/testutil_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" ) @@ -16,7 +17,6 @@ func testApp(t *testing.T) *App { t.Fatal(err) } t.Cleanup(func() { db.Close() }) - // Ensure config table exists (normally created by getOrCreateSecret) db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`) return &App{ db: db, @@ -29,17 +29,18 @@ func testApp(t *testing.T) *App { func testAdminUser(t *testing.T, app *App) *User { t.Helper() hash, _ := hashPassword("admin123") - u, err := app.createUser("admin", hash, "admin", []int{}) + u, err := app.createUser("oberon@athens.example", "Oberon", hash, []string{"admin"}, []int{}) if err != nil { t.Fatal(err) } return u } -func testUserWithRole(t *testing.T, app *App, username, role string, deptIDs []int) *User { +func testUserWithRoles(t *testing.T, app *App, name string, roles []string, deptIDs []int) *User { t.Helper() - hash, _ := hashPassword(username + "123") - u, err := app.createUser(username, hash, role, deptIDs) + email := strings.ToLower(name) + "@athens.example" + hash, _ := hashPassword(name + "123") + u, err := app.createUser(email, name, hash, roles, deptIDs) if err != nil { t.Fatal(err) } From da5f3524fa42a97a0f8c2ea44929f1d6cdf449e5 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 10 Mar 2026 14:24:51 -0500 Subject: [PATCH 37/47] Fixed db resync. --- frontend/src/db.js | 11 ++++++++++- frontend/src/sync.js | 39 +++++++++++++++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/frontend/src/db.js b/frontend/src/db.js index cbc0d38..bfddf9f 100644 --- a/frontend/src/db.js +++ b/frontend/src/db.js @@ -51,7 +51,16 @@ db.version(5).stores({ participants: 'id, email, preferred_name, email_confirmed, updated_at, deleted_at', }) -db.version(6).stores({}).upgrade(tx => tx.table('session').clear()) +db.version(6).stores({}).upgrade(async tx => { + await tx.table('session').clear() + await tx.table('meta').clear() + await tx.table('participants').clear() + await tx.table('tickets').clear() + await tx.table('departments').clear() + await tx.table('volunteers').clear() + await tx.table('shifts').clear() + await tx.table('volunteer_shifts').clear() +}) export async function getLastSync() { const m = await db.meta.get('last_sync') diff --git a/frontend/src/sync.js b/frontend/src/sync.js index fa3b641..e2c4bff 100644 --- a/frontend/src/sync.js +++ b/frontend/src/sync.js @@ -4,10 +4,36 @@ import { api } from './api.js' let syncing = false let sseSource = null +async function checkBuildChanged() { + try { + const res = await fetch('/api/version') + const { build } = await res.json() + if (!build) return + const stored = await db.meta.get('build') + if (!stored || stored.value !== build) { + await db.transaction('rw', + [db.meta, db.event, db.participants, db.tickets, db.departments, db.volunteers, db.shifts, db.volunteer_shifts], + async () => { + await db.meta.clear() + await db.event.clear() + await db.participants.clear() + await db.tickets.clear() + await db.departments.clear() + await db.volunteers.clear() + await db.shifts.clear() + await db.volunteer_shifts.clear() + } + ) + } + await db.meta.put({ key: 'build', value: build }) + } catch {} +} + export async function syncPull() { if (syncing) return syncing = true try { + await checkBuildChanged() const since = await getLastSync() const data = await api.sync.pull(since) @@ -51,7 +77,7 @@ export async function syncPull() { } ) - await setLastSync(data.server_time) + if (data.server_time) await setLastSync(data.server_time) return true } catch (err) { console.warn('Sync pull failed:', err.message) @@ -97,7 +123,7 @@ export function startSSE(onEvent) { syncPull() }, 5000) } - }) + }).catch(() => {}) } connect() @@ -108,18 +134,23 @@ export function stopSSE() { sseSource = null } -// Poll for sync when online, with exponential backoff on failure let syncInterval = null +let onlineHandler = null export function startSyncLoop(intervalMs = 30000) { if (syncInterval) return syncInterval = setInterval(() => { if (navigator.onLine) syncPull() }, intervalMs) - window.addEventListener('online', () => syncPull()) + onlineHandler = () => syncPull() + window.addEventListener('online', onlineHandler) } export function stopSyncLoop() { clearInterval(syncInterval) syncInterval = null + if (onlineHandler) { + window.removeEventListener('online', onlineHandler) + onlineHandler = null + } } From 7dbcd052620f92d953a21e1a63b072533a3e54b4 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 10 Mar 2026 15:14:36 -0500 Subject: [PATCH 38/47] Rescoped colead role and revised session handling. --- auth.go | 14 +++ db.go | 31 ++++-- frontend/src/App.svelte | 3 +- frontend/src/db.js | 16 ++- frontend/src/pages/Dashboard.svelte | 12 +-- frontend/src/pages/ScheduleBoard.svelte | 12 ++- frontend/src/sync.js | 2 +- handle_settings.go | 2 +- handle_shifts.go | 51 ++++++++-- handle_shifts_test.go | 80 +++++++++++++++ handle_volunteers.go | 79 +++++++++++---- handle_volunteers_test.go | 124 ++++++++++++++++++++++++ 12 files changed, 376 insertions(+), 50 deletions(-) diff --git a/auth.go b/auth.go index 0cc812a..b675e6f 100644 --- a/auth.go +++ b/auth.go @@ -108,6 +108,20 @@ func hasAnyRole(roles []string, allowed []string) bool { return false } +func isCoLeadOnly(claims *Claims) bool { + return hasAnyRole(claims.Roles, []string{"colead"}) && + !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) +} + +func inSlice(v int, s []int) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} + func claimsFromContext(r *http.Request) *Claims { c, _ := r.Context().Value(claimsKey).(*Claims) return c diff --git a/db.go b/db.go index 99b335c..7857020 100644 --- a/db.go +++ b/db.go @@ -939,6 +939,8 @@ func (app *App) mergeParticipants(canonicalID, otherID int) error { ); err != nil { return err } + app.db.Exec(`INSERT OR IGNORE INTO participant_roles (participant_id, role) SELECT ?, role FROM participant_roles WHERE participant_id = ?`, canonicalID, otherID) + app.db.Exec(`INSERT OR IGNORE INTO participant_departments (participant_id, department_id) SELECT ?, department_id FROM participant_departments WHERE participant_id = ?`, canonicalID, otherID) _, err := app.db.Exec( `UPDATE participants SET deleted_at=?, updated_at=? WHERE id=?`, ts, ts, otherID, ) @@ -1206,7 +1208,7 @@ const volunteerSelect = `v.id, v.participant_id, v.created_at, v.updated_at, v.deleted_at` const volunteerFrom = `FROM volunteers v INNER JOIN participants p ON p.id = v.participant_id` -func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) { +func (app *App) listVolunteers(search string, deptIDs []int, since string) ([]Volunteer, error) { q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1` var args []any if since != "" { @@ -1220,9 +1222,14 @@ func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volu s := "%" + search + "%" args = append(args, s, s) } - if deptID != nil { + if len(deptIDs) == 1 { q += ` AND v.department_id = ?` - args = append(args, *deptID) + args = append(args, deptIDs[0]) + } else if len(deptIDs) > 1 { + q += ` AND v.department_id IN (` + placeholders(len(deptIDs)) + `)` + for _, id := range deptIDs { + args = append(args, id) + } } q += ` ORDER BY p.preferred_name` return queryVolunteers(app.db, q, args...) @@ -1422,7 +1429,7 @@ func generateConfirmationToken() (string, error) { // --- Shifts --- -func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) { +func (app *App) listShifts(deptIDs []int, day, since string) ([]Shift, error) { q := `SELECT ` + shiftCols + ` FROM shifts WHERE 1=1` var args []any if since != "" { @@ -1431,9 +1438,14 @@ func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) { } else { q += ` AND deleted_at IS NULL` } - if deptID != nil { + if len(deptIDs) == 1 { q += ` AND department_id = ?` - args = append(args, *deptID) + args = append(args, deptIDs[0]) + } else if len(deptIDs) > 1 { + q += ` AND department_id IN (` + placeholders(len(deptIDs)) + `)` + for _, id := range deptIDs { + args = append(args, id) + } } if day != "" { q += ` AND day = ?` @@ -1669,3 +1681,10 @@ func boolInt(b bool) int { } return 0 } + +func placeholders(n int) string { + if n <= 0 { + return "" + } + return strings.Repeat("?,", n-1) + "?" +} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 52c6741..a1fa253 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -84,7 +84,6 @@ const path = $derived(route || '/') const roles = $derived(session?.user?.roles ?? []) - function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } {#if updateAvailable} @@ -125,7 +124,7 @@ {#if roles.length === 1 && roles[0] === 'colead'} {:else} - + {/if} {:else if path.startsWith('/participants')} diff --git a/frontend/src/db.js b/frontend/src/db.js index bfddf9f..bd3b490 100644 --- a/frontend/src/db.js +++ b/frontend/src/db.js @@ -80,6 +80,18 @@ export async function saveSession(token, user) { } export async function clearSession() { - await db.session.clear() - await db.meta.clear() + await db.transaction('rw', + [db.session, db.meta, db.event, db.participants, db.tickets, db.departments, db.volunteers, db.shifts, db.volunteer_shifts], + async () => { + await db.session.clear() + await db.meta.clear() + await db.event.clear() + await db.participants.clear() + await db.tickets.clear() + await db.departments.clear() + await db.volunteers.clear() + await db.shifts.clear() + await db.volunteer_shifts.clear() + } + ) } diff --git a/frontend/src/pages/Dashboard.svelte b/frontend/src/pages/Dashboard.svelte index 0f6decc..9756b31 100644 --- a/frontend/src/pages/Dashboard.svelte +++ b/frontend/src/pages/Dashboard.svelte @@ -2,7 +2,7 @@ import { liveQuery } from 'dexie' import { db } from '../db.js' - let { session } = $props() + let { session, navigate } = $props() const roles = $derived(session?.user?.roles ?? []) function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } @@ -147,14 +147,14 @@ {#if isAdmin} {:else if isStaffing || isColead} {/if} diff --git a/frontend/src/pages/ScheduleBoard.svelte b/frontend/src/pages/ScheduleBoard.svelte index 922a0b0..6bea05e 100644 --- a/frontend/src/pages/ScheduleBoard.svelte +++ b/frontend/src/pages/ScheduleBoard.svelte @@ -135,11 +135,13 @@ try { const res = await api.shifts.reorder(positions) - if (res && !res.ok) throw new Error() - for (const p of positions) { - const s = await db.shifts.get(p.id) - if (s) await db.shifts.put({ ...s, position: p.position }) - } + if (res && !res.ok) throw new Error('Reorder failed') + await db.transaction('rw', db.shifts, async () => { + for (const p of positions) { + const s = await db.shifts.get(p.id) + if (s) await db.shifts.put({ ...s, position: p.position }) + } + }) } catch (err) { error = err.message } diff --git a/frontend/src/sync.js b/frontend/src/sync.js index e2c4bff..ef22313 100644 --- a/frontend/src/sync.js +++ b/frontend/src/sync.js @@ -22,10 +22,10 @@ async function checkBuildChanged() { await db.volunteers.clear() await db.shifts.clear() await db.volunteer_shifts.clear() + await db.meta.put({ key: 'build', value: build }) } ) } - await db.meta.put({ key: 'build', value: build }) } catch {} } diff --git a/handle_settings.go b/handle_settings.go index d4ed01c..2c0c991 100644 --- a/handle_settings.go +++ b/handle_settings.go @@ -58,7 +58,7 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) { var val string switch vv := v.(type) { case string: - if k == "smtp_password" && vv == "" { + if k == "smtp_password" && (vv == "" || vv == "***") { continue } val = vv diff --git a/handle_shifts.go b/handle_shifts.go index 67312c5..9299916 100644 --- a/handle_shifts.go +++ b/handle_shifts.go @@ -8,20 +8,19 @@ import ( func (app *App) handleListShifts(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() - var deptID *int + var deptIDs []int if d := q.Get("dept"); d != "" { - id, err := strconv.Atoi(d) - if err == nil { - deptID = &id + if id, err := strconv.Atoi(d); err == nil { + deptIDs = []int{id} } } claims := claimsFromContext(r) - if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && deptID == nil && len(claims.DeptIDs) > 0 { - deptID = &claims.DeptIDs[0] + if isCoLeadOnly(claims) && len(deptIDs) == 0 { + deptIDs = claims.DeptIDs } - shifts, err := app.listShifts(deptID, q.Get("day"), q.Get("since")) + shifts, err := app.listShifts(deptIDs, q.Get("day"), q.Get("since")) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -40,7 +39,7 @@ func (app *App) handleCreateShift(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && !inSlice(s.DepartmentID, claims.DeptIDs) { + if isCoLeadOnly(claims) && !inSlice(s.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) return } @@ -65,7 +64,7 @@ func (app *App) handleUpdateShift(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) { + if isCoLeadOnly(claims) { existing, _ := app.getShift(id) if existing == nil || !inSlice(existing.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) @@ -87,6 +86,14 @@ func (app *App) handleDeleteShift(w http.ResponseWriter, r *http.Request) { writeError(w, "invalid id", http.StatusBadRequest) return } + claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + s, _ := app.getShift(id) + if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } if err := app.deleteShift(id); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -111,6 +118,14 @@ func (app *App) handleAssignShiftVolunteer(w http.ResponseWriter, r *http.Reques writeError(w, "volunteer_id required", http.StatusBadRequest) return } + claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + s, _ := app.getShift(shiftID) + if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } if !body.Force { conflicts, err := app.checkShiftConflict(body.VolunteerID, shiftID) @@ -149,6 +164,14 @@ func (app *App) handleUnassignShiftVolunteer(w http.ResponseWriter, r *http.Requ writeError(w, "invalid volunteer id", http.StatusBadRequest) return } + claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + s, _ := app.getShift(shiftID) + if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } if err := app.unassignShift(volunteerID, shiftID); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -167,6 +190,16 @@ func (app *App) handleReorderShifts(w http.ResponseWriter, r *http.Request) { writeError(w, "array of {id, position} required", http.StatusBadRequest) return } + claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + for _, p := range raw { + s, _ := app.getShift(p.ID) + if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } + } positions := make([]struct{ ID, Position int }, len(raw)) for i, p := range raw { positions[i] = struct{ ID, Position int }{p.ID, p.Position} diff --git a/handle_shifts_test.go b/handle_shifts_test.go index 940164e..19c49bf 100644 --- a/handle_shifts_test.go +++ b/handle_shifts_test.go @@ -104,6 +104,86 @@ func TestShiftAssignConflict(t *testing.T) { } } +func TestCoLeadDeleteShiftOtherDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + deptB, _ := app.createDepartment(Department{Name: "Build"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + s, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/shifts/"+itoa(s.ID), nil, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for other dept, got %d", w.Code) + } +} + +func TestCoLeadDeleteShiftOwnDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + s, _ := app.createShift(Shift{DepartmentID: deptA.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/shifts/"+itoa(s.ID), nil, tok)) + if w.Code != http.StatusNoContent { + t.Errorf("expected 204 for own dept, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestCoLeadAssignShiftVolunteerOtherDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + deptB, _ := app.createDepartment(Department{Name: "Build"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + s, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + deptBID := deptB.ID + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/shifts/"+itoa(s.ID)+"/volunteers", map[string]any{ + "volunteer_id": v.ID, + }, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for other dept, got %d", w.Code) + } +} + +func TestCoLeadReorderShiftsOtherDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + deptB, _ := app.createDepartment(Department{Name: "Build"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + s1, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "A", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + s2, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "B", Day: "2026-03-15", StartTime: "12:00", EndTime: "16:00"}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/shifts/reorder", []map[string]int{ + {"id": s1.ID, "position": 2}, + {"id": s2.ID, "position": 1}, + }, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for other dept reorder, got %d", w.Code) + } +} + func TestShiftReorder(t *testing.T) { app := testApp(t) admin := testAdminUser(t, app) diff --git a/handle_volunteers.go b/handle_volunteers.go index ec3a317..cd891d2 100644 --- a/handle_volunteers.go +++ b/handle_volunteers.go @@ -12,20 +12,19 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) { search := q.Get("search") since := q.Get("since") - var deptID *int + var deptIDs []int if d := q.Get("dept"); d != "" { - id, err := strconv.Atoi(d) - if err == nil { - deptID = &id + if id, err := strconv.Atoi(d); err == nil { + deptIDs = []int{id} } } claims := claimsFromContext(r) - if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && deptID == nil && len(claims.DeptIDs) > 0 { - deptID = &claims.DeptIDs[0] + if isCoLeadOnly(claims) && len(deptIDs) == 0 { + deptIDs = claims.DeptIDs } - volunteers, err := app.listVolunteers(search, deptID, since) + volunteers, err := app.listVolunteers(search, deptIDs, since) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -55,7 +54,7 @@ func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) { + if isCoLeadOnly(claims) { if body.DepartmentID == nil || !inSlice(*body.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) return @@ -127,12 +126,16 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) { + if isCoLeadOnly(claims) { existing, _ := app.getVolunteer(id) if existing == nil || existing.DepartmentID == nil || !inSlice(*existing.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) return } + if body.DepartmentID != nil && !inSlice(*body.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: cannot move volunteer to that department", http.StatusForbidden) + return + } } v := Volunteer{ ID: id, @@ -157,6 +160,14 @@ func (app *App) handleDeleteVolunteer(w http.ResponseWriter, r *http.Request) { writeError(w, "invalid id", http.StatusBadRequest) return } + claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + v, _ := app.getVolunteer(id) + if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } if err := app.deleteVolunteer(id); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -171,6 +182,13 @@ func (app *App) handleMarkVolunteerReady(w http.ResponseWriter, r *http.Request) return } claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + v, _ := app.getVolunteer(id) + if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } v, err := app.markVolunteerReady(id, claims.ParticipantID) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) @@ -186,6 +204,14 @@ func (app *App) handleConfirmVolunteer(w http.ResponseWriter, r *http.Request) { writeError(w, "invalid id", http.StatusBadRequest) return } + claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + v, _ := app.getVolunteer(id) + if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } v, err := app.confirmVolunteer(id) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) @@ -207,7 +233,24 @@ func (app *App) handleAssignShift(w http.ResponseWriter, r *http.Request) { writeError(w, "shift_id required", http.StatusBadRequest) return } - if err := app.assignShift(volunteerID, body.ShiftID); err != nil { + claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + v, _ := app.getVolunteer(volunteerID) + if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } + shift, err := app.getShift(body.ShiftID) + if err != nil || shift == nil { + writeError(w, "shift not found", http.StatusNotFound) + return + } + if err := app.assignShiftWithCapacity(volunteerID, body.ShiftID, shift.Capacity); err != nil { + if err == errShiftFull { + writeError(w, "shift is at capacity", http.StatusConflict) + return + } writeError(w, err.Error(), http.StatusInternalServerError) return } @@ -225,6 +268,14 @@ func (app *App) handleUnassignShift(w http.ResponseWriter, r *http.Request) { writeError(w, "invalid shift id", http.StatusBadRequest) return } + claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + v, _ := app.getVolunteer(volunteerID) + if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } if err := app.unassignShift(volunteerID, shiftID); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -232,11 +283,3 @@ func (app *App) handleUnassignShift(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -func inSlice(v int, s []int) bool { - for _, x := range s { - if x == v { - return true - } - } - return false -} diff --git a/handle_volunteers_test.go b/handle_volunteers_test.go index 19ff1b0..dc61f28 100644 --- a/handle_volunteers_test.go +++ b/handle_volunteers_test.go @@ -79,6 +79,130 @@ func TestConfirmVolunteerRequiresRole(t *testing.T) { } } +func TestCoLeadDeleteVolunteerOwnDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + deptAID := deptA.ID + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptAID}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/volunteers/"+itoa(v.ID), nil, tok)) + if w.Code != http.StatusNoContent { + t.Errorf("expected 204 for own dept, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestCoLeadDeleteVolunteerOtherDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + deptB, _ := app.createDepartment(Department{Name: "Build"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + deptBID := deptB.ID + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/volunteers/"+itoa(v.ID), nil, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for other dept, got %d", w.Code) + } +} + +func TestCoLeadConfirmVolunteerOtherDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + deptB, _ := app.createDepartment(Department{Name: "Build"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + deptBID := deptB.ID + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for other dept, got %d", w.Code) + } +} + +func TestCoLeadReadyVolunteerOtherDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + deptB, _ := app.createDepartment(Department{Name: "Build"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + deptBID := deptB.ID + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/ready", nil, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for other dept, got %d", w.Code) + } +} + +func TestCoLeadAssignShiftOtherDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + deptB, _ := app.createDepartment(Department{Name: "Build"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + deptBID := deptB.ID + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID}) + s, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/shifts", map[string]any{ + "shift_id": s.ID, + }, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for other dept, got %d", w.Code) + } +} + +func TestCoLeadUpdateVolunteerTargetDeptForbidden(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + deptB, _ := app.createDepartment(Department{Name: "Build"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + deptAID := deptA.ID + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptAID}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{ + "department_id": deptB.ID, + }, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 moving to other dept, got %d: %s", w.Code, w.Body.String()) + } +} + func TestUpdateVolunteerDepartment(t *testing.T) { app := testApp(t) mux := testMux(app) From ad8c3a64b64d8bbd98873513503e67a5ecd2ec93 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 10 Mar 2026 15:27:49 -0500 Subject: [PATCH 39/47] Removed dead attendees DB code. --- db.go | 325 +---------------------------------------------------- db_test.go | 94 +--------------- 2 files changed, 4 insertions(+), 415 deletions(-) diff --git a/db.go b/db.go index 7857020..0315da8 100644 --- a/db.go +++ b/db.go @@ -49,28 +49,6 @@ func migrate(db *sql.DB) error { deleted_at TEXT ); - CREATE TABLE IF NOT EXISTS attendees ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - email TEXT NOT NULL DEFAULT '', - phone TEXT NOT NULL DEFAULT '', - ticket_id TEXT NOT NULL DEFAULT '', - ticket_type TEXT NOT NULL DEFAULT '', - volunteer_token TEXT UNIQUE, - party_size INTEGER NOT NULL DEFAULT 1, - checked_in INTEGER NOT NULL DEFAULT 0, - checked_in_count INTEGER NOT NULL DEFAULT 0, - checked_in_at TEXT, - checked_in_by INTEGER REFERENCES participants(id), - note TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - deleted_at TEXT - ); - - CREATE UNIQUE INDEX IF NOT EXISTS idx_attendees_name_ticket - ON attendees(name, ticket_id) WHERE deleted_at IS NULL; - CREATE TABLE IF NOT EXISTS volunteers ( id INTEGER PRIMARY KEY AUTOINCREMENT, participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE, @@ -122,6 +100,8 @@ func migrate(db *sql.DB) error { note TEXT NOT NULL DEFAULT '', email_confirmed INTEGER NOT NULL DEFAULT 0, confirmation_token TEXT, + password_hash TEXT, + login_enabled INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), deleted_at TEXT @@ -161,95 +141,11 @@ func migrate(db *sql.DB) error { PRIMARY KEY (participant_id, department_id) ); `) - if err != nil { - return err - } - if err := migrateAuth(db); err != nil { - return err - } - return nil -} - -func migrateAuth(db *sql.DB) error { - // Add auth columns to participants (idempotent — ignore "duplicate column" errors). - db.Exec(`ALTER TABLE participants ADD COLUMN password_hash TEXT`) - db.Exec(`ALTER TABLE participants ADD COLUMN login_enabled INTEGER NOT NULL DEFAULT 0`) - - // Migrate users → participants if the old users table exists. - var hasUsers int - if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'`).Scan(&hasUsers); err != nil || hasUsers == 0 { - return nil - } - - // Collect all users first (single connection — can't query and exec concurrently). - type oldUser struct { - id int - name string - hash string - role string - } - rows, err := db.Query(`SELECT id, username, password_hash, role FROM users`) - if err != nil { - return nil - } - var users []oldUser - for rows.Next() { - var u oldUser - if err := rows.Scan(&u.id, &u.name, &u.hash, &u.role); err != nil { - continue - } - if u.role == "ticketing" { - u.role = "admin" - } - users = append(users, u) - } - rows.Close() - - // Collect department assignments. - type deptAssign struct { - userID int - deptID int - } - deptRows, err := db.Query(`SELECT user_id, department_id FROM user_departments`) - var deptAssigns []deptAssign - if err == nil { - for deptRows.Next() { - var da deptAssign - deptRows.Scan(&da.userID, &da.deptID) - deptAssigns = append(deptAssigns, da) - } - deptRows.Close() - } - - // Now insert with the connection free. - for _, u := range users { - res, err := db.Exec( - `INSERT INTO participants (preferred_name, password_hash, login_enabled, updated_at) VALUES (?, ?, 1, ?)`, - u.name, u.hash, now(), - ) - if err != nil { - continue - } - pid, _ := res.LastInsertId() - db.Exec(`INSERT OR IGNORE INTO participant_roles (participant_id, role) VALUES (?, ?)`, pid, u.role) - for _, da := range deptAssigns { - if da.userID == u.id { - db.Exec(`INSERT OR IGNORE INTO participant_departments (participant_id, department_id) VALUES (?, ?)`, pid, da.deptID) - } - } - } - - db.Exec(`DROP TABLE IF EXISTS user_departments`) - db.Exec(`DROP TABLE IF EXISTS users`) - return nil + return err } // --- Types --- -const attendeeCols = `id, name, email, phone, ticket_id, ticket_type, volunteer_token, - party_size, checked_in, checked_in_count, checked_in_at, checked_in_by, - note, created_at, updated_at, deleted_at` - const shiftCols = `id, department_id, name, day, start_time, end_time, capacity, position, updated_at, deleted_at` const shiftColsS = `s.id, s.department_id, s.name, s.day, s.start_time, s.end_time, s.capacity, s.position, s.updated_at, s.deleted_at` @@ -273,25 +169,6 @@ type User struct { CreatedAt string `json:"created_at"` } -type Attendee struct { - ID int `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - Phone string `json:"phone"` - TicketID string `json:"ticket_id"` - TicketType string `json:"ticket_type"` - VolunteerToken *string `json:"volunteer_token,omitempty"` - PartySize int `json:"party_size"` - CheckedIn bool `json:"checked_in"` - CheckedInCount int `json:"checked_in_count"` - CheckedInAt *string `json:"checked_in_at,omitempty"` - CheckedInBy *int `json:"checked_in_by,omitempty"` - Note string `json:"note"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - DeletedAt *string `json:"deleted_at,omitempty"` -} - type Department struct { ID int `json:"id"` Name string `json:"name"` @@ -688,174 +565,6 @@ func (app *App) generateCodesForAll() (int, error) { return count, nil } -// incrementPartySize is kept for backward compatibility with existing tests. -func (app *App) incrementPartySize(name, ticketID string) (bool, error) { - res, err := app.db.Exec( - `UPDATE attendees SET party_size = party_size + 1, updated_at = ? - WHERE name = ? AND ticket_id = ? AND deleted_at IS NULL`, - now(), name, ticketID, - ) - if err != nil { - return false, err - } - n, _ := res.RowsAffected() - return n > 0, nil -} - -// --- Attendees --- - -func (app *App) listAttendees(search, ticketType, checkedIn string) ([]Attendee, error) { - q := `SELECT ` + attendeeCols + ` FROM attendees WHERE deleted_at IS NULL` - var args []any - if search != "" { - q += ` AND (name LIKE ? OR email LIKE ? OR ticket_id LIKE ?)` - s := "%" + search + "%" - args = append(args, s, s, s) - } - if ticketType != "" { - q += ` AND ticket_type = ?` - args = append(args, ticketType) - } - if checkedIn == "true" { - q += ` AND checked_in = 1` - } else if checkedIn == "false" { - q += ` AND checked_in = 0` - } - q += ` ORDER BY name ASC` - return queryAttendees(app.db, q, args...) -} - -func (app *App) getAttendee(id int) (*Attendee, error) { - rows, err := queryAttendees(app.db, - `SELECT `+attendeeCols+` FROM attendees WHERE id = ?`, id) - if err != nil || len(rows) == 0 { - return nil, err - } - return &rows[0], nil -} - -func (app *App) createAttendee(a Attendee) (*Attendee, error) { - res, err := app.db.Exec( - `INSERT INTO attendees (name, email, phone, ticket_id, ticket_type, note, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - a.Name, a.Email, a.Phone, a.TicketID, a.TicketType, a.Note, now(), - ) - if err != nil { - return nil, err - } - id, _ := res.LastInsertId() - return app.getAttendee(int(id)) -} - -func (app *App) updateAttendee(a Attendee) error { - _, err := app.db.Exec( - `UPDATE attendees SET name=?, email=?, phone=?, ticket_id=?, ticket_type=?, note=?, updated_at=? - WHERE id = ? AND deleted_at IS NULL`, - a.Name, a.Email, a.Phone, a.TicketID, a.TicketType, a.Note, now(), a.ID, - ) - return err -} - -func (app *App) deleteAttendee(id int) error { - _, err := app.db.Exec( - `UPDATE attendees SET deleted_at = ?, updated_at = ? WHERE id = ?`, now(), now(), id, - ) - return err -} - -// checkInAttendee increments checked_in_count by count (capped at party_size). -// Sets checked_in and checked_in_at on the first check-in. -func (app *App) checkInAttendee(id, userID, count int) (*Attendee, error) { - if count < 1 { - count = 1 - } - a, err := app.getAttendee(id) - if err != nil || a == nil { - return nil, err - } - remaining := a.PartySize - a.CheckedInCount - if count > remaining { - count = remaining - } - if count <= 0 { - return a, nil - } - t := now() - _, err = app.db.Exec(` - UPDATE attendees SET - checked_in_count = checked_in_count + ?, - checked_in = CASE WHEN checked_in = 0 THEN 1 ELSE checked_in END, - checked_in_at = CASE WHEN checked_in = 0 THEN ? ELSE checked_in_at END, - checked_in_by = CASE WHEN checked_in = 0 THEN ? ELSE checked_in_by END, - updated_at = ? - WHERE id = ? AND deleted_at IS NULL`, - count, t, userID, t, id, - ) - if err != nil { - return nil, err - } - return app.getAttendee(id) -} - -func (app *App) attendeesSince(since string) ([]Attendee, error) { - return queryAttendees(app.db, - `SELECT `+attendeeCols+` FROM attendees WHERE updated_at > ? ORDER BY updated_at ASC`, since) -} - -func queryAttendees(db *sql.DB, q string, args ...any) ([]Attendee, error) { - rows, err := db.Query(q, args...) - if err != nil { - return nil, err - } - defer rows.Close() - var result []Attendee - for rows.Next() { - var a Attendee - var checkedIn int - var token sql.NullString - if err := rows.Scan( - &a.ID, &a.Name, &a.Email, &a.Phone, &a.TicketID, &a.TicketType, - &token, &a.PartySize, &checkedIn, &a.CheckedInCount, - &a.CheckedInAt, &a.CheckedInBy, &a.Note, - &a.CreatedAt, &a.UpdatedAt, &a.DeletedAt, - ); err != nil { - return nil, err - } - if token.Valid && token.String != "" { - a.VolunteerToken = &token.String - } - a.CheckedIn = checkedIn == 1 - if a.PartySize < 1 { - a.PartySize = 1 - } - result = append(result, a) - } - return result, rows.Err() -} - -func (app *App) attendeeTicketTypes() ([]string, error) { - rows, err := app.db.Query( - `SELECT DISTINCT ticket_type FROM attendees WHERE ticket_type != '' AND deleted_at IS NULL ORDER BY ticket_type`, - ) - if err != nil { - return nil, err - } - defer rows.Close() - var types []string - for rows.Next() { - var t string - rows.Scan(&t) - types = append(types, t) - } - return types, rows.Err() -} - -func (app *App) attendeeCounts() (total, checkedIn int, err error) { - app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE deleted_at IS NULL`).Scan(&total) - app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE checked_in=1 AND deleted_at IS NULL`).Scan(&checkedIn) - return -} - // --- Participants --- const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, email_confirmed, confirmation_token, created_at, updated_at, deleted_at` @@ -1021,15 +730,6 @@ func (app *App) getTicket(id int) (*Ticket, error) { return &rows[0], nil } -func (app *App) getTicketByCode(code string) (*Ticket, error) { - rows, err := queryTickets(app.db, - `SELECT `+ticketCols+` FROM tickets WHERE code = ? AND deleted_at IS NULL`, code) - if err != nil || len(rows) == 0 { - return nil, err - } - return &rows[0], nil -} - func (app *App) createTicket(t Ticket) (*Ticket, error) { res, err := app.db.Exec( `INSERT INTO tickets (participant_id, name, ticket_type, source, external_id, order_id, code, updated_at) @@ -1066,16 +766,6 @@ func (app *App) deleteTicket(id int) error { return err } -func (app *App) ticketsSince(since string) ([]Ticket, error) { - return queryTickets(app.db, - `SELECT `+ticketCols+` FROM tickets WHERE updated_at > ? ORDER BY updated_at ASC`, since) -} - -func (app *App) participantsSince(since string) ([]Participant, error) { - return queryParticipants(app.db, - `SELECT `+participantCols+` FROM participants WHERE updated_at > ? ORDER BY updated_at ASC`, since) -} - func queryTickets(db *sql.DB, q string, args ...any) ([]Ticket, error) { rows, err := db.Query(q, args...) if err != nil { @@ -1244,15 +934,6 @@ func (app *App) getVolunteer(id int) (*Volunteer, error) { return &rows[0], nil } -func (app *App) getVolunteerByParticipantID(participantID int) (*Volunteer, error) { - rows, err := queryVolunteers(app.db, - `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.participant_id = ? AND v.deleted_at IS NULL LIMIT 1`, participantID) - if err != nil || len(rows) == 0 { - return nil, err - } - return &rows[0], nil -} - func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { res, err := app.db.Exec( `INSERT INTO volunteers (participant_id, department_id, is_lead, note, updated_at) diff --git a/db_test.go b/db_test.go index edb2b6d..5755d08 100644 --- a/db_test.go +++ b/db_test.go @@ -7,7 +7,7 @@ import ( func TestMigrate(t *testing.T) { app := testApp(t) // Verify tables exist by querying each one - tables := []string{"event", "participants", "participant_roles", "attendees", "departments", "volunteers", "shifts", "volunteer_shifts"} + tables := []string{"event", "participants", "participant_roles", "departments", "volunteers", "shifts", "volunteer_shifts"} for _, table := range tables { var count int err := app.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) @@ -17,98 +17,6 @@ func TestMigrate(t *testing.T) { } } -func TestAttendeesCRUD(t *testing.T) { - app := testApp(t) - - a, err := app.createAttendee(Attendee{Name: "Titania", Email: "titania@test.com", TicketType: "GA"}) - if err != nil { - t.Fatal(err) - } - if a.ID == 0 || a.Name != "Titania" { - t.Errorf("create: got %+v", a) - } - - got, err := app.getAttendee(a.ID) - if err != nil || got == nil { - t.Fatal("get: not found") - } - if got.Email != "titania@test.com" { - t.Errorf("get: email = %q", got.Email) - } - - got.Name = "Titania Fairweather" - if err := app.updateAttendee(*got); err != nil { - t.Fatal(err) - } - got2, _ := app.getAttendee(a.ID) - if got2.Name != "Titania Fairweather" { - t.Errorf("update: name = %q", got2.Name) - } - - if err := app.deleteAttendee(a.ID); err != nil { - t.Fatal(err) - } - // getAttendee returns soft-deleted records; listAttendees filters them - attendees, _ := app.listAttendees("", "", "") - for _, at := range attendees { - if at.ID == a.ID { - t.Error("delete: still visible in list") - } - } -} - -func TestIncrementPartySize(t *testing.T) { - app := testApp(t) - - app.createAttendee(Attendee{Name: "Oberon", TicketID: "ORD-100"}) - - merged, err := app.incrementPartySize("Oberon", "ORD-100") - if err != nil || !merged { - t.Fatalf("increment: merged=%v, err=%v", merged, err) - } - - a, _ := app.getAttendee(1) - if a.PartySize != 2 { - t.Errorf("party_size = %d, want 2", a.PartySize) - } - - // Different ticket_id should not merge - merged2, _ := app.incrementPartySize("Oberon", "ORD-200") - if merged2 { - t.Error("should not merge different ticket_id") - } -} - -func TestCheckInAttendee(t *testing.T) { - app := testApp(t) - admin := testAdminUser(t, app) - - app.createAttendee(Attendee{Name: "Puck"}) - // Set party_size directly since createAttendee defaults to 1 - app.db.Exec(`UPDATE attendees SET party_size = 3 WHERE id = 1`) - - // Check in 1 - a, err := app.checkInAttendee(1, admin.ID, 1) - if err != nil { - t.Fatal(err) - } - if a.CheckedInCount != 1 || !a.CheckedIn { - t.Errorf("after 1: count=%d, checked_in=%v", a.CheckedInCount, a.CheckedIn) - } - - // Check in 2 more (should cap at party_size=3) - a, _ = app.checkInAttendee(1, admin.ID, 5) - if a.CheckedInCount != 3 { - t.Errorf("after cap: count=%d, want 3", a.CheckedInCount) - } - - // Check in again — already full, should stay at 3 - a, _ = app.checkInAttendee(1, admin.ID, 1) - if a.CheckedInCount != 3 { - t.Errorf("after full: count=%d, want 3", a.CheckedInCount) - } -} - func TestGenerateToken(t *testing.T) { token, err := generateToken() if err != nil { From 5527c1eb91df025343052c06faef1eaac2d29578 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 10 Mar 2026 15:37:34 -0500 Subject: [PATCH 40/47] Clarified distinction between Preferred and Ticketed Name. --- frontend/src/pages/Participants.svelte | 22 +++++++++++++++------- frontend/src/pages/Users.svelte | 4 ++-- frontend/src/pages/Volunteers.svelte | 6 +++++- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/frontend/src/pages/Participants.svelte b/frontend/src/pages/Participants.svelte index fa850a3..65eda24 100644 --- a/frontend/src/pages/Participants.svelte +++ b/frontend/src/pages/Participants.svelte @@ -19,6 +19,7 @@ let showAdd = $state(false) let adding = $state(false) let newName = $state('') + let newTicketedName = $state('') let newEmail = $state('') let newPhone = $state('') let newPronouns = $state('') @@ -27,6 +28,7 @@ // Edit participant let editId = $state(null) let editName = $state('') + let editTicketedName = $state('') let editEmail = $state('') let editPhone = $state('') let editPronouns = $state('') @@ -151,12 +153,12 @@ adding = true; error = '' try { const p = await api.participants.create({ - preferred_name: newName, email: newEmail, phone: newPhone, - pronouns: newPronouns, note: newNote, + preferred_name: newName, ticket_name: newTicketedName, email: newEmail, + phone: newPhone, pronouns: newPronouns, note: newNote, }) await db.participants.put(p) showAdd = false - newName = newEmail = newPhone = newPronouns = newNote = '' + newName = newTicketedName = newEmail = newPhone = newPronouns = newNote = '' } catch (err) { error = err.message } finally { @@ -167,6 +169,7 @@ function startEdit(p) { editId = p.id editName = p.preferred_name + editTicketedName = p.ticket_name || '' editEmail = p.email editPhone = p.phone editPronouns = p.pronouns @@ -178,8 +181,8 @@ saving = true; error = '' try { const p = await api.participants.update(editId, { - preferred_name: editName, email: editEmail, phone: editPhone, - pronouns: editPronouns, note: editNote, + preferred_name: editName, ticket_name: editTicketedName, email: editEmail, + phone: editPhone, pronouns: editPronouns, note: editNote, }) await db.participants.put(p) editId = null @@ -247,9 +250,13 @@
- +
+
+ + +
@@ -324,7 +331,7 @@
UsernameRoleNameRoles Departments
{u.username} {#if u.id === me}you{/if}{u.preferred_name || u.email} {#if u.id === me}you{/if} - editRoles = toggleItem(r, editRoles)} /> + {roleLabel(r)} + {/each} - + {#if ($allDepts ?? []).length > 0} @@ -229,7 +244,7 @@ {/each} @@ -251,18 +266,19 @@ {:else}
- {u.username} + {u.preferred_name || u.email} {#if u.id === me} you {/if} +
{u.email}
{roleLabel(u.role)}{#each u.roles ?? [] as r}{roleLabel(r)}{/each} {deptNamesFor(u.department_ids || [])}
{#if u.id !== me} - + {/if}
- + @@ -344,6 +351,7 @@
+ diff --git a/frontend/src/pages/Users.svelte b/frontend/src/pages/Users.svelte index 328cc66..cb7a3f8 100644 --- a/frontend/src/pages/Users.svelte +++ b/frontend/src/pages/Users.svelte @@ -155,7 +155,7 @@
- +
@@ -214,7 +214,7 @@
NamePreferred Name Email Tickets Status
- + diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index 79681f8..d61cc3d 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -256,7 +256,7 @@
NamePreferred Name Roles Departments
- + @@ -296,6 +296,7 @@ {:else} + {@const participant = participantFor(v.participant_id)} - +
NamePreferred Name Department Status
{v.name} @@ -307,6 +308,9 @@ {:else if !participantHasTickets(v.participant_id)} No ticket {/if} + {#if participant?.ticket_name && participant.ticket_name !== v.name} +
Ticket: {participant.ticket_name}
+ {/if} {#if v.email}
{v.email}
{/if} From 54da04763f35a6752588ea71e450268fe0c4a41b Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 10 Mar 2026 17:45:38 -0500 Subject: [PATCH 41/47] Added optional Discourse SSO. --- db.go | 26 ++++ frontend/src/App.svelte | 32 ++++- frontend/src/api.js | 4 + frontend/src/pages/Login.svelte | 49 +++++++- frontend/src/pages/Settings.svelte | 27 +++- handle_settings.go | 14 ++- handle_sso.go | 190 +++++++++++++++++++++++++++++ main.go | 3 + 8 files changed, 337 insertions(+), 8 deletions(-) create mode 100644 handle_sso.go diff --git a/db.go b/db.go index 0315da8..0ec6716 100644 --- a/db.go +++ b/db.go @@ -140,6 +140,11 @@ func migrate(db *sql.DB) error { department_id INTEGER NOT NULL REFERENCES departments(id) ON DELETE CASCADE, PRIMARY KEY (participant_id, department_id) ); + + CREATE TABLE IF NOT EXISTS sso_nonces ( + nonce TEXT PRIMARY KEY, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); `) return err } @@ -1350,6 +1355,27 @@ func (app *App) listOpenShiftsForDept(deptID int) ([]Shift, error) { ORDER BY s.day, s.position, s.start_time`, deptID) } +// --- SSO Nonces --- + +func (app *App) createSSONonce(nonce string) error { + _, err := app.db.Exec(`INSERT INTO sso_nonces (nonce) VALUES (?)`, nonce) + return err +} + +func (app *App) consumeSSONonce(nonce string) (bool, error) { + res, err := app.db.Exec( + `DELETE FROM sso_nonces WHERE nonce = ? AND created_at > datetime('now', '-10 minutes')`, nonce) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +func (app *App) cleanExpiredNonces() { + app.db.Exec(`DELETE FROM sso_nonces WHERE created_at < datetime('now', '-10 minutes')`) +} + // --- Helpers --- func now() string { diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index a1fa253..f680143 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,6 +1,6 @@ + + diff --git a/frontend/src/pages/Settings.svelte b/frontend/src/pages/Settings.svelte index 2f6ee8e..bc87738 100644 --- a/frontend/src/pages/Settings.svelte +++ b/frontend/src/pages/Settings.svelte @@ -26,6 +26,8 @@ let eventEndDate = $state('') let eventTimezone = $state('') const timezones = Intl.supportedValuesOf('timeZone') + let discourseSSOUrl = $state('') + let discourseSSOSecret = $state('') let shiftSignupsOpen = $state(false) let togglingSignups = $state(false) @@ -49,6 +51,8 @@ baseURL = s.base_url ?? '' noteLabel = s.volunteer_note_label ?? 'Additional note' noteRequired = s.volunteer_note_required ?? false + discourseSSOUrl = s.discourse_sso_url ?? '' + discourseSSOSecret = '' shiftSignupsOpen = s.shift_signups_open ?? false } catch (err) { error = err.message @@ -89,14 +93,17 @@ smtp_host: smtpHost, smtp_port: smtpPort, smtp_user: smtpUser, - smtp_password: smtpPassword, // empty = keep existing + smtp_password: smtpPassword, smtp_from: smtpFrom, smtp_from_name: smtpFromName, base_url: baseURL, volunteer_note_label: noteLabel, volunteer_note_required: noteRequired, + discourse_sso_url: discourseSSOUrl, + discourse_sso_secret: discourseSSOSecret, }) smtpPassword = '' + discourseSSOSecret = '' success = 'Settings saved.' } catch (err) { error = err.message @@ -240,6 +247,24 @@ + +

Discourse SSO

+

+ Enable DiscourseConnect SSO so users can log in with their Discourse account. + Set the same secret in your Discourse admin under Connect > discourse connect secret. +

+
+
+ + +
+
+ + +
+
+
-
{#each availableRoles as r} -
- From 374316944ecb2cebd5ba61dfde7435e3721caa07 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Wed, 11 Mar 2026 10:04:30 -0500 Subject: [PATCH 44/47] Refactored inline styles. --- frontend/src/app.css | 6 +++ frontend/src/pages/Dashboard.svelte | 2 +- frontend/src/pages/Participants.svelte | 2 +- frontend/src/pages/ScheduleBoard.svelte | 4 +- frontend/src/pages/Settings.svelte | 53 ++++++++++++------------- frontend/src/pages/Users.svelte | 4 +- frontend/src/pages/Volunteers.svelte | 8 ++-- 7 files changed, 42 insertions(+), 37 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index 70ec51c..0395a12 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -66,6 +66,9 @@ a:hover { color: var(--c-accent-h); } /* Cards */ .card { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: 1.25rem; } +.card + .card, .card + form, form + .card, form + form { margin-top: 1.5rem; } +.card-title { font-size: 0.95rem; font-weight: 700; margin-bottom: 1rem; } +.card-hint { font-size: 0.78rem; color: var(--c-muted); } /* Stats */ .stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; } @@ -106,6 +109,8 @@ input, select, textarea { input[type="checkbox"] { width: auto; } input:focus, select:focus, textarea:focus { outline: none; border-color: var(--c-accent); } input::placeholder { color: var(--c-muted); } +.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } +.form-grid .full { grid-column: 1 / -1; } .checkbox-label { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; cursor: pointer; } .checkbox-label-sm { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.82rem; cursor: pointer; color: var(--c-text); } @@ -131,6 +136,7 @@ tr:hover td { background: rgba(255,255,255,0.02); } padding: 0.18rem 0.55rem; border-radius: 99px; font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; + margin-left: 0.3rem; } .badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); } .badge-confirmed { background: rgba(99,102,241,0.15); color: var(--c-accent-h); } diff --git a/frontend/src/pages/Dashboard.svelte b/frontend/src/pages/Dashboard.svelte index 9756b31..73800d6 100644 --- a/frontend/src/pages/Dashboard.svelte +++ b/frontend/src/pages/Dashboard.svelte @@ -160,7 +160,7 @@

Welcome, {session?.user?.preferred_name} - · {#each roles as r}{r}{/each} + · {#each roles as r}{r}{/each}

diff --git a/frontend/src/pages/Participants.svelte b/frontend/src/pages/Participants.svelte index 65eda24..0be1485 100644 --- a/frontend/src/pages/Participants.svelte +++ b/frontend/src/pages/Participants.svelte @@ -248,7 +248,7 @@ {#if showAdd && canManage}
-
+
diff --git a/frontend/src/pages/ScheduleBoard.svelte b/frontend/src/pages/ScheduleBoard.svelte index 6bea05e..a09bf0f 100644 --- a/frontend/src/pages/ScheduleBoard.svelte +++ b/frontend/src/pages/ScheduleBoard.svelte @@ -275,7 +275,7 @@ {#if showAdd && canManage}
-
+
-
+
@@ -192,7 +192,7 @@
-
+
@@ -211,11 +211,11 @@
-
-

SMTP Email

+
+

SMTP Email

-
-
+
+
@@ -243,22 +243,21 @@
- +
- -

Discourse SSO

-

+

Discourse SSO

+

Enable DiscourseConnect SSO so users can log in with their Discourse account. Set the same secret in your Discourse admin under Connect > discourse connect secret.

-
-
+
+
-
+
@@ -274,8 +273,8 @@ -
-

Test Email

+
+

Test Email

@@ -288,8 +287,8 @@
-
-

Volunteer Signup

+
+

Volunteer Signup

@@ -298,14 +297,14 @@ Note field is required -

+

Signup form: /volunteer-signup

-
-

Shift Signups

+
+

Shift Signups

Status: {shiftSignupsOpen ? 'Open' : 'Closed'} @@ -320,7 +319,7 @@
{#if !shiftSignupsOpen} -

+

Opening signups will email all confirmed volunteers their shift signup links.

{/if} @@ -328,8 +327,8 @@
-

Data Management

-

+

Data Management

+

Permanently delete all records of a given type. This cannot be undone.

diff --git a/frontend/src/pages/Users.svelte b/frontend/src/pages/Users.svelte index c1c3a58..e75364c 100644 --- a/frontend/src/pages/Users.svelte +++ b/frontend/src/pages/Users.svelte @@ -268,11 +268,11 @@
{u.preferred_name || u.email} {#if u.id === me} - you + you {/if}
{u.email}
{#each u.roles ?? [] as r}{roleLabel(r)}{/each}{#each u.roles ?? [] as r}{roleLabel(r)}{/each} {deptNamesFor(u.department_ids || [])}
diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index fb92897..4bced77 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -181,7 +181,7 @@ {#if showAdd && canManage}
-
+
@@ -301,12 +301,12 @@
{v.name} {#if v.is_lead} - Co-Lead + Co-Lead {/if} {#if !v.participant_id} - No ticket + No ticket {:else if !participantHasTickets(v.participant_id)} - No ticket + No ticket {/if} {#if participant?.ticket_name && participant.ticket_name !== v.name}
Ticket: {participant.ticket_name}
From 6d4c49a223ece4c0dae2e74bc048b043e2b08571 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Wed, 11 Mar 2026 10:22:54 -0500 Subject: [PATCH 45/47] Added autozoom fix. --- frontend/src/app.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index 0395a12..546dc22 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -243,6 +243,7 @@ tr:hover td { background: rgba(255,255,255,0.02); } td { display: inline; padding: 0; border: none; } td:empty { display: none; } - /* Forms */ + /* Forms — 16px prevents iOS auto-zoom on focus */ + input, select, textarea { font-size: 16px; } .form-grid { grid-template-columns: 1fr !important; } } From d73a74965d2a04f432215dbd4065a81acb654944 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Wed, 11 Mar 2026 10:31:50 -0500 Subject: [PATCH 46/47] Added datepicker fix. --- frontend/src/app.css | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/app.css b/frontend/src/app.css index 546dc22..b61ed5e 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -107,6 +107,7 @@ input, select, textarea { transition: border-color var(--transition); } input[type="checkbox"] { width: auto; } +input[type="date"], input[type="time"], input[type="datetime-local"] { -webkit-appearance: none; appearance: none; min-height: 2.35rem; } input:focus, select:focus, textarea:focus { outline: none; border-color: var(--c-accent); } input::placeholder { color: var(--c-muted); } .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } From d64e93674e2960de61dc8968c5b8f742e67d548b Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Wed, 11 Mar 2026 12:26:33 -0500 Subject: [PATCH 47/47] Refactored styles. --- frontend/src/App.svelte | 1 + frontend/src/api.js | 4 +- frontend/src/app.css | 5 +- frontend/src/pages/Departments.svelte | 5 +- frontend/src/pages/Login.svelte | 6 ++- frontend/src/pages/ScheduleBoard.svelte | 64 +++++++++++++------------ frontend/src/pages/Settings.svelte | 15 ++++-- frontend/src/pages/Users.svelte | 2 +- frontend/src/pages/Volunteers.svelte | 7 ++- 9 files changed, 64 insertions(+), 45 deletions(-) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index f680143..ac0957e 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -37,6 +37,7 @@ history.pushState(null, '', path) route = path mobileNavOpen = false + window.scrollTo(0, 0) } async function checkVersion() { diff --git a/frontend/src/api.js b/frontend/src/api.js index 31a517f..d15abc4 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1,4 +1,4 @@ -import { db } from './db.js' +import { db, clearSession } from './db.js' async function getToken() { const session = await db.session.get(1) @@ -17,7 +17,7 @@ export async function apiFetch(path, options = {}) { const res = await fetch(path, { ...options, headers }) if (res.status === 401) { - await db.session.clear() + await clearSession() window.location.pathname = '/login' throw new Error('unauthorized') } diff --git a/frontend/src/app.css b/frontend/src/app.css index b61ed5e..3a685ae 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -111,6 +111,7 @@ input[type="date"], input[type="time"], input[type="datetime-local"] { -webkit-a input:focus, select:focus, textarea:focus { outline: none; border-color: var(--c-accent); } input::placeholder { color: var(--c-muted); } .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } +.form-grid-3 { display: grid; grid-template-columns: 1fr 1fr auto; gap: 1rem; align-items: end; } .form-grid .full { grid-column: 1 / -1; } .checkbox-label { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; cursor: pointer; } .checkbox-label-sm { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.82rem; cursor: pointer; color: var(--c-text); } @@ -137,8 +138,8 @@ tr:hover td { background: rgba(255,255,255,0.02); } padding: 0.18rem 0.55rem; border-radius: 99px; font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; - margin-left: 0.3rem; } +* + .badge { margin-left: 0.3rem; } .badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); } .badge-confirmed { background: rgba(99,102,241,0.15); color: var(--c-accent-h); } .badge-registered { background: rgba(14,165,233,0.15); color: #0ea5e9; } @@ -246,5 +247,5 @@ tr:hover td { background: rgba(255,255,255,0.02); } /* Forms — 16px prevents iOS auto-zoom on focus */ input, select, textarea { font-size: 16px; } - .form-grid { grid-template-columns: 1fr !important; } + .form-grid, .form-grid-3 { grid-template-columns: 1fr !important; } } diff --git a/frontend/src/pages/Departments.svelte b/frontend/src/pages/Departments.svelte index 26164eb..863c4a1 100644 --- a/frontend/src/pages/Departments.svelte +++ b/frontend/src/pages/Departments.svelte @@ -101,7 +101,7 @@ {#if showAdd && canCreate}
-
+
@@ -112,7 +112,7 @@
- +
@@ -191,6 +191,7 @@