From e7b25ea0c6044103b4f0b0c178f3cd7a996e4832 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Wed, 4 Mar 2026 20:52:12 -0600 Subject: [PATCH 01/18] 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 02/18] 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 03/18] 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 04/18] 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 05/18] 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 06/18] 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 09/18] 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 10/18] 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 11/18] 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 12/18] 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 13/18] 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 14/18] 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 17/18] 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 2ff06bdb768f7bdb860c594d66e313a405b19eae Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 17:15:41 -0600 Subject: [PATCH 18/18] Move Ticket Name to the Participant model. --- db.go | 14 ++++++++------ frontend/src/pages/Participants.svelte | 4 ++++ frontend/src/pages/Volunteers.svelte | 3 --- handle_signup.go | 10 +++++----- handle_signup_test.go | 7 +++---- 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/db.go b/db.go index 9129489..0f814e7 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(` @@ -424,6 +425,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 +862,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 +902,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 +914,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 +959,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 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,