diff --git a/Makefile b/Makefile index a8de0d3..30bca30 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ .PHONY: build frontend-build dev clean test patch minor major LAST_TAG := $(shell git tag --sort=-v:refname | head -1) -MAJOR := $(shell echo $(LAST_TAG) | cut -d. -f1) -MINOR := $(shell echo $(LAST_TAG) | cut -d. -f2) -PATCH := $(shell echo $(LAST_TAG) | cut -d. -f3) +MAJOR := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f1) +MINOR := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f2) +PATCH := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f3) build: frontend-build CGO_ENABLED=0 go build -ldflags "-X main.buildID=$$(git rev-parse --short HEAD)" -o turnpike . @@ -25,13 +25,13 @@ clean: rm -rf frontend/dist patch: - git tag $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1))) - @echo "Tagged $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))" + git tag v$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1))) + @echo "Tagged v$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))" minor: - git tag $(MAJOR).$(shell echo $$(($(MINOR)+1))).0 - @echo "Tagged $(MAJOR).$(shell echo $$(($(MINOR)+1))).0" + git tag v$(MAJOR).$(shell echo $$(($(MINOR)+1))).0 + @echo "Tagged v$(MAJOR).$(shell echo $$(($(MINOR)+1))).0" major: - git tag $(shell echo $$(($(MAJOR)+1))).0.0 - @echo "Tagged $(shell echo $$(($(MAJOR)+1))).0.0" + git tag v$(shell echo $$(($(MAJOR)+1))).0.0 + @echo "Tagged v$(shell echo $$(($(MAJOR)+1))).0.0" diff --git a/README.md b/README.md index 71132c4..b526fbe 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,20 @@ # Turnpike -Self-hosted event ticketing and volunteer management. One instance, one event. +Self-hosted event attendee and volunteer management. One instance, one event. Turnpike handles gate check-in, volunteer scheduling, and department coordination for events ranging from a single evening to a multi-day festival. It works offline — gate volunteers can check people in without a network connection and sync when connectivity returns. ## Features -- **Participant & ticket management** — CSV import (CrowdWork/Zeffy auto-detected), search, check-in -- **Volunteer scheduling** — departments, shifts with capacity, conflict detection, reordering -- **Public volunteer signup** — self-registration form with email confirmation, auto-participant linking -- **Volunteer kiosk** — public volunteer flow: signup, email confirmation, code-authenticated shift self-scheduling -- **Gate kiosk** — full-screen check-in UI with QR scanner for gatekeepers -- **Schedule** — create shifts, assign volunteers, manage assignments with conflict awareness -- **Role-based access** — admin, ticketing, staffing, colead (department-scoped), gatekeeper +- **Attendee management** — CSV import (CrowdWork/Zeffy auto-detected), party-size tracking, search, check-in +- **Volunteer scheduling** — departments, shifts with capacity, conflict detection, drag-and-drop reordering +- **Volunteer kiosk** — token-authenticated self-service shift signup, no login required +- **Gate check-in** — full-screen UI with QR scanner, party check-in ("2/3 checked in"), volunteer dual check-in +- **Schedule board** — department leads and coordinators manage shift assignments with conflict awareness +- **Role-based access** — admin, coordinator, volunteer lead (department-scoped), gate - **Offline-first PWA** — installs on phones/tablets, full offline check-in with background sync - **Real-time** — check-ins and changes broadcast live via SSE -- **SMTP email** — volunteer confirmation emails, kiosk link distribution when shift signups open +- **SMTP email** — send volunteer token links directly or export CSV for bulk email platforms - **Single binary** — Go backend embeds the frontend; no runtime dependencies ## Tech Stack @@ -60,11 +59,10 @@ See [docs/INSTALLATION.md](docs/INSTALLATION.md) for systemd, Docker, NixOS, and | Role | Access | |------|--------| -| `admin` | Full access: participant import, user management, SMTP settings, all departments and shifts | -| `ticketing` | Participants, tickets, import. No user management | -| `staffing` | All departments: volunteers, shifts, schedule. No user management or settings | -| `colead` | Own department only: volunteers and shifts scoped to assigned department(s) | -| `gatekeeper` | Full-screen Gate Kiosk with QR scanner. No access to other pages | +| `admin` | Full access: attendee import, user management, SMTP settings, all departments and shifts | +| `coordinator` | All departments: volunteers, shifts, schedule board. No user management or settings | +| `volunteer_lead` | Own department only: volunteers and shifts scoped to assigned department | +| `gate` | Full-screen check-in UI with QR scanner. No access to other pages | See [docs/USAGE.md](docs/USAGE.md) for detailed workflow documentation. @@ -92,7 +90,7 @@ The Vite dev server runs on `:5173` and proxies `/api` requests to the Go server ## Documentation -- [Usage Guide](docs/USAGE.md) — event setup, participant import, volunteer signup, volunteer kiosk, gate check-in, schedule +- [Usage Guide](docs/USAGE.md) — event setup, attendee import, volunteer kiosk, gate check-in, schedule board - [Installation Guide](docs/INSTALLATION.md) — building, deploying, systemd, Docker, NixOS, reverse proxy, backup ## License diff --git a/auth.go b/auth.go index b675e6f..c2d11af 100644 --- a/auth.go +++ b/auth.go @@ -12,10 +12,10 @@ import ( ) type Claims struct { - ParticipantID int `json:"pid"` - Email string `json:"sub"` - Roles []string `json:"roles"` - DeptIDs []int `json:"dept_ids,omitempty"` + UserID int `json:"uid"` + Username string `json:"sub"` + Role string `json:"role"` + DeptIDs []int `json:"dept_ids,omitempty"` jwt.RegisteredClaims } @@ -28,13 +28,13 @@ func checkPassword(hash, password string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil } -func (app *App) signToken(s *User) (string, error) { +func (app *App) signToken(u *User) (string, error) { expiry := time.Duration(app.tokenExpiry) * time.Hour claims := Claims{ - ParticipantID: s.ID, - Email: s.Email, - Roles: s.Roles, - DeptIDs: s.DepartmentIDs, + UserID: u.ID, + Username: u.Username, + Role: u.Role, + DeptIDs: u.DepartmentIDs, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)), IssuedAt: jwt.NewNumericDate(time.Now()), @@ -88,7 +88,7 @@ func (app *App) requireAuth(next http.HandlerFunc, roles ...string) http.Handler writeError(w, "unauthorized", http.StatusUnauthorized) return } - if len(roles) > 0 && !hasAnyRole(claims.Roles, roles) { + if len(roles) > 0 && !hasRole(claims.Role, roles) { writeError(w, "forbidden", http.StatusForbidden) return } @@ -97,25 +97,9 @@ func (app *App) requireAuth(next http.HandlerFunc, roles ...string) http.Handler } } -func hasAnyRole(roles []string, allowed []string) bool { - for _, r := range roles { - for _, a := range allowed { - if r == a { - return true - } - } - } - return false -} - -func isCoLeadOnly(claims *Claims) bool { - return hasAnyRole(claims.Roles, []string{"colead"}) && - !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) -} - -func inSlice(v int, s []int) bool { - for _, x := range s { - if x == v { +func hasRole(role string, allowed []string) bool { + for _, r := range allowed { + if r == role { return true } } diff --git a/auth_test.go b/auth_test.go index 602c6cf..1a16571 100644 --- a/auth_test.go +++ b/auth_test.go @@ -12,7 +12,7 @@ func TestLoginValid(t *testing.T) { mux := testMux(app) req := testRequest("POST", "/api/login", map[string]string{ - "email": admin.Email, + "username": admin.Username, "password": "admin123", }) w := httptest.NewRecorder() @@ -26,7 +26,7 @@ func TestLoginValid(t *testing.T) { t.Error("missing token in response") } user, ok := result["user"].(map[string]any) - if !ok || user["email"] != "oberon@athens.example" { + if !ok || user["username"] != "admin" { t.Errorf("user = %v", result["user"]) } } @@ -37,7 +37,7 @@ func TestLoginWrongPassword(t *testing.T) { mux := testMux(app) req := testRequest("POST", "/api/login", map[string]string{ - "email": "oberon@athens.example", + "username": "admin", "password": "wrong", }) w := httptest.NewRecorder() @@ -53,7 +53,7 @@ func TestLoginNonexistentUser(t *testing.T) { mux := testMux(app) req := testRequest("POST", "/api/login", map[string]string{ - "email": "nobody@test.com", + "username": "nobody", "password": "test", }) w := httptest.NewRecorder() @@ -94,7 +94,8 @@ func TestAuthMiddlewareRoleEnforcement(t *testing.T) { app := testApp(t) mux := testMux(app) - gate := testUserWithRoles(t, app, "Starveling", []string{"gatekeeper"}, []int{}) + // Create a gate user — should not be able to access /api/users (admin only) + gate := testUserWithRole(t, app, "gateuser", "gate", []int{}) token := testToken(t, app, gate) req := testAuthRequest("GET", "/api/users", nil, token) @@ -120,7 +121,7 @@ func TestMeEndpoint(t *testing.T) { t.Fatalf("status = %d", w.Code) } result := parseJSON(t, w) - if result["email"] != "oberon@athens.example" { - t.Errorf("email = %v", result["email"]) + if result["username"] != "admin" { + t.Errorf("username = %v", result["username"]) } } diff --git a/db.go b/db.go index 0ec6716..ef3a339 100644 --- a/db.go +++ b/db.go @@ -40,6 +40,20 @@ func migrate(db *sql.DB) error { updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('admin','coordinator','gate','ticketing','volunteer_lead')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS user_departments ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + department_id INTEGER NOT NULL REFERENCES departments(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, department_id) + ); + CREATE TABLE IF NOT EXISTS departments ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, @@ -49,24 +63,43 @@ func migrate(db *sql.DB) error { deleted_at TEXT ); - CREATE TABLE IF NOT EXISTS volunteers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE, - department_id INTEGER REFERENCES departments(id) ON DELETE SET NULL, - is_lead INTEGER NOT NULL DEFAULT 0, - ready INTEGER NOT NULL DEFAULT 0, - ready_at TEXT, - confirmed INTEGER NOT NULL DEFAULT 0, - confirmed_at TEXT, - kiosk_code TEXT, - note TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - deleted_at TEXT + CREATE TABLE IF NOT EXISTS attendees ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '', + ticket_id TEXT NOT NULL DEFAULT '', + ticket_type TEXT NOT NULL DEFAULT '', + volunteer_token TEXT UNIQUE, + party_size INTEGER NOT NULL DEFAULT 1, + checked_in INTEGER NOT NULL DEFAULT 0, + checked_in_count INTEGER NOT NULL DEFAULT 0, + checked_in_at TEXT, + checked_in_by INTEGER REFERENCES users(id), + note TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_volunteers_kiosk_code - ON volunteers(kiosk_code) WHERE kiosk_code IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS idx_attendees_name_ticket + ON attendees(name, ticket_id) WHERE deleted_at IS NULL; + + CREATE TABLE IF NOT EXISTS volunteers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + attendee_id INTEGER REFERENCES attendees(id) ON DELETE SET NULL, + name TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '', + department_id INTEGER REFERENCES departments(id) ON DELETE SET NULL, + is_lead INTEGER NOT NULL DEFAULT 0, + checked_in INTEGER NOT NULL DEFAULT 0, + checked_in_at TEXT, + note TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT + ); CREATE TABLE IF NOT EXISTS shifts ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -86,71 +119,53 @@ func migrate(db *sql.DB) error { shift_id INTEGER NOT NULL REFERENCES shifts(id) ON DELETE CASCADE, confirmed INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL DEFAULT (datetime('now')), - deleted_at TEXT, PRIMARY KEY (volunteer_id, shift_id) ); - - CREATE TABLE IF NOT EXISTS participants ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - email TEXT NOT NULL DEFAULT '', - preferred_name TEXT NOT NULL DEFAULT '', - ticket_name TEXT NOT NULL DEFAULT '', - phone TEXT NOT NULL DEFAULT '', - pronouns TEXT NOT NULL DEFAULT '', - note TEXT NOT NULL DEFAULT '', - email_confirmed INTEGER NOT NULL DEFAULT 0, - confirmation_token TEXT, - password_hash TEXT, - login_enabled INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - deleted_at TEXT - ); - - CREATE UNIQUE INDEX IF NOT EXISTS idx_participants_email - ON participants(email) WHERE deleted_at IS NULL AND email != ''; - - CREATE TABLE IF NOT EXISTS tickets ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - participant_id INTEGER REFERENCES participants(id) ON DELETE SET NULL, - name TEXT NOT NULL DEFAULT '', - ticket_type TEXT NOT NULL DEFAULT '', - source TEXT NOT NULL DEFAULT 'manual', - external_id TEXT NOT NULL DEFAULT '', - order_id TEXT NOT NULL DEFAULT '', - code TEXT UNIQUE, - checked_in_at TEXT, - checked_in_by INTEGER REFERENCES participants(id), - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - deleted_at TEXT - ); - - CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_source_external - ON tickets(source, external_id) WHERE external_id != '' AND deleted_at IS NULL; - - CREATE TABLE IF NOT EXISTS participant_roles ( - participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE, - role TEXT NOT NULL CHECK(role IN ('admin','staffing','colead','gatekeeper')), - PRIMARY KEY (participant_id, role) - ); - - CREATE TABLE IF NOT EXISTS participant_departments ( - participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE, - department_id INTEGER NOT NULL REFERENCES departments(id) ON DELETE CASCADE, - PRIMARY KEY (participant_id, department_id) - ); - - CREATE TABLE IF NOT EXISTS sso_nonces ( - nonce TEXT PRIMARY KEY, - created_at TEXT NOT NULL DEFAULT (datetime('now')) - ); `) - return err + if err != nil { + return err + } + return migrateV2(db) +} + +// migrateV2 adds new columns to existing databases without data loss. +func migrateV2(db *sql.DB) error { + addColumnIfMissing(db, "attendees", "volunteer_token TEXT UNIQUE") + addColumnIfMissing(db, "attendees", "party_size INTEGER NOT NULL DEFAULT 1") + addColumnIfMissing(db, "attendees", "checked_in_count INTEGER NOT NULL DEFAULT 0") + addColumnIfMissing(db, "shifts", "position INTEGER NOT NULL DEFAULT 0") + addColumnIfMissing(db, "volunteer_shifts", "deleted_at TEXT") + // Widen the uniqueness constraint from name-only to (name, ticket_id). + db.Exec(`DROP INDEX IF EXISTS idx_attendees_name`) + db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_attendees_name_ticket ON attendees(name, ticket_id) WHERE deleted_at IS NULL`) + return nil +} + +func addColumnIfMissing(db *sql.DB, table, colDef string) { + colName := strings.Fields(colDef)[0] + rows, err := db.Query(`PRAGMA table_info("` + table + `")`) + if err != nil { + return + } + defer rows.Close() + for rows.Next() { + var cid, notNull, pk int + var name, typ string + var dflt sql.NullString + rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk) + if name == colName { + return + } + } + db.Exec(`ALTER TABLE "` + table + `" ADD COLUMN ` + colDef) } // --- Types --- +const attendeeCols = `id, name, email, phone, ticket_id, ticket_type, volunteer_token, + party_size, checked_in, checked_in_count, checked_in_at, checked_in_by, + note, created_at, updated_at, deleted_at` + const shiftCols = `id, department_id, name, day, start_time, end_time, capacity, position, updated_at, deleted_at` const shiftColsS = `s.id, s.department_id, s.name, s.day, s.start_time, s.end_time, s.capacity, s.position, s.updated_at, s.deleted_at` @@ -166,12 +181,30 @@ type Event struct { } type User struct { - ID int `json:"id"` - Email string `json:"email"` - PreferredName string `json:"preferred_name"` - Roles []string `json:"roles"` - DepartmentIDs []int `json:"department_ids"` - CreatedAt string `json:"created_at"` + ID int `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + DepartmentIDs []int `json:"department_ids"` + CreatedAt string `json:"created_at"` +} + +type Attendee struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` + TicketID string `json:"ticket_id"` + TicketType string `json:"ticket_type"` + VolunteerToken *string `json:"volunteer_token,omitempty"` + PartySize int `json:"party_size"` + CheckedIn bool `json:"checked_in"` + CheckedInCount int `json:"checked_in_count"` + CheckedInAt *string `json:"checked_in_at,omitempty"` + CheckedInBy *int `json:"checked_in_by,omitempty"` + Note string `json:"note"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at,omitempty"` } type Department struct { @@ -184,56 +217,19 @@ type Department struct { } type Volunteer struct { - ID int `json:"id"` - ParticipantID int `json:"participant_id"` - DepartmentID *int `json:"department_id,omitempty"` - IsLead bool `json:"is_lead"` - Ready bool `json:"ready"` - ReadyAt *string `json:"ready_at,omitempty"` - Confirmed bool `json:"confirmed"` - ConfirmedAt *string `json:"confirmed_at,omitempty"` - KioskCode *string `json:"kiosk_code,omitempty"` - Note string `json:"note"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - DeletedAt *string `json:"deleted_at,omitempty"` - // Populated via JOIN from participant, not stored on volunteers table: - Name string `json:"name"` - Email string `json:"email"` - Phone string `json:"phone"` - Pronouns string `json:"pronouns"` - EmailConfirmed bool `json:"email_confirmed"` -} - -type Participant struct { - ID int `json:"id"` - Email string `json:"email"` - PreferredName string `json:"preferred_name"` - TicketName string `json:"ticket_name"` - Phone string `json:"phone"` - Pronouns string `json:"pronouns"` - Note string `json:"note"` - EmailConfirmed bool `json:"email_confirmed"` - ConfirmationToken *string `json:"-"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - DeletedAt *string `json:"deleted_at,omitempty"` -} - -type Ticket struct { - ID int `json:"id"` - ParticipantID *int `json:"participant_id,omitempty"` - Name string `json:"name"` - TicketType string `json:"ticket_type"` - Source string `json:"source"` - ExternalID string `json:"external_id"` - OrderID string `json:"order_id"` - Code *string `json:"code,omitempty"` - CheckedInAt *string `json:"checked_in_at,omitempty"` - CheckedInBy *int `json:"checked_in_by,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - DeletedAt *string `json:"deleted_at,omitempty"` + ID int `json:"id"` + AttendeeID *int `json:"attendee_id,omitempty"` + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` + DepartmentID *int `json:"department_id,omitempty"` + IsLead bool `json:"is_lead"` + CheckedIn bool `json:"checked_in"` + CheckedInAt *string `json:"checked_in_at,omitempty"` + Note string `json:"note"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at,omitempty"` } type Shift struct { @@ -283,45 +279,11 @@ func (app *App) upsertEvent(e Event) error { return err } -// --- Staff (participants with login_enabled) --- +// --- Users --- -func (app *App) getParticipantRoles(participantID int) ([]string, error) { +func (app *App) getUserDeptIDs(userID int) ([]int, error) { rows, err := app.db.Query( - `SELECT role FROM participant_roles WHERE participant_id = ? ORDER BY role`, participantID, - ) - if err != nil { - return nil, err - } - defer rows.Close() - var roles []string - for rows.Next() { - var r string - rows.Scan(&r) - roles = append(roles, r) - } - if roles == nil { - roles = []string{} - } - return roles, rows.Err() -} - -func (app *App) setParticipantRoles(participantID int, roles []string) error { - if _, err := app.db.Exec(`DELETE FROM participant_roles WHERE participant_id = ?`, participantID); err != nil { - return err - } - for _, role := range roles { - if _, err := app.db.Exec( - `INSERT INTO participant_roles (participant_id, role) VALUES (?, ?)`, participantID, role, - ); err != nil { - return err - } - } - return nil -} - -func (app *App) getUserDeptIDs(participantID int) ([]int, error) { - rows, err := app.db.Query( - `SELECT department_id FROM participant_departments WHERE participant_id = ? ORDER BY department_id`, participantID, + `SELECT department_id FROM user_departments WHERE user_id = ? ORDER BY department_id`, userID, ) if err != nil { return nil, err @@ -339,13 +301,14 @@ func (app *App) getUserDeptIDs(participantID int) ([]int, error) { return ids, rows.Err() } -func (app *App) setUserDeptIDs(participantID int, deptIDs []int) error { - if _, err := app.db.Exec(`DELETE FROM participant_departments WHERE participant_id = ?`, participantID); err != nil { +func (app *App) setUserDeptIDs(userID int, deptIDs []int) error { + _, err := app.db.Exec(`DELETE FROM user_departments WHERE user_id = ?`, userID) + if err != nil { return err } for _, deptID := range deptIDs { if _, err := app.db.Exec( - `INSERT INTO participant_departments (participant_id, department_id) VALUES (?, ?)`, participantID, deptID, + `INSERT INTO user_departments (user_id, department_id) VALUES (?, ?)`, userID, deptID, ); err != nil { return err } @@ -353,157 +316,98 @@ func (app *App) setUserDeptIDs(participantID int, deptIDs []int) error { return nil } -func (app *App) getLoginParticipant(email string) (*User, string, error) { - var s User - var hash sql.NullString +func (app *App) getUserByUsername(username string) (*User, string, error) { + var u User + var hash string err := app.db.QueryRow( - `SELECT id, email, preferred_name, password_hash, created_at - FROM participants WHERE LOWER(email) = LOWER(?) AND login_enabled = 1 AND deleted_at IS NULL`, email, - ).Scan(&s.ID, &s.Email, &s.PreferredName, &hash, &s.CreatedAt) + `SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?`, username, + ).Scan(&u.ID, &u.Username, &hash, &u.Role, &u.CreatedAt) if err == sql.ErrNoRows { return nil, "", nil } if err != nil { return nil, "", err } - var hashStr string - if hash.Valid { - hashStr = hash.String - } - s.Roles, _ = app.getParticipantRoles(s.ID) - s.DepartmentIDs, _ = app.getUserDeptIDs(s.ID) - return &s, hashStr, nil + u.DepartmentIDs, err = app.getUserDeptIDs(u.ID) + return &u, hash, err } -func (app *App) getUser(id int) (*User, error) { - var s User +func (app *App) getUserByID(id int) (*User, error) { + var u User err := app.db.QueryRow( - `SELECT id, email, preferred_name, created_at - FROM participants WHERE id = ? AND login_enabled = 1 AND deleted_at IS NULL`, id, - ).Scan(&s.ID, &s.Email, &s.PreferredName, &s.CreatedAt) + `SELECT id, username, role, created_at FROM users WHERE id = ?`, id, + ).Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } - s.Roles, _ = app.getParticipantRoles(s.ID) - s.DepartmentIDs, _ = app.getUserDeptIDs(s.ID) - return &s, nil + u.DepartmentIDs, err = app.getUserDeptIDs(u.ID) + return &u, err } func (app *App) listUsers() ([]User, error) { rows, err := app.db.Query( - `SELECT id, email, preferred_name, created_at - FROM participants WHERE login_enabled = 1 AND deleted_at IS NULL ORDER BY preferred_name, email`, + `SELECT id, username, role, created_at FROM users ORDER BY username`, ) if err != nil { return nil, err } defer rows.Close() - var staff []User + var users []User for rows.Next() { - var s User - if err := rows.Scan(&s.ID, &s.Email, &s.PreferredName, &s.CreatedAt); err != nil { + var u User + if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt); err != nil { return nil, err } - s.Roles = []string{} - s.DepartmentIDs = []int{} - staff = append(staff, s) + u.DepartmentIDs = []int{} + users = append(users, u) } if err := rows.Err(); err != nil { return nil, err } - for i := range staff { - staff[i].Roles, _ = app.getParticipantRoles(staff[i].ID) - staff[i].DepartmentIDs, _ = app.getUserDeptIDs(staff[i].ID) + for i := range users { + users[i].DepartmentIDs, _ = app.getUserDeptIDs(users[i].ID) } - return staff, nil + return users, nil } -func (app *App) createUser(email, preferredName, hash string, roles []string, deptIDs []int) (*User, error) { - // Find or create participant by email. - p, err := app.getParticipantByEmail(email) - if err != nil { - return nil, err - } - if p != nil { - // Participant exists — promote to staff. - if _, err := app.db.Exec( - `UPDATE participants SET password_hash = ?, login_enabled = 1, updated_at = ? WHERE id = ?`, - hash, now(), p.ID, - ); err != nil { - return nil, err - } - if err := app.setParticipantRoles(p.ID, roles); err != nil { - return nil, err - } - if err := app.setUserDeptIDs(p.ID, deptIDs); err != nil { - return nil, err - } - return app.getUser(p.ID) - } - // Create new participant with auth. +func (app *App) createUser(username, hash, role string, deptIDs []int) (*User, error) { res, err := app.db.Exec( - `INSERT INTO participants (email, preferred_name, password_hash, login_enabled, updated_at) - VALUES (?, ?, ?, 1, ?)`, - strings.ToLower(email), preferredName, hash, now(), + `INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)`, + username, hash, role, ) if err != nil { return nil, err } id, _ := res.LastInsertId() - if err := app.setParticipantRoles(int(id), roles); err != nil { - return nil, err - } if err := app.setUserDeptIDs(int(id), deptIDs); err != nil { return nil, err } - return app.getUser(int(id)) + return app.getUserByID(int(id)) } -func (app *App) updateUserRoles(id int, roles []string, deptIDs []int) error { - var enabled int - err := app.db.QueryRow(`SELECT login_enabled FROM participants WHERE id = ? AND deleted_at IS NULL`, id).Scan(&enabled) - if err != nil || enabled != 1 { - return fmt.Errorf("participant not found or not a staff member") - } - if err := app.setParticipantRoles(id, roles); err != nil { +func (app *App) updateUser(id int, role string, deptIDs []int) error { + if _, err := app.db.Exec(`UPDATE users SET role = ? WHERE id = ?`, role, id); err != nil { return err } return app.setUserDeptIDs(id, deptIDs) } func (app *App) updateUserPassword(id int, hash string) error { - _, err := app.db.Exec( - `UPDATE participants SET password_hash = ?, updated_at = ? WHERE id = ? AND login_enabled = 1`, hash, now(), id, - ) + _, err := app.db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, hash, id) return err } -func (app *App) removeUser(id int) error { - tx, err := app.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(`DELETE FROM participant_roles WHERE participant_id = ?`, id); err != nil { - return err - } - if _, err := tx.Exec(`DELETE FROM participant_departments WHERE participant_id = ?`, id); err != nil { - return err - } - if _, err := tx.Exec( - `UPDATE participants SET login_enabled = 0, password_hash = NULL, updated_at = ? WHERE id = ?`, now(), id, - ); err != nil { - return err - } - return tx.Commit() +func (app *App) deleteUser(id int) error { + _, err := app.db.Exec(`DELETE FROM users WHERE id = ?`, id) + return err } func (app *App) countUsers() (int, error) { var n int - err := app.db.QueryRow(`SELECT COUNT(*) FROM participants WHERE login_enabled = 1 AND deleted_at IS NULL`).Scan(&n) + err := app.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n) return n, err } @@ -530,7 +434,7 @@ func (app *App) generateUniqueToken() (string, error) { return "", err } var count int - if err := app.db.QueryRow(`SELECT COUNT(*) FROM tickets WHERE code = ?`, t).Scan(&count); err != nil { + if err := app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE volunteer_token = ?`, t).Scan(&count); err != nil { return "", fmt.Errorf("check token uniqueness: %w", err) } if count == 0 { @@ -540,10 +444,19 @@ func (app *App) generateUniqueToken() (string, error) { return "", fmt.Errorf("failed to generate unique token") } -// generateCodesForAll generates codes for every ticket that doesn't have one yet. -func (app *App) generateCodesForAll() (int, error) { +func (app *App) getAttendeeByToken(token string) (*Attendee, error) { + rows, err := queryAttendees(app.db, + `SELECT `+attendeeCols+` FROM attendees WHERE volunteer_token = ? AND deleted_at IS NULL`, token) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +// generateTokensForAll creates tokens for every attendee that doesn't have one yet. +func (app *App) generateTokensForAll() (int, error) { rows, err := app.db.Query( - `SELECT id FROM tickets WHERE code IS NULL AND deleted_at IS NULL`, + `SELECT id FROM attendees WHERE volunteer_token IS NULL AND deleted_at IS NULL`, ) if err != nil { return 0, err @@ -553,7 +466,7 @@ func (app *App) generateCodesForAll() (int, error) { for rows.Next() { var id int if err := rows.Scan(&id); err != nil { - return 0, fmt.Errorf("scan ticket id: %w", err) + return 0, fmt.Errorf("scan attendee id: %w", err) } ids = append(ids, id) } @@ -564,256 +477,161 @@ func (app *App) generateCodesForAll() (int, error) { if err != nil { continue } - app.db.Exec(`UPDATE tickets SET code=?, updated_at=? WHERE id=?`, t, now(), id) + app.db.Exec(`UPDATE attendees SET volunteer_token=?, updated_at=? WHERE id=?`, t, now(), id) count++ } return count, nil } -// --- Participants --- - -const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, email_confirmed, confirmation_token, created_at, updated_at, deleted_at` - -func (app *App) listParticipants(search, since string) ([]Participant, error) { - var q string - var args []any - if since != "" { - q = `SELECT ` + participantCols + ` FROM participants WHERE updated_at > ? ORDER BY preferred_name, email` - args = append(args, since) - } else { - q = `SELECT ` + participantCols + ` FROM participants WHERE deleted_at IS NULL` - if search != "" { - q += ` AND (preferred_name LIKE ? OR email LIKE ?)` - s := "%" + search + "%" - args = append(args, s, s) - } - q += ` ORDER BY preferred_name, email` - } - return queryParticipants(app.db, q, args...) -} - -func (app *App) getParticipant(id int) (*Participant, error) { - rows, err := queryParticipants(app.db, - `SELECT `+participantCols+` FROM participants WHERE id = ?`, id) - if err != nil || len(rows) == 0 { - return nil, err - } - return &rows[0], nil -} - -func (app *App) getParticipantByEmail(email string) (*Participant, error) { - rows, err := queryParticipants(app.db, - `SELECT `+participantCols+` FROM participants WHERE LOWER(email) = LOWER(?) AND deleted_at IS NULL LIMIT 1`, email) - if err != nil || len(rows) == 0 { - return nil, err - } - return &rows[0], nil -} - -func (app *App) createParticipant(p Participant) (*Participant, error) { +// incrementPartySize bumps party_size for an existing attendee matched by name+ticket_id. +// Used during import to handle duplicate ticket rows from the same order. +func (app *App) incrementPartySize(name, ticketID string) (bool, error) { res, err := app.db.Exec( - `INSERT INTO participants (email, preferred_name, ticket_name, phone, pronouns, note, email_confirmed, confirmation_token, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - strings.ToLower(p.Email), p.PreferredName, p.TicketName, p.Phone, p.Pronouns, p.Note, boolInt(p.EmailConfirmed), p.ConfirmationToken, now(), + `UPDATE attendees SET party_size = party_size + 1, updated_at = ? + WHERE name = ? AND ticket_id = ? AND deleted_at IS NULL`, + now(), name, ticketID, + ) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +// --- Attendees --- + +func (app *App) listAttendees(search, ticketType, checkedIn string) ([]Attendee, error) { + q := `SELECT ` + attendeeCols + ` FROM attendees WHERE deleted_at IS NULL` + var args []any + if search != "" { + q += ` AND (name LIKE ? OR email LIKE ? OR ticket_id LIKE ?)` + s := "%" + search + "%" + args = append(args, s, s, s) + } + if ticketType != "" { + q += ` AND ticket_type = ?` + args = append(args, ticketType) + } + if checkedIn == "true" { + q += ` AND checked_in = 1` + } else if checkedIn == "false" { + q += ` AND checked_in = 0` + } + q += ` ORDER BY name ASC` + return queryAttendees(app.db, q, args...) +} + +func (app *App) getAttendee(id int) (*Attendee, error) { + rows, err := queryAttendees(app.db, + `SELECT `+attendeeCols+` FROM attendees WHERE id = ?`, id) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +func (app *App) createAttendee(a Attendee) (*Attendee, error) { + res, err := app.db.Exec( + `INSERT INTO attendees (name, email, phone, ticket_id, ticket_type, note, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + a.Name, a.Email, a.Phone, a.TicketID, a.TicketType, a.Note, now(), ) if err != nil { return nil, err } id, _ := res.LastInsertId() - return app.getParticipant(int(id)) + return app.getAttendee(int(id)) } -func (app *App) updateParticipant(p Participant) error { +func (app *App) updateAttendee(a Attendee) error { _, err := app.db.Exec( - `UPDATE participants SET email=?, preferred_name=?, ticket_name=?, phone=?, pronouns=?, note=?, updated_at=? - WHERE id=? AND deleted_at IS NULL`, - strings.ToLower(p.Email), p.PreferredName, p.TicketName, p.Phone, p.Pronouns, p.Note, now(), p.ID, + `UPDATE attendees SET name=?, email=?, phone=?, ticket_id=?, ticket_type=?, note=?, updated_at=? + WHERE id = ? AND deleted_at IS NULL`, + a.Name, a.Email, a.Phone, a.TicketID, a.TicketType, a.Note, now(), a.ID, ) return err } -func (app *App) deleteParticipant(id int) error { +func (app *App) deleteAttendee(id int) error { _, err := app.db.Exec( - `UPDATE participants SET deleted_at=?, updated_at=? WHERE id=?`, now(), now(), id, + `UPDATE attendees SET deleted_at = ?, updated_at = ? WHERE id = ?`, now(), now(), id, ) return err } -// mergeParticipants reassigns all tickets and volunteers from other → canonical, then soft-deletes other. -func (app *App) mergeParticipants(canonicalID, otherID int) error { - ts := now() - if _, err := app.db.Exec( - `UPDATE tickets SET participant_id=?, updated_at=? WHERE participant_id=? AND deleted_at IS NULL`, - canonicalID, ts, otherID, - ); err != nil { - return err +// checkInAttendee increments checked_in_count by count (capped at party_size). +// Sets checked_in and checked_in_at on the first check-in. +func (app *App) checkInAttendee(id, userID, count int) (*Attendee, error) { + if count < 1 { + count = 1 } - if _, err := app.db.Exec( - `UPDATE volunteers SET participant_id=?, updated_at=? WHERE participant_id=? AND deleted_at IS NULL`, - canonicalID, ts, otherID, - ); err != nil { - return err - } - app.db.Exec(`INSERT OR IGNORE INTO participant_roles (participant_id, role) SELECT ?, role FROM participant_roles WHERE participant_id = ?`, canonicalID, otherID) - app.db.Exec(`INSERT OR IGNORE INTO participant_departments (participant_id, department_id) SELECT ?, department_id FROM participant_departments WHERE participant_id = ?`, canonicalID, otherID) - _, err := app.db.Exec( - `UPDATE participants SET deleted_at=?, updated_at=? WHERE id=?`, ts, ts, otherID, - ) - return err -} - -func queryParticipants(db *sql.DB, q string, args ...any) ([]Participant, error) { - rows, err := db.Query(q, args...) - if err != nil { + a, err := app.getAttendee(id) + if err != nil || a == nil { return nil, err } - defer rows.Close() - var result []Participant - for rows.Next() { - var p Participant - var emailConfirmed int - var confirmationToken sql.NullString - if err := rows.Scan( - &p.ID, &p.Email, &p.PreferredName, &p.TicketName, &p.Phone, &p.Pronouns, &p.Note, - &emailConfirmed, &confirmationToken, - &p.CreatedAt, &p.UpdatedAt, &p.DeletedAt, - ); err != nil { - return nil, err - } - p.EmailConfirmed = emailConfirmed == 1 - if confirmationToken.Valid { - p.ConfirmationToken = &confirmationToken.String - } - result = append(result, p) + remaining := a.PartySize - a.CheckedInCount + if count > remaining { + count = remaining } - return result, rows.Err() -} - -// upsertParticipant finds a participant by email or creates one. -// Returns the participant and whether it was newly created. -func (app *App) upsertParticipant(email, name string) (*Participant, bool, error) { - p, err := app.getParticipantByEmail(email) - if err != nil { - return nil, false, err + if count <= 0 { + return a, nil } - if p != nil { - return p, false, nil - } - created, err := app.createParticipant(Participant{ - Email: email, - PreferredName: name, - }) - return created, true, err -} - -// --- Tickets --- - -const ticketCols = `id, participant_id, name, ticket_type, source, external_id, order_id, code, checked_in_at, checked_in_by, created_at, updated_at, deleted_at` - -func (app *App) listTickets(participantID *int, since string) ([]Ticket, error) { - q := `SELECT ` + ticketCols + ` FROM tickets WHERE 1=1` - var args []any - if since != "" { - q += ` AND updated_at > ?` - args = append(args, since) - } else { - q += ` AND deleted_at IS NULL` - } - if participantID != nil { - q += ` AND participant_id = ?` - args = append(args, *participantID) - } - q += ` ORDER BY created_at` - return queryTickets(app.db, q, args...) -} - -func (app *App) getTicket(id int) (*Ticket, error) { - rows, err := queryTickets(app.db, - `SELECT `+ticketCols+` FROM tickets WHERE id = ?`, id) - if err != nil || len(rows) == 0 { - return nil, err - } - return &rows[0], nil -} - -func (app *App) createTicket(t Ticket) (*Ticket, error) { - res, err := app.db.Exec( - `INSERT INTO tickets (participant_id, name, ticket_type, source, external_id, order_id, code, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - t.ParticipantID, t.Name, t.TicketType, t.Source, t.ExternalID, t.OrderID, t.Code, now(), - ) - if err != nil { - return nil, err - } - id, _ := res.LastInsertId() - return app.getTicket(int(id)) -} - -func (app *App) checkInTicket(id, userID int) (*Ticket, error) { t := now() - _, err := app.db.Exec(` - UPDATE tickets SET - checked_in_at = CASE WHEN checked_in_at IS NULL THEN ? ELSE checked_in_at END, - checked_in_by = CASE WHEN checked_in_at IS NULL THEN ? ELSE checked_in_by END, - updated_at = ? + _, err = app.db.Exec(` + UPDATE attendees SET + checked_in_count = checked_in_count + ?, + checked_in = CASE WHEN checked_in = 0 THEN 1 ELSE checked_in END, + checked_in_at = CASE WHEN checked_in = 0 THEN ? ELSE checked_in_at END, + checked_in_by = CASE WHEN checked_in = 0 THEN ? ELSE checked_in_by END, + updated_at = ? WHERE id = ? AND deleted_at IS NULL`, - t, userID, t, id, + count, t, userID, t, id, ) if err != nil { return nil, err } - return app.getTicket(id) + return app.getAttendee(id) } -func (app *App) deleteTicket(id int) error { - _, err := app.db.Exec( - `UPDATE tickets SET deleted_at=?, updated_at=? WHERE id=?`, now(), now(), id, - ) - return err +func (app *App) attendeesSince(since string) ([]Attendee, error) { + return queryAttendees(app.db, + `SELECT `+attendeeCols+` FROM attendees WHERE updated_at > ? ORDER BY updated_at ASC`, since) } -func queryTickets(db *sql.DB, q string, args ...any) ([]Ticket, error) { +func queryAttendees(db *sql.DB, q string, args ...any) ([]Attendee, error) { rows, err := db.Query(q, args...) if err != nil { return nil, err } defer rows.Close() - var result []Ticket + var result []Attendee for rows.Next() { - var t Ticket - var participantID, checkedInBy sql.NullInt64 - var code sql.NullString + var a Attendee + var checkedIn int + var token sql.NullString if err := rows.Scan( - &t.ID, &participantID, &t.Name, &t.TicketType, &t.Source, &t.ExternalID, &t.OrderID, - &code, &t.CheckedInAt, &checkedInBy, &t.CreatedAt, &t.UpdatedAt, &t.DeletedAt, + &a.ID, &a.Name, &a.Email, &a.Phone, &a.TicketID, &a.TicketType, + &token, &a.PartySize, &checkedIn, &a.CheckedInCount, + &a.CheckedInAt, &a.CheckedInBy, &a.Note, + &a.CreatedAt, &a.UpdatedAt, &a.DeletedAt, ); err != nil { return nil, err } - if participantID.Valid { - id := int(participantID.Int64) - t.ParticipantID = &id + if token.Valid && token.String != "" { + a.VolunteerToken = &token.String } - if checkedInBy.Valid { - id := int(checkedInBy.Int64) - t.CheckedInBy = &id + a.CheckedIn = checkedIn == 1 + if a.PartySize < 1 { + a.PartySize = 1 } - if code.Valid && code.String != "" { - t.Code = &code.String - } - result = append(result, t) + result = append(result, a) } return result, rows.Err() } -// ticketCounts returns total and checked-in ticket counts for participants page. -func (app *App) ticketCounts() (total, checkedIn int, err error) { - app.db.QueryRow(`SELECT COUNT(*) FROM tickets WHERE deleted_at IS NULL`).Scan(&total) - app.db.QueryRow(`SELECT COUNT(*) FROM tickets WHERE checked_in_at IS NOT NULL AND deleted_at IS NULL`).Scan(&checkedIn) - return -} - -func (app *App) ticketTypes() ([]string, error) { +func (app *App) attendeeTicketTypes() ([]string, error) { rows, err := app.db.Query( - `SELECT DISTINCT ticket_type FROM tickets WHERE ticket_type != '' AND deleted_at IS NULL ORDER BY ticket_type`, + `SELECT DISTINCT ticket_type FROM attendees WHERE ticket_type != '' AND deleted_at IS NULL ORDER BY ticket_type`, ) if err != nil { return nil, err @@ -828,6 +646,12 @@ func (app *App) ticketTypes() ([]string, error) { return types, rows.Err() } +func (app *App) attendeeCounts() (total, checkedIn int, err error) { + app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE deleted_at IS NULL`).Scan(&total) + app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE checked_in=1 AND deleted_at IS NULL`).Scan(&checkedIn) + return +} + // --- Departments --- func (app *App) listDepartments(since string) ([]Department, error) { @@ -895,44 +719,42 @@ func queryDepartments(db *sql.DB, q string, args ...any) ([]Department, error) { // --- Volunteers --- -const volunteerSelect = `v.id, v.participant_id, - p.preferred_name, p.email, p.phone, p.pronouns, - v.department_id, v.is_lead, v.ready, v.ready_at, - v.confirmed, v.confirmed_at, - p.email_confirmed, v.kiosk_code, v.note, - v.created_at, v.updated_at, v.deleted_at` -const volunteerFrom = `FROM volunteers v INNER JOIN participants p ON p.id = v.participant_id` +const volunteerCols = `id, attendee_id, name, email, phone, department_id, is_lead, checked_in, checked_in_at, note, created_at, updated_at, deleted_at` -func (app *App) listVolunteers(search string, deptIDs []int, since string) ([]Volunteer, error) { - q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1` +func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) { + q := `SELECT ` + volunteerCols + ` FROM volunteers WHERE 1=1` var args []any if since != "" { - q += ` AND v.updated_at > ?` + q += ` AND updated_at > ?` args = append(args, since) } else { - q += ` AND v.deleted_at IS NULL` + q += ` AND deleted_at IS NULL` } if search != "" { - q += ` AND (p.preferred_name LIKE ? OR p.email LIKE ?)` + q += ` AND (name LIKE ? OR email LIKE ?)` s := "%" + search + "%" args = append(args, s, s) } - if len(deptIDs) == 1 { - q += ` AND v.department_id = ?` - args = append(args, deptIDs[0]) - } else if len(deptIDs) > 1 { - q += ` AND v.department_id IN (` + placeholders(len(deptIDs)) + `)` - for _, id := range deptIDs { - args = append(args, id) - } + if deptID != nil { + q += ` AND department_id = ?` + args = append(args, *deptID) } - q += ` ORDER BY p.preferred_name` + q += ` ORDER BY name` return queryVolunteers(app.db, q, args...) } func (app *App) getVolunteer(id int) (*Volunteer, error) { rows, err := queryVolunteers(app.db, - `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.id = ?`, id) + `SELECT `+volunteerCols+` FROM volunteers WHERE id = ?`, id) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +func (app *App) getVolunteerByAttendeeID(attendeeID int) (*Volunteer, error) { + rows, err := queryVolunteers(app.db, + `SELECT `+volunteerCols+` FROM volunteers WHERE attendee_id = ? AND deleted_at IS NULL LIMIT 1`, attendeeID) if err != nil || len(rows) == 0 { return nil, err } @@ -941,9 +763,9 @@ func (app *App) getVolunteer(id int) (*Volunteer, error) { func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { res, err := app.db.Exec( - `INSERT INTO volunteers (participant_id, department_id, is_lead, note, updated_at) - VALUES (?, ?, ?, ?, ?)`, - v.ParticipantID, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), + `INSERT INTO volunteers (attendee_id, name, email, phone, department_id, is_lead, note, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + v.AttendeeID, v.Name, v.Email, v.Phone, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), ) if err != nil { return nil, err @@ -954,9 +776,9 @@ func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { func (app *App) updateVolunteer(v Volunteer) error { _, err := app.db.Exec( - `UPDATE volunteers SET department_id=?, is_lead=?, note=?, updated_at=? + `UPDATE volunteers SET attendee_id=?, name=?, email=?, phone=?, department_id=?, is_lead=?, note=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, - v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID, + v.AttendeeID, v.Name, v.Email, v.Phone, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID, ) return err } @@ -968,30 +790,26 @@ func (app *App) deleteVolunteer(id int) error { return err } -func (app *App) markVolunteerReady(id, userID int) (*Volunteer, error) { +// checkInVolunteer marks the volunteer as checked in and, if linked to an attendee, +// also increments the attendee's checked_in_count. +func (app *App) checkInVolunteer(id, userID int) (*Volunteer, error) { t := now() _, err := app.db.Exec( - `UPDATE volunteers SET ready=1, ready_at=?, updated_at=? - WHERE id=? AND deleted_at IS NULL AND ready=0`, + `UPDATE volunteers SET checked_in=1, checked_in_at=?, updated_at=? + WHERE id=? AND deleted_at IS NULL AND checked_in=0`, t, t, id, ) if err != nil { return nil, err } - return app.getVolunteer(id) -} - -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 + v, err := app.getVolunteer(id) + if err != nil || v == nil { + return v, err } - return app.getVolunteer(id) + if v.AttendeeID != nil { + app.checkInAttendee(*v.AttendeeID, userID, 1) + } + return v, nil } func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { @@ -1003,119 +821,33 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { var result []Volunteer for rows.Next() { var v Volunteer - var deptID sql.NullInt64 - var isLead, ready, confirmed, emailConfirmed int - var confirmedAt, kioskCode sql.NullString + var attendeeID, deptID sql.NullInt64 + var isLead, checkedIn int if err := rows.Scan( - &v.ID, &v.ParticipantID, - &v.Name, &v.Email, &v.Phone, &v.Pronouns, - &deptID, &isLead, &ready, &v.ReadyAt, - &confirmed, &confirmedAt, - &emailConfirmed, &kioskCode, &v.Note, + &v.ID, &attendeeID, &v.Name, &v.Email, &v.Phone, &deptID, + &isLead, &checkedIn, &v.CheckedInAt, &v.Note, &v.CreatedAt, &v.UpdatedAt, &v.DeletedAt, ); err != nil { return nil, err } + if attendeeID.Valid { + id := int(attendeeID.Int64) + v.AttendeeID = &id + } if deptID.Valid { id := int(deptID.Int64) v.DepartmentID = &id } - if confirmedAt.Valid { - v.ConfirmedAt = &confirmedAt.String - } - if kioskCode.Valid { - v.KioskCode = &kioskCode.String - } v.IsLead = isLead == 1 - v.Ready = ready == 1 - v.Confirmed = confirmed == 1 - v.EmailConfirmed = emailConfirmed == 1 + v.CheckedIn = checkedIn == 1 result = append(result, v) } return result, rows.Err() } -func (app *App) getVolunteerByEmail(email string) (*Volunteer, error) { - rows, err := queryVolunteers(app.db, - `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE LOWER(p.email) = LOWER(?) AND v.deleted_at IS NULL LIMIT 1`, email) - if err != nil || len(rows) == 0 { - return nil, err - } - return &rows[0], nil -} - -func (app *App) getVolunteerByConfirmationToken(token string) (*Volunteer, error) { - rows, err := queryVolunteers(app.db, - `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE p.confirmation_token = ? AND v.deleted_at IS NULL LIMIT 1`, token) - if err != nil || len(rows) == 0 { - return nil, err - } - return &rows[0], nil -} - -func (app *App) confirmParticipantEmail(participantID int) error { - _, err := app.db.Exec( - `UPDATE participants SET email_confirmed = 1, confirmation_token = NULL, updated_at = ? WHERE id = ?`, - now(), participantID) - return err -} - -func (app *App) setParticipantConfirmationToken(participantID int, token string) error { - _, err := app.db.Exec( - `UPDATE participants SET confirmation_token = ?, updated_at = ? WHERE id = ?`, - token, now(), participantID) - return err -} - -func (app *App) getVolunteerByKioskCode(code string) (*Volunteer, error) { - rows, err := queryVolunteers(app.db, - `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.kiosk_code = ? AND v.deleted_at IS NULL LIMIT 1`, code) - if err != nil || len(rows) == 0 { - return nil, err - } - return &rows[0], nil -} - -func (app *App) assignKioskCode(id int, code string) error { - _, err := app.db.Exec( - `UPDATE volunteers SET kiosk_code=?, updated_at=? WHERE id=?`, code, now(), id) - return err -} - -func (app *App) listVolunteersNeedingKioskCode() ([]Volunteer, error) { - return queryVolunteers(app.db, ` - SELECT `+volunteerSelect+` `+volunteerFrom+` - WHERE p.email_confirmed = 1 AND v.kiosk_code IS NULL AND v.deleted_at IS NULL`) -} - -func (app *App) generateVolunteerKioskCode() (string, error) { - for range 10 { - t, err := generateToken() - if err != nil { - return "", err - } - var count int - if err := app.db.QueryRow(`SELECT COUNT(*) FROM volunteers WHERE kiosk_code = ?`, t).Scan(&count); err != nil { - return "", fmt.Errorf("check kiosk code uniqueness: %w", err) - } - if count == 0 { - return t, nil - } - } - return "", fmt.Errorf("failed to generate unique kiosk code") -} - -func generateConfirmationToken() (string, error) { - b := make([]byte, 16) - if _, err := rand.Read(b); err != nil { - return "", fmt.Errorf("read random: %w", err) - } - return fmt.Sprintf("%x", b), nil -} - // --- Shifts --- -func (app *App) listShifts(deptIDs []int, day, since string) ([]Shift, error) { +func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) { q := `SELECT ` + shiftCols + ` FROM shifts WHERE 1=1` var args []any if since != "" { @@ -1124,14 +856,9 @@ func (app *App) listShifts(deptIDs []int, day, since string) ([]Shift, error) { } else { q += ` AND deleted_at IS NULL` } - if len(deptIDs) == 1 { + if deptID != nil { q += ` AND department_id = ?` - args = append(args, deptIDs[0]) - } else if len(deptIDs) > 1 { - q += ` AND department_id IN (` + placeholders(len(deptIDs)) + `)` - for _, id := range deptIDs { - args = append(args, id) - } + args = append(args, *deptID) } if day != "" { q += ` AND day = ?` @@ -1302,7 +1029,7 @@ var errShiftFull = fmt.Errorf("shift is full") func (app *App) unassignShift(volunteerID, shiftID int) error { _, err := app.db.Exec( - `UPDATE volunteer_shifts SET deleted_at=?, updated_at=? WHERE volunteer_id=? AND shift_id=?`, + `UPDATE volunteer_shifts SET deleted_at = ?, updated_at = ? WHERE volunteer_id=? AND shift_id=?`, now(), now(), volunteerID, shiftID, ) return err @@ -1355,27 +1082,6 @@ func (app *App) listOpenShiftsForDept(deptID int) ([]Shift, error) { ORDER BY s.day, s.position, s.start_time`, deptID) } -// --- SSO Nonces --- - -func (app *App) createSSONonce(nonce string) error { - _, err := app.db.Exec(`INSERT INTO sso_nonces (nonce) VALUES (?)`, nonce) - return err -} - -func (app *App) consumeSSONonce(nonce string) (bool, error) { - res, err := app.db.Exec( - `DELETE FROM sso_nonces WHERE nonce = ? AND created_at > datetime('now', '-10 minutes')`, nonce) - if err != nil { - return false, err - } - n, _ := res.RowsAffected() - return n > 0, nil -} - -func (app *App) cleanExpiredNonces() { - app.db.Exec(`DELETE FROM sso_nonces WHERE created_at < datetime('now', '-10 minutes')`) -} - // --- Helpers --- func now() string { @@ -1388,10 +1094,3 @@ func boolInt(b bool) int { } return 0 } - -func placeholders(n int) string { - if n <= 0 { - return "" - } - return strings.Repeat("?,", n-1) + "?" -} diff --git a/db_test.go b/db_test.go index 5755d08..0d453bb 100644 --- a/db_test.go +++ b/db_test.go @@ -7,7 +7,7 @@ import ( func TestMigrate(t *testing.T) { app := testApp(t) // Verify tables exist by querying each one - tables := []string{"event", "participants", "participant_roles", "departments", "volunteers", "shifts", "volunteer_shifts"} + tables := []string{"event", "users", "attendees", "departments", "volunteers", "shifts", "volunteer_shifts"} for _, table := range tables { var count int err := app.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) @@ -17,6 +17,98 @@ func TestMigrate(t *testing.T) { } } +func TestAttendeesCRUD(t *testing.T) { + app := testApp(t) + + a, err := app.createAttendee(Attendee{Name: "Titania", Email: "titania@test.com", TicketType: "GA"}) + if err != nil { + t.Fatal(err) + } + if a.ID == 0 || a.Name != "Titania" { + t.Errorf("create: got %+v", a) + } + + got, err := app.getAttendee(a.ID) + if err != nil || got == nil { + t.Fatal("get: not found") + } + if got.Email != "titania@test.com" { + t.Errorf("get: email = %q", got.Email) + } + + got.Name = "Titania Fairweather" + if err := app.updateAttendee(*got); err != nil { + t.Fatal(err) + } + got2, _ := app.getAttendee(a.ID) + if got2.Name != "Titania Fairweather" { + t.Errorf("update: name = %q", got2.Name) + } + + if err := app.deleteAttendee(a.ID); err != nil { + t.Fatal(err) + } + // getAttendee returns soft-deleted records; listAttendees filters them + attendees, _ := app.listAttendees("", "", "") + for _, at := range attendees { + if at.ID == a.ID { + t.Error("delete: still visible in list") + } + } +} + +func TestIncrementPartySize(t *testing.T) { + app := testApp(t) + + app.createAttendee(Attendee{Name: "Oberon", TicketID: "ORD-100"}) + + merged, err := app.incrementPartySize("Oberon", "ORD-100") + if err != nil || !merged { + t.Fatalf("increment: merged=%v, err=%v", merged, err) + } + + a, _ := app.getAttendee(1) + if a.PartySize != 2 { + t.Errorf("party_size = %d, want 2", a.PartySize) + } + + // Different ticket_id should not merge + merged2, _ := app.incrementPartySize("Oberon", "ORD-200") + if merged2 { + t.Error("should not merge different ticket_id") + } +} + +func TestCheckInAttendee(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + + app.createAttendee(Attendee{Name: "Puck"}) + // Set party_size directly since createAttendee defaults to 1 + app.db.Exec(`UPDATE attendees SET party_size = 3 WHERE id = 1`) + + // Check in 1 + a, err := app.checkInAttendee(1, admin.ID, 1) + if err != nil { + t.Fatal(err) + } + if a.CheckedInCount != 1 || !a.CheckedIn { + t.Errorf("after 1: count=%d, checked_in=%v", a.CheckedInCount, a.CheckedIn) + } + + // Check in 2 more (should cap at party_size=3) + a, _ = app.checkInAttendee(1, admin.ID, 5) + if a.CheckedInCount != 3 { + t.Errorf("after cap: count=%d, want 3", a.CheckedInCount) + } + + // Check in again — already full, should stay at 3 + a, _ = app.checkInAttendee(1, admin.ID, 1) + if a.CheckedInCount != 3 { + t.Errorf("after full: count=%d, want 3", a.CheckedInCount) + } +} + func TestGenerateToken(t *testing.T) { token, err := generateToken() if err != nil { @@ -105,8 +197,7 @@ func TestAssignAndUnassignShift(t *testing.T) { dept, _ := app.createDepartment(Department{Name: "Gate"}) deptID := dept.ID s, _ := app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2}) - p, _ := app.createParticipant(Participant{PreferredName: "Helena", Email: "helena@test.com"}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) + v, _ := app.createVolunteer(Volunteer{Name: "Helena", DepartmentID: &deptID}) if err := app.assignShift(v.ID, s.ID); err != nil { t.Fatal(err) @@ -130,8 +221,7 @@ func TestCheckShiftConflict(t *testing.T) { dept, _ := app.createDepartment(Department{Name: "Gate"}) deptID := dept.ID - p, _ := app.createParticipant(Participant{PreferredName: "Hermia", Email: "hermia@test.com"}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) + v, _ := app.createVolunteer(Volunteer{Name: "Hermia", DepartmentID: &deptID}) s1, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) s2, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"}) @@ -160,8 +250,7 @@ func TestCheckShiftConflictMidnight(t *testing.T) { dept, _ := app.createDepartment(Department{Name: "Sound"}) deptID := dept.ID - p, _ := app.createParticipant(Participant{PreferredName: "Lysander", Email: "lysander@test.com"}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) + v, _ := app.createVolunteer(Volunteer{Name: "Lysander", DepartmentID: &deptID}) // Night shift: 22:00-02:00 (spans midnight) night, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Night", Day: "2026-03-15", StartTime: "22:00", EndTime: "02:00"}) diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 9bc0dbc..1f9a967 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -105,27 +105,23 @@ docker run -p 8180:8180 \ ## NixOS -Turnpike builds with `buildGoModule` + `buildNpmPackage` (pure-Go SQLite, no CGO). The frontend is built separately and copied into the Go build: +Turnpike builds with `buildGoModule` using the pure-Go SQLite driver (no CGO): ```nix -frontendDist = pkgs.buildNpmPackage { - pname = "turnpike-frontend"; - src = "${src}/frontend"; - npmDepsHash = "sha256-..."; - buildPhase = "npm run build"; - installPhase = "cp -r dist $out"; -}; - turnpike = pkgs.buildGoModule { pname = "turnpike"; - src = fetchgit { url = "..."; rev = "v2.0.0"; hash = "sha256-..."; }; - vendorHash = "sha256-..."; + version = "0.1.0"; + src = ./path/to/turnpike; # must include vendor/ and frontend/dist/ + vendorHash = null; env.CGO_ENABLED = 0; - preBuild = "cp -r ${frontendDist} frontend/dist"; }; ``` -A complete NixOS module with `DynamicUser`, `StateDirectory`, and secrets is in the project's `homelab/turnpike.nix`. +The source directory must contain: +- Go source files and `vendor/` (run `go mod vendor`) +- Pre-built frontend at `frontend/dist/` (run `cd frontend && npm run build`) + +A complete NixOS module example with `DynamicUser`, `StateDirectory`, and agenix secrets is in the project's `homelab/turnpike.nix`. ## Reverse Proxy diff --git a/docs/USAGE.md b/docs/USAGE.md index c08ec50..80b25f0 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -12,22 +12,23 @@ After logging in, create accounts for your team under **Users**. Each user gets | Role | What they see | What they can do | |------|--------------|------------------| -| **admin** | All pages + Settings | Everything: participant import, user management, SMTP config, departments, shifts, volunteers | -| **ticketing** | Participants, Tickets, Import | Manage participants and tickets, run CSV imports | -| **staffing** | Dashboard, Schedule, Volunteers, Departments | Manage volunteers, departments, and shifts across all departments. No user management or settings | -| **colead** | Dashboard, Schedule, Volunteers | Manage volunteers and shifts within their assigned department(s) only | -| **gatekeeper** | Full-screen Gate Kiosk | Check in ticket holders (search + QR scan). No access to other pages | +| **admin** | All pages + Settings | Everything: attendee import, user management, SMTP config, departments, shifts, volunteers | +| **coordinator** | Dashboard, Schedule Board, Volunteers, Departments, Shifts | Manage volunteers, departments, and shifts across all departments. Cannot manage users or settings | +| **volunteer_lead** | Schedule Board, Volunteers, Departments | Manage volunteers and shifts within their assigned department only | +| **gate** | Full-screen Gate UI | Check in attendees (search + QR scan). No access to other pages | -Coleads are scoped to one or more departments. When creating a colead user, assign their department(s). +Ticketing and ops staff should use the **admin** role. The `ticketing` role exists in the codebase but is effectively unused — admin covers all ticketing functions. + +Volunteer leads are scoped to a single department. When creating a volunteer_lead user, assign their department. ## Event Setup -1. **Configure your event** — go to **Settings** and set the event name, venue, dates, and timezone. These appear on the Dashboard and volunteer signup page. +1. **Configure your event** — go to the Dashboard and set the event name and dates. 2. **Create departments** — under Departments, add each department your event needs (e.g., Gate, Greeters, Rangers, Build, LNT). -3. **Import participants** — see next section. -4. **Create shifts** — under Schedule, create shifts for each department with day, start/end time, and capacity. +3. **Import attendees** — see next section. +4. **Create shifts** — under Shifts, create shifts for each department with day, start/end time, and capacity. -## Importing Participants +## Importing Attendees Go to **Import** and upload a CSV file. Turnpike auto-detects two formats: @@ -35,7 +36,7 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats: | Column | Maps to | |--------|---------| -| `Patron Name` | Ticket name | +| `Patron Name` | Name | | `Patron Email` | Email | | `Order Number` | Ticket ID | | `Tier Name` | Ticket type | @@ -44,7 +45,7 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats: | Column | Maps to | |--------|---------| -| `name` (required) | Ticket name | +| `name` (required) | Name | | `email` | Email | | `ticket_id` | Ticket ID | | `ticket_type` | Ticket type | @@ -52,67 +53,32 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats: Column matching is case-insensitive. Extra columns are ignored. BOM-encoded files (Windows Excel exports) are handled automatically. -### Participants and tickets +### Party-size dedup -Each row in the CSV creates one **ticket**. Participants are deduplicated by email — multiple tickets with the same email address are linked to a single participant record. The import result shows `inserted` (new tickets) and `skipped` (exact duplicates). +CrowdWork exports one row per ticket, even when the same person bought multiple tickets in one order. Turnpike handles this automatically: -Re-importing the same CSV is safe — exact duplicates are skipped, not duplicated. +- First row for "Titania Fairweather" (order 1234) creates a record with `party_size=1` +- Subsequent rows with the same name + order number increment `party_size` (no duplicate record) +- Result: one attendee record, `party_size=3` if three tickets were purchased -## Volunteer Signup +The import result shows `inserted` (new records), `grouped` (merged into existing party), and `skipped` (exact duplicates). -Turnpike provides a public signup form for volunteers at `/volunteer-signup`. No login is required. - -### Signup flow - -1. Volunteer visits the signup form and fills in: preferred name (required), ticket name, email (required), pronouns, phone, department preference, and an optional note. -2. Turnpike creates a volunteer record and auto-links it to an existing participant by email match, or creates a new participant record. -3. A confirmation email is sent with a unique link (`/confirm/{token}`). -4. The volunteer clicks the link to confirm their email. -5. If shift signups are already open, the confirmation page includes a link to the kiosk for shift selection. - -Duplicate signups with the same email silently succeed — no error is shown and no duplicate is created. This prevents email enumeration. - -### Configuring the signup form - -In **Settings**, the "Volunteer Signup" card controls: - -- **Note field label** — customize the label shown on the form (default: "Additional note") -- **Note field required** — when checked, volunteers must fill in the note to submit - -### Opening shift signups - -In **Settings**, the "Shift Signups" card has an open/close toggle: - -- **Opening** signups generates kiosk codes for all registered (email-confirmed) volunteers and emails them their shift signup links. A confirmation dialog warns before sending. -- **Closing** signups prevents new kiosk links from being issued on confirmation, but existing links continue to work. - -If a volunteer confirms their email while signups are already open, they receive their kiosk link immediately in the confirmation response and via email. +Re-importing the same CSV is safe — existing records are skipped, not duplicated. ## Managing Volunteers Under **Volunteers**, you can: -- Create volunteers manually (name, email, department, co-lead, note) -- Edit existing volunteers (department, co-lead, note) via the inline Edit button -- Confirm registered volunteers (admin, staffing, colead) -- Mark volunteers as ready (briefed at the volunteer station) +- Create volunteers manually (name, email, department) +- Link a volunteer to an existing attendee record (for dual check-in at the gate) +- Assign volunteers to departments +- Check in volunteers -### Volunteer statuses - -| Status | Meaning | Who sets it | -|--------|---------|-------------| -| **Unconfirmed** | Signed up but hasn't confirmed their email | Automatic (not yet done) | -| **Registered** | Email confirmed — volunteer is in the system | Automatic (email link) | -| **Confirmed** | Staff has verified the volunteer is assigned and ready to be scheduled | Admin, staffing, or co-lead | -| **Ready** | Briefed at the volunteer station, cleared to report for shifts | Gate staff at check-in | - -**Confirmation** is a deliberate staff action — it signals that you're expecting the volunteer for shifts. Use the **Confirm** button on a registered volunteer's row. Marking a volunteer as a co-lead (`is_lead`) automatically confirms them. - -Volunteers are separate from participants. A person can be both a ticket holder and a volunteer. When a volunteer signs up via the public form, they are automatically linked to their participant record by email. +Volunteers are separate from attendees. A person can be both an attendee (ticket holder) and a volunteer (shift worker). Linking them enables the gate team to check in both records simultaneously. ## Shift Scheduling -Under **Schedule**, create shifts for each department: +Under **Shifts**, create shifts for each department: - **Day** — the date of the shift - **Start/end time** — HH:MM format @@ -120,29 +86,27 @@ Under **Schedule**, create shifts for each department: ### Assigning volunteers -From the Schedule page, assign volunteers to shifts. Turnpike checks for conflicts — if a volunteer already has a shift on the same day with overlapping times, you'll see a warning and can choose to force the assignment. +From the Shifts page or the Schedule Board, assign volunteers to shifts. Turnpike checks for conflicts — if a volunteer already has a shift on the same day with overlapping times, you'll see a warning and can choose to force the assignment. ### Reordering -Shifts can be reordered within a department to reflect priority or sequence using the up/down buttons on each shift card. +Shifts can be reordered within a department to reflect priority or sequence. The Schedule Board supports drag-and-drop reordering. ## Volunteer Kiosk -The Volunteer Kiosk is the public-facing flow for volunteers: signup, email confirmation, and shift self-scheduling. The shift scheduling page lets volunteers self-select shifts without logging in. +The kiosk lets volunteers self-select shifts without logging in. ### Setup -Kiosk links are generated and distributed automatically through the volunteer signup flow: - -1. Volunteers sign up via the public signup form (`/volunteer-signup`) and confirm their email. -2. In **Settings**, open shift signups. This generates kiosk codes for all registered (email-confirmed) volunteers and emails them their links. A confirmation dialog warns before sending. -3. If a volunteer confirms their email while signups are already open, they receive their kiosk link immediately. - -**Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Kiosk links use this URL. +1. **Generate tokens** — on the Attendees page, click "Generate Tokens." This creates a unique 8-character code for every attendee that doesn't have one. +2. **Distribute tokens** — two options: + - **Export CSV** — downloads a file with columns `Email Address`, `First Name`, `Token`, `Signup Link`. Import this into MailChimp, Zeffy, or any email platform. + - **Email directly** — if SMTP is configured (see below), use "Email All" to send token links, or email individually per attendee. +3. **Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Token links use this URL. ### Volunteer experience -Each volunteer receives a link like `https://turnpike.example.com/v/ABC12345`. This opens a mobile-friendly page showing: +Each volunteer receives a link like `https://turnpike.example.com/#/v/ABC12345`. This opens a mobile-friendly page showing: - Their name and department - Currently assigned shifts @@ -150,45 +114,43 @@ Each volunteer receives a link like `https://turnpike.example.com/v/ABC12345`. T Claiming a shift checks for time conflicts. If a conflict exists, the volunteer sees which shifts overlap and can confirm to proceed anyway. -No login is required. The kiosk code authenticates the request. +No login is required. The 8-character token authenticates the request. -### Code format +### Token format -Kiosk codes use the character set `A-Z, 2-9` (excluding 0/O, 1/I/L to avoid ambiguity when reading aloud or on printed badges). +Tokens use the character set `A-Z, 2-9` (excluding 0/O, 1/I/L to avoid ambiguity when reading aloud or on printed badges). -## Gate Kiosk +## Gate Check-In -Users with the **gatekeeper** role see a dedicated full-screen Gate Kiosk: +Users with the **gate** role see a dedicated full-screen UI: - **QR scanner** — uses the device camera via the BarcodeDetector API. Scanned codes populate the search field. -- **Search** — type a name to filter tickets in real-time (searches local IndexedDB, works offline). +- **Search** — type a name to filter attendees in real-time (searches local IndexedDB, works offline). +- **Party check-in** — for attendees with `party_size > 1`, the gate UI shows progress ("2/3 checked in") and offers "Check in 1" or "Check in all remaining." +- **Volunteer dual check-in** — if an attendee is linked to a volunteer record, the gate UI shows their volunteer status and offers to check in both simultaneously. - **Recent check-ins** — the last 10 check-ins are shown for quick reference. -Admins and ticketing leads can also check in tickets directly from the **Participants** page by expanding a participant's tickets. - Gate devices should install Turnpike as a PWA (Add to Home Screen) for the best experience. Check-ins are stored locally and sync when connectivity is available. -## Schedule +## Schedule Board -The Schedule page is the primary UI for managing shifts and volunteer assignments. It shows: +The Schedule Board is the primary UI for coordinators and volunteer leads. It shows: - Shifts grouped by department and day - Each shift card shows: name, time, capacity (used/total), assigned volunteers - Conflict badges when a volunteer has overlapping shifts on the same day -**Admins and staffing** see all departments. **Coleads** see only their assigned department(s). +**Coordinators and admins** see all departments. **Volunteer leads** see only their assigned department. Actions available: -- Create new shifts (+ Add shift button) -- Edit shift details inline -- Delete shifts - Assign volunteers to shifts from a dropdown - Remove volunteer assignments - Reorder shifts within a department +- Edit shift details inline ## SMTP Configuration -SMTP enables volunteer confirmation emails, kiosk link distribution, and test emails. Configure in **Settings** (admin only): +SMTP enables token email distribution and test emails. Configure in **Settings** (admin only): | Field | Description | |-------|-------------| @@ -209,13 +171,13 @@ Turnpike is a Progressive Web App (PWA). After the first load, it works offline: - **Gate check-ins** are stored in the browser's IndexedDB and sync when connectivity returns. - **Real-time updates** use Server-Sent Events (SSE). When the connection drops, the client reconnects automatically. -- **Sync** pulls all changes from the server on startup and periodically thereafter. +- **Sync** pulls all changes from the server on startup and periodically thereafter. Local changes are queued in an outbox and flushed in order. Install Turnpike as a PWA (Add to Home Screen on mobile, or Install App in desktop Chrome) for the best offline experience. ## CSV Exports -CSV exports are available from the Participants page: +Two CSV exports are available from the Attendees page: -- **Participant export** — all participant records with check-in status -- **Ticket export** — all ticket records with codes and check-in status +- **Attendee export** — all attendee records with check-in status +- **Token link export** — columns: `Email Address`, `First Name`, `Token`, `Signup Link`. Only includes attendees with tokens. Compatible with MailChimp and Zeffy for bulk email campaigns. diff --git a/email.go b/email.go index 41a7a55..05c94f0 100644 --- a/email.go +++ b/email.go @@ -106,73 +106,35 @@ func sendEmail(cfg SMTPConfig, to, subject, body string) error { return smtp.SendMail(addr, auth, cfg.From, []string{to}, []byte(msg)) } -func (app *App) resolveBaseURL() string { +// sendTokenEmail sends a volunteer token link to the attendee's email address. +func (app *App) sendTokenEmail(a Attendee) error { + if a.Email == "" { + return fmt.Errorf("attendee has no email address") + } + if a.VolunteerToken == nil || *a.VolunteerToken == "" { + return fmt.Errorf("attendee has no volunteer token") + } + + cfg := app.loadSMTPConfig() + baseURL := app.baseURL if baseURL == "" { app.db.QueryRow(`SELECT value FROM config WHERE key = 'base_url'`).Scan(&baseURL) } - return strings.TrimRight(baseURL, "/") -} + baseURL = strings.TrimRight(baseURL, "/") -func (app *App) eventName() string { event, _ := app.getEvent() + eventName := "the event" if event != nil && event.Name != "" { - return event.Name - } - return "the event" -} - -// sendTicketTokenEmail sends a volunteer token link for a ticket to its participant's email. -func (app *App) sendTicketTokenEmail(tk Ticket) error { - if tk.Code == nil || *tk.Code == "" { - return fmt.Errorf("ticket has no code") - } - if tk.ParticipantID == nil { - return fmt.Errorf("ticket has no participant") - } - p, err := app.getParticipant(*tk.ParticipantID) - if err != nil || p == nil { - return fmt.Errorf("participant not found") - } - if p.Email == "" { - return fmt.Errorf("participant has no email address") + eventName = event.Name } - cfg := app.loadSMTPConfig() - eventName := app.eventName() - link := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), *tk.Code) - name := p.PreferredName - if name == "" { - name = tk.Name - } + link := fmt.Sprintf("%s/#/v/%s", baseURL, *a.VolunteerToken) subject := fmt.Sprintf("Your volunteer link for %s", eventName) body := fmt.Sprintf( "Hi %s,\n\nThank you for volunteering at %s!\n\nYour volunteer token: %s\nYour signup link: %s\n\nUse this link to sign up for available shifts in your department.\n\nSee you there!\n", - name, eventName, *tk.Code, link, + a.Name, eventName, *a.VolunteerToken, link, ) - return sendEmail(cfg, p.Email, subject, body) -} - -func (app *App) sendConfirmationEmail(to, name, confirmToken string) error { - cfg := app.loadSMTPConfig() - eventName := app.eventName() - link := fmt.Sprintf("%s/confirm/%s", app.resolveBaseURL(), confirmToken) - subject := fmt.Sprintf("Please confirm your email for %s", eventName) - body := fmt.Sprintf( - "Hi %s,\n\nThank you for signing up to volunteer at %s!\n\nPlease confirm your email address by visiting:\n%s\n\nIf you did not sign up, you can safely ignore this email.\n", - name, eventName, link, - ) - return sendEmail(cfg, to, subject, body) -} - -func (app *App) sendShiftSignupEmail(to, name, kioskLink string) error { - cfg := app.loadSMTPConfig() - eventName := app.eventName() - subject := fmt.Sprintf("Shift signups are open for %s!", eventName) - body := fmt.Sprintf( - "Hi %s,\n\nShift signups are now open for %s!\n\nUse this link to sign up for available shifts:\n%s\n\nSee you there!\n", - name, eventName, kioskLink, - ) - return sendEmail(cfg, to, subject, body) + return sendEmail(cfg, a.Email, subject, body) } diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index ac0957e..52ed392 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,18 +1,17 @@ {#if updateAvailable} @@ -123,23 +86,19 @@ {#if loading} {:else if kioskToken} - -{:else if isVolunteerSignup} - -{:else if isConfirmEmail} - + {:else if !session} - -{:else if roles.length === 1 && roles[0] === 'gatekeeper'} - - + +{:else if role === 'gate'} + + {:else}
{#if mobileNavOpen} {/if} -