Compare commits

..

No commits in common. "2ff06bdb768f7bdb860c594d66e313a405b19eae" and "260e017f7964ece5f090c65f361ec0b9f4618d16" have entirely different histories.

24 changed files with 345 additions and 1028 deletions

View file

@ -1,21 +1,21 @@
# Turnpike # 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. 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
- **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 - **Volunteer scheduling** — departments, shifts with capacity, conflict detection, reordering
- **Public volunteer signup** — self-registration form with email confirmation, auto-participant linking - **Public volunteer signup** — self-registration form with email confirmation, auto-attendee linking
- **Volunteer kiosk**public volunteer flow: signup, email confirmation, code-authenticated shift self-scheduling - **Volunteer kiosk**token-authenticated self-service shift signup, no login required
- **Gate kiosk** — full-screen check-in UI with QR scanner for gatekeepers - **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 - **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 - **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**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 - **Single binary** — Go backend embeds the frontend; no runtime dependencies
## Tech Stack ## Tech Stack
@ -60,11 +60,10 @@ See [docs/INSTALLATION.md](docs/INSTALLATION.md) for systemd, Docker, NixOS, and
| Role | Access | | Role | Access |
|------|--------| |------|--------|
| `admin` | Full access: participant import, user management, SMTP settings, all departments and shifts | | `admin` | Full access: attendee import, user management, SMTP settings, all departments and shifts |
| `ticketing` | Participants, tickets, import. No user management | | `coordinator` | All departments: volunteers, shifts, schedule. No user management or settings |
| `staffing` | All departments: volunteers, shifts, schedule. No user management or settings | | `volunteer_lead` | Own department only: volunteers and shifts scoped to assigned department |
| `colead` | Own department only: volunteers and shifts scoped to assigned department(s) | | `gate` | 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. 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 ## 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 - [Installation Guide](docs/INSTALLATION.md) — building, deploying, systemd, Docker, NixOS, reverse proxy, backup
## License ## License

110
db.go
View file

@ -174,23 +174,6 @@ func migrateV2(db *sql.DB) error {
addColumnIfMissing(db, "volunteers", "pronouns TEXT NOT NULL DEFAULT ''") addColumnIfMissing(db, "volunteers", "pronouns TEXT NOT NULL DEFAULT ''")
addColumnIfMissing(db, "volunteers", "email_confirmed INTEGER NOT NULL DEFAULT 0") addColumnIfMissing(db, "volunteers", "email_confirmed INTEGER NOT NULL DEFAULT 0")
addColumnIfMissing(db, "volunteers", "confirmation_token TEXT") 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). // Widen the uniqueness constraint from name-only to (name, ticket_id).
db.Exec(`DROP INDEX IF EXISTS idx_attendees_name`) 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`) 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. // and links volunteers to participants via participant_id.
func migrateV3(db *sql.DB) error { func migrateV3(db *sql.DB) error {
addColumnIfMissing(db, "volunteers", "participant_id INTEGER REFERENCES participants(id)") 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). // Seed participants from volunteers first (better name data: preferred_name).
db.Exec(` db.Exec(`
@ -410,11 +392,8 @@ type Volunteer struct {
IsLead bool `json:"is_lead"` IsLead bool `json:"is_lead"`
CheckedIn bool `json:"checked_in"` CheckedIn bool `json:"checked_in"`
CheckedInAt *string `json:"checked_in_at,omitempty"` CheckedInAt *string `json:"checked_in_at,omitempty"`
Confirmed bool `json:"confirmed"`
ConfirmedAt *string `json:"confirmed_at,omitempty"`
EmailConfirmed bool `json:"email_confirmed"` EmailConfirmed bool `json:"email_confirmed"`
ConfirmationToken *string `json:"-"` ConfirmationToken *string `json:"-"`
KioskCode *string `json:"kiosk_code,omitempty"`
Note string `json:"note"` Note string `json:"note"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
@ -425,7 +404,6 @@ type Participant struct {
ID int `json:"id"` ID int `json:"id"`
Email string `json:"email"` Email string `json:"email"`
PreferredName string `json:"preferred_name"` PreferredName string `json:"preferred_name"`
TicketName string `json:"ticket_name"`
Phone string `json:"phone"` Phone string `json:"phone"`
Pronouns string `json:"pronouns"` Pronouns string `json:"pronouns"`
Note string `json:"note"` Note string `json:"note"`
@ -862,7 +840,7 @@ func (app *App) attendeeCounts() (total, checkedIn int, err error) {
// --- Participants --- // --- 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) { func (app *App) listParticipants(search, since string) ([]Participant, error) {
var q string var q string
@ -902,8 +880,8 @@ func (app *App) getParticipantByEmail(email string) (*Participant, error) {
func (app *App) createParticipant(p Participant) (*Participant, error) { func (app *App) createParticipant(p Participant) (*Participant, error) {
res, err := app.db.Exec( res, err := app.db.Exec(
`INSERT INTO participants (email, preferred_name, ticket_name, phone, pronouns, note, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, `INSERT INTO participants (email, preferred_name, phone, pronouns, note, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
strings.ToLower(p.Email), p.PreferredName, p.TicketName, p.Phone, p.Pronouns, p.Note, now(), strings.ToLower(p.Email), p.PreferredName, p.Phone, p.Pronouns, p.Note, now(),
) )
if err != nil { if err != nil {
return nil, err return nil, err
@ -914,9 +892,9 @@ func (app *App) createParticipant(p Participant) (*Participant, error) {
func (app *App) updateParticipant(p Participant) error { func (app *App) updateParticipant(p Participant) error {
_, err := app.db.Exec( _, 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`, 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 return err
} }
@ -959,7 +937,7 @@ func queryParticipants(db *sql.DB, q string, args ...any) ([]Participant, error)
for rows.Next() { for rows.Next() {
var p Participant var p Participant
if err := rows.Scan( 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, &p.CreatedAt, &p.UpdatedAt, &p.DeletedAt,
); err != nil { ); err != nil {
return nil, err 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.phone,''), v.phone),
COALESCE(NULLIF(p.pronouns,''), v.pronouns), COALESCE(NULLIF(p.pronouns,''), v.pronouns),
v.department_id, v.is_lead, v.checked_in, v.checked_in_at, v.department_id, v.is_lead, v.checked_in, v.checked_in_at,
v.confirmed, v.confirmed_at, v.email_confirmed, v.confirmation_token, v.note,
v.email_confirmed, v.confirmation_token, v.kiosk_code, v.note,
v.created_at, v.updated_at, v.deleted_at` v.created_at, v.updated_at, v.deleted_at`
const volunteerFrom = `FROM volunteers v LEFT JOIN participants p ON p.id = v.participant_id` 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 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) { func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
rows, err := db.Query(q, args...) rows, err := db.Query(q, args...)
if err != nil { if err != nil {
@ -1339,14 +1303,13 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
for rows.Next() { for rows.Next() {
var v Volunteer var v Volunteer
var participantID, attendeeID, deptID sql.NullInt64 var participantID, attendeeID, deptID sql.NullInt64
var isLead, checkedIn, confirmed, emailConfirmed int var isLead, checkedIn, emailConfirmed int
var confirmationToken, confirmedAt, kioskCode sql.NullString var confirmationToken sql.NullString
if err := rows.Scan( if err := rows.Scan(
&v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName, &v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName,
&v.Email, &v.Phone, &v.Pronouns, &deptID, &v.Email, &v.Phone, &v.Pronouns, &deptID,
&isLead, &checkedIn, &v.CheckedInAt, &isLead, &checkedIn, &v.CheckedInAt,
&confirmed, &confirmedAt, &emailConfirmed, &confirmationToken, &v.Note,
&emailConfirmed, &confirmationToken, &kioskCode, &v.Note,
&v.CreatedAt, &v.UpdatedAt, &v.DeletedAt, &v.CreatedAt, &v.UpdatedAt, &v.DeletedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
@ -1366,15 +1329,8 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
if confirmationToken.Valid { if confirmationToken.Valid {
v.ConfirmationToken = &confirmationToken.String v.ConfirmationToken = &confirmationToken.String
} }
if confirmedAt.Valid {
v.ConfirmedAt = &confirmedAt.String
}
if kioskCode.Valid {
v.KioskCode = &kioskCode.String
}
v.IsLead = isLead == 1 v.IsLead = isLead == 1
v.CheckedIn = checkedIn == 1 v.CheckedIn = checkedIn == 1
v.Confirmed = confirmed == 1
v.EmailConfirmed = emailConfirmed == 1 v.EmailConfirmed = emailConfirmed == 1
result = append(result, v) result = append(result, v)
} }
@ -1406,43 +1362,19 @@ func (app *App) confirmVolunteerEmail(id int) error {
return err return err
} }
func (app *App) getVolunteerByKioskCode(code string) (*Volunteer, error) { // listConfirmedVolunteersNeedingCode returns confirmed volunteers whose participant
rows, err := queryVolunteers(app.db, // has no ticket with a code yet.
`SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.kiosk_code = ? AND v.deleted_at IS NULL LIMIT 1`, code) func (app *App) listConfirmedVolunteersNeedingCode() ([]Volunteer, error) {
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) {
return queryVolunteers(app.db, ` return queryVolunteers(app.db, `
SELECT `+volunteerSelect+` `+volunteerFrom+` SELECT `+volunteerSelect+` `+volunteerFrom+`
WHERE v.email_confirmed = 1 AND v.kiosk_code IS 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
AND NOT EXISTS (
func (app *App) generateVolunteerKioskCode() (string, error) { SELECT 1 FROM tickets t
for range 10 { WHERE t.participant_id = v.participant_id
t, err := generateToken() AND t.code IS NOT NULL
if err != nil { AND t.deleted_at IS NULL
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")
} }
func generateConfirmationToken() (string, error) { func generateConfirmationToken() (string, error) {

View file

@ -105,27 +105,23 @@ docker run -p 8180:8180 \
## NixOS ## 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 ```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";
src = fetchgit { url = "..."; rev = "v2.0.0"; hash = "sha256-..."; }; version = "0.1.0";
vendorHash = "sha256-..."; src = ./path/to/turnpike; # must include vendor/ and frontend/dist/
vendorHash = null;
env.CGO_ENABLED = 0; 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 ## Reverse Proxy

View file

@ -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 | | Role | What they see | What they can do |
|------|--------------|------------------| |------|--------------|------------------|
| **admin** | All pages + Settings | Everything: participant import, user management, SMTP config, departments, shifts, volunteers | | **admin** | All pages + Settings | Everything: attendee import, user management, SMTP config, departments, shifts, volunteers |
| **ticketing** | Participants, Tickets, Import | Manage participants and tickets, run CSV imports | | **coordinator** | Dashboard, Schedule, Volunteers, Departments | Manage volunteers, departments, and shifts across all departments. Cannot manage users or settings |
| **staffing** | Dashboard, Schedule, Volunteers, Departments | Manage volunteers, departments, and shifts across all departments. No user management or settings | | **volunteer_lead** | Schedule, Volunteers, Departments | Manage volunteers and shifts within their assigned department only |
| **colead** | Dashboard, Schedule, Volunteers | Manage volunteers and shifts within their assigned department(s) only | | **gate** | Full-screen Gate UI | Check in attendees (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). 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 ## 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). 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. 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: 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 | | Column | Maps to |
|--------|---------| |--------|---------|
| `Patron Name` | Ticket name | | `Patron Name` | Name |
| `Patron Email` | Email | | `Patron Email` | Email |
| `Order Number` | Ticket ID | | `Order Number` | Ticket ID |
| `Tier Name` | Ticket type | | `Tier Name` | Ticket type |
@ -44,7 +45,7 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats:
| Column | Maps to | | Column | Maps to |
|--------|---------| |--------|---------|
| `name` (required) | Ticket name | | `name` (required) | Name |
| `email` | Email | | `email` | Email |
| `ticket_id` | Ticket ID | | `ticket_id` | Ticket ID |
| `ticket_type` | Ticket type | | `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. 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 ## 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 participant by email match, or creates a new participant record. 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}`). 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.
@ -83,7 +90,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 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. - **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.
@ -92,23 +99,12 @@ 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, co-lead, note) - Create volunteers manually (name, email, department)
- Edit existing volunteers (department, co-lead, note) via the inline Edit button - Link a volunteer to an existing attendee record (for dual check-in at the gate)
- Confirm registered volunteers (admin, staffing, colead) - Assign volunteers to departments
- Mark volunteers as ready (briefed at the volunteer station) - Check in volunteers
### Volunteer statuses 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.
| 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.
## Shift Scheduling ## Shift Scheduling
@ -128,21 +124,19 @@ Shifts can be reordered within a department to reflect priority or sequence usin
## Volunteer Kiosk ## 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 ### Setup
Kiosk links are generated and distributed automatically through the volunteer signup flow: 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:
1. Volunteers sign up via the public signup form (`/volunteer-signup`) and confirm their email. - **Export CSV** — downloads a file with columns `Email Address`, `First Name`, `Token`, `Signup Link`. Import this into MailChimp, Zeffy, or any email platform.
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. - **Email directly** — if SMTP is configured (see below), use "Email All" to send token links, or email individually per attendee.
3. If a volunteer confirms their email while signups are already open, they receive their kiosk link immediately. 3. **Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Token links use this URL.
**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
@ -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. 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. - **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. - **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. 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 ## 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 - 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
**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: Actions available:
- Create new shifts (+ Add shift button) - Create new shifts (+ Add shift button)
@ -188,7 +182,7 @@ Actions available:
## SMTP Configuration ## 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 | | 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. - **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. - **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. 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
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 - **Attendee export** — all attendee records with check-in status
- **Ticket export** — all ticket records with codes and 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.

View file

@ -9,10 +9,10 @@
import Departments from './pages/Departments.svelte' import Departments from './pages/Departments.svelte'
import Users from './pages/Users.svelte' import Users from './pages/Users.svelte'
import Import from './pages/Import.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 VolunteerSignup from './pages/VolunteerSignup.svelte'
import ConfirmEmail from './pages/ConfirmEmail.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 ScheduleBoard from './pages/ScheduleBoard.svelte'
import Settings from './pages/Settings.svelte' import Settings from './pages/Settings.svelte'
import Nav from './components/Nav.svelte' import Nav from './components/Nav.svelte'
@ -96,7 +96,7 @@
{#if loading} {#if loading}
<!-- checking session --> <!-- checking session -->
{:else if kioskToken} {:else if kioskToken}
<VolunteerKiosk /> <Kiosk />
{:else if isVolunteerSignup} {:else if isVolunteerSignup}
<VolunteerSignup /> <VolunteerSignup />
{:else if isConfirmEmail} {:else if isConfirmEmail}
@ -104,8 +104,8 @@
{:else if !session} {:else if !session}
<Login onlogin={onLogin} /> <Login onlogin={onLogin} />
{:else if role === 'gatekeeper'} {:else if role === 'gatekeeper'}
<!-- Gate users get the full-screen GateKiosk instead of the standard layout --> <!-- Gate users get the full-screen GateUI instead of the standard layout -->
<GateKiosk {session} {onLogout} /> <GateUI {session} {onLogout} />
{:else} {:else}
<div class="layout"> <div class="layout">
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->

View file

@ -79,7 +79,6 @@ export const api = {
update: (id, data) => apiJSON(`/api/volunteers/${id}`, { method: 'PUT', body: JSON.stringify(data) }), update: (id, data) => apiJSON(`/api/volunteers/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id) => apiFetch(`/api/volunteers/${id}`, { method: 'DELETE' }), delete: (id) => apiFetch(`/api/volunteers/${id}`, { method: 'DELETE' }),
checkIn: (id) => apiJSON(`/api/volunteers/${id}/checkin`, { method: 'POST' }), 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 }) }), 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' }), unassignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts/${shiftId}`, { method: 'DELETE' }),
}, },

View file

@ -129,10 +129,8 @@ tr:hover td { background: rgba(255,255,255,0.02); }
font-size: 0.72rem; font-weight: 600; font-size: 0.72rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.04em; text-transform: uppercase; letter-spacing: 0.04em;
} }
.badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); } .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-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-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-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); } .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; } .page { padding: 1rem; }
.stats { grid-template-columns: repeat(2, 1fr); } .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; }
} }

View file

@ -9,5 +9,5 @@
</script> </script>
<button class="btn btn-success btn-sm" onclick={handle} disabled={loading}> <button class="btn btn-success btn-sm" onclick={handle} disabled={loading}>
{loading ? '…' : '✓ Ready'} {loading ? '…' : '✓ Check in'}
</button> </button>

View file

@ -4,54 +4,13 @@
let { session } = $props() let { session } = $props()
const role = $derived(session?.user?.role ?? '') const attendees = liveQuery(() => db.attendees.toArray())
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 event = liveQuery(() => db.event.get(1)) 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 total = $derived(($attendees ?? []).length)
const tickets = $derived($allTickets ?? []) const checkedIn = $derived(($attendees ?? []).filter(a => a.checked_in).length)
const ticketTotal = $derived(tickets.length) const remaining = $derived(total - checkedIn)
const ticketCheckedIn = $derived(tickets.filter(t => t.checked_in_at).length) const pct = $derived(total > 0 ? Math.round((checkedIn / total) * 100) : 0)
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)
})
</script> </script>
<div class="page"> <div class="page">
@ -69,113 +28,35 @@
</p> </p>
{/if} {/if}
{#if isColead && myDeptNames.length > 0} <div class="stats">
<p style="margin-bottom:1.5rem;font-size:0.9rem"> <div class="stat">
Your department{myDeptNames.length > 1 ? 's' : ''}: <div class="stat-label">Total</div>
<strong>{myDeptNames.join(', ')}</strong> <div class="stat-value">{total}</div>
</p>
{/if}
<!-- Ticket check-in (admin/ticketing) -->
{#if isTicketing}
<h2 class="dash-section">Ticket Check-in</h2>
<div class="stats">
<div class="stat">
<div class="stat-label">Total tickets</div>
<div class="stat-value">{ticketTotal}</div>
</div>
<div class="stat">
<div class="stat-label">Checked in</div>
<div class="stat-value" style="color:var(--c-success)">{ticketCheckedIn}</div>
</div>
<div class="stat">
<div class="stat-label">Remaining</div>
<div class="stat-value">{ticketRemaining}</div>
</div>
<div class="stat">
<div class="stat-label">Progress</div>
<div class="stat-value">{ticketPct}%</div>
</div>
</div> </div>
<div class="stat">
<div class="stat-label">Checked in</div>
<div class="stat-value" style="color:var(--c-success)">{checkedIn}</div>
</div>
<div class="stat">
<div class="stat-label">Remaining</div>
<div class="stat-value">{remaining}</div>
</div>
<div class="stat">
<div class="stat-label">Progress</div>
<div class="stat-value">{pct}%</div>
</div>
</div>
{#if ticketTotal > 0} {#if total > 0}
<div style="height:8px;background:var(--c-border);border-radius:99px;overflow:hidden;margin-bottom:2rem"> <div class="card" style="margin-bottom:1rem">
<div style="height:100%;width:{ticketPct}%;background:var(--c-success);border-radius:99px;transition:width 0.4s ease"></div> <div style="height:8px;background:var(--c-border);border-radius:99px;overflow:hidden">
</div> <div style="height:100%;width:{pct}%;background:var(--c-success);border-radius:99px;transition:width 0.4s ease"></div>
{/if}
{/if}
<!-- Volunteer stats (admin/ticketing/staffing/colead) -->
{#if isStaffing || isColead}
<h2 class="dash-section">{isColead ? 'My Volunteers' : 'Volunteers'}</h2>
<div class="stats">
<div class="stat">
<div class="stat-label">Total</div>
<div class="stat-value">{volTotal}</div>
</div>
<div class="stat">
<div class="stat-label">Checked in</div>
<div class="stat-value" style="color:var(--c-success)">{volCheckedIn}</div>
</div>
<div class="stat">
<div class="stat-label">Leads</div>
<div class="stat-value">{volLeads}</div>
</div> </div>
</div> </div>
{/if} {/if}
<!-- Shift coverage (admin/ticketing/staffing/colead) --> <p class="text-muted" style="font-size:0.85rem">
{#if isStaffing || isColead}
<h2 class="dash-section">{isColead ? 'My Shifts' : 'Shift Coverage'}</h2>
<div class="stats">
<div class="stat">
<div class="stat-label">Total shifts</div>
<div class="stat-value">{shiftTotal}</div>
</div>
<div class="stat">
<div class="stat-label">With volunteers</div>
<div class="stat-value">{shiftsFilled}</div>
</div>
<div class="stat">
<div class="stat-label">Fill rate</div>
<div class="stat-value">{shiftFillPct}%</div>
</div>
</div>
{/if}
<!-- Quick actions -->
{#if isTicketing}
<div class="dash-actions">
<a href="/import" class="btn btn-ghost btn-sm">Import CSV</a>
<a href="/participants" class="btn btn-ghost btn-sm">Manage Participants</a>
<a href="/settings" class="btn btn-ghost btn-sm">Settings</a>
</div>
{:else if isStaffing || isColead}
<div class="dash-actions">
<a href="/schedule" class="btn btn-ghost btn-sm">View Schedule</a>
<a href="/volunteers" class="btn btn-ghost btn-sm">Manage Volunteers</a>
</div>
{/if}
<p class="text-muted" style="font-size:0.85rem;margin-top:2rem">
Welcome, <strong style="color:var(--c-text)">{session?.user?.username}</strong> Welcome, <strong style="color:var(--c-text)">{session?.user?.username}</strong>
· <span class="badge badge-role">{session?.user?.role}</span> · <span class="badge badge-role">{session?.user?.role}</span>
</p> </p>
</div> </div>
<style>
.dash-section {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--c-muted);
margin-bottom: 0.75rem;
}
.dash-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
</style>

View file

@ -100,7 +100,7 @@
{#if showAdd && canCreate} {#if showAdd && canCreate}
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<form onsubmit={addDept}> <form onsubmit={addDept}>
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr auto;gap:1rem;align-items:end"> <div style="display:grid;grid-template-columns:1fr 1fr auto;gap:1rem;align-items:end">
<div class="form-group" style="margin-bottom:0"> <div class="form-group" style="margin-bottom:0">
<label for="d-name">Name *</label> <label for="d-name">Name *</label>
<input id="d-name" bind:value={newName} required placeholder="e.g. Security" /> <input id="d-name" bind:value={newName} required placeholder="e.g. Security" />
@ -127,7 +127,7 @@
{#if ($allDepts ?? []).length === 0} {#if ($allDepts ?? []).length === 0}
<div class="empty"> <div class="empty">
<strong>No departments yet</strong> <strong>No departments yet</strong>
<p>Create departments to organize shifts and volunteer teams. Coleads are assigned to specific departments.</p> <p>Add departments to organize your volunteer teams.</p>
</div> </div>
{:else} {:else}
<div class="table-wrap"> <div class="table-wrap">
@ -142,8 +142,8 @@
<tbody> <tbody>
{#each $allDepts ?? [] as d (d.id)} {#each $allDepts ?? [] as d (d.id)}
{#if editID === d.id} {#if editID === d.id}
<tr class="edit-row"> <tr>
<td class="td-name"> <td>
<div style="display:flex;align-items:center;gap:0.5rem"> <div style="display:flex;align-items:center;gap:0.5rem">
<input type="color" bind:value={editColor} style="width:36px;height:36px;padding:0.1rem;border-radius:4px;cursor:pointer;flex-shrink:0" /> <input type="color" bind:value={editColor} style="width:36px;height:36px;padding:0.1rem;border-radius:4px;cursor:pointer;flex-shrink:0" />
<input bind:value={editName} required placeholder="Name" style="margin:0" /> <input bind:value={editName} required placeholder="Name" style="margin:0" />
@ -153,7 +153,7 @@
<input bind:value={editDesc} placeholder="Description" style="margin:0" /> <input bind:value={editDesc} placeholder="Description" style="margin:0" />
</td> </td>
{#if canCreate} {#if canCreate}
<td class="td-actions"> <td>
<div class="actions"> <div class="actions">
<button class="btn btn-primary btn-sm" onclick={() => saveDept(d)} disabled={saving}> <button class="btn btn-primary btn-sm" onclick={() => saveDept(d)} disabled={saving}>
{saving ? '…' : 'Save'} {saving ? '…' : 'Save'}
@ -165,13 +165,13 @@
</tr> </tr>
{:else} {:else}
<tr> <tr>
<td class="td-name"> <td>
<span class="dept-dot" style="background:{d.color};margin-right:0.5rem"></span> <span class="dept-dot" style="background:{d.color};margin-right:0.5rem"></span>
<strong>{d.name}</strong> <strong>{d.name}</strong>
</td> </td>
<td class="td-desc text-muted">{d.description || '—'}</td> <td class="text-muted">{d.description || '—'}</td>
{#if canCreate} {#if canCreate}
<td class="td-actions"> <td>
<div class="actions"> <div class="actions">
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(d)}>Edit</button> <button class="btn btn-ghost btn-sm" onclick={() => startEdit(d)}>Edit</button>
{#if canDelete} {#if canDelete}
@ -188,12 +188,3 @@
</div> </div>
{/if} {/if}
</div> </div>
<style>
@media (max-width: 640px) {
.td-name { width: 100%; }
.td-desc { width: 100%; }
.td-actions { width: 100%; display: flex; justify-content: flex-end; }
.edit-row td { width: 100%; }
}
</style>

View file

@ -7,8 +7,6 @@
let { session, onLogout } = $props() let { session, onLogout } = $props()
let search = $state('') let search = $state('')
let manuallySelectedId = $state(null)
let showAll = $state(false)
let error = $state('') let error = $state('')
let scannerMsg = $state('') let scannerMsg = $state('')
let qrSupported = $state(false) let qrSupported = $state(false)
@ -46,44 +44,22 @@
return ($tickets ?? []).find(t => t.external_id?.toLowerCase() === sl) ?? null return ($tickets ?? []).find(t => t.external_id?.toLowerCase() === sl) ?? null
}) })
const allParticipantsSorted = $derived.by(() => // Name/email search across participants
($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(() => { const filteredParticipants = $derived.by(() => {
if (matchedTicket) return [] if (matchedTicket) return []
const s = search.trim().toLowerCase() const s = search.trim().toLowerCase()
if (!s || s.length < 2) return [] 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 ?? []) return ($participants ?? [])
.filter(p => .filter(p =>
p.preferred_name?.toLowerCase().includes(s) || 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 || '')) .sort((a, b) => (a.preferred_name || '').localeCompare(b.preferred_name || ''))
.slice(0, 8) .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(() => { const selectedParticipant = $derived.by(() => {
if (manuallySelectedId) {
return ($participants ?? []).find(p => p.id === manuallySelectedId) ?? null
}
if (filteredParticipants.length === 1) return filteredParticipants[0] if (filteredParticipants.length === 1) return filteredParticipants[0]
return null return null
}) })
@ -189,9 +165,6 @@
{scanning ? '■ Stop' : '⊡ Scan QR'} {scanning ? '■ Stop' : '⊡ Scan QR'}
</button> </button>
{/if} {/if}
<button class="gbtn gbtn-ghost" onclick={() => { showAll = !showAll; search = ''; manuallySelectedId = null }}>
{showAll ? '✕ Close' : '☰ Browse'}
</button>
</div> </div>
{#if scanning} {#if scanning}
@ -238,13 +211,7 @@
{:else if selectedParticipant} {:else if selectedParticipant}
{@const pts = ticketsFor(selectedParticipant.id)} {@const pts = ticketsFor(selectedParticipant.id)}
<div class="gate-match"> <div class="gate-match">
<div class="gate-match-name-row"> <div class="gate-match-name">{selectedParticipant.preferred_name || selectedParticipant.email || '(unknown)'}</div>
<div class="gate-match-name">{selectedParticipant.preferred_name || selectedParticipant.email || '(unknown)'}</div>
{#if manuallySelectedId}
<button class="gbtn gbtn-ghost" style="font-size:0.8rem;padding:0.3rem 0.6rem"
onclick={() => manuallySelectedId = null}>← Back</button>
{/if}
</div>
{#if selectedParticipant.email} {#if selectedParticipant.email}
<div class="gate-match-sub text-muted">{selectedParticipant.email}</div> <div class="gate-match-sub text-muted">{selectedParticipant.email}</div>
{/if} {/if}
@ -276,7 +243,7 @@
{#each filteredParticipants as p} {#each filteredParticipants as p}
{@const pts = ticketsFor(p.id)} {@const pts = ticketsFor(p.id)}
{@const ci = pts.filter(t => t.checked_in_at).length} {@const ci = pts.filter(t => t.checked_in_at).length}
<button class="gate-result-row" onclick={() => manuallySelectedId = p.id}> <button class="gate-result-row" onclick={() => search = p.preferred_name || p.email || ''}>
<span> <span>
<strong>{p.preferred_name || p.email || '(unknown)'}</strong> <strong>{p.preferred_name || p.email || '(unknown)'}</strong>
{#if p.email && p.preferred_name} {#if p.email && p.preferred_name}
@ -294,27 +261,6 @@
<div class="gate-msg gate-msg-warn">No matching participants or tickets found.</div> <div class="gate-msg gate-msg-warn">No matching participants or tickets found.</div>
{/if} {/if}
<!-- Browse all participants -->
{#if showAll && !matchedTicket && !selectedParticipant && search.trim().length < 2}
<div class="gate-results">
{#each allParticipantsSorted as p}
{@const pts = ticketsFor(p.id)}
{@const ci = pts.filter(t => t.checked_in_at).length}
<button class="gate-result-row" onclick={() => { manuallySelectedId = p.id; showAll = false }}>
<span>
<strong>{p.preferred_name || p.email || '(unknown)'}</strong>
{#if p.email && p.preferred_name}
<span class="text-muted" style="font-size:0.8rem"> · {p.email}</span>
{/if}
</span>
<span class="badge {ci === pts.length && pts.length > 0 ? 'badge-checked' : ci > 0 ? 'badge-partial' : 'badge-unchecked'}">
{pts.length > 0 ? `${ci}/${pts.length}` : 'No ticket'}
</span>
</button>
{/each}
</div>
{/if}
<!-- Recent check-ins --> <!-- Recent check-ins -->
<div class="gate-recent"> <div class="gate-recent">
<div class="gate-recent-title">Recent Check-ins</div> <div class="gate-recent-title">Recent Check-ins</div>
@ -439,8 +385,7 @@
padding: 1.25rem; padding: 1.25rem;
margin-bottom: 1rem; 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; 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-match-sub { color: var(--c-muted); font-size: 0.875rem; }
.gate-party { .gate-party {
margin: 0.5rem 0; margin: 0.5rem 0;

View file

@ -149,7 +149,7 @@
<div class="kiosk-vol-name">{state.volunteer.name}</div> <div class="kiosk-vol-name">{state.volunteer.name}</div>
<div class="kiosk-vol-meta"> <div class="kiosk-vol-meta">
{state.volunteer.email || ''} {state.volunteer.email || ''}
{state.volunteer.is_lead ? ' · Co-Lead' : ''} {state.volunteer.is_lead ? ' · Department Lead' : ''}
</div> </div>
<div class="kiosk-token">Token: <code>{token}</code></div> <div class="kiosk-token">Token: <code>{token}</code></div>
</div> </div>

View file

@ -63,7 +63,6 @@
return ($allTickets ?? []).filter(t => t.participant_id === participantId) return ($allTickets ?? []).filter(t => t.participant_id === participantId)
} }
function checkedInCount(participantId) { function checkedInCount(participantId) {
return ticketsFor(participantId).filter(t => t.checked_in_at).length 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) { function fmtTime(ts) {
if (!ts) return '' if (!ts) return ''
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
@ -244,7 +233,7 @@
{#if showAdd && canManage} {#if showAdd && canManage}
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<form onsubmit={addParticipant}> <form onsubmit={addParticipant}>
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:1rem"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<div class="form-group"> <div class="form-group">
<label for="p-name">Name</label> <label for="p-name">Name</label>
<input id="p-name" bind:value={newName} placeholder="Preferred name" /> <input id="p-name" bind:value={newName} placeholder="Preferred name" />
@ -363,14 +352,11 @@
onclick={mergeMode && mergeSource?.id !== p.id ? () => { mergeTarget = p } : null} onclick={mergeMode && mergeSource?.id !== p.id ? () => { mergeTarget = p } : null}
style={mergeMode && mergeSource?.id !== p.id ? 'cursor:pointer' : ''} style={mergeMode && mergeSource?.id !== p.id ? 'cursor:pointer' : ''}
> >
<td class="td-name"> <td>
<strong>{p.preferred_name || '—'}</strong> <strong>{p.preferred_name || '—'}</strong>
{#if p.pronouns} {#if p.pronouns}
<span class="text-muted" style="font-size:0.78rem"> · {p.pronouns}</span> <span class="text-muted" style="font-size:0.78rem"> · {p.pronouns}</span>
{/if} {/if}
{#if p.ticket_name && p.ticket_name !== p.preferred_name}
<div class="text-muted" style="font-size:0.78rem">Ticket: {p.ticket_name}</div>
{/if}
{#if p.note} {#if p.note}
<div class="text-muted" style="font-size:0.78rem">{p.note}</div> <div class="text-muted" style="font-size:0.78rem">{p.note}</div>
{/if} {/if}
@ -401,7 +387,7 @@
{/if} {/if}
</td> </td>
{#if canManage} {#if canManage}
<td class="td-actions"> <td>
{#if !mergeMode} {#if !mergeMode}
<button class="btn btn-ghost btn-sm" onclick={(e) => { e.stopPropagation(); startEdit(p) }} <button class="btn btn-ghost btn-sm" onclick={(e) => { e.stopPropagation(); startEdit(p) }}
title="Edit participant">✎</button> title="Edit participant">✎</button>
@ -438,9 +424,9 @@
</div> </div>
<div style="text-align:right"> <div style="text-align:right">
{#if tk.checked_in_at} {#if tk.checked_in_at}
<span class="badge badge-checked">Checked in {fmtTime(tk.checked_in_at)}</span> <span class="badge badge-checked">In {fmtTime(tk.checked_in_at)}</span>
{:else} {:else}
<button class="btn btn-success btn-sm" onclick={() => checkInTicket(tk)}> Check in</button> <span class="badge badge-unchecked">Pending</span>
{/if} {/if}
<div class="text-muted" style="font-size:0.75rem">{tk.source}</div> <div class="text-muted" style="font-size:0.75rem">{tk.source}</div>
</div> </div>
@ -508,12 +494,4 @@
.edit-fields { display: flex; gap: 0.4rem; flex-wrap: wrap; } .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; } .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%; }
}
</style> </style>

View file

@ -315,8 +315,8 @@
{#if ($allShifts ?? []).length === 0 && !showAdd} {#if ($allShifts ?? []).length === 0 && !showAdd}
<div class="empty"> <div class="empty">
<strong>No shifts scheduled yet</strong> <strong>No shifts yet</strong>
<p>Create departments first, then add shifts here. Volunteers can self-select shifts via the kiosk.</p> <p>Add shifts to schedule your volunteers.</p>
</div> </div>
{:else} {:else}
{#each board as { dept, days }} {#each board as { dept, days }}
@ -397,9 +397,6 @@
{#each assigned as { vs, volunteer }} {#each assigned as { vs, volunteer }}
<div class="board-vol-chip"> <div class="board-vol-chip">
{volunteer.name} {volunteer.name}
{#if volunteer.is_lead}
<span class="chip-lead">Co-Lead</span>
{/if}
{#if checkConflict(volunteer.id, shift.id, $allVolunteerShifts ?? [], $allShifts ?? [])} {#if checkConflict(volunteer.id, shift.id, $allVolunteerShifts ?? [], $allShifts ?? [])}
<span title="Scheduling conflict" style="color:var(--c-warn)"></span> <span title="Scheduling conflict" style="color:var(--c-warn)"></span>
{/if} {/if}
@ -517,14 +514,6 @@
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 500; 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 { .board-vol-remove {
background: none; background: none;
border: none; border: none;

View file

@ -1,11 +1,9 @@
<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('')
@ -20,24 +18,10 @@
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('')
const timezones = Intl.supportedValuesOf('timeZone')
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 ?? ''
@ -57,28 +41,6 @@
} }
}) })
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
@ -165,44 +127,6 @@
{#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/Chicago" list="tz-list" />
<datalist id="tz-list">
{#each timezones as tz}
<option value={tz} />
{/each}
</datalist>
</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>
@ -236,7 +160,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 kiosk links in emails)</span></label> <label for="s-url">Base URL <span class="text-muted" style="font-weight:400">(for volunteer token links)</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>

View file

@ -117,7 +117,7 @@
} }
function roleLabel(r) { function roleLabel(r) {
return { admin: 'Admin', ticketing: 'Ticketing', staffing: 'Staffing', colead: 'Colead', gatekeeper: 'Gatekeeper' }[r] || r return { admin: 'Admin', coordinator: 'Coordinator', ticketing: 'Ticketing', gate: 'Gate', volunteer_lead: 'Vol. Lead' }[r] || r
} }
</script> </script>
@ -129,15 +129,6 @@
</div> </div>
</div> </div>
<p class="text-muted" style="font-size:0.82rem;margin-bottom:1.5rem;line-height:1.6">
<strong style="color:var(--c-text)">Roles:</strong>
admin — full access ·
ticketing — participants, tickets, import ·
staffing — volunteers, shifts, departments ·
colead — manage assigned departments only ·
gatekeeper — check-in only
</p>
{#if loadError} {#if loadError}
<div class="alert alert-error">{loadError}</div> <div class="alert alert-error">{loadError}</div>
{/if} {/if}
@ -148,7 +139,7 @@
{#if showAdd} {#if showAdd}
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<form onsubmit={addUser}> <form onsubmit={addUser}>
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem"> <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem">
<div class="form-group"> <div class="form-group">
<label for="u-username">Username *</label> <label for="u-username">Username *</label>
<input id="u-username" bind:value={newUsername} required placeholder="username" autocomplete="off" /> <input id="u-username" bind:value={newUsername} required placeholder="username" autocomplete="off" />
@ -196,8 +187,7 @@
<div class="text-muted" style="padding:2rem 0">Loading…</div> <div class="text-muted" style="padding:2rem 0">Loading…</div>
{:else if users.length === 0} {:else if users.length === 0}
<div class="empty"> <div class="empty">
<strong>No additional users</strong> <strong>No users yet</strong>
<p>The admin account was created at setup. Add users above to delegate access.</p>
</div> </div>
{:else} {:else}
<div class="table-wrap"> <div class="table-wrap">
@ -213,8 +203,8 @@
<tbody> <tbody>
{#each users as u (u.id)} {#each users as u (u.id)}
{#if editID === u.id} {#if editID === u.id}
<tr class="edit-row"> <tr>
<td class="td-name"><strong>{u.username}</strong> {#if u.id === me}<span class="badge badge-role">you</span>{/if}</td> <td><strong>{u.username}</strong> {#if u.id === me}<span class="badge badge-role">you</span>{/if}</td>
<td> <td>
<select bind:value={editRole} style="width:auto;margin:0"> <select bind:value={editRole} style="width:auto;margin:0">
{#each roles as r} {#each roles as r}
@ -239,7 +229,7 @@
placeholder="New password (leave blank to keep)" placeholder="New password (leave blank to keep)"
style="margin-top:0.5rem" autocomplete="new-password" /> style="margin-top:0.5rem" autocomplete="new-password" />
</td> </td>
<td class="td-actions"> <td>
<div class="actions"> <div class="actions">
<button class="btn btn-primary btn-sm" onclick={() => saveUser(u)} disabled={saving}> <button class="btn btn-primary btn-sm" onclick={() => saveUser(u)} disabled={saving}>
{saving ? '…' : 'Save'} {saving ? '…' : 'Save'}
@ -250,7 +240,7 @@
</tr> </tr>
{:else} {:else}
<tr> <tr>
<td class="td-name"> <td>
<strong>{u.username}</strong> <strong>{u.username}</strong>
{#if u.id === me} {#if u.id === me}
<span class="badge badge-role" style="margin-left:0.4rem">you</span> <span class="badge badge-role" style="margin-left:0.4rem">you</span>
@ -258,7 +248,7 @@
</td> </td>
<td><span class="badge badge-role">{roleLabel(u.role)}</span></td> <td><span class="badge badge-role">{roleLabel(u.role)}</span></td>
<td class="text-muted">{deptNamesFor(u.department_ids || [])}</td> <td class="text-muted">{deptNamesFor(u.department_ids || [])}</td>
<td class="td-actions"> <td>
<div class="actions"> <div class="actions">
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(u)}>Edit</button> <button class="btn btn-ghost btn-sm" onclick={() => startEdit(u)}>Edit</button>
{#if u.id !== me} {#if u.id !== me}
@ -274,11 +264,3 @@
</div> </div>
{/if} {/if}
</div> </div>
<style>
@media (max-width: 640px) {
.td-name { width: 100%; }
.td-actions { width: 100%; display: flex; justify-content: flex-end; }
.edit-row td { width: 100%; }
}
</style>

View file

@ -8,7 +8,7 @@
let search = $state('') let search = $state('')
let filterDept = $state('') let filterDept = $state('')
let filterStatus = $state('') let filterChecked = $state('')
let error = $state('') let error = $state('')
let showAdd = $state(false) let showAdd = $state(false)
let adding = $state(false) let adding = $state(false)
@ -18,29 +18,13 @@
let newIsLead = $state(false) let newIsLead = $state(false)
let newNote = $state('') let newNote = $state('')
let editID = $state(null)
let editDeptID = $state('')
let editIsLead = $state(false)
let editNote = $state('')
let saving = $state(false)
const role = $derived(session?.user?.role ?? '') const role = $derived(session?.user?.role ?? '')
const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(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
$effect(() => {
if (role === 'colead' && myDeptIDs.length > 0 && !filterDept) {
filterDept = String(myDeptIDs[0])
}
})
const allVolunteers = liveQuery(() => const allVolunteers = liveQuery(() =>
db.volunteers.filter(v => !v.deleted_at).toArray() db.volunteers.filter(v => !v.deleted_at).toArray()
) )
const allParticipants = liveQuery(() => db.participants.toArray()) const allParticipants = liveQuery(() => db.participants.toArray())
const allTickets = liveQuery(() => db.tickets.filter(t => !t.deleted_at).toArray())
const allDepts = liveQuery(() => const allDepts = liveQuery(() =>
db.departments.filter(d => !d.deleted_at).toArray() db.departments.filter(d => !d.deleted_at).toArray()
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name))) .then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
@ -52,10 +36,8 @@
return list return list
.filter(v => { .filter(v => {
if (filterDept && v.department_id !== parseInt(filterDept)) return false if (filterDept && v.department_id !== parseInt(filterDept)) return false
if (filterStatus === 'unconfirmed' && v.email_confirmed) return false if (filterChecked === 'true' && !v.checked_in) return false
if (filterStatus === 'registered' && (!v.email_confirmed || v.confirmed)) return false if (filterChecked === 'false' && v.checked_in) 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) && if (s && !v.name.toLowerCase().includes(s) &&
!(v.email || '').toLowerCase().includes(s)) return false !(v.email || '').toLowerCase().includes(s)) return false
return true return true
@ -72,15 +54,6 @@
} }
} }
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) { async function addVolunteer(e) {
e.preventDefault() e.preventDefault()
adding = true adding = true
@ -116,45 +89,10 @@
} }
} }
function startEdit(v) {
editID = v.id
editDeptID = v.department_id ? String(v.department_id) : ''
editIsLead = v.is_lead
editNote = v.note ?? ''
}
function cancelEdit() {
editID = null
}
async function saveVolunteer(v) {
saving = true
error = ''
try {
const updated = await api.volunteers.update(v.id, {
...v,
department_id: editDeptID ? parseInt(editDeptID) : null,
is_lead: editIsLead,
note: editNote,
})
await db.volunteers.put(updated)
editID = null
} catch (err) {
error = err.message
} finally {
saving = false
}
}
function deptFor(id) { function deptFor(id) {
return ($allDepts ?? []).find(d => d.id === id) 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) { function participantFor(id) {
return ($allParticipants ?? []).find(p => p.id === id) ?? null return ($allParticipants ?? []).find(p => p.id === id) ?? null
} }
@ -177,7 +115,7 @@
{#if showAdd && canManage} {#if showAdd && canManage}
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<form onsubmit={addVolunteer}> <form onsubmit={addVolunteer}>
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:1rem"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<div class="form-group"> <div class="form-group">
<label for="v-name">Name *</label> <label for="v-name">Name *</label>
<input id="v-name" bind:value={newName} required placeholder="Full name" /> <input id="v-name" bind:value={newName} required placeholder="Full name" />
@ -226,12 +164,10 @@
{/each} {/each}
</select> </select>
{/if} {/if}
<select bind:value={filterStatus} style="width:auto"> <select bind:value={filterChecked} style="width:auto">
<option value="">All statuses</option> <option value="">All</option>
<option value="unconfirmed">Unconfirmed</option> <option value="false">Not checked in</option>
<option value="registered">Registered</option> <option value="true">Checked in</option>
<option value="confirmed">Confirmed</option>
<option value="ready">Ready</option>
</select> </select>
<span class="text-muted" style="font-size:0.85rem;white-space:nowrap"> <span class="text-muted" style="font-size:0.85rem;white-space:nowrap">
{filtered.length} shown {filtered.length} shown
@ -241,7 +177,7 @@
{#if ($allVolunteers ?? []).length === 0} {#if ($allVolunteers ?? []).length === 0}
<div class="empty"> <div class="empty">
<strong>No volunteers yet</strong> <strong>No volunteers yet</strong>
<p>Add volunteers manually above, or enable public signup in Settings.</p> <p>Add volunteers manually.</p>
</div> </div>
{:else} {:else}
<div class="table-wrap"> <div class="table-wrap">
@ -258,108 +194,54 @@
<tbody> <tbody>
{#each filtered as v (v.id)} {#each filtered as v (v.id)}
{@const dept = deptFor(v.department_id)} {@const dept = deptFor(v.department_id)}
{#if editID === v.id} {@const participant = participantFor(v.participant_id)}
<tr class="edit-row"> <tr>
<td class="td-name" style="width:100%"> <td>
<strong>{v.name}</strong> <strong>{v.name}</strong>
{#if v.email}<div class="text-muted" style="font-size:0.78rem">{v.email}</div>{/if} {#if v.is_lead}
</td> <span class="badge badge-lead" style="margin-left:0.4rem">Lead</span>
<td class="td-edit-dept">
<select bind:value={editDeptID} style="margin:0">
<option value="">No department</option>
{#each $allDepts ?? [] as d}
<option value={String(d.id)}>{d.name}</option>
{/each}
</select>
</td>
<td class="td-edit-checks">
<label style="display:flex;align-items:center;gap:0.4rem;cursor:pointer;white-space:nowrap">
<input type="checkbox" style="width:auto" bind:checked={editIsLead} /> Co-Lead
</label>
</td>
<td class="td-edit-note">
<input bind:value={editNote} placeholder="Note" style="margin:0" />
</td>
<td class="td-actions">
<button class="btn btn-primary btn-sm" onclick={() => saveVolunteer(v)} disabled={saving}>
{saving ? '…' : 'Save'}
</button>
<button class="btn btn-ghost btn-sm" onclick={cancelEdit}>Cancel</button>
</td>
</tr>
{:else}
<tr>
<td class="td-name">
<strong>{v.name}</strong>
{#if v.is_lead}
<span class="badge badge-lead" style="margin-left:0.4rem">Co-Lead</span>
{/if}
{#if !v.participant_id}
<span class="badge badge-unchecked" style="margin-left:0.4rem" title="Not linked to a participant">No ticket</span>
{:else if !participantHasTickets(v.participant_id)}
<span class="badge badge-partial" style="margin-left:0.4rem" title="No ticket on file">No ticket</span>
{/if}
{#if v.email}
<div class="text-muted" style="font-size:0.78rem">{v.email}</div>
{/if}
{#if v.note}
<div class="text-muted" style="font-size:0.78rem">{v.note}</div>
{/if}
</td>
<td class="td-dept text-muted">
{#if dept}
<span class="dept-dot" style="background:{dept.color};margin-right:0.4rem"></span>{dept.name}
{:else}
{/if}
</td>
<td class="td-status">
{#if v.checked_in}
<span class="badge badge-checked">Ready</span>
{:else if v.confirmed}
<span class="badge badge-confirmed">Confirmed</span>
{:else if v.email_confirmed}
<span class="badge badge-registered">Registered</span>
{:else}
<span class="badge badge-unchecked">Unconfirmed</span>
{/if}
{#if v.checked_in_at}
<div class="text-muted" style="font-size:0.75rem">
{new Date(v.checked_in_at).toLocaleTimeString()}
</div>
{/if}
</td>
<td class="td-ready">
{#if !v.checked_in}
<CheckInButton onclick={() => checkIn(v)} />
{/if}
</td>
{#if canManage}
<td class="td-actions">
{#if canConfirm && v.email_confirmed && !v.confirmed}
<button class="btn btn-primary btn-sm" onclick={() => confirmVolunteer(v)}>Confirm</button>
{/if}
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(v)}>Edit</button>
<button class="btn btn-danger btn-sm" onclick={() => deleteVolunteer(v)}>Delete</button>
</td>
{/if} {/if}
</tr> {#if !v.participant_id}
{/if} <span class="badge badge-unchecked" style="margin-left:0.4rem" title="Not linked to a participant — no ticket record">No ticket</span>
{/if}
{#if v.email}
<div class="text-muted" style="font-size:0.78rem">{v.email}</div>
{/if}
{#if v.note}
<div class="text-muted" style="font-size:0.78rem">{v.note}</div>
{/if}
</td>
<td class="text-muted">
{#if dept}
<span class="dept-dot" style="background:{dept.color};margin-right:0.4rem"></span>{dept.name}
{:else}
{/if}
</td>
<td>
<span class="badge {v.checked_in ? 'badge-checked' : 'badge-unchecked'}">
{v.checked_in ? 'Checked in' : 'Pending'}
</span>
{#if v.checked_in_at}
<div class="text-muted" style="font-size:0.75rem">
{new Date(v.checked_in_at).toLocaleTimeString()}
</div>
{/if}
</td>
<td>
{#if !v.checked_in}
<CheckInButton onclick={() => checkIn(v)} />
{/if}
</td>
{#if canManage}
<td>
<button class="btn btn-danger btn-sm" onclick={() => deleteVolunteer(v)}>Delete</button>
</td>
{/if}
</tr>
{/each} {/each}
</tbody> </tbody>
</table> </table>
</div> </div>
{/if} {/if}
</div> </div>
<style>
@media (max-width: 640px) {
.td-name { flex: 1; min-width: 0; order: 1; }
.td-ready { flex-shrink: 0; align-self: flex-start; order: 2; }
.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%; }
}
</style>

View file

@ -6,21 +6,26 @@ import (
"strconv" "strconv"
) )
func (app *App) volunteerFromKioskToken(token string) (*Volunteer, error) {
return app.getVolunteerByKioskCode(token)
}
// handleKioskGet returns the volunteer's profile, current shift assignments, and // 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. // no JWT required.
func (app *App) handleKioskGet(w http.ResponseWriter, r *http.Request) { func (app *App) handleKioskGet(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token") token := r.PathValue("token")
v, err := app.volunteerFromKioskToken(token) t, err := app.getTicketByCode(token)
if err != nil || v == nil { if err != nil || t == nil {
writeError(w, "not found", http.StatusNotFound) writeError(w, "not found", http.StatusNotFound)
return 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) assigned, _ := app.listShiftsForVolunteer(v.ID)
if assigned == nil { if assigned == nil {
assigned = []Shift{} assigned = []Shift{}
@ -51,11 +56,19 @@ func (app *App) handleKioskClaim(w http.ResponseWriter, r *http.Request) {
return return
} }
v, err := app.volunteerFromKioskToken(token) t, err := app.getTicketByCode(token)
if err != nil || v == nil { if err != nil || t == nil {
writeError(w, "not found", http.StatusNotFound) writeError(w, "not found", http.StatusNotFound)
return 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" force := r.URL.Query().Get("force") == "true"
@ -103,11 +116,19 @@ func (app *App) handleKioskUnclaim(w http.ResponseWriter, r *http.Request) {
return return
} }
v, err := app.volunteerFromKioskToken(token) t, err := app.getTicketByCode(token)
if err != nil || v == nil { if err != nil || t == nil {
writeError(w, "not found", http.StatusNotFound) writeError(w, "not found", http.StatusNotFound)
return 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 { if err := app.unassignShift(v.ID, shiftID); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)

View file

@ -14,11 +14,14 @@ func setupKiosk(t *testing.T) (*App, *http.ServeMux, string) {
dept, _ := app.createDepartment(Department{Name: "Gate"}) dept, _ := app.createDepartment(Department{Name: "Gate"})
deptID := dept.ID 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"}) p, _ := app.createParticipant(Participant{Email: "titania@test.com", PreferredName: "Titania"})
v, _ := app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: &p.ID, DepartmentID: &deptID}) token, _ := app.generateUniqueToken()
token, _ := app.generateVolunteerKioskCode() tk, _ := app.createTicket(Ticket{ParticipantID: &p.ID, Name: "Titania", Source: "manual", Code: &token})
app.assignKioskCode(v.ID, token) _ = tk
// Create linked volunteer via participant_id
app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: &p.ID, DepartmentID: &deptID})
// Create shifts // Create shifts
app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2}) app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2})

View file

@ -69,19 +69,22 @@ func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) {
} }
// Find or create participant by email. // 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 { if err != nil {
writeError(w, "internal error", http.StatusInternalServerError) writeError(w, "internal error", http.StatusInternalServerError)
return return
} }
// Update participant's personal details if they signed up with more info. // 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 app.db.Exec(`UPDATE participants SET
phone = CASE WHEN phone = '' THEN ? ELSE phone END, phone = CASE WHEN phone = '' THEN ? ELSE phone END,
pronouns = CASE WHEN pronouns = '' THEN ? ELSE pronouns END, pronouns = CASE WHEN pronouns = '' THEN ? ELSE pronouns END,
ticket_name = CASE WHEN ticket_name = '' THEN ? ELSE ticket_name END,
updated_at = ? 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() confirmToken, err := generateConfirmationToken()
@ -94,6 +97,7 @@ func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) {
ParticipantID: &participant.ID, ParticipantID: &participant.ID,
Name: body.PreferredName, Name: body.PreferredName,
PreferredName: body.PreferredName, PreferredName: body.PreferredName,
TicketName: body.TicketName,
Email: body.Email, Email: body.Email,
Phone: body.Phone, Phone: body.Phone,
Pronouns: body.Pronouns, Pronouns: body.Pronouns,
@ -146,19 +150,46 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) {
var signupsOpen string var signupsOpen string
app.db.QueryRow(`SELECT value FROM config WHERE key = 'shift_signups_open'`).Scan(&signupsOpen) app.db.QueryRow(`SELECT value FROM config WHERE key = 'shift_signups_open'`).Scan(&signupsOpen)
if signupsOpen == "true" { if signupsOpen == "true" && vol.ParticipantID != nil {
code, err := app.generateVolunteerKioskCode() // Find a ticket with a code, or create/assign one.
if err == nil { tickets, _ := app.listTickets(vol.ParticipantID, "")
if err := app.assignKioskCode(vol.ID, code); err == nil { var code *string
kioskLink := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), code) for _, tk := range tickets {
response["kiosk_link"] = kioskLink if tk.Code != nil {
go func() { code = tk.Code
if err := app.sendShiftSignupEmail(vol.Email, vol.PreferredName, kioskLink); err != nil { break
log.Printf("shift signup email to %s failed: %v", vol.Email, err)
}
}()
} }
} }
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) writeJSON(w, response)
@ -185,28 +216,57 @@ func (app *App) handleToggleShiftSignups(w http.ResponseWriter, r *http.Request)
} }
func (app *App) openShiftSignups() { func (app *App) openShiftSignups() {
// Assign kiosk codes to email-confirmed volunteers that don't have one yet. // Generate codes for tickets belonging to confirmed volunteers that have no code yet.
vols, _ := app.listVolunteersNeedingKioskCode() vols, _ := app.listConfirmedVolunteersNeedingCode()
for _, v := range vols { 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 { if err != nil {
continue 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, ` confirmed, _ := queryVolunteers(app.db, `
SELECT `+volunteerSelect+` `+volunteerFrom+` 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() baseURL := app.resolveBaseURL()
sent := 0 sent := 0
for _, v := range confirmed { for _, v := range confirmed {
if v.Email == "" || v.KioskCode == nil { if v.ParticipantID == nil || v.Email == "" {
continue 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 name := v.PreferredName
if name == "" { if name == "" {
name = v.Name name = v.Name

View file

@ -301,86 +301,17 @@ func TestConfirmEmailWithSignupsOpen(t *testing.T) {
t.Error("expected kiosk_link when signups are open") t.Error("expected kiosk_link when signups are open")
} }
// Volunteer should now have a kiosk_code, no stub ticket created. // Ticket for participant should now have a code
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")
}
tickets, _ := app.listTickets(&participant.ID, "") tickets, _ := app.listTickets(&participant.ID, "")
if len(tickets) != 0 { hasCode := false
t.Errorf("expected no stub tickets, got %d", len(tickets)) for _, tk := range tickets {
if tk.Code != nil && *tk.Code != "" {
hasCode = true
break
}
} }
} if !hasCode {
t.Error("participant should have a ticket with code after confirm with signups open")
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))
} }
} }

View file

@ -109,10 +109,6 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
} }
if v.IsLead {
app.confirmVolunteer(id)
}
updated, _ := app.getVolunteer(id) updated, _ := app.getVolunteer(id)
writeJSON(w, updated) writeJSON(w, updated)
} }
@ -146,20 +142,6 @@ func (app *App) handleCheckInVolunteer(w http.ResponseWriter, r *http.Request) {
writeJSON(w, v) 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) { func (app *App) handleAssignShift(w http.ResponseWriter, r *http.Request) {
volunteerID, err := strconv.Atoi(r.PathValue("id")) volunteerID, err := strconv.Atoi(r.PathValue("id"))
if err != nil { if err != nil {

View file

@ -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")
}
}

View file

@ -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("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("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}/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("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "ticketing", "staffing", "colead"))
mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "ticketing", "staffing", "colead")) mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "ticketing", "staffing", "colead"))