Compare commits

..

2 commits

Author SHA1 Message Date
4d3da023fc Added event edit. 2026-03-04 23:06:03 -06:00
a60ef7d25b Updated docs. 2026-03-04 23:02:35 -06:00
4 changed files with 137 additions and 68 deletions

View file

@ -1,21 +1,21 @@
# Turnpike # 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. 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 ## 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 - **Volunteer scheduling** — departments, shifts with capacity, conflict detection, reordering
- **Public volunteer signup** — self-registration form with email confirmation, auto-attendee linking - **Public volunteer signup** — self-registration form with email confirmation, auto-participant linking
- **Volunteer kiosk**token-authenticated self-service shift signup, no login required - **Volunteer kiosk**code-authenticated self-service shift signup, no login required
- **Gate check-in** — full-screen UI with QR scanner, party check-in ("2/3 checked in"), volunteer dual check-in - **Gate check-in** — full-screen UI with QR scanner, volunteer dual check-in
- **Schedule** — create shifts, assign volunteers, manage assignments with conflict awareness - **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 - **Offline-first PWA** — installs on phones/tablets, full offline check-in with background sync
- **Real-time** — check-ins and changes broadcast live via SSE - **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 - **Single binary** — Go backend embeds the frontend; no runtime dependencies
## Tech Stack ## Tech Stack
@ -60,10 +60,11 @@ See [docs/INSTALLATION.md](docs/INSTALLATION.md) for systemd, Docker, NixOS, and
| Role | Access | | Role | Access |
|------|--------| |------|--------|
| `admin` | Full access: attendee import, user management, SMTP settings, all departments and shifts | | `admin` | Full access: participant import, user management, SMTP settings, all departments and shifts |
| `coordinator` | All departments: volunteers, shifts, schedule. No user management or settings | | `ticketing` | Participants, tickets, import. No user management |
| `volunteer_lead` | Own department only: volunteers and shifts scoped to assigned department | | `staffing` | All departments: volunteers, shifts, schedule. No user management or settings |
| `gate` | Full-screen check-in UI with QR scanner. No access to other pages | | `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. 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 ## 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 - [Installation Guide](docs/INSTALLATION.md) — building, deploying, systemd, Docker, NixOS, reverse proxy, backup
## License ## License

View file

@ -105,23 +105,27 @@ docker run -p 8180:8180 \
## NixOS ## 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 ```nix
frontendDist = pkgs.buildNpmPackage {
pname = "turnpike-frontend";
src = "${src}/frontend";
npmDepsHash = "sha256-...";
buildPhase = "npm run build";
installPhase = "cp -r dist $out";
};
turnpike = pkgs.buildGoModule { turnpike = pkgs.buildGoModule {
pname = "turnpike"; pname = "turnpike";
version = "0.1.0"; src = fetchgit { url = "..."; rev = "v2.0.0"; hash = "sha256-..."; };
src = ./path/to/turnpike; # must include vendor/ and frontend/dist/ vendorHash = "sha256-...";
vendorHash = null;
env.CGO_ENABLED = 0; env.CGO_ENABLED = 0;
preBuild = "cp -r ${frontendDist} frontend/dist";
}; };
``` ```
The source directory must contain: A complete NixOS module with `DynamicUser`, `StateDirectory`, and secrets is in the project's `homelab/turnpike.nix`.
- 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 ## Reverse Proxy

View file

@ -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 | | Role | What they see | What they can do |
|------|--------------|------------------| |------|--------------|------------------|
| **admin** | All pages + Settings | Everything: attendee import, user management, SMTP config, departments, shifts, volunteers | | **admin** | All pages + Settings | Everything: participant 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 | | **ticketing** | Participants, Tickets, Import | Manage participants and tickets, run CSV imports |
| **volunteer_lead** | Schedule, Volunteers, Departments | Manage volunteers and shifts within their assigned department only | | **staffing** | Dashboard, Schedule, Volunteers, Departments | Manage volunteers, departments, and shifts across all departments. No user management or settings |
| **gate** | Full-screen Gate UI | Check in attendees (search + QR scan). No access to other pages | | **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. Coleads are scoped to one or more departments. When creating a colead user, assign their department(s).
Volunteer leads are scoped to a single department. When creating a volunteer_lead user, assign their department.
## Event Setup ## Event Setup
1. **Configure your event** — go to the Dashboard and set the event name and dates. 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). 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. 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: 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 | | Column | Maps to |
|--------|---------| |--------|---------|
| `Patron Name` | Name | | `Patron Name` | Ticket name |
| `Patron Email` | Email | | `Patron Email` | Email |
| `Order Number` | Ticket ID | | `Order Number` | Ticket ID |
| `Tier Name` | Ticket type | | `Tier Name` | Ticket type |
@ -45,7 +44,7 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats:
| Column | Maps to | | Column | Maps to |
|--------|---------| |--------|---------|
| `name` (required) | Name | | `name` (required) | Ticket name |
| `email` | Email | | `email` | Email |
| `ticket_id` | Ticket ID | | `ticket_id` | Ticket ID |
| `ticket_type` | Ticket type | | `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. 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` Re-importing the same CSV is safe — exact duplicates are skipped, not duplicated.
- 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 ## 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 ### 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. 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. 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}`). 3. A confirmation email is sent with a unique link (`/confirm/{token}`).
4. The volunteer clicks the link to confirm their email. 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. 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: 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. - **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. 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: Under **Volunteers**, you can:
- Create volunteers manually (name, email, department) - 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 - Assign volunteers to departments
- Mark volunteers as co-leads
- Check in volunteers - 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 ## Shift Scheduling
@ -128,15 +121,17 @@ The kiosk lets volunteers self-select shifts without logging in.
### Setup ### 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. Kiosk links are generated and distributed automatically through the volunteer signup flow:
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. 1. Volunteers sign up via the public signup form (`/volunteer-signup`) and confirm their email.
- **Email directly** — if SMTP is configured (see below), use "Email All" to send token links, or email individually per attendee. 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. **Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Token links use this URL. 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 ### 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 - Their name and department
- Currently assigned shifts - 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. 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 ## 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. - **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). - **Search** — type a name to filter tickets 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 a ticket holder is also a volunteer, the gate UI shows their volunteer status and offers to check in both simultaneously.
- **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. - **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. 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 - Each shift card shows: name, time, capacity (used/total), assigned volunteers
- Conflict badges when a volunteer has overlapping shifts on the same day - 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: Actions available:
- Create new shifts (+ Add shift button) - Create new shifts (+ Add shift button)
@ -182,7 +176,7 @@ Actions available:
## SMTP Configuration ## 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 | | 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. - **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. - **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. 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
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 - **Participant export** — all participant 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. - **Ticket export** — all ticket records with codes and check-in status

View file

@ -1,9 +1,11 @@
<script> <script>
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { api } from '../api.js' import { api } from '../api.js'
import { db } from '../db.js'
let loading = $state(true) let loading = $state(true)
let saving = $state(false) let saving = $state(false)
let savingEvent = $state(false)
let testing = $state(false) let testing = $state(false)
let error = $state('') let error = $state('')
let success = $state('') let success = $state('')
@ -18,10 +20,23 @@
let testEmail = $state('') let testEmail = $state('')
let noteLabel = $state('Additional note') let noteLabel = $state('Additional note')
let noteRequired = $state(false) let noteRequired = $state(false)
let eventName = $state('')
let eventVenue = $state('')
let eventStartDate = $state('')
let eventEndDate = $state('')
let eventTimezone = $state('')
let shiftSignupsOpen = $state(false) let shiftSignupsOpen = $state(false)
let togglingSignups = $state(false) let togglingSignups = $state(false)
onMount(async () => { onMount(async () => {
try {
const ev = await api.event.get()
eventName = ev.name ?? ''
eventVenue = ev.venue ?? ''
eventStartDate = ev.start_date ?? ''
eventEndDate = ev.end_date ?? ''
eventTimezone = ev.timezone ?? ''
} catch {}
try { try {
const s = await api.settings.get() const s = await api.settings.get()
smtpHost = s.smtp_host ?? '' smtpHost = s.smtp_host ?? ''
@ -41,6 +56,28 @@
} }
}) })
async function saveEvent(e) {
e.preventDefault()
savingEvent = true
error = ''
success = ''
try {
const updated = await api.event.update({
name: eventName,
venue: eventVenue,
start_date: eventStartDate,
end_date: eventEndDate,
timezone: eventTimezone,
})
await db.event.put({ ...updated, id: 1 })
success = 'Event saved.'
} catch (err) {
error = err.message
} finally {
savingEvent = false
}
}
async function save(e) { async function save(e) {
e.preventDefault() e.preventDefault()
saving = true saving = true
@ -127,6 +164,39 @@
{#if loading} {#if loading}
<div class="text-muted">Loading…</div> <div class="text-muted">Loading…</div>
{:else} {:else}
<form onsubmit={saveEvent}>
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Event</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<div class="form-group" style="grid-column:1/-1">
<label for="e-name">Event Name *</label>
<input id="e-name" bind:value={eventName} required placeholder="My Event 2026" />
</div>
<div class="form-group" style="grid-column:1/-1">
<label for="e-venue">Venue</label>
<input id="e-venue" bind:value={eventVenue} placeholder="Location name" />
</div>
<div class="form-group">
<label for="e-start">Start Date *</label>
<input id="e-start" type="date" bind:value={eventStartDate} required />
</div>
<div class="form-group">
<label for="e-end">End Date *</label>
<input id="e-end" type="date" bind:value={eventEndDate} required />
</div>
<div class="form-group" style="grid-column:1/-1">
<label for="e-tz">Timezone</label>
<input id="e-tz" bind:value={eventTimezone} placeholder="America/Los_Angeles" />
</div>
</div>
<div class="actions">
<button type="submit" class="btn btn-primary" disabled={savingEvent}>
{savingEvent ? 'Saving…' : 'Save Event'}
</button>
</div>
</div>
</form>
<form onsubmit={save}> <form onsubmit={save}>
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">SMTP Email</h2> <h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">SMTP Email</h2>
@ -160,7 +230,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="s-url">Base URL <span class="text-muted" style="font-weight:400">(for volunteer token links)</span></label> <label for="s-url">Base URL <span class="text-muted" style="font-weight:400">(for kiosk links in emails)</span></label>
<input id="s-url" bind:value={baseURL} placeholder="https://events.example.com" /> <input id="s-url" bind:value={baseURL} placeholder="https://events.example.com" />
</div> </div>