diff --git a/README.md b/README.md index 71132c4..88b09ef 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,21 @@ # Turnpike -Self-hosted event ticketing and volunteer management. One instance, one event. +Self-hosted event attendee 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 -- **Participant & ticket management** — CSV import (CrowdWork/Zeffy auto-detected), search, check-in +- **Attendee management** — CSV import (CrowdWork/Zeffy auto-detected), party-size tracking, search, check-in - **Volunteer scheduling** — departments, shifts with capacity, conflict detection, reordering -- **Public volunteer signup** — self-registration form with email confirmation, auto-participant linking -- **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 +- **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** — create shifts, assign volunteers, manage assignments with conflict awareness -- **Role-based access** — admin, ticketing, staffing, colead (department-scoped), gatekeeper +- **Role-based access** — admin, coordinator, volunteer lead (department-scoped), gate - **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** — volunteer confirmation emails, kiosk link distribution when shift signups open +- **SMTP email** — send volunteer token links directly or export CSV for bulk email platforms - **Single binary** — Go backend embeds the frontend; no runtime dependencies ## Tech Stack @@ -60,11 +60,10 @@ See [docs/INSTALLATION.md](docs/INSTALLATION.md) for systemd, Docker, NixOS, and | Role | Access | |------|--------| -| `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 Gate Kiosk with QR scanner. No access to other pages | +| `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 | See [docs/USAGE.md](docs/USAGE.md) for detailed workflow documentation. @@ -92,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, participant import, volunteer signup, volunteer kiosk, gate check-in, schedule +- [Usage Guide](docs/USAGE.md) — event setup, attendee 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/db.go b/db.go index 0f814e7..d93807d 100644 --- a/db.go +++ b/db.go @@ -174,23 +174,6 @@ 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") - addColumnIfMissing(db, "volunteers", "kiosk_code TEXT") - 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`) @@ -201,7 +184,6 @@ 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(` @@ -410,11 +392,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:"-"` - KioskCode *string `json:"kiosk_code,omitempty"` Note string `json:"note"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` @@ -425,7 +404,6 @@ 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"` @@ -862,7 +840,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, phone, pronouns, note, created_at, updated_at, deleted_at` func (app *App) listParticipants(search, since string) ([]Participant, error) { var q string @@ -902,8 +880,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, phone, pronouns, note, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, + strings.ToLower(p.Email), p.PreferredName, p.Phone, p.Pronouns, p.Note, now(), ) if err != nil { return nil, err @@ -914,9 +892,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=?, ticket_name=?, phone=?, pronouns=?, note=?, updated_at=? + `UPDATE participants SET email=?, preferred_name=?, phone=?, pronouns=?, note=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, - strings.ToLower(p.Email), p.PreferredName, p.TicketName, p.Phone, p.Pronouns, p.Note, now(), p.ID, + strings.ToLower(p.Email), p.PreferredName, p.Phone, p.Pronouns, p.Note, now(), p.ID, ) return err } @@ -959,7 +937,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.TicketName, &p.Phone, &p.Pronouns, &p.Note, + &p.ID, &p.Email, &p.PreferredName, &p.Phone, &p.Pronouns, &p.Note, &p.CreatedAt, &p.UpdatedAt, &p.DeletedAt, ); err != nil { return nil, err @@ -1206,8 +1184,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.kiosk_code, v.note, + 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` @@ -1316,19 +1293,6 @@ 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 { @@ -1339,14 +1303,13 @@ 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 confirmationToken, confirmedAt, kioskCode sql.NullString + var isLead, checkedIn, emailConfirmed int + var confirmationToken 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, &kioskCode, &v.Note, + &emailConfirmed, &confirmationToken, &v.Note, &v.CreatedAt, &v.UpdatedAt, &v.DeletedAt, ); err != nil { return nil, err @@ -1366,15 +1329,8 @@ 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 - } - if kioskCode.Valid { - v.KioskCode = &kioskCode.String - } v.IsLead = isLead == 1 v.CheckedIn = checkedIn == 1 - v.Confirmed = confirmed == 1 v.EmailConfirmed = emailConfirmed == 1 result = append(result, v) } @@ -1406,43 +1362,19 @@ func (app *App) confirmVolunteerEmail(id int) error { return err } -func (app *App) getVolunteerByKioskCode(code string) (*Volunteer, error) { - rows, err := queryVolunteers(app.db, - `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.kiosk_code = ? AND v.deleted_at IS NULL LIMIT 1`, code) - if err != nil || len(rows) == 0 { - return nil, err - } - return &rows[0], nil -} - -func (app *App) assignKioskCode(id int, code string) error { - _, err := app.db.Exec( - `UPDATE volunteers SET kiosk_code=?, updated_at=? WHERE id=?`, code, now(), id) - return err -} - -// listVolunteersNeedingKioskCode returns email-confirmed volunteers without a kiosk code. -func (app *App) listVolunteersNeedingKioskCode() ([]Volunteer, error) { +// listConfirmedVolunteersNeedingCode returns confirmed volunteers whose participant +// has no ticket with a code yet. +func (app *App) listConfirmedVolunteersNeedingCode() ([]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`) -} - -func (app *App) generateVolunteerKioskCode() (string, error) { - for range 10 { - t, err := generateToken() - if err != nil { - return "", err - } - var count int - if err := app.db.QueryRow(`SELECT COUNT(*) FROM volunteers WHERE kiosk_code = ?`, t).Scan(&count); err != nil { - return "", fmt.Errorf("check kiosk code uniqueness: %w", err) - } - if count == 0 { - return t, nil - } - } - return "", fmt.Errorf("failed to generate unique kiosk code") + WHERE v.email_confirmed = 1 AND v.deleted_at IS NULL + AND v.participant_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM tickets t + WHERE t.participant_id = v.participant_id + AND t.code IS NOT NULL + AND t.deleted_at IS NULL + )`) } func generateConfirmationToken() (string, error) { diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 9bc0dbc..1f9a967 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -105,27 +105,23 @@ docker run -p 8180:8180 \ ## NixOS -Turnpike builds with `buildGoModule` + `buildNpmPackage` (pure-Go SQLite, no CGO). The frontend is built separately and copied into the Go build: +Turnpike builds with `buildGoModule` using the pure-Go SQLite driver (no CGO): ```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"; - src = fetchgit { url = "..."; rev = "v2.0.0"; hash = "sha256-..."; }; - vendorHash = "sha256-..."; + version = "0.1.0"; + src = ./path/to/turnpike; # must include vendor/ and frontend/dist/ + vendorHash = null; env.CGO_ENABLED = 0; - preBuild = "cp -r ${frontendDist} frontend/dist"; }; ``` -A complete NixOS module with `DynamicUser`, `StateDirectory`, and secrets is in the project's `homelab/turnpike.nix`. +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`. ## Reverse Proxy diff --git a/docs/USAGE.md b/docs/USAGE.md index c08ec50..de4e765 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -12,22 +12,23 @@ 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: 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 Kiosk | Check in ticket holders (search + QR scan). No access to other pages | +| **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 | -Coleads are scoped to one or more departments. When creating a colead user, assign their department(s). +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. ## Event Setup -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. +1. **Configure your event** — go to the Dashboard and set the event name and dates. 2. **Create departments** — under Departments, add each department your event needs (e.g., Gate, Greeters, Rangers, Build, LNT). -3. **Import participants** — see next section. +3. **Import attendees** — see next section. 4. **Create shifts** — under Schedule, create shifts for each department with day, start/end time, and capacity. -## Importing Participants +## Importing Attendees Go to **Import** and upload a CSV file. Turnpike auto-detects two formats: @@ -35,7 +36,7 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats: | Column | Maps to | |--------|---------| -| `Patron Name` | Ticket name | +| `Patron Name` | Name | | `Patron Email` | Email | | `Order Number` | Ticket ID | | `Tier Name` | Ticket type | @@ -44,7 +45,7 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats: | Column | Maps to | |--------|---------| -| `name` (required) | Ticket name | +| `name` (required) | Name | | `email` | Email | | `ticket_id` | Ticket ID | | `ticket_type` | Ticket type | @@ -52,21 +53,27 @@ 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. -### Participants and tickets +### Party-size dedup -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). +CrowdWork exports one row per ticket, even when the same person bought multiple tickets in one order. Turnpike handles this automatically: -Re-importing the same CSV is safe — exact duplicates are skipped, not duplicated. +- 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. ## 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 participant by email match, or creates a new participant 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 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. @@ -83,7 +90,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 registered (email-confirmed) volunteers and emails them their shift signup links. A confirmation dialog warns before sending. +- **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. @@ -92,23 +99,12 @@ If a volunteer confirms their email while signups are already open, they receive Under **Volunteers**, you can: -- 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) +- 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 +- Check in volunteers -### Volunteer statuses - -| 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 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. +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. ## Shift Scheduling @@ -128,21 +124,19 @@ Shifts can be reordered within a department to reflect priority or sequence usin ## Volunteer Kiosk -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. +The kiosk lets volunteers self-select shifts without logging in. ### Setup -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 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. +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. ### 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 @@ -150,22 +144,22 @@ Each volunteer receives a link like `https://turnpike.example.com/v/ABC12345`. T 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 kiosk code authenticates the request. +No login is required. The 8-character token authenticates the request. -### Code format +### Token format -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). +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). -## Gate Kiosk +## Gate Check-In -Users with the **gatekeeper** role see a dedicated full-screen Gate Kiosk: +Users with the **gate** 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 tickets in real-time (searches local IndexedDB, works offline). +- **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. - **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 @@ -176,7 +170,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 -**Admins and staffing** see all departments. **Coleads** see only their assigned department(s). +**Coordinators and admins** see all departments. **Volunteer leads** see only their assigned department. Actions available: - Create new shifts (+ Add shift button) @@ -188,7 +182,7 @@ Actions available: ## SMTP Configuration -SMTP enables volunteer confirmation emails, kiosk link distribution, and test emails. Configure in **Settings** (admin only): +SMTP enables token email distribution and test emails. Configure in **Settings** (admin only): | Field | Description | |-------|-------------| @@ -209,13 +203,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. +- **Sync** pulls all changes from the server on startup and periodically thereafter. Local changes are queued in an outbox and flushed in order. Install Turnpike as a PWA (Add to Home Screen on mobile, or Install App in desktop Chrome) for the best offline experience. ## CSV Exports -CSV exports are available from the Participants page: +Two CSV exports are available from the Attendees page: -- **Participant export** — all participant records with check-in status -- **Ticket export** — all ticket records with codes and check-in status +- **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. diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 7ad6bc9..7f28a8a 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -9,10 +9,10 @@ import Departments from './pages/Departments.svelte' import Users from './pages/Users.svelte' import Import from './pages/Import.svelte' - import VolunteerKiosk from './pages/VolunteerKiosk.svelte' + import Kiosk from './pages/Kiosk.svelte' import VolunteerSignup from './pages/VolunteerSignup.svelte' import ConfirmEmail from './pages/ConfirmEmail.svelte' - import GateKiosk from './pages/GateKiosk.svelte' + import GateUI from './pages/GateUI.svelte' import ScheduleBoard from './pages/ScheduleBoard.svelte' import Settings from './pages/Settings.svelte' import Nav from './components/Nav.svelte' @@ -96,7 +96,7 @@ {#if loading} {:else if kioskToken} - + {:else if isVolunteerSignup} {:else if isConfirmEmail} @@ -104,8 +104,8 @@ {:else if !session} {:else if role === 'gatekeeper'} - - + + {:else}
diff --git a/frontend/src/api.js b/frontend/src/api.js index 6700d4b..b0767e6 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -79,7 +79,6 @@ 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 0cd0ee8..5727e4d 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -129,10 +129,8 @@ 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-registered { background: rgba(14,165,233,0.15); color: #0ea5e9; } -.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-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); } @@ -208,32 +206,4 @@ 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/components/CheckInButton.svelte b/frontend/src/components/CheckInButton.svelte index f0af073..b3ce533 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..c1ae495 100644 --- a/frontend/src/pages/Dashboard.svelte +++ b/frontend/src/pages/Dashboard.svelte @@ -4,54 +4,13 @@ let { session } = $props() - 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 attendees = liveQuery(() => db.attendees.toArray()) 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) - }) + 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)
@@ -69,113 +28,35 @@

{/if} - {#if isColead && myDeptNames.length > 0} -

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

- {/if} - - - {#if isTicketing} -

Ticket Check-in

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

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

-
-
-
Total
-
{volTotal}
-
-
-
Checked in
-
{volCheckedIn}
-
-
-
Leads
-
{volLeads}
+ {#if total > 0} +
+
+
{/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 c2cf82b..b50fde4 100644 --- a/frontend/src/pages/Departments.svelte +++ b/frontend/src/pages/Departments.svelte @@ -100,7 +100,7 @@ {#if showAdd && canCreate}
-
+
@@ -127,7 +127,7 @@ {#if ($allDepts ?? []).length === 0}
No departments yet -

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

+

Add departments to organize your volunteer teams.

{:else}
@@ -142,8 +142,8 @@ {#each $allDepts ?? [] as d (d.id)} {#if editID === d.id} - - + +
@@ -153,7 +153,7 @@ {#if canCreate} - +
{#if canDelete} @@ -188,12 +188,3 @@
{/if}
- - diff --git a/frontend/src/pages/GateKiosk.svelte b/frontend/src/pages/GateUI.svelte similarity index 83% rename from frontend/src/pages/GateKiosk.svelte rename to frontend/src/pages/GateUI.svelte index d1eeaa1..6c7dc71 100644 --- a/frontend/src/pages/GateKiosk.svelte +++ b/frontend/src/pages/GateUI.svelte @@ -7,8 +7,6 @@ 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) @@ -46,44 +44,22 @@ return ($tickets ?? []).find(t => t.external_id?.toLowerCase() === sl) ?? null }) - 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 + // Name/email 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) || - byTicketName.has(p.id) + p.email?.toLowerCase().includes(s) ) .sort((a, b) => (a.preferred_name || '').localeCompare(b.preferred_name || '')) .slice(0, 8) }) - // Manual selection takes priority; fall back to auto-select on single match + // Auto-select when exactly one participant matches const selectedParticipant = $derived.by(() => { - if (manuallySelectedId) { - return ($participants ?? []).find(p => p.id === manuallySelectedId) ?? null - } if (filteredParticipants.length === 1) return filteredParticipants[0] return null }) @@ -189,9 +165,6 @@ {scanning ? '■ Stop' : '⊡ Scan QR'} {/if} -
{#if scanning} @@ -238,13 +211,7 @@ {:else if selectedParticipant} {@const pts = ticketsFor(selectedParticipant.id)}
-
-
{selectedParticipant.preferred_name || selectedParticipant.email || '(unknown)'}
- {#if manuallySelectedId} - - {/if} -
+
{selectedParticipant.preferred_name || selectedParticipant.email || '(unknown)'}
{#if selectedParticipant.email}
{selectedParticipant.email}
{/if} @@ -276,7 +243,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
@@ -439,8 +385,7 @@ padding: 1.25rem; margin-bottom: 1rem; } - .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-name { font-size: 1.4rem; font-weight: 700; margin-bottom: 0.2rem; } .gate-match-sub { color: var(--c-muted); font-size: 0.875rem; } .gate-party { margin: 0.5rem 0; diff --git a/frontend/src/pages/VolunteerKiosk.svelte b/frontend/src/pages/Kiosk.svelte similarity index 99% rename from frontend/src/pages/VolunteerKiosk.svelte rename to frontend/src/pages/Kiosk.svelte index c9eb58a..983e039 100644 --- a/frontend/src/pages/VolunteerKiosk.svelte +++ b/frontend/src/pages/Kiosk.svelte @@ -149,7 +149,7 @@
{state.volunteer.name}
{state.volunteer.email || ''} - {state.volunteer.is_lead ? ' · Co-Lead' : ''} + {state.volunteer.is_lead ? ' · Department Lead' : ''}
Token: {token}
diff --git a/frontend/src/pages/Participants.svelte b/frontend/src/pages/Participants.svelte index 2867958..2495958 100644 --- a/frontend/src/pages/Participants.svelte +++ b/frontend/src/pages/Participants.svelte @@ -63,7 +63,6 @@ return ($allTickets ?? []).filter(t => t.participant_id === participantId) } - function checkedInCount(participantId) { return ticketsFor(participantId).filter(t => t.checked_in_at).length } @@ -130,16 +129,6 @@ } } - 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' }) @@ -244,7 +233,7 @@ {#if showAdd && canManage}
-
+
@@ -363,14 +352,11 @@ 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} {/if} - {#if p.ticket_name && p.ticket_name !== p.preferred_name} -
Ticket: {p.ticket_name}
- {/if} {#if p.note}
{p.note}
{/if} @@ -401,7 +387,7 @@ {/if} {#if canManage} - + {#if !mergeMode} @@ -438,9 +424,9 @@
{#if tk.checked_in_at} - Checked in {fmtTime(tk.checked_in_at)} + In {fmtTime(tk.checked_in_at)} {:else} - + Pending {/if}
{tk.source}
@@ -508,12 +494,4 @@ .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%; display: flex; justify-content: flex-end; } - .ticket-rows { padding: 0; border: none; border-radius: 0; margin-top: -0.5rem; } - .ticket-rows td { width: 100%; } - .edit-row { padding: 0.75rem; } - .edit-row td { width: 100%; } - } diff --git a/frontend/src/pages/ScheduleBoard.svelte b/frontend/src/pages/ScheduleBoard.svelte index 65391e0..5d9d265 100644 --- a/frontend/src/pages/ScheduleBoard.svelte +++ b/frontend/src/pages/ScheduleBoard.svelte @@ -315,8 +315,8 @@ {#if ($allShifts ?? []).length === 0 && !showAdd}
- No shifts scheduled yet -

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

+ No shifts yet +

Add shifts to schedule your volunteers.

{:else} {#each board as { dept, days }} @@ -397,9 +397,6 @@ {#each assigned as { vs, volunteer }}
{volunteer.name} - {#if volunteer.is_lead} - Co-Lead - {/if} {#if checkConflict(volunteer.id, shift.id, $allVolunteerShifts ?? [], $allShifts ?? [])} {/if} @@ -517,14 +514,6 @@ 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/Settings.svelte b/frontend/src/pages/Settings.svelte index 2f6ee8e..5caf2ee 100644 --- a/frontend/src/pages/Settings.svelte +++ b/frontend/src/pages/Settings.svelte @@ -1,11 +1,9 @@ @@ -129,15 +129,6 @@
-

- 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} @@ -148,7 +139,7 @@ {#if showAdd}
-
+
@@ -196,8 +187,7 @@
Loading…
{:else if users.length === 0}
- No additional users -

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

+ No users yet
{:else}
@@ -213,8 +203,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} @@ -226,12 +164,10 @@ {/each} {/if} - + + + {filtered.length} shown @@ -241,7 +177,7 @@ {#if ($allVolunteers ?? []).length === 0}
No volunteers yet -

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

+

Add volunteers manually.

{:else}
@@ -258,108 +194,54 @@ {#each filtered as v (v.id)} {@const dept = deptFor(v.department_id)} - {#if editID === v.id} - - - {v.name} - {#if v.email}
{v.email}
{/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.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} - - - + {@const participant = participantFor(v.participant_id)} + + + {v.name} + {#if v.is_lead} + Lead {/if} - - {/if} + {#if !v.participant_id} + No ticket + {/if} + {#if v.email} +
{v.email}
+ {/if} + {#if v.note} +
{v.note}
+ {/if} + + + {#if dept} + {dept.name} + {:else} + — + {/if} + + + + {v.checked_in ? 'Checked in' : 'Pending'} + + {#if v.checked_in_at} +
+ {new Date(v.checked_in_at).toLocaleTimeString()} +
+ {/if} + + + {#if !v.checked_in} + checkIn(v)} /> + {/if} + + {#if canManage} + + + + {/if} + {/each}
{/if}
- - diff --git a/handle_kiosk.go b/handle_kiosk.go index 773ccfe..646db43 100644 --- a/handle_kiosk.go +++ b/handle_kiosk.go @@ -6,21 +6,26 @@ import ( "strconv" ) -func (app *App) volunteerFromKioskToken(token string) (*Volunteer, error) { - return app.getVolunteerByKioskCode(token) -} - // handleKioskGet returns the volunteer's profile, current shift assignments, and -// available open shifts in their department. Authenticated by kiosk code only — +// available open shifts in their department. Authenticated by ticket code only — // no JWT required. func (app *App) handleKioskGet(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") - v, err := app.volunteerFromKioskToken(token) - if err != nil || v == nil { + t, err := app.getTicketByCode(token) + if err != nil || t == nil { writeError(w, "not found", http.StatusNotFound) return } + var v *Volunteer + if t.ParticipantID != nil { + v, _ = app.getVolunteerByParticipantID(*t.ParticipantID) + } + if v == nil { + writeError(w, "no volunteer record linked to this token", http.StatusNotFound) + return + } + assigned, _ := app.listShiftsForVolunteer(v.ID) if assigned == nil { assigned = []Shift{} @@ -51,11 +56,19 @@ func (app *App) handleKioskClaim(w http.ResponseWriter, r *http.Request) { return } - v, err := app.volunteerFromKioskToken(token) - if err != nil || v == nil { + t, err := app.getTicketByCode(token) + if err != nil || t == nil { writeError(w, "not found", http.StatusNotFound) return } + var v *Volunteer + if t.ParticipantID != nil { + v, _ = app.getVolunteerByParticipantID(*t.ParticipantID) + } + if v == nil { + writeError(w, "no volunteer linked to this token", http.StatusNotFound) + return + } force := r.URL.Query().Get("force") == "true" @@ -103,11 +116,19 @@ func (app *App) handleKioskUnclaim(w http.ResponseWriter, r *http.Request) { return } - v, err := app.volunteerFromKioskToken(token) - if err != nil || v == nil { + t, err := app.getTicketByCode(token) + if err != nil || t == nil { writeError(w, "not found", http.StatusNotFound) return } + var v *Volunteer + if t.ParticipantID != nil { + v, _ = app.getVolunteerByParticipantID(*t.ParticipantID) + } + if v == nil { + writeError(w, "no volunteer linked to this token", http.StatusNotFound) + return + } if err := app.unassignShift(v.ID, shiftID); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) diff --git a/handle_kiosk_test.go b/handle_kiosk_test.go index e385ad3..1e3c682 100644 --- a/handle_kiosk_test.go +++ b/handle_kiosk_test.go @@ -14,11 +14,14 @@ func setupKiosk(t *testing.T) (*App, *http.ServeMux, string) { dept, _ := app.createDepartment(Department{Name: "Gate"}) deptID := dept.ID - // Create volunteer with a kiosk_code directly on the volunteer record + // Create participant + ticket with code p, _ := app.createParticipant(Participant{Email: "titania@test.com", PreferredName: "Titania"}) - v, _ := app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: &p.ID, DepartmentID: &deptID}) - token, _ := app.generateVolunteerKioskCode() - app.assignKioskCode(v.ID, token) + token, _ := app.generateUniqueToken() + tk, _ := app.createTicket(Ticket{ParticipantID: &p.ID, Name: "Titania", Source: "manual", Code: &token}) + _ = tk + + // Create linked volunteer via participant_id + app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: &p.ID, DepartmentID: &deptID}) // Create shifts app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2}) diff --git a/handle_signup.go b/handle_signup.go index 77a7c63..31c796b 100644 --- a/handle_signup.go +++ b/handle_signup.go @@ -69,19 +69,22 @@ func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) { } // Find or create participant by email. - participant, _, err := app.upsertParticipant(body.Email, body.PreferredName) + name := body.PreferredName + if body.TicketName != "" { + name = body.TicketName + } + participant, _, err := app.upsertParticipant(body.Email, name) if err != nil { writeError(w, "internal error", http.StatusInternalServerError) return } // Update participant's personal details if they signed up with more info. - if body.Phone != "" || body.Pronouns != "" || body.TicketName != "" { + if body.Phone != "" || body.Pronouns != "" { app.db.Exec(`UPDATE participants SET - 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, + phone = CASE WHEN phone = '' THEN ? ELSE phone END, + pronouns = CASE WHEN pronouns = '' THEN ? ELSE pronouns END, updated_at = ? - WHERE id = ?`, body.Phone, body.Pronouns, body.TicketName, now(), participant.ID) + WHERE id = ?`, body.Phone, body.Pronouns, now(), participant.ID) } confirmToken, err := generateConfirmationToken() @@ -94,6 +97,7 @@ 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, @@ -146,19 +150,46 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) { var signupsOpen string app.db.QueryRow(`SELECT value FROM config WHERE key = 'shift_signups_open'`).Scan(&signupsOpen) - if signupsOpen == "true" { - code, err := app.generateVolunteerKioskCode() - if err == nil { - if err := app.assignKioskCode(vol.ID, code); err == nil { - 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 { - log.Printf("shift signup email to %s failed: %v", vol.Email, err) - } - }() + if signupsOpen == "true" && vol.ParticipantID != nil { + // Find a ticket with a code, or create/assign one. + tickets, _ := app.listTickets(vol.ParticipantID, "") + var code *string + for _, tk := range tickets { + if tk.Code != nil { + code = tk.Code + break } } + if code == nil { + // No coded ticket — find any ticket or create a stub, then generate code. + var ticketID int + if len(tickets) > 0 { + ticketID = tickets[0].ID + } else { + stub, err := app.createTicket(Ticket{ + ParticipantID: vol.ParticipantID, + Source: "manual", + }) + if err == nil { + ticketID = stub.ID + } + } + if ticketID > 0 { + if t, err := app.generateUniqueToken(); err == nil { + app.db.Exec(`UPDATE tickets SET code=?, updated_at=? WHERE id=?`, t, now(), ticketID) + code = &t + } + } + } + if code != nil { + 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 { + log.Printf("shift signup email to %s failed: %v", vol.Email, err) + } + }() + } } writeJSON(w, response) @@ -185,28 +216,57 @@ func (app *App) handleToggleShiftSignups(w http.ResponseWriter, r *http.Request) } func (app *App) openShiftSignups() { - // Assign kiosk codes to email-confirmed volunteers that don't have one yet. - vols, _ := app.listVolunteersNeedingKioskCode() + // Generate codes for tickets belonging to confirmed volunteers that have no code yet. + vols, _ := app.listConfirmedVolunteersNeedingCode() for _, v := range vols { - code, err := app.generateVolunteerKioskCode() + if v.ParticipantID == nil { + continue + } + // Find any ticket for this participant, or create a stub one. + tickets, _ := app.listTickets(v.ParticipantID, "") + var ticketID int + if len(tickets) > 0 { + ticketID = tickets[0].ID + } else { + stub, err := app.createTicket(Ticket{ + ParticipantID: v.ParticipantID, + Source: "manual", + }) + if err != nil { + continue + } + ticketID = stub.ID + } + t, err := app.generateUniqueToken() if err != nil { continue } - app.assignKioskCode(v.ID, code) + app.db.Exec(`UPDATE tickets SET code=?, updated_at=? WHERE id=?`, t, now(), ticketID) } - // Email all email-confirmed volunteers that now have a kiosk code. + // Email all confirmed volunteers that now have a ticket with a 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 v.email_confirmed = 1 AND v.deleted_at IS NULL AND v.participant_id IS NOT NULL`) baseURL := app.resolveBaseURL() sent := 0 for _, v := range confirmed { - if v.Email == "" || v.KioskCode == nil { + if v.ParticipantID == nil || v.Email == "" { continue } - kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *v.KioskCode) + tickets, _ := app.listTickets(v.ParticipantID, "") + var code *string + for _, tk := range tickets { + if tk.Code != nil { + code = tk.Code + break + } + } + if code == nil { + continue + } + kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *code) name := v.PreferredName if name == "" { name = v.Name diff --git a/handle_signup_test.go b/handle_signup_test.go index 2b63c16..a9bdab0 100644 --- a/handle_signup_test.go +++ b/handle_signup_test.go @@ -301,86 +301,17 @@ func TestConfirmEmailWithSignupsOpen(t *testing.T) { t.Error("expected kiosk_link when signups are open") } - // Volunteer should now have a kiosk_code, no stub ticket created. - vol, _ := app.getVolunteerByEmail("titania@example.com") - if vol == nil || vol.KioskCode == nil { - t.Error("volunteer should have a kiosk_code after confirm with signups open") - } + // Ticket for participant should now have a code tickets, _ := app.listTickets(&participant.ID, "") - if len(tickets) != 0 { - t.Errorf("expected no stub tickets, got %d", len(tickets)) + hasCode := false + for _, tk := range tickets { + if tk.Code != nil && *tk.Code != "" { + hasCode = true + break + } } -} - -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 p.TicketName != "Titania Fairweather" { - t.Errorf("participant.TicketName = %q, want %q", p.TicketName, "Titania Fairweather") - } -} - -func TestConfirmEmailAssignsKioskCode(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" - - 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, - }) - - 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"]) - } - if result["kiosk_link"] == nil { - t.Error("expected kiosk_link in response when signups are open") - } - - // Kiosk code should be on the volunteer record, not a stub ticket. - vol, _ := app.getVolunteerByEmail("titania@example.com") - if vol == nil || vol.KioskCode == nil { - t.Fatal("expected volunteer to have a kiosk_code") - } - // No stub ticket should have been created. - tickets, _ := app.listTickets(&participant.ID, "") - if len(tickets) != 0 { - t.Errorf("expected no stub tickets, got %d", len(tickets)) + if !hasCode { + t.Error("participant should have a ticket with code after confirm with signups open") } } diff --git a/handle_volunteers.go b/handle_volunteers.go index 584927b..967ff2e 100644 --- a/handle_volunteers.go +++ b/handle_volunteers.go @@ -109,10 +109,6 @@ 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) } @@ -146,20 +142,6 @@ func (app *App) handleCheckInVolunteer(w http.ResponseWriter, r *http.Request) { writeJSON(w, v) } -func (app *App) handleConfirmVolunteer(w http.ResponseWriter, r *http.Request) { - id, err := strconv.Atoi(r.PathValue("id")) - if err != nil { - writeError(w, "invalid id", http.StatusBadRequest) - return - } - v, err := app.confirmVolunteer(id) - if err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - writeJSON(w, v) -} - func (app *App) handleAssignShift(w http.ResponseWriter, r *http.Request) { volunteerID, err := strconv.Atoi(r.PathValue("id")) if err != nil { diff --git a/handle_volunteers_test.go b/handle_volunteers_test.go deleted file mode 100644 index e10815c..0000000 --- a/handle_volunteers_test.go +++ /dev/null @@ -1,141 +0,0 @@ -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") - } -} diff --git a/main.go b/main.go index ea79877..2eedcd0 100644 --- a/main.go +++ b/main.go @@ -126,7 +126,6 @@ func (app *App) registerRoutes(mux *http.ServeMux) { 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}/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"))