diff --git a/Makefile b/Makefile
index 30bca30..a8de0d3 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) | 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)
+MAJOR := $(shell echo $(LAST_TAG) | cut -d. -f1)
+MINOR := $(shell echo $(LAST_TAG) | cut -d. -f2)
+PATCH := $(shell echo $(LAST_TAG) | 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 v$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))
- @echo "Tagged v$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))"
+ git tag $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))
+ @echo "Tagged $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))"
minor:
- git tag v$(MAJOR).$(shell echo $$(($(MINOR)+1))).0
- @echo "Tagged v$(MAJOR).$(shell echo $$(($(MINOR)+1))).0"
+ git tag $(MAJOR).$(shell echo $$(($(MINOR)+1))).0
+ @echo "Tagged $(MAJOR).$(shell echo $$(($(MINOR)+1))).0"
major:
- git tag v$(shell echo $$(($(MAJOR)+1))).0.0
- @echo "Tagged v$(shell echo $$(($(MAJOR)+1))).0.0"
+ git tag $(shell echo $$(($(MAJOR)+1))).0.0
+ @echo "Tagged $(shell echo $$(($(MAJOR)+1))).0.0"
diff --git a/README.md b/README.md
index b526fbe..71132c4 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,21 @@
# Turnpike
-Self-hosted event attendee and volunteer management. One instance, one event.
+Self-hosted event ticketing and volunteer management. One instance, one event.
Turnpike handles gate check-in, volunteer scheduling, and department coordination for events ranging from a single evening to a multi-day festival. It works offline — gate volunteers can check people in without a network connection and sync when connectivity returns.
## Features
-- **Attendee management** — CSV import (CrowdWork/Zeffy auto-detected), party-size tracking, search, check-in
-- **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
+- **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
- **Offline-first PWA** — installs on phones/tablets, full offline check-in with background sync
- **Real-time** — check-ins and changes broadcast live via SSE
-- **SMTP email** — send volunteer token links directly or export CSV for bulk email platforms
+- **SMTP email** — volunteer confirmation emails, kiosk link distribution when shift signups open
- **Single binary** — Go backend embeds the frontend; no runtime dependencies
## Tech Stack
@@ -59,10 +60,11 @@ See [docs/INSTALLATION.md](docs/INSTALLATION.md) for systemd, Docker, NixOS, and
| Role | Access |
|------|--------|
-| `admin` | Full access: attendee import, user management, SMTP settings, all departments and shifts |
-| `coordinator` | All departments: volunteers, shifts, schedule 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 |
+| `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 |
See [docs/USAGE.md](docs/USAGE.md) for detailed workflow documentation.
@@ -90,7 +92,7 @@ The Vite dev server runs on `:5173` and proxies `/api` requests to the Go server
## Documentation
-- [Usage Guide](docs/USAGE.md) — event setup, attendee import, volunteer kiosk, gate check-in, schedule board
+- [Usage Guide](docs/USAGE.md) — event setup, participant import, volunteer signup, volunteer kiosk, gate check-in, schedule
- [Installation Guide](docs/INSTALLATION.md) — building, deploying, systemd, Docker, NixOS, reverse proxy, backup
## License
diff --git a/auth.go b/auth.go
index c2d11af..b675e6f 100644
--- a/auth.go
+++ b/auth.go
@@ -12,10 +12,10 @@ import (
)
type Claims struct {
- UserID int `json:"uid"`
- Username string `json:"sub"`
- Role string `json:"role"`
- DeptIDs []int `json:"dept_ids,omitempty"`
+ ParticipantID int `json:"pid"`
+ Email string `json:"sub"`
+ Roles []string `json:"roles"`
+ 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(u *User) (string, error) {
+func (app *App) signToken(s *User) (string, error) {
expiry := time.Duration(app.tokenExpiry) * time.Hour
claims := Claims{
- UserID: u.ID,
- Username: u.Username,
- Role: u.Role,
- DeptIDs: u.DepartmentIDs,
+ ParticipantID: s.ID,
+ Email: s.Email,
+ Roles: s.Roles,
+ DeptIDs: s.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 && !hasRole(claims.Role, roles) {
+ if len(roles) > 0 && !hasAnyRole(claims.Roles, roles) {
writeError(w, "forbidden", http.StatusForbidden)
return
}
@@ -97,9 +97,25 @@ func (app *App) requireAuth(next http.HandlerFunc, roles ...string) http.Handler
}
}
-func hasRole(role string, allowed []string) bool {
- for _, r := range allowed {
- if r == role {
+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 {
return true
}
}
diff --git a/auth_test.go b/auth_test.go
index 1a16571..602c6cf 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{
- "username": admin.Username,
+ "email": admin.Email,
"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["username"] != "admin" {
+ if !ok || user["email"] != "oberon@athens.example" {
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{
- "username": "admin",
+ "email": "oberon@athens.example",
"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{
- "username": "nobody",
+ "email": "nobody@test.com",
"password": "test",
})
w := httptest.NewRecorder()
@@ -94,8 +94,7 @@ func TestAuthMiddlewareRoleEnforcement(t *testing.T) {
app := testApp(t)
mux := testMux(app)
- // Create a gate user — should not be able to access /api/users (admin only)
- gate := testUserWithRole(t, app, "gateuser", "gate", []int{})
+ gate := testUserWithRoles(t, app, "Starveling", []string{"gatekeeper"}, []int{})
token := testToken(t, app, gate)
req := testAuthRequest("GET", "/api/users", nil, token)
@@ -121,7 +120,7 @@ func TestMeEndpoint(t *testing.T) {
t.Fatalf("status = %d", w.Code)
}
result := parseJSON(t, w)
- if result["username"] != "admin" {
- t.Errorf("username = %v", result["username"])
+ if result["email"] != "oberon@athens.example" {
+ t.Errorf("email = %v", result["email"])
}
}
diff --git a/db.go b/db.go
index ef3a339..0ec6716 100644
--- a/db.go
+++ b/db.go
@@ -40,20 +40,6 @@ 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,
@@ -63,44 +49,25 @@ func migrate(db *sql.DB) error {
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_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
+ 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 UNIQUE INDEX IF NOT EXISTS idx_volunteers_kiosk_code
+ ON volunteers(kiosk_code) WHERE kiosk_code IS NOT NULL;
+
CREATE TABLE IF NOT EXISTS shifts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
department_id INTEGER NOT NULL REFERENCES departments(id) ON DELETE CASCADE,
@@ -119,53 +86,71 @@ 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'))
+ );
`)
- 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)
+ return err
}
// --- 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`
@@ -181,30 +166,12 @@ type Event struct {
}
type User struct {
- 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"`
+ 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"`
}
type Department struct {
@@ -217,19 +184,56 @@ type Department struct {
}
type Volunteer struct {
- 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"`
+ 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"`
}
type Shift struct {
@@ -279,11 +283,45 @@ func (app *App) upsertEvent(e Event) error {
return err
}
-// --- Users ---
+// --- Staff (participants with login_enabled) ---
-func (app *App) getUserDeptIDs(userID int) ([]int, error) {
+func (app *App) getParticipantRoles(participantID int) ([]string, error) {
rows, err := app.db.Query(
- `SELECT department_id FROM user_departments WHERE user_id = ? ORDER BY department_id`, userID,
+ `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,
)
if err != nil {
return nil, err
@@ -301,14 +339,13 @@ func (app *App) getUserDeptIDs(userID int) ([]int, error) {
return ids, rows.Err()
}
-func (app *App) setUserDeptIDs(userID int, deptIDs []int) error {
- _, err := app.db.Exec(`DELETE FROM user_departments WHERE user_id = ?`, userID)
- if err != nil {
+func (app *App) setUserDeptIDs(participantID int, deptIDs []int) error {
+ if _, err := app.db.Exec(`DELETE FROM participant_departments WHERE participant_id = ?`, participantID); err != nil {
return err
}
for _, deptID := range deptIDs {
if _, err := app.db.Exec(
- `INSERT INTO user_departments (user_id, department_id) VALUES (?, ?)`, userID, deptID,
+ `INSERT INTO participant_departments (participant_id, department_id) VALUES (?, ?)`, participantID, deptID,
); err != nil {
return err
}
@@ -316,98 +353,157 @@ func (app *App) setUserDeptIDs(userID int, deptIDs []int) error {
return nil
}
-func (app *App) getUserByUsername(username string) (*User, string, error) {
- var u User
- var hash string
+func (app *App) getLoginParticipant(email string) (*User, string, error) {
+ var s User
+ var hash sql.NullString
err := app.db.QueryRow(
- `SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?`, username,
- ).Scan(&u.ID, &u.Username, &hash, &u.Role, &u.CreatedAt)
+ `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)
if err == sql.ErrNoRows {
return nil, "", nil
}
if err != nil {
return nil, "", err
}
- u.DepartmentIDs, err = app.getUserDeptIDs(u.ID)
- return &u, hash, 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
}
-func (app *App) getUserByID(id int) (*User, error) {
- var u User
+func (app *App) getUser(id int) (*User, error) {
+ var s User
err := app.db.QueryRow(
- `SELECT id, username, role, created_at FROM users WHERE id = ?`, id,
- ).Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt)
+ `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)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
- u.DepartmentIDs, err = app.getUserDeptIDs(u.ID)
- return &u, err
+ s.Roles, _ = app.getParticipantRoles(s.ID)
+ s.DepartmentIDs, _ = app.getUserDeptIDs(s.ID)
+ return &s, nil
}
func (app *App) listUsers() ([]User, error) {
rows, err := app.db.Query(
- `SELECT id, username, role, created_at FROM users ORDER BY username`,
+ `SELECT id, email, preferred_name, created_at
+ FROM participants WHERE login_enabled = 1 AND deleted_at IS NULL ORDER BY preferred_name, email`,
)
if err != nil {
return nil, err
}
defer rows.Close()
- var users []User
+ var staff []User
for rows.Next() {
- var u User
- if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt); err != nil {
+ var s User
+ if err := rows.Scan(&s.ID, &s.Email, &s.PreferredName, &s.CreatedAt); err != nil {
return nil, err
}
- u.DepartmentIDs = []int{}
- users = append(users, u)
+ s.Roles = []string{}
+ s.DepartmentIDs = []int{}
+ staff = append(staff, s)
}
if err := rows.Err(); err != nil {
return nil, err
}
- for i := range users {
- users[i].DepartmentIDs, _ = app.getUserDeptIDs(users[i].ID)
+ for i := range staff {
+ staff[i].Roles, _ = app.getParticipantRoles(staff[i].ID)
+ staff[i].DepartmentIDs, _ = app.getUserDeptIDs(staff[i].ID)
}
- return users, nil
+ return staff, nil
}
-func (app *App) createUser(username, hash, role string, deptIDs []int) (*User, error) {
+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.
res, err := app.db.Exec(
- `INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)`,
- username, hash, role,
+ `INSERT INTO participants (email, preferred_name, password_hash, login_enabled, updated_at)
+ VALUES (?, ?, ?, 1, ?)`,
+ strings.ToLower(email), preferredName, hash, now(),
)
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.getUserByID(int(id))
+ return app.getUser(int(id))
}
-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 {
+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 {
return err
}
return app.setUserDeptIDs(id, deptIDs)
}
func (app *App) updateUserPassword(id int, hash string) error {
- _, err := app.db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, hash, id)
+ _, err := app.db.Exec(
+ `UPDATE participants SET password_hash = ?, updated_at = ? WHERE id = ? AND login_enabled = 1`, hash, now(), id,
+ )
return err
}
-func (app *App) deleteUser(id int) error {
- _, err := app.db.Exec(`DELETE FROM users WHERE id = ?`, 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) countUsers() (int, error) {
var n int
- err := app.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
+ err := app.db.QueryRow(`SELECT COUNT(*) FROM participants WHERE login_enabled = 1 AND deleted_at IS NULL`).Scan(&n)
return n, err
}
@@ -434,7 +530,7 @@ func (app *App) generateUniqueToken() (string, error) {
return "", err
}
var count int
- if err := app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE volunteer_token = ?`, t).Scan(&count); err != nil {
+ if err := app.db.QueryRow(`SELECT COUNT(*) FROM tickets WHERE code = ?`, t).Scan(&count); err != nil {
return "", fmt.Errorf("check token uniqueness: %w", err)
}
if count == 0 {
@@ -444,19 +540,10 @@ func (app *App) generateUniqueToken() (string, error) {
return "", fmt.Errorf("failed to generate unique token")
}
-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) {
+// generateCodesForAll generates codes for every ticket that doesn't have one yet.
+func (app *App) generateCodesForAll() (int, error) {
rows, err := app.db.Query(
- `SELECT id FROM attendees WHERE volunteer_token IS NULL AND deleted_at IS NULL`,
+ `SELECT id FROM tickets WHERE code IS NULL AND deleted_at IS NULL`,
)
if err != nil {
return 0, err
@@ -466,7 +553,7 @@ func (app *App) generateTokensForAll() (int, error) {
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
- return 0, fmt.Errorf("scan attendee id: %w", err)
+ return 0, fmt.Errorf("scan ticket id: %w", err)
}
ids = append(ids, id)
}
@@ -477,161 +564,256 @@ func (app *App) generateTokensForAll() (int, error) {
if err != nil {
continue
}
- app.db.Exec(`UPDATE attendees SET volunteer_token=?, updated_at=? WHERE id=?`, t, now(), id)
+ app.db.Exec(`UPDATE tickets SET code=?, updated_at=? WHERE id=?`, t, now(), id)
count++
}
return count, nil
}
-// 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(
- `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
-}
+// --- Participants ---
-// --- Attendees ---
+const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, email_confirmed, confirmation_token, created_at, updated_at, deleted_at`
-func (app *App) listAttendees(search, ticketType, checkedIn string) ([]Attendee, error) {
- q := `SELECT ` + attendeeCols + ` FROM attendees WHERE deleted_at IS NULL`
+func (app *App) listParticipants(search, since string) ([]Participant, error) {
+ var q string
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 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`
}
- 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...)
+ return queryParticipants(app.db, q, args...)
}
-func (app *App) getAttendee(id int) (*Attendee, error) {
- rows, err := queryAttendees(app.db,
- `SELECT `+attendeeCols+` FROM attendees WHERE id = ?`, id)
+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) createAttendee(a Attendee) (*Attendee, error) {
+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) {
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(),
+ `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(),
)
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
- return app.getAttendee(int(id))
+ return app.getParticipant(int(id))
}
-func (app *App) updateAttendee(a Attendee) error {
+func (app *App) updateParticipant(p Participant) error {
_, err := app.db.Exec(
- `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,
+ `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,
)
return err
}
-func (app *App) deleteAttendee(id int) error {
+func (app *App) deleteParticipant(id int) error {
_, err := app.db.Exec(
- `UPDATE attendees SET deleted_at = ?, updated_at = ? WHERE id = ?`, now(), now(), id,
+ `UPDATE participants SET deleted_at=?, updated_at=? WHERE id=?`, now(), now(), id,
)
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
+// 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
}
- a, err := app.getAttendee(id)
- if err != nil || a == nil {
- return nil, err
+ 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
}
- remaining := a.PartySize - a.CheckedInCount
- if count > remaining {
- count = remaining
- }
- if count <= 0 {
- return a, nil
- }
- t := now()
- _, 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`,
- count, t, userID, t, id,
+ 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,
)
- if err != nil {
- return nil, err
- }
- return app.getAttendee(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 queryAttendees(db *sql.DB, q string, args ...any) ([]Attendee, error) {
+func queryParticipants(db *sql.DB, q string, args ...any) ([]Participant, error) {
rows, err := db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
- var result []Attendee
+ var result []Participant
for rows.Next() {
- var a Attendee
- var checkedIn int
- var token sql.NullString
+ var p Participant
+ var emailConfirmed int
+ var confirmationToken sql.NullString
if err := rows.Scan(
- &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,
+ &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
}
- if token.Valid && token.String != "" {
- a.VolunteerToken = &token.String
+ p.EmailConfirmed = emailConfirmed == 1
+ if confirmationToken.Valid {
+ p.ConfirmationToken = &confirmationToken.String
}
- a.CheckedIn = checkedIn == 1
- if a.PartySize < 1 {
- a.PartySize = 1
- }
- result = append(result, a)
+ result = append(result, p)
}
return result, rows.Err()
}
-func (app *App) attendeeTicketTypes() ([]string, error) {
+// 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 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 = ?
+ WHERE id = ? AND deleted_at IS NULL`,
+ t, userID, t, id,
+ )
+ if err != nil {
+ return nil, err
+ }
+ return app.getTicket(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 queryTickets(db *sql.DB, q string, args ...any) ([]Ticket, error) {
+ rows, err := db.Query(q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var result []Ticket
+ for rows.Next() {
+ var t Ticket
+ var participantID, checkedInBy sql.NullInt64
+ var code 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,
+ ); err != nil {
+ return nil, err
+ }
+ if participantID.Valid {
+ id := int(participantID.Int64)
+ t.ParticipantID = &id
+ }
+ if checkedInBy.Valid {
+ id := int(checkedInBy.Int64)
+ t.CheckedInBy = &id
+ }
+ if code.Valid && code.String != "" {
+ t.Code = &code.String
+ }
+ result = append(result, t)
+ }
+ 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) {
rows, err := app.db.Query(
- `SELECT DISTINCT ticket_type FROM attendees WHERE ticket_type != '' AND deleted_at IS NULL ORDER BY ticket_type`,
+ `SELECT DISTINCT ticket_type FROM tickets WHERE ticket_type != '' AND deleted_at IS NULL ORDER BY ticket_type`,
)
if err != nil {
return nil, err
@@ -646,12 +828,6 @@ func (app *App) attendeeTicketTypes() ([]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) {
@@ -719,42 +895,44 @@ func queryDepartments(db *sql.DB, q string, args ...any) ([]Department, error) {
// --- Volunteers ---
-const volunteerCols = `id, attendee_id, name, email, phone, department_id, is_lead, checked_in, checked_in_at, note, created_at, updated_at, deleted_at`
+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`
-func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) {
- q := `SELECT ` + volunteerCols + ` FROM volunteers WHERE 1=1`
+func (app *App) listVolunteers(search string, deptIDs []int, since string) ([]Volunteer, error) {
+ q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1`
var args []any
if since != "" {
- q += ` AND updated_at > ?`
+ q += ` AND v.updated_at > ?`
args = append(args, since)
} else {
- q += ` AND deleted_at IS NULL`
+ q += ` AND v.deleted_at IS NULL`
}
if search != "" {
- q += ` AND (name LIKE ? OR email LIKE ?)`
+ q += ` AND (p.preferred_name LIKE ? OR p.email LIKE ?)`
s := "%" + search + "%"
args = append(args, s, s)
}
- if deptID != nil {
- q += ` AND department_id = ?`
- args = append(args, *deptID)
+ 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)
+ }
}
- q += ` ORDER BY name`
+ q += ` ORDER BY p.preferred_name`
return queryVolunteers(app.db, q, args...)
}
func (app *App) getVolunteer(id int) (*Volunteer, error) {
rows, err := queryVolunteers(app.db,
- `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)
+ `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.id = ?`, id)
if err != nil || len(rows) == 0 {
return nil, err
}
@@ -763,9 +941,9 @@ func (app *App) getVolunteerByAttendeeID(attendeeID int) (*Volunteer, error) {
func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) {
res, err := app.db.Exec(
- `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(),
+ `INSERT INTO volunteers (participant_id, department_id, is_lead, note, updated_at)
+ VALUES (?, ?, ?, ?, ?)`,
+ v.ParticipantID, v.DepartmentID, boolInt(v.IsLead), v.Note, now(),
)
if err != nil {
return nil, err
@@ -776,9 +954,9 @@ func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) {
func (app *App) updateVolunteer(v Volunteer) error {
_, err := app.db.Exec(
- `UPDATE volunteers SET attendee_id=?, name=?, email=?, phone=?, department_id=?, is_lead=?, note=?, updated_at=?
+ `UPDATE volunteers SET department_id=?, is_lead=?, note=?, updated_at=?
WHERE id=? AND deleted_at IS NULL`,
- v.AttendeeID, v.Name, v.Email, v.Phone, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID,
+ v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID,
)
return err
}
@@ -790,26 +968,30 @@ func (app *App) deleteVolunteer(id int) error {
return err
}
-// 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) {
+func (app *App) markVolunteerReady(id, userID int) (*Volunteer, error) {
t := now()
_, err := app.db.Exec(
- `UPDATE volunteers SET checked_in=1, checked_in_at=?, updated_at=?
- WHERE id=? AND deleted_at IS NULL AND checked_in=0`,
+ `UPDATE volunteers SET ready=1, ready_at=?, updated_at=?
+ WHERE id=? AND deleted_at IS NULL AND ready=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)
+}
+
+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
}
- if v.AttendeeID != nil {
- app.checkInAttendee(*v.AttendeeID, userID, 1)
- }
- return v, nil
+ return app.getVolunteer(id)
}
func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
@@ -821,33 +1003,119 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
var result []Volunteer
for rows.Next() {
var v Volunteer
- var attendeeID, deptID sql.NullInt64
- var isLead, checkedIn int
+ var deptID sql.NullInt64
+ var isLead, ready, confirmed, emailConfirmed int
+ var confirmedAt, kioskCode sql.NullString
if err := rows.Scan(
- &v.ID, &attendeeID, &v.Name, &v.Email, &v.Phone, &deptID,
- &isLead, &checkedIn, &v.CheckedInAt, &v.Note,
+ &v.ID, &v.ParticipantID,
+ &v.Name, &v.Email, &v.Phone, &v.Pronouns,
+ &deptID, &isLead, &ready, &v.ReadyAt,
+ &confirmed, &confirmedAt,
+ &emailConfirmed, &kioskCode, &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.CheckedIn = checkedIn == 1
+ v.Ready = ready == 1
+ v.Confirmed = confirmed == 1
+ v.EmailConfirmed = emailConfirmed == 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(deptID *int, day, since string) ([]Shift, error) {
+func (app *App) listShifts(deptIDs []int, day, since string) ([]Shift, error) {
q := `SELECT ` + shiftCols + ` FROM shifts WHERE 1=1`
var args []any
if since != "" {
@@ -856,9 +1124,14 @@ func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) {
} else {
q += ` AND deleted_at IS NULL`
}
- if deptID != nil {
+ if len(deptIDs) == 1 {
q += ` AND department_id = ?`
- args = append(args, *deptID)
+ 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)
+ }
}
if day != "" {
q += ` AND day = ?`
@@ -1029,7 +1302,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
@@ -1082,6 +1355,27 @@ 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 {
@@ -1094,3 +1388,10 @@ 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 0d453bb..5755d08 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", "users", "attendees", "departments", "volunteers", "shifts", "volunteer_shifts"}
+ tables := []string{"event", "participants", "participant_roles", "departments", "volunteers", "shifts", "volunteer_shifts"}
for _, table := range tables {
var count int
err := app.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count)
@@ -17,98 +17,6 @@ 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 {
@@ -197,7 +105,8 @@ 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})
- v, _ := app.createVolunteer(Volunteer{Name: "Helena", DepartmentID: &deptID})
+ p, _ := app.createParticipant(Participant{PreferredName: "Helena", Email: "helena@test.com"})
+ v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
if err := app.assignShift(v.ID, s.ID); err != nil {
t.Fatal(err)
@@ -221,7 +130,8 @@ func TestCheckShiftConflict(t *testing.T) {
dept, _ := app.createDepartment(Department{Name: "Gate"})
deptID := dept.ID
- v, _ := app.createVolunteer(Volunteer{Name: "Hermia", DepartmentID: &deptID})
+ p, _ := app.createParticipant(Participant{PreferredName: "Hermia", Email: "hermia@test.com"})
+ v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, 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"})
@@ -250,7 +160,8 @@ func TestCheckShiftConflictMidnight(t *testing.T) {
dept, _ := app.createDepartment(Department{Name: "Sound"})
deptID := dept.ID
- v, _ := app.createVolunteer(Volunteer{Name: "Lysander", DepartmentID: &deptID})
+ p, _ := app.createParticipant(Participant{PreferredName: "Lysander", Email: "lysander@test.com"})
+ v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, 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 1f9a967..9bc0dbc 100644
--- a/docs/INSTALLATION.md
+++ b/docs/INSTALLATION.md
@@ -105,23 +105,27 @@ docker run -p 8180:8180 \
## NixOS
-Turnpike builds with `buildGoModule` using the pure-Go SQLite driver (no CGO):
+Turnpike builds with `buildGoModule` + `buildNpmPackage` (pure-Go SQLite, no CGO). The frontend is built separately and copied into the Go build:
```nix
+frontendDist = pkgs.buildNpmPackage {
+ pname = "turnpike-frontend";
+ src = "${src}/frontend";
+ npmDepsHash = "sha256-...";
+ buildPhase = "npm run build";
+ installPhase = "cp -r dist $out";
+};
+
turnpike = pkgs.buildGoModule {
pname = "turnpike";
- version = "0.1.0";
- src = ./path/to/turnpike; # must include vendor/ and frontend/dist/
- vendorHash = null;
+ src = fetchgit { url = "..."; rev = "v2.0.0"; hash = "sha256-..."; };
+ vendorHash = "sha256-...";
env.CGO_ENABLED = 0;
+ preBuild = "cp -r ${frontendDist} frontend/dist";
};
```
-The source directory must contain:
-- Go source files and `vendor/` (run `go mod vendor`)
-- Pre-built frontend at `frontend/dist/` (run `cd frontend && npm run build`)
-
-A complete NixOS module example with `DynamicUser`, `StateDirectory`, and agenix secrets is in the project's `homelab/turnpike.nix`.
+A complete NixOS module with `DynamicUser`, `StateDirectory`, and secrets is in the project's `homelab/turnpike.nix`.
## Reverse Proxy
diff --git a/docs/USAGE.md b/docs/USAGE.md
index 80b25f0..c08ec50 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -12,23 +12,22 @@ After logging in, create accounts for your team under **Users**. Each user gets
| Role | What they see | What they can do |
|------|--------------|------------------|
-| **admin** | All pages + Settings | Everything: attendee import, user management, SMTP config, departments, shifts, volunteers |
-| **coordinator** | Dashboard, Schedule 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 |
+| **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 |
-Ticketing and ops staff should use the **admin** role. The `ticketing` role exists in the codebase but is effectively unused — admin covers all ticketing functions.
-
-Volunteer leads are scoped to a single department. When creating a volunteer_lead user, assign their department.
+Coleads are scoped to one or more departments. When creating a colead user, assign their department(s).
## Event Setup
-1. **Configure your event** — go to the Dashboard and set the event name and dates.
+1. **Configure your event** — go to **Settings** and set the event name, venue, dates, and timezone. These appear on the Dashboard and volunteer signup page.
2. **Create departments** — under Departments, add each department your event needs (e.g., Gate, Greeters, Rangers, Build, LNT).
-3. **Import attendees** — see next section.
-4. **Create shifts** — under Shifts, create shifts for each department with day, start/end time, and capacity.
+3. **Import participants** — see next section.
+4. **Create shifts** — under Schedule, create shifts for each department with day, start/end time, and capacity.
-## Importing Attendees
+## Importing Participants
Go to **Import** and upload a CSV file. Turnpike auto-detects two formats:
@@ -36,7 +35,7 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats:
| Column | Maps to |
|--------|---------|
-| `Patron Name` | Name |
+| `Patron Name` | Ticket name |
| `Patron Email` | Email |
| `Order Number` | Ticket ID |
| `Tier Name` | Ticket type |
@@ -45,7 +44,7 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats:
| Column | Maps to |
|--------|---------|
-| `name` (required) | Name |
+| `name` (required) | Ticket name |
| `email` | Email |
| `ticket_id` | Ticket ID |
| `ticket_type` | Ticket type |
@@ -53,32 +52,67 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats:
Column matching is case-insensitive. Extra columns are ignored. BOM-encoded files (Windows Excel exports) are handled automatically.
-### Party-size dedup
+### Participants and tickets
-CrowdWork exports one row per ticket, even when the same person bought multiple tickets in one order. Turnpike handles this automatically:
+Each row in the CSV creates one **ticket**. Participants are deduplicated by email — multiple tickets with the same email address are linked to a single participant record. The import result shows `inserted` (new tickets) and `skipped` (exact duplicates).
-- First row for "Titania Fairweather" (order 1234) creates a record with `party_size=1`
-- Subsequent rows with the same name + order number increment `party_size` (no duplicate record)
-- Result: one attendee record, `party_size=3` if three tickets were purchased
+Re-importing the same CSV is safe — exact duplicates are skipped, not duplicated.
-The import result shows `inserted` (new records), `grouped` (merged into existing party), and `skipped` (exact duplicates).
+## Volunteer Signup
-Re-importing the same CSV is safe — existing records are skipped, not duplicated.
+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.
## Managing Volunteers
Under **Volunteers**, you can:
-- Create volunteers manually (name, email, department)
-- Link a volunteer to an existing attendee record (for dual check-in at the gate)
-- Assign volunteers to departments
-- Check in volunteers
+- 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)
-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.
+### 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.
## Shift Scheduling
-Under **Shifts**, create shifts for each department:
+Under **Schedule**, create shifts for each department:
- **Day** — the date of the shift
- **Start/end time** — HH:MM format
@@ -86,27 +120,29 @@ Under **Shifts**, create shifts for each department:
### Assigning volunteers
-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.
+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.
### Reordering
-Shifts can be reordered within a department to reflect priority or sequence. The Schedule Board supports drag-and-drop reordering.
+Shifts can be reordered within a department to reflect priority or sequence using the up/down buttons on each shift card.
## Volunteer Kiosk
-The kiosk lets volunteers self-select shifts without logging in.
+The Volunteer Kiosk is the public-facing flow for volunteers: signup, email confirmation, and shift self-scheduling. The shift scheduling page lets volunteers self-select shifts without logging in.
### Setup
-1. **Generate tokens** — on the Attendees page, click "Generate Tokens." This creates a unique 8-character code for every attendee that doesn't have one.
-2. **Distribute tokens** — two options:
- - **Export CSV** — downloads a file with columns `Email Address`, `First Name`, `Token`, `Signup Link`. Import this into MailChimp, Zeffy, or any email platform.
- - **Email directly** — if SMTP is configured (see below), use "Email All" to send token links, or email individually per attendee.
-3. **Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Token links use this URL.
+Kiosk links are generated and distributed automatically through the volunteer signup flow:
+
+1. Volunteers sign up via the public signup form (`/volunteer-signup`) and confirm their email.
+2. In **Settings**, open shift signups. This generates kiosk codes for all 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.
### 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
@@ -114,43 +150,45 @@ Each volunteer receives a link like `https://turnpike.example.com/#/v/ABC12345`.
Claiming a shift checks for time conflicts. If a conflict exists, the volunteer sees which shifts overlap and can confirm to proceed anyway.
-No login is required. The 8-character token authenticates the request.
+No login is required. The kiosk code authenticates the request.
-### Token format
+### Code format
-Tokens use the character set `A-Z, 2-9` (excluding 0/O, 1/I/L to avoid ambiguity when reading aloud or on printed badges).
+Kiosk codes use the character set `A-Z, 2-9` (excluding 0/O, 1/I/L to avoid ambiguity when reading aloud or on printed badges).
-## Gate Check-In
+## Gate Kiosk
-Users with the **gate** role see a dedicated full-screen UI:
+Users with the **gatekeeper** role see a dedicated full-screen Gate Kiosk:
- **QR scanner** — uses the device camera via the BarcodeDetector API. Scanned codes populate the search field.
-- **Search** — type a name to filter attendees in real-time (searches local IndexedDB, works offline).
-- **Party check-in** — for attendees with `party_size > 1`, the gate UI shows progress ("2/3 checked in") and offers "Check in 1" or "Check in all remaining."
-- **Volunteer dual check-in** — if an attendee is linked to a volunteer record, the gate UI shows their volunteer status and offers to check in both simultaneously.
+- **Search** — type a name to filter tickets in real-time (searches local IndexedDB, works offline).
- **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 Board
+## Schedule
-The Schedule Board is the primary UI for coordinators and volunteer leads. It shows:
+The Schedule page is the primary UI for managing shifts and volunteer assignments. 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
-**Coordinators and admins** see all departments. **Volunteer leads** see only their assigned department.
+**Admins and staffing** see all departments. **Coleads** see only their assigned department(s).
Actions available:
+- Create new shifts (+ Add shift button)
+- 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 token email distribution and test emails. Configure in **Settings** (admin only):
+SMTP enables volunteer confirmation emails, kiosk link distribution, and test emails. Configure in **Settings** (admin only):
| Field | Description |
|-------|-------------|
@@ -171,13 +209,13 @@ Turnpike is a Progressive Web App (PWA). After the first load, it works offline:
- **Gate check-ins** are stored in the browser's IndexedDB and sync when connectivity returns.
- **Real-time updates** use Server-Sent Events (SSE). When the connection drops, the client reconnects automatically.
-- **Sync** pulls all changes from the server on startup and periodically thereafter. Local changes are queued in an outbox and flushed in order.
+- **Sync** pulls all changes from the server on startup and periodically thereafter.
Install Turnpike as a PWA (Add to Home Screen on mobile, or Install App in desktop Chrome) for the best offline experience.
## CSV Exports
-Two CSV exports are available from the Attendees page:
+CSV exports are available from the Participants page:
-- **Attendee export** — all attendee records with check-in status
-- **Token link export** — columns: `Email Address`, `First Name`, `Token`, `Signup Link`. Only includes attendees with tokens. Compatible with MailChimp and Zeffy for bulk email campaigns.
+- **Participant export** — all participant records with check-in status
+- **Ticket export** — all ticket records with codes and check-in status
diff --git a/email.go b/email.go
index 05c94f0..41a7a55 100644
--- a/email.go
+++ b/email.go
@@ -106,35 +106,73 @@ func sendEmail(cfg SMTPConfig, to, subject, body string) error {
return smtp.SendMail(addr, auth, cfg.From, []string{to}, []byte(msg))
}
-// 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()
-
+func (app *App) resolveBaseURL() string {
baseURL := app.baseURL
if baseURL == "" {
app.db.QueryRow(`SELECT value FROM config WHERE key = 'base_url'`).Scan(&baseURL)
}
- baseURL = strings.TrimRight(baseURL, "/")
+ return strings.TrimRight(baseURL, "/")
+}
+func (app *App) eventName() string {
event, _ := app.getEvent()
- eventName := "the event"
if event != nil && event.Name != "" {
- eventName = 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")
}
- link := fmt.Sprintf("%s/#/v/%s", baseURL, *a.VolunteerToken)
+ cfg := app.loadSMTPConfig()
+ eventName := app.eventName()
+ link := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), *tk.Code)
+ name := p.PreferredName
+ if name == "" {
+ name = tk.Name
+ }
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",
- a.Name, eventName, *a.VolunteerToken, link,
+ name, eventName, *tk.Code, link,
)
- return sendEmail(cfg, a.Email, subject, body)
+ 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)
}
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 52ed392..ac0957e 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -1,17 +1,18 @@
{#if updateAvailable}
@@ -86,19 +123,23 @@
{#if loading}
{:else if kioskToken}
-