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..88b09ef 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,21 @@ # Turnpike -Self-hosted event ticketing and volunteer management. One instance, one event. +Self-hosted event attendee and volunteer management. One instance, one event. Turnpike handles gate check-in, volunteer scheduling, and department coordination for events ranging from a single evening to a multi-day festival. It works offline — gate volunteers can check people in without a network connection and sync when connectivity returns. ## Features -- **Participant & ticket management** — CSV import (CrowdWork/Zeffy auto-detected), search, check-in +- **Attendee management** — CSV import (CrowdWork/Zeffy auto-detected), party-size tracking, search, check-in - **Volunteer scheduling** — departments, shifts with capacity, conflict detection, reordering -- **Public volunteer signup** — self-registration form with email confirmation, auto-participant linking -- **Volunteer kiosk** — public volunteer flow: signup, email confirmation, code-authenticated shift self-scheduling -- **Gate kiosk** — full-screen check-in UI with QR scanner for gatekeepers +- **Public volunteer signup** — self-registration form with email confirmation, auto-attendee linking +- **Volunteer kiosk** — token-authenticated self-service shift signup, no login required +- **Gate check-in** — full-screen UI with QR scanner, party check-in ("2/3 checked in"), volunteer dual check-in - **Schedule** — create shifts, assign volunteers, manage assignments with conflict awareness -- **Role-based access** — admin, ticketing, staffing, colead (department-scoped), gatekeeper +- **Role-based access** — admin, coordinator, volunteer lead (department-scoped), gate - **Offline-first PWA** — installs on phones/tablets, full offline check-in with background sync - **Real-time** — check-ins and changes broadcast live via SSE -- **SMTP email** — volunteer confirmation emails, kiosk link distribution when shift signups open +- **SMTP email** — send volunteer token links directly or export CSV for bulk email platforms - **Single binary** — Go backend embeds the frontend; no runtime dependencies ## Tech Stack @@ -60,11 +60,10 @@ See [docs/INSTALLATION.md](docs/INSTALLATION.md) for systemd, Docker, NixOS, and | Role | Access | |------|--------| -| `admin` | Full access: participant import, user management, SMTP settings, all departments and shifts | -| `ticketing` | Participants, tickets, import. No user management | -| `staffing` | All departments: volunteers, shifts, schedule. No user management or settings | -| `colead` | Own department only: volunteers and shifts scoped to assigned department(s) | -| `gatekeeper` | Full-screen Gate Kiosk with QR scanner. No access to other pages | +| `admin` | Full access: attendee import, user management, SMTP settings, all departments and shifts | +| `coordinator` | All departments: volunteers, shifts, schedule. No user management or settings | +| `volunteer_lead` | Own department only: volunteers and shifts scoped to assigned department | +| `gate` | Full-screen check-in UI with QR scanner. No access to other pages | See [docs/USAGE.md](docs/USAGE.md) for detailed workflow documentation. @@ -92,7 +91,7 @@ The Vite dev server runs on `:5173` and proxies `/api` requests to the Go server ## Documentation -- [Usage Guide](docs/USAGE.md) — event setup, participant import, volunteer signup, volunteer kiosk, gate check-in, schedule +- [Usage Guide](docs/USAGE.md) — event setup, attendee import, volunteer signup, volunteer kiosk, gate check-in, schedule - [Installation Guide](docs/INSTALLATION.md) — building, deploying, systemd, Docker, NixOS, reverse proxy, backup ## License diff --git a/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..bc97c91 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,58 @@ 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") + addColumnIfMissing(db, "volunteers", "preferred_name TEXT NOT NULL DEFAULT ''") + addColumnIfMissing(db, "volunteers", "ticket_name TEXT NOT NULL DEFAULT ''") + addColumnIfMissing(db, "volunteers", "pronouns TEXT NOT NULL DEFAULT ''") + addColumnIfMissing(db, "volunteers", "email_confirmed INTEGER NOT NULL DEFAULT 0") + addColumnIfMissing(db, "volunteers", "confirmation_token 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 +186,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,58 +222,26 @@ 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"` + AttendeeID *int `json:"attendee_id,omitempty"` + Name string `json:"name"` PreferredName string `json:"preferred_name"` TicketName string `json:"ticket_name"` + Email string `json:"email"` Phone string `json:"phone"` Pronouns string `json:"pronouns"` - Note string `json:"note"` + DepartmentID *int `json:"department_id,omitempty"` + IsLead bool `json:"is_lead"` + CheckedIn bool `json:"checked_in"` + CheckedInAt *string `json:"checked_in_at,omitempty"` EmailConfirmed bool `json:"email_confirmed"` ConfirmationToken *string `json:"-"` + Note string `json:"note"` 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 { ID int `json:"id"` DepartmentID int `json:"department_id"` @@ -283,45 +289,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 +311,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 +326,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 +444,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 +454,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 +476,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 +487,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 +656,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 +729,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, preferred_name, ticket_name, email, phone, pronouns, department_id, is_lead, checked_in, checked_in_at, email_confirmed, confirmation_token, 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 +773,10 @@ 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, preferred_name, ticket_name, email, phone, pronouns, department_id, is_lead, email_confirmed, confirmation_token, note, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + v.AttendeeID, v.Name, v.PreferredName, v.TicketName, v.Email, v.Phone, v.Pronouns, + v.DepartmentID, boolInt(v.IsLead), boolInt(v.EmailConfirmed), v.ConfirmationToken, v.Note, now(), ) if err != nil { return nil, err @@ -954,8 +787,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=?, preferred_name=?, ticket_name=?, email=?, phone=?, pronouns=?, department_id=?, is_lead=?, note=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, + v.AttendeeID, v.Name, v.PreferredName, v.TicketName, v.Email, v.Phone, v.Pronouns, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID, ) return err @@ -968,30 +802,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,32 +833,31 @@ 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, emailConfirmed int + var confirmationToken sql.NullString 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.PreferredName, &v.TicketName, + &v.Email, &v.Phone, &v.Pronouns, &deptID, + &isLead, &checkedIn, &v.CheckedInAt, + &emailConfirmed, &confirmationToken, &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 + if confirmationToken.Valid { + v.ConfirmationToken = &confirmationToken.String } v.IsLead = isLead == 1 - v.Ready = ready == 1 - v.Confirmed = confirmed == 1 + v.CheckedIn = checkedIn == 1 v.EmailConfirmed = emailConfirmed == 1 result = append(result, v) } @@ -1037,7 +866,7 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { 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) + `SELECT `+volunteerCols+` FROM volunteers WHERE LOWER(email) = LOWER(?) AND deleted_at IS NULL LIMIT 1`, email) if err != nil || len(rows) == 0 { return nil, err } @@ -1046,63 +875,27 @@ func (app *App) getVolunteerByEmail(email string) (*Volunteer, error) { 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) + `SELECT `+volunteerCols+` FROM volunteers WHERE confirmation_token = ? AND 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 { +func (app *App) confirmVolunteerEmail(id int) error { _, err := app.db.Exec( - `UPDATE participants SET email_confirmed = 1, confirmation_token = NULL, updated_at = ? WHERE id = ?`, - now(), participantID) + `UPDATE volunteers SET email_confirmed = 1, confirmation_token = NULL, updated_at = ? WHERE id = ?`, + now(), id) 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) { +func (app *App) listConfirmedVolunteersWithoutKioskToken() ([]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") + SELECT `+volunteerCols+` + FROM volunteers + WHERE email_confirmed = 1 AND deleted_at IS NULL + AND attendee_id IS NOT NULL + AND (SELECT a.volunteer_token FROM attendees a WHERE a.id = volunteers.attendee_id) IS NULL`) } func generateConfirmationToken() (string, error) { @@ -1115,7 +908,7 @@ func generateConfirmationToken() (string, error) { // --- 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 +917,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 +1090,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 +1143,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 +1155,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..de4e765 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -12,22 +12,23 @@ After logging in, create accounts for your team under **Users**. Each user gets | Role | What they see | What they can do | |------|--------------|------------------| -| **admin** | All pages + Settings | Everything: participant import, user management, SMTP config, departments, shifts, volunteers | -| **ticketing** | Participants, Tickets, Import | Manage participants and tickets, run CSV imports | -| **staffing** | Dashboard, Schedule, Volunteers, Departments | Manage volunteers, departments, and shifts across all departments. No user management or settings | -| **colead** | Dashboard, Schedule, Volunteers | Manage volunteers and shifts within their assigned department(s) only | -| **gatekeeper** | Full-screen Gate Kiosk | Check in ticket holders (search + QR scan). No access to other pages | +| **admin** | All pages + Settings | Everything: attendee import, user management, SMTP config, departments, shifts, volunteers | +| **coordinator** | Dashboard, Schedule, Volunteers, Departments | Manage volunteers, departments, and shifts across all departments. Cannot manage users or settings | +| **volunteer_lead** | Schedule, Volunteers, Departments | Manage volunteers and shifts within their assigned department only | +| **gate** | Full-screen Gate UI | Check in attendees (search + QR scan). No access to other pages | -Coleads are scoped to one or more departments. When creating a colead user, assign their department(s). +Ticketing and ops staff should use the **admin** role. The `ticketing` role exists in the codebase but is effectively unused — admin covers all ticketing functions. + +Volunteer leads are scoped to a single department. When creating a volunteer_lead user, assign their department. ## Event Setup -1. **Configure your event** — go to **Settings** and set the event name, venue, dates, and timezone. These appear on the Dashboard and volunteer signup page. +1. **Configure your event** — go to the Dashboard and set the event name and dates. 2. **Create departments** — under Departments, add each department your event needs (e.g., Gate, Greeters, Rangers, Build, LNT). -3. **Import participants** — see next section. +3. **Import attendees** — see next section. 4. **Create shifts** — under Schedule, create shifts for each department with day, start/end time, and capacity. -## Importing Participants +## Importing Attendees Go to **Import** and upload a CSV file. Turnpike auto-detects two formats: @@ -35,7 +36,7 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats: | Column | Maps to | |--------|---------| -| `Patron Name` | Ticket name | +| `Patron Name` | Name | | `Patron Email` | Email | | `Order Number` | Ticket ID | | `Tier Name` | Ticket type | @@ -44,7 +45,7 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats: | Column | Maps to | |--------|---------| -| `name` (required) | Ticket name | +| `name` (required) | Name | | `email` | Email | | `ticket_id` | Ticket ID | | `ticket_type` | Ticket type | @@ -52,21 +53,27 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats: Column matching is case-insensitive. Extra columns are ignored. BOM-encoded files (Windows Excel exports) are handled automatically. -### Participants and tickets +### Party-size dedup -Each row in the CSV creates one **ticket**. Participants are deduplicated by email — multiple tickets with the same email address are linked to a single participant record. The import result shows `inserted` (new tickets) and `skipped` (exact duplicates). +CrowdWork exports one row per ticket, even when the same person bought multiple tickets in one order. Turnpike handles this automatically: -Re-importing the same CSV is safe — exact duplicates are skipped, not duplicated. +- First row for "Titania Fairweather" (order 1234) creates a record with `party_size=1` +- Subsequent rows with the same name + order number increment `party_size` (no duplicate record) +- Result: one attendee record, `party_size=3` if three tickets were purchased + +The import result shows `inserted` (new records), `grouped` (merged into existing party), and `skipped` (exact duplicates). + +Re-importing the same CSV is safe — existing records are skipped, not duplicated. ## Volunteer Signup -Turnpike provides a public signup form for volunteers at `/volunteer-signup`. No login is required. +Turnpike provides a public signup form for volunteers at `/#/volunteer-signup`. No login is required. ### Signup flow 1. Volunteer visits the signup form and fills in: preferred name (required), ticket name, email (required), pronouns, phone, department preference, and an optional note. -2. Turnpike creates a volunteer record and auto-links it to an existing participant by email match, or creates a new participant record. -3. A confirmation email is sent with a unique link (`/confirm/{token}`). +2. Turnpike creates a volunteer record and auto-links it to an existing attendee by email match, or creates a new attendee record. +3. A confirmation email is sent with a unique link (`/#/confirm/{token}`). 4. The volunteer clicks the link to confirm their email. 5. If shift signups are already open, the confirmation page includes a link to the kiosk for shift selection. @@ -83,7 +90,7 @@ In **Settings**, the "Volunteer Signup" card controls: In **Settings**, the "Shift Signups" card has an open/close toggle: -- **Opening** signups generates kiosk codes for all registered (email-confirmed) volunteers and emails them their shift signup links. A confirmation dialog warns before sending. +- **Opening** signups generates kiosk tokens for all confirmed volunteers and emails them their shift signup links. A confirmation dialog warns before sending. - **Closing** signups prevents new kiosk links from being issued on confirmation, but existing links continue to work. If a volunteer confirms their email while signups are already open, they receive their kiosk link immediately in the confirmation response and via email. @@ -92,23 +99,12 @@ If a volunteer confirms their email while signups are already open, they receive Under **Volunteers**, you can: -- Create volunteers manually (name, email, department, co-lead, note) -- Edit existing volunteers (department, co-lead, note) via the inline Edit button -- Confirm registered volunteers (admin, staffing, colead) -- Mark volunteers as ready (briefed at the volunteer station) +- Create volunteers manually (name, email, department) +- Link a volunteer to an existing attendee record (for dual check-in at the gate) +- Assign volunteers to departments +- Check in volunteers -### Volunteer statuses - -| Status | Meaning | Who sets it | -|--------|---------|-------------| -| **Unconfirmed** | Signed up but hasn't confirmed their email | Automatic (not yet done) | -| **Registered** | Email confirmed — volunteer is in the system | Automatic (email link) | -| **Confirmed** | Staff has verified the volunteer is assigned and ready to be scheduled | Admin, staffing, or co-lead | -| **Ready** | Briefed at the volunteer station, cleared to report for shifts | Gate staff at check-in | - -**Confirmation** is a deliberate staff action — it signals that you're expecting the volunteer for shifts. Use the **Confirm** button on a registered volunteer's row. Marking a volunteer as a co-lead (`is_lead`) automatically confirms them. - -Volunteers are separate from participants. A person can be both a ticket holder and a volunteer. When a volunteer signs up via the public form, they are automatically linked to their participant record by email. +Volunteers are separate from attendees. A person can be both an attendee (ticket holder) and a volunteer (shift worker). Linking them enables the gate team to check in both records simultaneously. ## Shift Scheduling @@ -128,21 +124,19 @@ Shifts can be reordered within a department to reflect priority or sequence usin ## Volunteer Kiosk -The Volunteer Kiosk is the public-facing flow for volunteers: signup, email confirmation, and shift self-scheduling. The shift scheduling page lets volunteers self-select shifts without logging in. +The kiosk lets volunteers self-select shifts without logging in. ### Setup -Kiosk links are generated and distributed automatically through the volunteer signup flow: - -1. Volunteers sign up via the public signup form (`/volunteer-signup`) and confirm their email. -2. In **Settings**, open shift signups. This generates kiosk codes for all registered (email-confirmed) volunteers and emails them their links. A confirmation dialog warns before sending. -3. If a volunteer confirms their email while signups are already open, they receive their kiosk link immediately. - -**Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Kiosk links use this URL. +1. **Generate tokens** — on the Attendees page, click "Generate Tokens." This creates a unique 8-character code for every attendee that doesn't have one. +2. **Distribute tokens** — two options: + - **Export CSV** — downloads a file with columns `Email Address`, `First Name`, `Token`, `Signup Link`. Import this into MailChimp, Zeffy, or any email platform. + - **Email directly** — if SMTP is configured (see below), use "Email All" to send token links, or email individually per attendee. +3. **Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Token links use this URL. ### Volunteer experience -Each volunteer receives a link like `https://turnpike.example.com/v/ABC12345`. This opens a mobile-friendly page showing: +Each volunteer receives a link like `https://turnpike.example.com/#/v/ABC12345`. This opens a mobile-friendly page showing: - Their name and department - Currently assigned shifts @@ -150,22 +144,22 @@ Each volunteer receives a link like `https://turnpike.example.com/v/ABC12345`. T Claiming a shift checks for time conflicts. If a conflict exists, the volunteer sees which shifts overlap and can confirm to proceed anyway. -No login is required. The kiosk code authenticates the request. +No login is required. The 8-character token authenticates the request. -### Code format +### Token format -Kiosk codes use the character set `A-Z, 2-9` (excluding 0/O, 1/I/L to avoid ambiguity when reading aloud or on printed badges). +Tokens use the character set `A-Z, 2-9` (excluding 0/O, 1/I/L to avoid ambiguity when reading aloud or on printed badges). -## Gate Kiosk +## Gate Check-In -Users with the **gatekeeper** role see a dedicated full-screen Gate Kiosk: +Users with the **gate** role see a dedicated full-screen UI: - **QR scanner** — uses the device camera via the BarcodeDetector API. Scanned codes populate the search field. -- **Search** — type a name to filter tickets in real-time (searches local IndexedDB, works offline). +- **Search** — type a name to filter attendees in real-time (searches local IndexedDB, works offline). +- **Party check-in** — for attendees with `party_size > 1`, the gate UI shows progress ("2/3 checked in") and offers "Check in 1" or "Check in all remaining." +- **Volunteer dual check-in** — if an attendee is linked to a volunteer record, the gate UI shows their volunteer status and offers to check in both simultaneously. - **Recent check-ins** — the last 10 check-ins are shown for quick reference. -Admins and ticketing leads can also check in tickets directly from the **Participants** page by expanding a participant's tickets. - Gate devices should install Turnpike as a PWA (Add to Home Screen) for the best experience. Check-ins are stored locally and sync when connectivity is available. ## Schedule @@ -176,7 +170,7 @@ The Schedule page is the primary UI for managing shifts and volunteer assignment - Each shift card shows: name, time, capacity (used/total), assigned volunteers - Conflict badges when a volunteer has overlapping shifts on the same day -**Admins and staffing** see all departments. **Coleads** see only their assigned department(s). +**Coordinators and admins** see all departments. **Volunteer leads** see only their assigned department. Actions available: - Create new shifts (+ Add shift button) @@ -188,7 +182,7 @@ Actions available: ## SMTP Configuration -SMTP enables volunteer confirmation emails, kiosk link distribution, and test emails. Configure in **Settings** (admin only): +SMTP enables token email distribution and test emails. Configure in **Settings** (admin only): | Field | Description | |-------|-------------| @@ -209,13 +203,13 @@ Turnpike is a Progressive Web App (PWA). After the first load, it works offline: - **Gate check-ins** are stored in the browser's IndexedDB and sync when connectivity returns. - **Real-time updates** use Server-Sent Events (SSE). When the connection drops, the client reconnects automatically. -- **Sync** pulls all changes from the server on startup and periodically thereafter. +- **Sync** pulls all changes from the server on startup and periodically thereafter. Local changes are queued in an outbox and flushed in order. Install Turnpike as a PWA (Add to Home Screen on mobile, or Install App in desktop Chrome) for the best offline experience. ## CSV Exports -CSV exports are available from the Participants page: +Two CSV exports are available from the Attendees page: -- **Participant export** — all participant records with check-in status -- **Ticket export** — all ticket records with codes and check-in status +- **Attendee export** — all attendee records with check-in status +- **Token link export** — columns: `Email Address`, `First Name`, `Token`, `Signup Link`. Only includes attendees with tokens. Compatible with MailChimp and Zeffy for bulk email campaigns. diff --git a/email.go b/email.go index 41a7a55..0a1c65d 100644 --- a/email.go +++ b/email.go @@ -122,36 +122,25 @@ func (app *App) eventName() string { 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") +// 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 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") + if a.VolunteerToken == nil || *a.VolunteerToken == "" { + return fmt.Errorf("attendee has no volunteer token") } 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", app.resolveBaseURL(), *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) + return sendEmail(cfg, a.Email, subject, body) } func (app *App) sendConfirmationEmail(to, name, confirmToken string) error { diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index ac0957e..0fed314 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,18 +1,18 @@ {#if updateAvailable} @@ -123,16 +96,16 @@ {#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}
@@ -148,13 +121,13 @@ Turnpike {#if path === '/' || path === ''} - {#if roles.length === 1 && roles[0] === 'colead'} + {#if role === 'volunteer_lead'} {:else} - + {/if} - {:else if path.startsWith('/participants')} - + {:else if path.startsWith('/attendees')} + {:else if path.startsWith('/volunteers')} {:else if path.startsWith('/departments')} diff --git a/frontend/src/api.js b/frontend/src/api.js index d15abc4..e288308 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1,4 +1,4 @@ -import { db, clearSession } from './db.js' +import { db } from './db.js' async function getToken() { const session = await db.session.get(1) @@ -17,7 +17,7 @@ export async function apiFetch(path, options = {}) { const res = await fetch(path, { ...options, headers }) if (res.status === 401) { - await clearSession() + await db.session.clear() window.location.pathname = '/login' throw new Error('unauthorized') } @@ -48,29 +48,28 @@ async function kioskFetch(path, options = {}) { } export const api = { - login: (email, password) => - apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ email, password }) }), + login: (username, password) => + apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ username, password }) }), logout: () => apiFetch('/api/logout', { method: 'POST' }), me: () => apiJSON('/api/me'), event: { get: () => apiJSON('/api/event'), update: (data) => apiJSON('/api/event', { method: 'PUT', body: JSON.stringify(data) }), }, - participants: { - list: (params = {}) => apiJSON('/api/participants?' + new URLSearchParams(params)), - get: (id) => apiJSON(`/api/participants/${id}`), - create: (data) => apiJSON('/api/participants', { method: 'POST', body: JSON.stringify(data) }), - update: (id, data) => apiJSON(`/api/participants/${id}`, { method: 'PUT', body: JSON.stringify(data) }), - delete: (id) => apiFetch(`/api/participants/${id}`, { method: 'DELETE' }), - merge: (id, otherId) => apiJSON(`/api/participants/${id}/merge/${otherId}`, { method: 'POST' }), - }, - tickets: { - list: () => apiJSON('/api/tickets'), - create: (data) => apiJSON('/api/tickets', { method: 'POST', body: JSON.stringify(data) }), - checkIn: (id) => apiJSON(`/api/tickets/${id}/checkin`, { method: 'POST' }), - generateCodes: () => apiJSON('/api/tickets/generate-codes', { method: 'POST' }), - emailCode: (id) => apiJSON(`/api/tickets/${id}/email-code`, { method: 'POST' }), - emailAllCodes: () => apiJSON('/api/tickets/email-codes', { method: 'POST' }), + attendees: { + list: (params = {}) => apiJSON('/api/attendees?' + new URLSearchParams(params)), + get: (id) => apiJSON(`/api/attendees/${id}`), + create: (data) => apiJSON('/api/attendees', { method: 'POST', body: JSON.stringify(data) }), + update: (id, data) => apiJSON(`/api/attendees/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + delete: (id) => apiFetch(`/api/attendees/${id}`, { method: 'DELETE' }), + checkIn: (id, opts = {}) => + apiJSON(`/api/attendees/${id}/checkin`, { method: 'POST', body: JSON.stringify(opts) }), + generateTokens: () => + apiJSON('/api/attendees/generate-tokens', { method: 'POST' }), + emailToken: (id) => + apiJSON(`/api/attendees/${id}/email-token`, { method: 'POST' }), + emailAllTokens: () => + apiJSON('/api/attendees/email-tokens', { method: 'POST' }), }, volunteers: { list: (params = {}) => apiJSON('/api/volunteers?' + new URLSearchParams(params)), @@ -78,8 +77,7 @@ export const api = { create: (data) => apiJSON('/api/volunteers', { method: 'POST', body: JSON.stringify(data) }), update: (id, data) => apiJSON(`/api/volunteers/${id}`, { method: 'PUT', body: JSON.stringify(data) }), delete: (id) => apiFetch(`/api/volunteers/${id}`, { method: 'DELETE' }), - markReady: (id) => apiJSON(`/api/volunteers/${id}/ready`, { method: 'POST' }), - confirm: (id) => apiJSON(`/api/volunteers/${id}/confirm`, { method: 'POST' }), + checkIn: (id) => apiJSON(`/api/volunteers/${id}/checkin`, { method: 'POST' }), assignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts`, { method: 'POST', body: JSON.stringify({ shift_id: shiftId }) }), unassignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts/${shiftId}`, { method: 'DELETE' }), }, @@ -112,16 +110,12 @@ export const api = { update: (data) => apiJSON('/api/settings', { method: 'PUT', body: JSON.stringify(data) }), testEmail: (to) => apiJSON('/api/settings/test-email', { method: 'POST', body: JSON.stringify({ to }) }), toggleShiftSignups: (open) => apiJSON('/api/settings/shift-signups', { method: 'POST', body: JSON.stringify({ open }) }), - resetTickets: () => apiJSON('/api/settings/reset-tickets', { method: 'POST' }), + resetAttendees: () => apiJSON('/api/settings/reset-attendees', { method: 'POST' }), resetVolunteers: () => apiJSON('/api/settings/reset-volunteers', { method: 'POST' }), resetShifts: () => apiJSON('/api/settings/reset-shifts', { method: 'POST' }), resetDepartments: () => apiJSON('/api/settings/reset-departments', { method: 'POST' }), resetVolunteerShifts: () => apiJSON('/api/settings/reset-volunteer-shifts', { method: 'POST' }), }, - sso: { - enabled: () => kioskFetch('/api/public/sso-enabled'), - init: () => kioskFetch('/api/sso/init'), - }, signup: { config: () => kioskFetch('/api/public/signup-config'), submit: (data) => kioskFetch('/api/public/signup', { method: 'POST', body: JSON.stringify(data) }), diff --git a/frontend/src/api.test.js b/frontend/src/api.test.js index a725f32..f6527f5 100644 --- a/frontend/src/api.test.js +++ b/frontend/src/api.test.js @@ -64,23 +64,23 @@ describe('apiJSON', () => { describe('api methods', () => { it('login calls correct endpoint', async () => { const f = mockFetch({ token: 'tok', user: { id: 1 } }) - await api.login('admin@example.com', 'pass') + await api.login('admin', 'pass') const [url, opts] = f.mock.calls[0] expect(url).toBe('/api/login') expect(opts.method).toBe('POST') - expect(JSON.parse(opts.body)).toEqual({ email: 'admin@example.com', password: 'pass' }) + expect(JSON.parse(opts.body)).toEqual({ username: 'admin', password: 'pass' }) }) - it('participants.list calls correct endpoint', async () => { - const f = mockFetch({ participants: [] }) - await api.participants.list({ search: 'test' }) - expect(f.mock.calls[0][0]).toBe('/api/participants?search=test') + it('attendees.list calls correct endpoint', async () => { + const f = mockFetch({ attendees: [] }) + await api.attendees.list({ search: 'test' }) + expect(f.mock.calls[0][0]).toBe('/api/attendees?search=test') }) - it('participants.delete uses DELETE method', async () => { + it('attendees.delete uses DELETE method', async () => { const f = mockFetch({}, 204) - await api.participants.delete(5) - expect(f.mock.calls[0][0]).toBe('/api/participants/5') + await api.attendees.delete(5) + expect(f.mock.calls[0][0]).toBe('/api/attendees/5') expect(f.mock.calls[0][1].method).toBe('DELETE') }) diff --git a/frontend/src/app.css b/frontend/src/app.css index 3a685ae..f10e75b 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -66,9 +66,6 @@ a:hover { color: var(--c-accent-h); } /* Cards */ .card { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: 1.25rem; } -.card + .card, .card + form, form + .card, form + form { margin-top: 1.5rem; } -.card-title { font-size: 0.95rem; font-weight: 700; margin-bottom: 1rem; } -.card-hint { font-size: 0.78rem; color: var(--c-muted); } /* Stats */ .stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; } @@ -106,15 +103,8 @@ input, select, textarea { width: 100%; font-family: var(--font); transition: border-color var(--transition); } -input[type="checkbox"] { width: auto; } -input[type="date"], input[type="time"], input[type="datetime-local"] { -webkit-appearance: none; appearance: none; min-height: 2.35rem; } input:focus, select:focus, textarea:focus { outline: none; border-color: var(--c-accent); } input::placeholder { color: var(--c-muted); } -.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } -.form-grid-3 { display: grid; grid-template-columns: 1fr 1fr auto; gap: 1rem; align-items: end; } -.form-grid .full { grid-column: 1 / -1; } -.checkbox-label { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; cursor: pointer; } -.checkbox-label-sm { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.82rem; cursor: pointer; color: var(--c-text); } /* Search */ .search-bar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; } @@ -139,12 +129,8 @@ tr:hover td { background: rgba(255,255,255,0.02); } font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; } -* + .badge { margin-left: 0.3rem; } -.badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); } -.badge-confirmed { background: rgba(99,102,241,0.15); color: var(--c-accent-h); } -.badge-registered { background: rgba(14,165,233,0.15); color: #0ea5e9; } -.badge-unchecked { background: rgba(122,127,150,0.15); color: var(--c-muted); } -.badge-partial { background: rgba(245,158,11,0.15); color: var(--c-warn); } +.badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); } +.badge-unchecked { background: rgba(122,127,150,0.15); color: var(--c-muted); } .badge-role { background: rgba(99,102,241,0.15); color: var(--c-accent-h); } .badge-lead { background: rgba(245,158,11,0.15); color: var(--c-warn); } @@ -219,33 +205,4 @@ tr:hover td { background: rgba(255,255,255,0.02); } } .page { padding: 1rem; } .stats { grid-template-columns: repeat(2, 1fr); } - - /* Touch targets */ - .btn { min-height: 44px; padding: 0.6rem 1rem; } - .btn-sm { min-height: 44px; padding: 0.5rem 0.75rem; font-size: 0.85rem; } - - /* Page header & actions */ - .page-header { flex-wrap: wrap; gap: 0.75rem; } - .page-title { width: 100%; } - .actions { flex-wrap: wrap; } - - /* Search bar */ - .search-bar { flex-wrap: wrap; } - .search-bar input { max-width: none; flex: 1 1 100%; } - - /* Table → card layout */ - .table-wrap { overflow-x: visible; } - table { display: block; } - thead { display: none; } - tbody { display: flex; flex-direction: column; gap: 0.5rem; } - tr { display: flex; flex-wrap: wrap; gap: 0.25rem 0.75rem; align-items: center; - padding: 0.75rem; border: 1px solid var(--c-border); border-radius: var(--radius-lg); - background: var(--c-surface); } - tr:hover td { background: transparent; } - td { display: inline; padding: 0; border: none; } - td:empty { display: none; } - - /* Forms — 16px prevents iOS auto-zoom on focus */ - input, select, textarea { font-size: 16px; } - .form-grid, .form-grid-3 { grid-template-columns: 1fr !important; } } diff --git a/frontend/src/components/CheckInButton.svelte b/frontend/src/components/CheckInButton.svelte index cbd4bd2..b3ce533 100644 --- a/frontend/src/components/CheckInButton.svelte +++ b/frontend/src/components/CheckInButton.svelte @@ -9,5 +9,5 @@ diff --git a/frontend/src/components/Nav.svelte b/frontend/src/components/Nav.svelte index 61015f5..cef8d7c 100644 --- a/frontend/src/components/Nav.svelte +++ b/frontend/src/components/Nav.svelte @@ -1,20 +1,23 @@ + +
+ + + {#if error} +
{error}
+ {/if} + {#if success} +
{success}
+ {/if} + + {#if showAdd && canManage} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ {/if} + + + + {#if ($allAttendees ?? []).length === 0} +
+ No attendees yet +

Import a CSV or add attendees manually.

+
+ {:else} +
+ + + + + + + + {#if canCheckIn}{/if} + + + + {#each filtered as a (a.id)} + + + + + + {#if canCheckIn} + + {/if} + + {/each} + +
NameTicket typeEmailStatus
+ {a.name} + {#if a.ticket_id} + · {a.ticket_id} + {/if} + {#if (a.party_size ?? 1) > 1} + ×{a.party_size} + {/if} + {#if a.note} +
{a.note}
+ {/if} +
{a.ticket_type || '—'} +
{a.email || '—'}
+ {#if a.volunteer_token && canManage} +
+ {a.volunteer_token} + {#if a.email} + + {/if} +
+ {/if} +
+ {#if (a.party_size ?? 1) > 1} + + {a.checked_in_count ?? 0}/{a.party_size} in + + {:else} + + {a.checked_in ? 'Checked in' : 'Pending'} + + {/if} + {#if a.checked_in_at} +
+ {new Date(a.checked_in_at).toLocaleTimeString()} +
+ {/if} +
+ {#if (a.checked_in_count ?? 0) < (a.party_size ?? 1)} + checkIn(a)} /> + {/if} +
+
+ {/if} +
diff --git a/frontend/src/pages/Dashboard.svelte b/frontend/src/pages/Dashboard.svelte index 73800d6..c1ae495 100644 --- a/frontend/src/pages/Dashboard.svelte +++ b/frontend/src/pages/Dashboard.svelte @@ -2,57 +2,15 @@ import { liveQuery } from 'dexie' import { db } from '../db.js' - let { session, navigate } = $props() - - const roles = $derived(session?.user?.roles ?? []) - function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } - const myDeptIDs = $derived(session?.user?.department_ids ?? []) - const isAdmin = $derived(hasRole('admin')) - const isStaffing = $derived(hasRole('admin', 'staffing')) - const isColead = $derived(hasRole('colead')) + let { session } = $props() + const attendees = liveQuery(() => db.attendees.toArray()) const event = liveQuery(() => db.event.get(1)) - const allTickets = liveQuery(() => db.tickets.toArray()) - const allVolunteers = liveQuery(() => db.volunteers.filter(v => !v.deleted_at).toArray()) - const allShifts = liveQuery(() => db.shifts.filter(s => !s.deleted_at).toArray()) - const allDepts = liveQuery(() => db.departments.filter(d => !d.deleted_at).toArray()) - const allVS = liveQuery(() => db.volunteer_shifts.toArray()) - // Ticket stats - const tickets = $derived($allTickets ?? []) - const ticketTotal = $derived(tickets.length) - const ticketCheckedIn = $derived(tickets.filter(t => t.checked_in_at).length) - const ticketRemaining = $derived(ticketTotal - ticketCheckedIn) - const ticketPct = $derived(ticketTotal > 0 ? Math.round((ticketCheckedIn / ticketTotal) * 100) : 0) - - // Volunteer stats (scoped for colead) - const volunteers = $derived.by(() => { - const vols = $allVolunteers ?? [] - if (isColead) return vols.filter(v => myDeptIDs.includes(v.department_id)) - return vols - }) - const volTotal = $derived(volunteers.length) - const volCheckedIn = $derived(volunteers.filter(v => v.ready).length) - const volLeads = $derived(volunteers.filter(v => v.is_lead).length) - - // Shift stats (scoped for colead) - const shifts = $derived.by(() => { - const all = $allShifts ?? [] - if (isColead) return all.filter(s => myDeptIDs.includes(s.department_id)) - return all - }) - const shiftTotal = $derived(shifts.length) - const shiftsFilled = $derived.by(() => { - const vs = $allVS ?? [] - return shifts.filter(s => vs.some(a => a.shift_id === s.id)).length - }) - const shiftFillPct = $derived(shiftTotal > 0 ? Math.round((shiftsFilled / shiftTotal) * 100) : 0) - - // Department names for colead header - const myDeptNames = $derived.by(() => { - const depts = $allDepts ?? [] - return myDeptIDs.map(id => depts.find(d => d.id === id)?.name).filter(Boolean) - }) + const total = $derived(($attendees ?? []).length) + const checkedIn = $derived(($attendees ?? []).filter(a => a.checked_in).length) + const remaining = $derived(total - checkedIn) + const pct = $derived(total > 0 ? Math.round((checkedIn / total) * 100) : 0)
@@ -70,113 +28,35 @@

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

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

- {/if} - - - {#if isAdmin} -

Ticket Check-in

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

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

-
-
-
Total
-
{volTotal}
-
-
-
Checked in
-
{volCheckedIn}
-
-
-
Leads
-
{volLeads}
+ {#if total > 0} +
+
+
{/if} - - {#if isStaffing || isColead} -

{isColead ? 'My Shifts' : 'Shift Coverage'}

-
-
-
Total shifts
-
{shiftTotal}
-
-
-
With volunteers
-
{shiftsFilled}
-
-
-
Fill rate
-
{shiftFillPct}%
-
-
- {/if} - - - {#if isAdmin} - - {:else if isStaffing || isColead} - - {/if} - -

- Welcome, {session?.user?.preferred_name} - · {#each roles as r}{r}{/each} +

+ Welcome, {session?.user?.username} + · {session?.user?.role}

- - diff --git a/frontend/src/pages/Departments.svelte b/frontend/src/pages/Departments.svelte index 863c4a1..81408d8 100644 --- a/frontend/src/pages/Departments.svelte +++ b/frontend/src/pages/Departments.svelte @@ -18,10 +18,9 @@ let editDesc = $state('') let saving = $state(false) - const roles = $derived(session?.user?.roles ?? []) - function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } - const canCreate = $derived(hasRole('admin', 'staffing')) - const canDelete = $derived(hasRole('admin')) + const role = $derived(session?.user?.role ?? '') + const canCreate = $derived(['admin', 'coordinator'].includes(role)) + const canDelete = $derived(role === 'admin') const allDepts = liveQuery(() => db.departments.filter(d => !d.deleted_at).toArray() @@ -101,7 +100,7 @@ {#if showAdd && canCreate}
-
+
@@ -112,7 +111,7 @@
- +
@@ -128,7 +127,7 @@ {#if ($allDepts ?? []).length === 0}
No departments yet -

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

+

Add departments to organize your volunteer teams.

{:else}
@@ -143,8 +142,8 @@ {#each $allDepts ?? [] as d (d.id)} {#if editID === d.id} - - + +
@@ -154,7 +153,7 @@ {#if canCreate} - +
{#if canDelete} @@ -189,13 +188,3 @@
{/if}
- - diff --git a/frontend/src/pages/GateKiosk.svelte b/frontend/src/pages/GateUI.svelte similarity index 54% rename from frontend/src/pages/GateKiosk.svelte rename to frontend/src/pages/GateUI.svelte index d1eeaa1..f3ea8a9 100644 --- a/frontend/src/pages/GateKiosk.svelte +++ b/frontend/src/pages/GateUI.svelte @@ -7,8 +7,6 @@ let { session, onLogout } = $props() let search = $state('') - let manuallySelectedId = $state(null) - let showAll = $state(false) let error = $state('') let scannerMsg = $state('') let qrSupported = $state(false) @@ -18,89 +16,36 @@ let detector = $state(null) let scanInterval = $state(null) - const tickets = liveQuery(() => - db.tickets.filter(t => !t.deleted_at).toArray() - ) - - const participants = liveQuery(() => - db.participants.filter(p => !p.deleted_at).toArray() + const attendees = liveQuery(() => + db.attendees.filter(a => !a.deleted_at).toArray() ) const recentCheckIns = liveQuery(() => - db.tickets - .filter(t => !!t.checked_in_at && !t.deleted_at) + db.attendees + .filter(a => a.checked_in && !a.deleted_at) .toArray() .then(arr => arr + .filter(a => a.checked_in_at) .sort((a, b) => b.checked_in_at.localeCompare(a.checked_in_at)) .slice(0, 10) ) ) - // Exact code/external_id match (QR scan or typed code) - const matchedTicket = $derived.by(() => { - const s = search.trim() - if (!s || s.length < 2) return null - const sl = s.toLowerCase() - const byCode = ($tickets ?? []).find(t => t.code?.toLowerCase() === sl) - if (byCode) return byCode - return ($tickets ?? []).find(t => t.external_id?.toLowerCase() === sl) ?? null - }) - - const allParticipantsSorted = $derived.by(() => - ($participants ?? []) - .filter(p => !p.deleted_at) - .sort((a, b) => (a.preferred_name || a.email || '').localeCompare(b.preferred_name || b.email || '')) - ) - - // Clear manual selection whenever search text changes - $effect(() => { - search - manuallySelectedId = null - }) - - // Name/email/ticket-name search across participants - const filteredParticipants = $derived.by(() => { - if (matchedTicket) return [] + const filtered = $derived.by(() => { const s = search.trim().toLowerCase() if (!s || s.length < 2) return [] - const byTicketName = new Set( - ($tickets ?? []) - .filter(t => t.name?.toLowerCase().includes(s)) - .map(t => t.participant_id) - .filter(Boolean) - ) - return ($participants ?? []) - .filter(p => - p.preferred_name?.toLowerCase().includes(s) || - p.email?.toLowerCase().includes(s) || - byTicketName.has(p.id) - ) - .sort((a, b) => (a.preferred_name || '').localeCompare(b.preferred_name || '')) + return ($attendees ?? []) + .filter(a => a.name.toLowerCase().includes(s) || a.ticket_id?.toLowerCase().includes(s) || a.email?.toLowerCase().includes(s)) + .sort((a, b) => a.name.localeCompare(b.name)) .slice(0, 8) }) - // Manual selection takes priority; fall back to auto-select on single match - const selectedParticipant = $derived.by(() => { - if (manuallySelectedId) { - return ($participants ?? []).find(p => p.id === manuallySelectedId) ?? null - } - if (filteredParticipants.length === 1) return filteredParticipants[0] - return null + const selected = $derived.by(() => { + if (filtered.length === 1) return filtered[0] + const s = search.trim().toLowerCase() + return filtered.find(a => a.ticket_id?.toLowerCase() === s) ?? null }) - function ticketsFor(participantId) { - return ($tickets ?? []).filter(t => t.participant_id === participantId && !t.deleted_at) - } - - function participantFor(ticket) { - if (!ticket?.participant_id) return null - return ($participants ?? []).find(p => p.id === ticket.participant_id) ?? null - } - - function nameFor(ticket) { - return ticket.name || participantFor(ticket)?.preferred_name || '(unknown)' - } - onMount(() => { qrSupported = 'BarcodeDetector' in window }) @@ -151,19 +96,40 @@ } catch {} } - async function checkInTicket(ticket) { + async function checkIn(attendee, count = 1) { error = '' try { - const result = await api.tickets.checkIn(ticket.id) - if (result.ticket) { - await db.tickets.put(result.ticket) - search = '' + const result = await api.attendees.checkIn(attendee.id, { count }) + if (result.attendee) { + await db.attendees.put(result.attendee) } } catch (err) { error = err.message } } + async function checkInWithVolunteer(attendee) { + error = '' + try { + const result = await api.attendees.checkIn(attendee.id, { count: 1, also_volunteer: true }) + if (result.attendee) await db.attendees.put(result.attendee) + if (result.volunteer) await db.volunteers.put(result.volunteer) + } catch (err) { + error = err.message + } + } + + function remaining(a) { + return (a.party_size ?? 1) - (a.checked_in_count ?? 0) + } + + function progressLabel(a) { + const ps = a.party_size ?? 1 + const ci = a.checked_in_count ?? 0 + if (ps <= 1) return null + return `${ci}/${ps} checked in` + } + function fmt(ts) { if (!ts) return '' return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) @@ -189,9 +155,6 @@ {scanning ? '■ Stop' : '⊡ Scan QR'} {/if} -
{#if scanning} @@ -209,122 +172,74 @@
{error}
{/if} - - {#if matchedTicket} - {@const p = participantFor(matchedTicket)} + + {#if selected} + {@const rem = remaining(selected)} + {@const prog = progressLabel(selected)}
-
{nameFor(matchedTicket)}
- {#if matchedTicket.ticket_type} -
{matchedTicket.ticket_type}
+
{selected.name}
+ {#if selected.ticket_type} +
{selected.ticket_type}
{/if} - {#if matchedTicket.external_id} -
#{matchedTicket.external_id}
+ {#if selected.ticket_id} +
#{selected.ticket_id}
{/if} - {#if p?.email} -
{p.email}
- {/if} -
- {#if !matchedTicket.checked_in_at} - - {:else} - ✓ Checked in {fmt(matchedTicket.checked_in_at)} - {/if} -
-
- - - {:else if selectedParticipant} - {@const pts = ticketsFor(selectedParticipant.id)} -
-
-
{selectedParticipant.preferred_name || selectedParticipant.email || '(unknown)'}
- {#if manuallySelectedId} - - {/if} -
- {#if selectedParticipant.email} -
{selectedParticipant.email}
- {/if} - {#if pts.length === 0} -
No tickets on file
- {:else} -
- {#each pts as tk (tk.id)} -
- - {tk.name || '(unnamed)'} - {#if tk.ticket_type} · {tk.ticket_type}{/if} - - {#if tk.checked_in_at} - ✓ {fmt(tk.checked_in_at)} - {:else} - - {/if} -
- {/each} + {#if prog} +
+ {prog}
{/if} -
- - {:else if search.trim().length >= 2 && filteredParticipants.length > 1} +
+ {#if rem > 0} + + {#if rem > 1} + + {/if} + {:else} + All checked in + {/if} + + {#if selected.volunteer_token && !selected.checked_in} + + {/if} +
+
+ {:else if search.trim().length >= 2 && filtered.length > 1} +
- {#each filteredParticipants as p} - {@const pts = ticketsFor(p.id)} - {@const ci = pts.filter(t => t.checked_in_at).length} - - {/each} -
- - {:else if search.trim().length >= 2} -
No matching participants or tickets found.
- {/if} - - - {#if showAll && !matchedTicket && !selectedParticipant && search.trim().length < 2} -
- {#each allParticipantsSorted as p} - {@const pts = ticketsFor(p.id)} - {@const ci = pts.filter(t => t.checked_in_at).length} - {/each}
+ {:else if search.trim().length >= 2 && filtered.length === 0} +
No matching attendees found.
{/if}
Recent Check-ins
{#if ($recentCheckIns ?? []).length === 0} -
No check-ins yet.
+
No check-ins yet today.
{:else} - {#each $recentCheckIns ?? [] as tk} + {#each $recentCheckIns ?? [] as a}
- {nameFor(tk)} - {fmt(tk.checked_in_at)} + {a.name} + {fmt(a.checked_in_at)}
{/each} {/if} @@ -439,8 +354,7 @@ padding: 1.25rem; margin-bottom: 1rem; } - .gate-match-name-row { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.2rem; } - .gate-match-name { font-size: 1.4rem; font-weight: 700; } + .gate-match-name { font-size: 1.4rem; font-weight: 700; margin-bottom: 0.2rem; } .gate-match-sub { color: var(--c-muted); font-size: 0.875rem; } .gate-party { margin: 0.5rem 0; @@ -470,16 +384,6 @@ align-items: center; } - .gate-ticket-row { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.4rem 0.6rem; - background: var(--c-bg); - border-radius: 6px; - font-size: 0.875rem; - } - .gate-results { background: var(--c-surface); border: 1px solid var(--c-border); diff --git a/frontend/src/pages/Import.svelte b/frontend/src/pages/Import.svelte index 17bff4d..a3f9ccc 100644 --- a/frontend/src/pages/Import.svelte +++ b/frontend/src/pages/Import.svelte @@ -53,7 +53,7 @@ Supported formats:
CrowdWork / ticketing platform: columns Patron Name, Patron Email, Tier Name, Order Number
Generic: columns name, email, ticket_id, ticket_type, note
- Duplicate tickets (same source + external ID) are skipped. Participants are matched or created by email. + Duplicate names are skipped.
diff --git a/frontend/src/pages/Login.svelte b/frontend/src/pages/Login.svelte index 5249231..de4f6af 100644 --- a/frontend/src/pages/Login.svelte +++ b/frontend/src/pages/Login.svelte @@ -1,32 +1,20 @@ - - diff --git a/frontend/src/pages/Participants.svelte b/frontend/src/pages/Participants.svelte deleted file mode 100644 index 0be1485..0000000 --- a/frontend/src/pages/Participants.svelte +++ /dev/null @@ -1,528 +0,0 @@ - - -
- - - {#if showAdd && canManage} -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
- - -
-
-
- {/if} - - {#if mergeMode && mergeSource} -
-
- Merge: "{mergeSource.preferred_name || mergeSource.email}" will be merged into the participant you select below. - All their tickets and volunteer records will move to the target. -
- {#if mergeTarget} -
- Target: {mergeTarget.preferred_name || mergeTarget.email} ({mergeTarget.email}) -
-
- - -
- {:else} -
Click a participant row below to select as merge target.
-
- -
- {/if} -
- {/if} - - {#if error} -
{error}
- {/if} - {#if success} -
{success}
- {/if} - - - - {#if ($allParticipants ?? []).length === 0} -
- No participants yet -

Import a CSV or wait for volunteer signups.

-
- {:else} -
- - - - - - - - {#if canManage}{/if} - - - - {#each filtered as p (p.id)} - {@const pts = ticketsFor(p.id)} - {@const ci = checkedInCount(p.id)} - {@const isExpanded = expandedId === p.id} - {@const isMergeTarget = mergeMode && mergeSource?.id !== p.id} - {@const isEditing = editId === p.id} - {#if isEditing} - - - - {:else} - { mergeTarget = p } : null} - style={mergeMode && mergeSource?.id !== p.id ? 'cursor:pointer' : ''} - > - - - - - {#if canManage} - - {/if} - - {/if} - {#if isExpanded && !isEditing} - - - - {/if} - {/each} - -
Preferred NameEmailTicketsStatus
-
-
- - - - - - -
-
- - - - -
-
-
- {p.preferred_name || '—'} - {#if p.pronouns} - · {p.pronouns} - {/if} - {#if p.ticket_name && p.ticket_name !== p.preferred_name} -
Ticket: {p.ticket_name}
- {/if} - {#if p.note} -
{p.note}
- {/if} -
- {p.email || '—'} - {#if p.phone} -
{p.phone}
- {/if} -
- {#if pts.length > 0} - - {:else} - - {/if} - - {#if pts.length > 0} - - {ci}/{pts.length} in - - {:else} - No ticket - {/if} - - {#if !mergeMode} - - - {/if} -
-
- {#each pts as tk (tk.id)} -
-
- {tk.name || '(unnamed)'} - {#if tk.ticket_type} - · {tk.ticket_type} - {/if} - {#if tk.external_id} - · #{tk.external_id} - {/if} - {#if tk.code} -
- {tk.code} - {#if p.email && canManage} - - {/if} -
- {/if} -
-
- {#if tk.checked_in_at} - Checked in {fmtTime(tk.checked_in_at)} - {:else} - - {/if} -
{tk.source}
-
-
- {/each} - {#if canManage} - {#if addTicketFor === p.id} -
addTicket(e, p.id)}> - - - - - -
- {:else} - - {/if} - {/if} -
-
-
- {/if} -
- - diff --git a/frontend/src/pages/ScheduleBoard.svelte b/frontend/src/pages/ScheduleBoard.svelte index 529264f..8e2efd3 100644 --- a/frontend/src/pages/ScheduleBoard.svelte +++ b/frontend/src/pages/ScheduleBoard.svelte @@ -25,9 +25,8 @@ let assignVolID = $state(0) let assigning = $state(false) - const roles = $derived(session?.user?.roles ?? []) - function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } - const canManage = $derived(hasRole('admin', 'staffing', 'colead')) + const role = $derived(session?.user?.role ?? '') + const canManage = $derived(['admin', 'coordinator', 'volunteer_lead'].includes(role)) const myDeptIDs = $derived(session?.user?.department_ids ?? []) const allDepts = liveQuery(() => @@ -55,7 +54,7 @@ // Departments visible to this user const visibleDepts = $derived.by(() => { const depts = $allDepts ?? [] - if (hasRole('colead') && !hasRole('admin', 'staffing')) return depts.filter(d => myDeptIDs.includes(d.id)) + if (role === 'volunteer_lead') return depts.filter(d => myDeptIDs.includes(d.id)) return depts }) @@ -135,13 +134,11 @@ try { const res = await api.shifts.reorder(positions) - if (res && !res.ok) throw new Error('Reorder failed') - await db.transaction('rw', db.shifts, async () => { - for (const p of positions) { - const s = await db.shifts.get(p.id) - if (s) await db.shifts.put({ ...s, position: p.position }) - } - }) + if (res && !res.ok) throw new Error() + for (const p of positions) { + const s = await db.shifts.get(p.id) + if (s) await db.shifts.put({ ...s, position: p.position }) + } } catch (err) { error = err.message } @@ -275,7 +272,7 @@ {#if showAdd && canManage}
-
+
- - {#each ($allVolunteers ?? []) - .filter(v => v.department_id === shift.department_id) - .filter(v => !assigned.some(a => a.volunteer.id === v.id)) - as v} - - {/each} - - - - -
- {:else} - - {/if} + {#if assigningShiftID === shift.id} +
+ + + + +
+ {:else} + {/if} {/if}
@@ -527,14 +514,6 @@ font-size: 0.78rem; font-weight: 500; } - .chip-lead { - font-size: 0.68rem; - font-weight: 600; - background: rgba(245,158,11,0.2); - color: var(--c-warn); - padding: 0.05rem 0.3rem; - border-radius: 99px; - } .board-vol-remove { background: none; border: none; diff --git a/frontend/src/pages/Settings.svelte b/frontend/src/pages/Settings.svelte index 9df6b81..72d6b5b 100644 --- a/frontend/src/pages/Settings.svelte +++ b/frontend/src/pages/Settings.svelte @@ -1,13 +1,10 @@ @@ -131,14 +129,6 @@
-

- Roles: - admin — full access · - staffing — volunteers, shifts, departments · - colead — manage assigned departments only · - gatekeeper — check-in only -

- {#if loadError}
{loadError}
{/if} @@ -149,31 +139,22 @@ {#if showAdd}
-
+
- - -
-
- - + +
-
-
- Roles -
- {#each availableRoles as r} - - {/each} +
+ +
{#if ($allDepts ?? []).length > 0} @@ -181,10 +162,10 @@ Departments
{#each $allDepts ?? [] as d} - @@ -206,16 +187,15 @@
Loading…
{:else if users.length === 0}
- No additional users -

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

+ No users yet
{:else}
- - + + @@ -223,28 +203,23 @@ {#each users as u (u.id)} {#if editID === u.id} - - + + - {:else} - - + - @@ -290,11 +264,3 @@ {/if} - - diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index 8f5abe5..2614272 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -8,43 +8,23 @@ let search = $state('') let filterDept = $state('') - let filterStatus = $state('') + let filterChecked = $state('') let error = $state('') let showAdd = $state(false) let adding = $state(false) let newName = $state('') - let newTicketName = $state('') let newEmail = $state('') + let newPhone = $state('') let newDeptID = $state('') let newIsLead = $state(false) let newNote = $state('') - let editID = $state(null) - let editDeptID = $state('') - let editIsLead = $state(false) - let editNote = $state('') - let saving = $state(false) - let confirmingID = $state(null) - - const roles = $derived(session?.user?.roles ?? []) - function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } - const canManage = $derived(hasRole('admin', 'staffing', 'colead')) - const canConfirm = $derived(hasRole('admin', 'staffing', 'colead')) - const myDeptIDs = $derived(session?.user?.department_ids ?? []) - - let deptInitialized = $state(false) - $effect(() => { - if (!deptInitialized && hasRole('colead') && !hasRole('admin', 'staffing') && myDeptIDs.length > 0) { - filterDept = String(myDeptIDs[0]) - deptInitialized = true - } - }) + const role = $derived(session?.user?.role ?? '') + const canManage = $derived(['admin', 'coordinator', 'volunteer_lead'].includes(role)) const allVolunteers = liveQuery(() => db.volunteers.filter(v => !v.deleted_at).toArray() ) - const allParticipants = liveQuery(() => db.participants.toArray()) - const allTickets = liveQuery(() => db.tickets.filter(t => !t.deleted_at).toArray()) const allDepts = liveQuery(() => db.departments.filter(d => !d.deleted_at).toArray() .then(arr => arr.sort((a, b) => a.name.localeCompare(b.name))) @@ -56,10 +36,8 @@ return list .filter(v => { if (filterDept && v.department_id !== parseInt(filterDept)) return false - if (filterStatus === 'unconfirmed' && v.email_confirmed) return false - if (filterStatus === 'registered' && (!v.email_confirmed || v.confirmed)) return false - if (filterStatus === 'confirmed' && (!v.confirmed || v.ready)) return false - if (filterStatus === 'ready' && !v.ready) return false + if (filterChecked === 'true' && !v.checked_in) return false + if (filterChecked === 'false' && v.checked_in) return false if (s && !v.name.toLowerCase().includes(s) && !(v.email || '').toLowerCase().includes(s)) return false return true @@ -67,28 +45,15 @@ .sort((a, b) => a.name.localeCompare(b.name)) }) - async function markReady(v) { + async function checkIn(v) { try { - const updated = await api.volunteers.markReady(v.id) + const updated = await api.volunteers.checkIn(v.id) await db.volunteers.put(updated) } catch (err) { error = err.message } } - async function confirmVolunteer(v) { - if (confirmingID) return - confirmingID = v.id - try { - const updated = await api.volunteers.confirm(v.id) - await db.volunteers.put(updated) - } catch (err) { - error = err.message - } finally { - confirmingID = null - } - } - async function addVolunteer(e) { e.preventDefault() adding = true @@ -96,8 +61,8 @@ try { const data = { name: newName, - ticket_name: newTicketName, email: newEmail, + phone: newPhone, is_lead: newIsLead, note: newNote, } @@ -105,7 +70,7 @@ const v = await api.volunteers.create(data) await db.volunteers.put(v) showAdd = false - newName = newEmail = newTicketName = newNote = '' + newName = newEmail = newPhone = newNote = '' newDeptID = '' newIsLead = false } catch (err) { @@ -125,48 +90,9 @@ } } - function startEdit(v) { - editID = v.id - editDeptID = v.department_id ? String(v.department_id) : '' - editIsLead = v.is_lead - editNote = v.note ?? '' - } - - function cancelEdit() { - editID = null - } - - async function saveVolunteer(v) { - saving = true - error = '' - try { - const updated = await api.volunteers.update(v.id, { - ...v, - department_id: editDeptID ? parseInt(editDeptID) : null, - is_lead: editIsLead, - note: editNote, - }) - await db.volunteers.put(updated) - editID = null - } catch (err) { - error = err.message - } finally { - saving = false - } - } - function deptFor(id) { return ($allDepts ?? []).find(d => d.id === id) } - - function participantHasTickets(participantId) { - if (!participantId) return false - return ($allTickets ?? []).some(t => t.participant_id === participantId) - } - - function participantFor(id) { - return ($allParticipants ?? []).find(p => p.id === id) ?? null - }
@@ -186,18 +112,18 @@ {#if showAdd && canManage}
-
+
- - + +
- - + +
- - + +
@@ -214,8 +140,8 @@
-
@@ -239,12 +165,10 @@ {/each} {/if} - + + + {filtered.length} shown @@ -254,14 +178,14 @@ {#if ($allVolunteers ?? []).length === 0}
No volunteers yet -

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

+

Add volunteers manually.

{:else}
Preferred NameRolesUsernameRole Departments
{u.preferred_name || u.email} {#if u.id === me}you{/if}
{u.username} {#if u.id === me}you{/if} -
- {#each availableRoles as r} - +
{#if ($allDepts ?? []).length > 0}
{#each $allDepts ?? [] as d} - {/each} @@ -254,7 +229,7 @@ placeholder="New password (leave blank to keep)" style="margin-top:0.5rem" autocomplete="new-password" />
+
- {u.preferred_name || u.email} + + {u.username} {#if u.id === me} - you + you {/if} -
{u.email}
{#each u.roles ?? [] as r}{roleLabel(r)}{/each}{roleLabel(u.role)} {deptNamesFor(u.department_ids || [])} +
{#if u.id !== me} - + {/if}
- + @@ -271,112 +195,47 @@ {#each filtered as v (v.id)} {@const dept = deptFor(v.department_id)} - {#if editID === v.id} - - - - - - - - {:else} - {@const participant = participantFor(v.participant_id)} - - - - - - {#if canManage} - + + - {/if} + {#if v.note} +
{v.note}
+ {/if} + + + + + {#if canManage} + + {/if} + {/each}
Preferred NameName Department Status
- {v.name} - {#if v.email}
{v.email}
{/if} -
- - - - - - - - -
- {v.name} - {#if v.is_lead} - Co-Lead - {/if} - {#if !v.participant_id} - No ticket - {:else if !participantHasTickets(v.participant_id)} - No ticket - {/if} - {#if participant?.ticket_name && participant.ticket_name !== v.name} -
Ticket: {participant.ticket_name}
- {/if} - {#if v.email} -
{v.email}
- {/if} - {#if v.note} -
{v.note}
- {/if} -
- {#if dept} - {dept.name} - {:else} - — - {/if} - - {#if v.ready} - Ready - {:else if v.confirmed} - Confirmed - {:else if v.email_confirmed} - Registered - {:else} - Unregistered - {/if} - {#if v.ready_at} -
- {new Date(v.ready_at).toLocaleTimeString()} -
- {/if} -
- {#if v.confirmed && !v.ready} - markReady(v)} /> - {/if} - - {#if canConfirm && v.email_confirmed && !v.confirmed} - - {/if} - - -
+ {v.name} + {#if v.is_lead} + Lead {/if} -
+ {#if dept} + {dept.name} + {:else} + — + {/if} + + + {v.checked_in ? 'Checked in' : 'Pending'} + + {#if v.checked_in_at} +
+ {new Date(v.checked_in_at).toLocaleTimeString()} +
+ {/if} +
+ {#if !v.checked_in} + checkIn(v)} /> + {/if} + + +
{/if}
- - diff --git a/frontend/src/sync.js b/frontend/src/sync.js index ef22313..05b16c0 100644 --- a/frontend/src/sync.js +++ b/frontend/src/sync.js @@ -4,54 +4,24 @@ import { api } from './api.js' let syncing = false let sseSource = null -async function checkBuildChanged() { - try { - const res = await fetch('/api/version') - const { build } = await res.json() - if (!build) return - const stored = await db.meta.get('build') - if (!stored || stored.value !== build) { - await db.transaction('rw', - [db.meta, db.event, db.participants, db.tickets, db.departments, db.volunteers, db.shifts, db.volunteer_shifts], - async () => { - await db.meta.clear() - await db.event.clear() - await db.participants.clear() - await db.tickets.clear() - await db.departments.clear() - await db.volunteers.clear() - await db.shifts.clear() - await db.volunteer_shifts.clear() - await db.meta.put({ key: 'build', value: build }) - } - ) - } - } catch {} -} - export async function syncPull() { if (syncing) return syncing = true try { - await checkBuildChanged() const since = await getLastSync() const data = await api.sync.pull(since) await db.transaction('rw', - [db.event, db.participants, db.tickets, db.departments, db.volunteers, db.shifts, db.volunteer_shifts], + [db.event, db.attendees, db.departments, db.volunteers, db.shifts, db.volunteer_shifts], async () => { if (data.event) { await db.event.put(data.event) } - if (data.participants?.length) { - await db.participants.bulkPut(data.participants) - const deleted = data.participants.filter(p => p.deleted_at).map(p => p.id) - if (deleted.length) await db.participants.bulkDelete(deleted) - } - if (data.tickets?.length) { - await db.tickets.bulkPut(data.tickets) - const deleted = data.tickets.filter(t => t.deleted_at).map(t => t.id) - if (deleted.length) await db.tickets.bulkDelete(deleted) + if (data.attendees?.length) { + await db.attendees.bulkPut(data.attendees) + // Purge hard-deleted records from Dexie + const deleted = data.attendees.filter(a => a.deleted_at).map(a => a.id) + if (deleted.length) await db.attendees.bulkDelete(deleted) } if (data.departments?.length) { await db.departments.bulkPut(data.departments) @@ -77,7 +47,7 @@ export async function syncPull() { } ) - if (data.server_time) await setLastSync(data.server_time) + await setLastSync(data.server_time) return true } catch (err) { console.warn('Sync pull failed:', err.message) @@ -102,8 +72,8 @@ export function startSSE(onEvent) { try { const payload = JSON.parse(e.data) if (payload.event === 'checkin') { - if (payload.data?.type === 'ticket' && payload.data?.ticket) { - await db.tickets.put(payload.data.ticket) + if (payload.data?.type === 'attendee' && payload.data?.attendee) { + await db.attendees.put(payload.data.attendee) } if (payload.data?.type === 'volunteer' && payload.data?.volunteer) { await db.volunteers.put(payload.data.volunteer) @@ -123,7 +93,7 @@ export function startSSE(onEvent) { syncPull() }, 5000) } - }).catch(() => {}) + }) } connect() @@ -134,23 +104,18 @@ export function stopSSE() { sseSource = null } +// Poll for sync when online, with exponential backoff on failure let syncInterval = null -let onlineHandler = null export function startSyncLoop(intervalMs = 30000) { if (syncInterval) return syncInterval = setInterval(() => { if (navigator.onLine) syncPull() }, intervalMs) - onlineHandler = () => syncPull() - window.addEventListener('online', onlineHandler) + window.addEventListener('online', () => syncPull()) } export function stopSyncLoop() { clearInterval(syncInterval) syncInterval = null - if (onlineHandler) { - window.removeEventListener('online', onlineHandler) - onlineHandler = null - } } diff --git a/frontend/src/sync.test.js b/frontend/src/sync.test.js index 21c213e..2c8915e 100644 --- a/frontend/src/sync.test.js +++ b/frontend/src/sync.test.js @@ -18,31 +18,30 @@ function mockFetch(body = {}, status = 200) { } describe('syncPull', () => { - it('writes participants to Dexie', async () => { + it('writes attendees to Dexie', async () => { mockFetch({ server_time: '2026-03-01T12:00:00Z', - participants: [{ id: 1, preferred_name: 'Titania', email: 'titania@example.com' }], - tickets: [], + attendees: [{ id: 1, name: 'Titania' }], departments: [], volunteers: [], shifts: [], volunteer_shifts: [], }) + // Import fresh to reset syncing guard const { syncPull } = await import('./sync.js') await syncPull() - const p = await db.participants.get(1) - expect(p.preferred_name).toBe('Titania') + const a = await db.attendees.get(1) + expect(a.name).toBe('Titania') expect(await getLastSync()).toBe('2026-03-01T12:00:00Z') }) - it('deletes soft-deleted participants from Dexie', async () => { - await db.participants.put({ id: 1, preferred_name: 'Titania', email: 'titania@example.com' }) + it('deletes soft-deleted attendees from Dexie', async () => { + await db.attendees.put({ id: 1, name: 'Titania' }) mockFetch({ server_time: '2026-03-01T13:00:00Z', - participants: [{ id: 1, preferred_name: 'Titania', deleted_at: '2026-03-01T12:30:00Z' }], - tickets: [], + attendees: [{ id: 1, name: 'Titania', deleted_at: '2026-03-01T12:30:00Z' }], departments: [], volunteers: [], shifts: [], @@ -51,8 +50,8 @@ describe('syncPull', () => { const { syncPull } = await import('./sync.js') await syncPull() - const p = await db.participants.get(1) - expect(p).toBeUndefined() + const a = await db.attendees.get(1) + expect(a).toBeUndefined() }) it('deletes soft-deleted volunteer_shifts from Dexie', async () => { @@ -60,8 +59,7 @@ describe('syncPull', () => { mockFetch({ server_time: '2026-03-01T13:00:00Z', - participants: [], - tickets: [], + attendees: [], departments: [], volunteers: [], shifts: [], @@ -77,8 +75,7 @@ describe('syncPull', () => { it('sets lastSync timestamp', async () => { mockFetch({ server_time: '2026-03-02T00:00:00Z', - participants: [], - tickets: [], + attendees: [], departments: [], volunteers: [], shifts: [], diff --git a/handle_attendees.go b/handle_attendees.go new file mode 100644 index 0000000..5e732ba --- /dev/null +++ b/handle_attendees.go @@ -0,0 +1,167 @@ +package main + +import ( + "encoding/csv" + "encoding/json" + "net/http" + "strconv" +) + +func (app *App) handleListAttendees(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + attendees, err := app.listAttendees(q.Get("search"), q.Get("ticket_type"), q.Get("checked_in")) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + types, _ := app.attendeeTicketTypes() + total, checkedIn, _ := app.attendeeCounts() + writeJSON(w, map[string]any{ + "attendees": attendees, + "ticket_types": types, + "total": total, + "checked_in": checkedIn, + }) +} + +func (app *App) handleCreateAttendee(w http.ResponseWriter, r *http.Request) { + var a Attendee + if err := json.NewDecoder(r.Body).Decode(&a); err != nil { + writeError(w, "invalid request", http.StatusBadRequest) + return + } + if a.Name == "" { + writeError(w, "name is required", http.StatusBadRequest) + return + } + created, err := app.createAttendee(a) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + writeJSON(w, created) +} + +func (app *App) handleGetAttendee(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + a, err := app.getAttendee(id) + if err != nil || a == nil { + writeError(w, "not found", http.StatusNotFound) + return + } + writeJSON(w, a) +} + +func (app *App) handleUpdateAttendee(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + var a Attendee + if err := json.NewDecoder(r.Body).Decode(&a); err != nil { + writeError(w, "invalid request", http.StatusBadRequest) + return + } + if a.Name == "" { + writeError(w, "name is required", http.StatusBadRequest) + return + } + a.ID = id + if err := app.updateAttendee(a); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + updated, _ := app.getAttendee(id) + writeJSON(w, updated) +} + +func (app *App) handleDeleteAttendee(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + if err := app.deleteAttendee(id); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// handleCheckInAttendee handles POST /api/attendees/:id/checkin. +// Optional body: {"count": N, "also_volunteer": true} +// Returns {"attendee": ..., "volunteer": ...} — volunteer is included if also_volunteer=true +// and the attendee has a linked volunteer record. +func (app *App) handleCheckInAttendee(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + + var body struct { + Count int `json:"count"` + AlsoVolunteer bool `json:"also_volunteer"` + } + body.Count = 1 + json.NewDecoder(r.Body).Decode(&body) + if body.Count < 1 { + body.Count = 1 + } + + claims := claimsFromContext(r) + a, err := app.checkInAttendee(id, claims.UserID, body.Count) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + result := map[string]any{"attendee": a} + + if body.AlsoVolunteer { + v, _ := app.getVolunteerByAttendeeID(id) + if v != nil { + if !v.CheckedIn { + if v2, err := app.checkInVolunteer(v.ID, claims.UserID); err == nil { + result["volunteer"] = v2 + app.broker.publish("checkin", map[string]any{"type": "volunteer", "volunteer": v2}) + } + } else { + result["volunteer"] = v + } + } + } + + app.broker.publish("checkin", map[string]any{"type": "attendee", "attendee": a}) + writeJSON(w, result) +} + +func (app *App) handleExportAttendees(w http.ResponseWriter, r *http.Request) { + attendees, err := app.listAttendees("", "", "") + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Content-Disposition", `attachment; filename="attendees.csv"`) + wr := csv.NewWriter(w) + wr.Write([]string{"name", "email", "phone", "ticket_id", "ticket_type", "party_size", "checked_in_count", "note", "checked_in"}) + for _, a := range attendees { + ci := "no" + if a.CheckedIn { + ci = "yes" + } + wr.Write([]string{ + a.Name, a.Email, a.Phone, a.TicketID, a.TicketType, + strconv.Itoa(a.PartySize), strconv.Itoa(a.CheckedInCount), + a.Note, ci, + }) + } + wr.Flush() +} diff --git a/handle_attendees_test.go b/handle_attendees_test.go index 7dd7ff8..827a4b2 100644 --- a/handle_attendees_test.go +++ b/handle_attendees_test.go @@ -6,14 +6,14 @@ import ( "testing" ) -func TestParticipantsListCreateDelete(t *testing.T) { +func TestAttendeesListCreateDelete(t *testing.T) { app := testApp(t) admin := testAdminUser(t, app) token := testToken(t, app, admin) mux := testMux(app) // Create - req := testAuthRequest("POST", "/api/participants", map[string]string{"preferred_name": "Titania", "email": "titania@example.com"}, token) + req := testAuthRequest("POST", "/api/attendees", map[string]string{"name": "Titania"}, token) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusCreated { @@ -23,20 +23,20 @@ func TestParticipantsListCreateDelete(t *testing.T) { id := created["id"].(float64) // List - req = testAuthRequest("GET", "/api/participants", nil, token) + req = testAuthRequest("GET", "/api/attendees", nil, token) w = httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("list: status = %d", w.Code) } list := parseJSON(t, w) - participants := list["participants"].([]any) - if len(participants) != 2 { // admin + Titania - t.Errorf("list: got %d, want 2", len(participants)) + attendees := list["attendees"].([]any) + if len(attendees) != 1 { + t.Errorf("list: got %d, want 1", len(attendees)) } // Delete - req = testAuthRequest("DELETE", "/api/participants/"+itoa(int(id)), nil, token) + req = testAuthRequest("DELETE", "/api/attendees/"+itoa(int(id)), nil, token) w = httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusNoContent { @@ -44,66 +44,66 @@ func TestParticipantsListCreateDelete(t *testing.T) { } // List again — should be empty - req = testAuthRequest("GET", "/api/participants", nil, token) + req = testAuthRequest("GET", "/api/attendees", nil, token) w = httptest.NewRecorder() mux.ServeHTTP(w, req) list = parseJSON(t, w) - if ps, ok := list["participants"].([]any); ok && len(ps) != 1 { // admin remains - t.Errorf("after delete: got %d, want 1", len(ps)) + if a2, ok := list["attendees"].([]any); ok && len(a2) != 0 { + t.Errorf("after delete: got %d, want 0", len(a2)) } } -func TestCheckInTicketHandler(t *testing.T) { +func TestCheckInAttendeeHandler(t *testing.T) { app := testApp(t) admin := testAdminUser(t, app) token := testToken(t, app, admin) mux := testMux(app) - p, _ := app.createParticipant(Participant{PreferredName: "Oberon", Email: "oberon@example.com"}) - tk, _ := app.createTicket(Ticket{ParticipantID: &p.ID, Name: "Oberon", Source: "manual"}) + app.createAttendee(Attendee{Name: "Oberon"}) + app.db.Exec(`UPDATE attendees SET party_size = 3 WHERE id = 1`) - req := testAuthRequest("POST", "/api/tickets/"+itoa(tk.ID)+"/checkin", nil, token) + // Check in 1 + req := testAuthRequest("POST", "/api/attendees/1/checkin", map[string]int{"count": 1}, token) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("checkin: status = %d\nbody: %s", w.Code, w.Body.String()) } result := parseJSON(t, w) - ticket := result["ticket"].(map[string]any) - if ticket["checked_in_at"] == nil { - t.Error("checked_in_at should be set after check-in") + attendee := result["attendee"].(map[string]any) + if attendee["checked_in_count"] != float64(1) { + t.Errorf("checked_in_count = %v, want 1", attendee["checked_in_count"]) } } -func TestGatekeeperRoleCanCheckIn(t *testing.T) { +func TestGateRoleCanCheckIn(t *testing.T) { app := testApp(t) - gate := testUserWithRoles(t, app, "Philostrate", []string{"gatekeeper"}, []int{}) + gate := testUserWithRole(t, app, "gateuser", "gate", []int{}) token := testToken(t, app, gate) mux := testMux(app) - p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@example.com"}) - tk, _ := app.createTicket(Ticket{ParticipantID: &p.ID, Name: "Puck", Source: "manual"}) + app.createAttendee(Attendee{Name: "Puck"}) - req := testAuthRequest("POST", "/api/tickets/"+itoa(tk.ID)+"/checkin", nil, token) + req := testAuthRequest("POST", "/api/attendees/1/checkin", nil, token) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusOK { - t.Errorf("gatekeeper checkin: status = %d", w.Code) + t.Errorf("gate checkin: status = %d", w.Code) } } -func TestGatekeeperRoleCannotDelete(t *testing.T) { +func TestGateRoleCannotDelete(t *testing.T) { app := testApp(t) - gate := testUserWithRoles(t, app, "Philostrate", []string{"gatekeeper"}, []int{}) + gate := testUserWithRole(t, app, "gateuser", "gate", []int{}) token := testToken(t, app, gate) mux := testMux(app) - p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@example.com"}) + app.createAttendee(Attendee{Name: "Puck"}) - req := testAuthRequest("DELETE", "/api/participants/"+itoa(p.ID), nil, token) + req := testAuthRequest("DELETE", "/api/attendees/1", nil, token) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusForbidden { - t.Errorf("gatekeeper delete: status = %d, want 403", w.Code) + t.Errorf("gate delete: status = %d, want 403", w.Code) } } diff --git a/handle_auth.go b/handle_auth.go index d75483b..282bd85 100644 --- a/handle_auth.go +++ b/handle_auth.go @@ -7,7 +7,7 @@ import ( func (app *App) handleLogin(w http.ResponseWriter, r *http.Request) { var body struct { - Email string `json:"email"` + Username string `json:"username"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { @@ -15,7 +15,7 @@ func (app *App) handleLogin(w http.ResponseWriter, r *http.Request) { return } - user, hash, err := app.getLoginParticipant(body.Email) + user, hash, err := app.getUserByUsername(body.Username) if err != nil { writeError(w, "internal error", http.StatusInternalServerError) return @@ -40,9 +40,9 @@ func (app *App) handleLogout(w http.ResponseWriter, r *http.Request) { func (app *App) handleMe(w http.ResponseWriter, r *http.Request) { claims := claimsFromContext(r) - user, err := app.getUser(claims.ParticipantID) + user, err := app.getUserByID(claims.UserID) if err != nil || user == nil { - writeError(w, "unauthorized", http.StatusUnauthorized) + writeError(w, "not found", http.StatusNotFound) return } writeJSON(w, user) diff --git a/handle_import.go b/handle_import.go index 7870e7a..359d8f9 100644 --- a/handle_import.go +++ b/handle_import.go @@ -10,6 +10,7 @@ import ( type ImportResult struct { Inserted int `json:"inserted"` + Grouped int `json:"grouped"` Skipped int `json:"skipped"` Errors []string `json:"errors"` } @@ -56,14 +57,12 @@ func (app *App) importCSV(r io.Reader) (ImportResult, error) { } var ( - nameIdx, emailIdx, ticketIDIdx, ticketTypeIdx int - hasEmail, hasTicketID, hasTicketType bool - isCrowdWork bool + nameIdx, emailIdx, ticketIDIdx, ticketTypeIdx, noteIdx int + hasEmail, hasTicketID, hasTicketType, hasNote bool ) if idx, ok := colIndex["patron name"]; ok { // CrowdWork / ticketing platform format - isCrowdWork = true nameIdx = idx if idx, ok := colIndex["patron email"]; ok { emailIdx, hasEmail = idx, true @@ -86,6 +85,9 @@ func (app *App) importCSV(r io.Reader) (ImportResult, error) { if idx, ok := colIndex["ticket_type"]; ok { ticketTypeIdx, hasTicketType = idx, true } + if idx, ok := colIndex["note"]; ok { + noteIdx, hasNote = idx, true + } } else { return ImportResult{}, fmt.Errorf("CSV must have a 'name' or 'patron name' column") } @@ -109,49 +111,33 @@ func (app *App) importCSV(r io.Reader) (ImportResult, error) { continue } - email := "" + a := Attendee{Name: name} if hasEmail { - email = strings.TrimSpace(csvGet(record, emailIdx)) + a.Email = strings.TrimSpace(csvGet(record, emailIdx)) } - externalID := "" if hasTicketID { - externalID = strings.TrimSpace(csvGet(record, ticketIDIdx)) + a.TicketID = strings.TrimSpace(csvGet(record, ticketIDIdx)) } - ticketType := "" if hasTicketType { - ticketType = strings.TrimSpace(csvGet(record, ticketTypeIdx)) + a.TicketType = strings.TrimSpace(csvGet(record, ticketTypeIdx)) + } + if hasNote { + a.Note = strings.TrimSpace(csvGet(record, noteIdx)) } - source := "manual" - orderID := "" - if isCrowdWork { - source = "crowdwork" - orderID = externalID - } - - // Find or create participant when email is present. - var participantID *int - if email != "" { - p, _, err := app.upsertParticipant(email, name) - if err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("line %d (%s): participant: %v", lineNum, name, err)) - continue - } - if p != nil { - participantID = &p.ID - } - } - - _, err = app.createTicket(Ticket{ - ParticipantID: participantID, - Name: name, - TicketType: ticketType, - Source: source, - ExternalID: externalID, - OrderID: orderID, - }) + _, err = app.createAttendee(a) if err != nil { if strings.Contains(err.Error(), "UNIQUE constraint failed") { + // CrowdWork exports one row per ticket under the purchaser's name. + // If we have a ticket_id and the same (name, ticket_id) already exists, + // increment party_size instead of skipping. + if hasTicketID && a.TicketID != "" { + merged, mergeErr := app.incrementPartySize(a.Name, a.TicketID) + if mergeErr == nil && merged { + result.Grouped++ + continue + } + } result.Skipped++ } else { result.Errors = append(result.Errors, fmt.Sprintf("line %d (%s): %v", lineNum, name, err)) diff --git a/handle_import_test.go b/handle_import_test.go index 2397ca2..a062c53 100644 --- a/handle_import_test.go +++ b/handle_import_test.go @@ -61,13 +61,13 @@ func TestImportGenericFormat(t *testing.T) { } } -func TestImportDedup(t *testing.T) { +func TestImportPartySizeDedup(t *testing.T) { app := testApp(t) admin := testAdminUser(t, app) token := testToken(t, app, admin) mux := testMux(app) - // 3 rows with same order number: first inserts, remaining 2 skip (same external_id) + // 3 rows same name+order = 1 record, party_size=3 csv := "Patron Name,Patron Email,Order Number,Tier Name\nTitania,titania@test.com,ORD-1,GA\nTitania,titania@test.com,ORD-1,GA\nTitania,titania@test.com,ORD-1,GA\n" w := postCSV(t, mux, token, csv) @@ -75,16 +75,16 @@ func TestImportDedup(t *testing.T) { if result["inserted"] != float64(1) { t.Errorf("inserted = %v, want 1", result["inserted"]) } - if result["skipped"] != float64(2) { - t.Errorf("skipped = %v, want 2", result["skipped"]) + if result["grouped"] != float64(2) { + t.Errorf("grouped = %v, want 2", result["grouped"]) } - tickets, _ := app.listTickets(nil, "") - if len(tickets) != 1 { - t.Fatalf("ticket count = %d, want 1", len(tickets)) + attendees, _ := app.listAttendees("", "", "") + if len(attendees) != 1 { + t.Fatalf("attendee count = %d, want 1", len(attendees)) } - if tickets[0].Source != "crowdwork" { - t.Errorf("source = %q, want crowdwork", tickets[0].Source) + if attendees[0].PartySize != 3 { + t.Errorf("party_size = %d, want 3", attendees[0].PartySize) } } @@ -94,8 +94,7 @@ func TestImportReimportSkips(t *testing.T) { token := testToken(t, app, admin) mux := testMux(app) - // Use ticket_ids so re-import dedup works via UNIQUE(source, external_id) - csv := "name,email,ticket_id\nTitania,titania@test.com,T001\nOberon,oberon@test.com,T002\n" + csv := "name\nTitania\nOberon\n" postCSV(t, mux, token, csv) // Re-import same data diff --git a/handle_kiosk.go b/handle_kiosk.go index 773ccfe..c782afb 100644 --- a/handle_kiosk.go +++ b/handle_kiosk.go @@ -6,21 +6,23 @@ import ( "strconv" ) -func (app *App) volunteerFromKioskToken(token string) (*Volunteer, error) { - return app.getVolunteerByKioskCode(token) -} - // handleKioskGet returns the volunteer's profile, current shift assignments, and -// available open shifts in their department. Authenticated by kiosk code only — +// available open shifts in their department. Authenticated by volunteer token only — // no JWT required. func (app *App) handleKioskGet(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") - v, err := app.volunteerFromKioskToken(token) - if err != nil || v == nil { + a, err := app.getAttendeeByToken(token) + if err != nil || a == nil { writeError(w, "not found", http.StatusNotFound) return } + v, _ := app.getVolunteerByAttendeeID(a.ID) + if v == nil { + writeError(w, "no volunteer record linked to this token", http.StatusNotFound) + return + } + assigned, _ := app.listShiftsForVolunteer(v.ID) if assigned == nil { assigned = []Shift{} @@ -51,11 +53,16 @@ func (app *App) handleKioskClaim(w http.ResponseWriter, r *http.Request) { return } - v, err := app.volunteerFromKioskToken(token) - if err != nil || v == nil { + a, err := app.getAttendeeByToken(token) + if err != nil || a == nil { writeError(w, "not found", http.StatusNotFound) return } + v, _ := app.getVolunteerByAttendeeID(a.ID) + if v == nil { + writeError(w, "no volunteer linked to this token", http.StatusNotFound) + return + } force := r.URL.Query().Get("force") == "true" @@ -103,11 +110,16 @@ func (app *App) handleKioskUnclaim(w http.ResponseWriter, r *http.Request) { return } - v, err := app.volunteerFromKioskToken(token) - if err != nil || v == nil { + a, err := app.getAttendeeByToken(token) + if err != nil || a == nil { writeError(w, "not found", http.StatusNotFound) return } + v, _ := app.getVolunteerByAttendeeID(a.ID) + if v == nil { + writeError(w, "no volunteer linked to this token", http.StatusNotFound) + return + } if err := app.unassignShift(v.ID, shiftID); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) diff --git a/handle_kiosk_test.go b/handle_kiosk_test.go index 2bac7cf..0eb252b 100644 --- a/handle_kiosk_test.go +++ b/handle_kiosk_test.go @@ -14,11 +14,13 @@ func setupKiosk(t *testing.T) (*App, *http.ServeMux, string) { dept, _ := app.createDepartment(Department{Name: "Gate"}) deptID := dept.ID - // Create volunteer with a kiosk_code directly on the volunteer record - p, _ := app.createParticipant(Participant{Email: "titania@test.com", PreferredName: "Titania"}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) - token, _ := app.generateVolunteerKioskCode() - app.assignKioskCode(v.ID, token) + // Create attendee with token + a, _ := app.createAttendee(Attendee{Name: "Titania", Email: "titania@test.com"}) + token, _ := app.generateUniqueToken() + app.db.Exec(`UPDATE attendees SET volunteer_token = ? WHERE id = ?`, token, a.ID) + + // Create linked volunteer + app.createVolunteer(Volunteer{Name: "Titania", AttendeeID: &a.ID, DepartmentID: &deptID}) // Create shifts app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2}) @@ -132,8 +134,7 @@ func TestKioskClaimFull(t *testing.T) { // Shift 2 has capacity 1. Fill it with another volunteer. dept, _ := app.createDepartment(Department{Name: "Build"}) deptID := dept.ID - otherP, _ := app.createParticipant(Participant{PreferredName: "Other", Email: "other@test.com"}) - other, _ := app.createVolunteer(Volunteer{ParticipantID: otherP.ID, DepartmentID: &deptID}) + other, _ := app.createVolunteer(Volunteer{Name: "Other", DepartmentID: &deptID}) app.assignShift(other.ID, 2) // fills the capacity-1 shift req := httptest.NewRequest("POST", "/api/v/"+token+"/shifts/2", nil) diff --git a/handle_participants.go b/handle_participants.go deleted file mode 100644 index 52624d5..0000000 --- a/handle_participants.go +++ /dev/null @@ -1,179 +0,0 @@ -package main - -import ( - "encoding/csv" - "encoding/json" - "net/http" - "strconv" -) - -func (app *App) handleListParticipants(w http.ResponseWriter, r *http.Request) { - search := r.URL.Query().Get("search") - participants, err := app.listParticipants(search, "") - if err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - total, checkedIn, _ := app.ticketCounts() - types, _ := app.ticketTypes() - writeJSON(w, map[string]any{ - "participants": participants, - "total": total, - "checked_in": checkedIn, - "ticket_types": types, - }) -} - -func (app *App) handleGetParticipant(w http.ResponseWriter, r *http.Request) { - id, err := strconv.Atoi(r.PathValue("id")) - if err != nil { - writeError(w, "invalid id", http.StatusBadRequest) - return - } - p, err := app.getParticipant(id) - if err != nil || p == nil { - writeError(w, "not found", http.StatusNotFound) - return - } - tickets, _ := app.listTickets(&id, "") - writeJSON(w, map[string]any{"participant": p, "tickets": tickets}) -} - -func (app *App) handleCreateParticipant(w http.ResponseWriter, r *http.Request) { - var p Participant - if err := json.NewDecoder(r.Body).Decode(&p); err != nil { - writeError(w, "invalid request", http.StatusBadRequest) - return - } - if p.PreferredName == "" && p.Email == "" { - writeError(w, "name or email is required", http.StatusBadRequest) - return - } - created, err := app.createParticipant(p) - if err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusCreated) - writeJSON(w, created) -} - -func (app *App) handleUpdateParticipant(w http.ResponseWriter, r *http.Request) { - id, err := strconv.Atoi(r.PathValue("id")) - if err != nil { - writeError(w, "invalid id", http.StatusBadRequest) - return - } - var p Participant - if err := json.NewDecoder(r.Body).Decode(&p); err != nil { - writeError(w, "invalid request", http.StatusBadRequest) - return - } - p.ID = id - if err := app.updateParticipant(p); err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - updated, _ := app.getParticipant(id) - writeJSON(w, updated) -} - -func (app *App) handleDeleteParticipant(w http.ResponseWriter, r *http.Request) { - id, err := strconv.Atoi(r.PathValue("id")) - if err != nil { - writeError(w, "invalid id", http.StatusBadRequest) - return - } - if err := app.deleteParticipant(id); err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusNoContent) -} - -// handleMergeParticipants reassigns all tickets and volunteers from otherID to -// canonicalID, then soft-deletes the other participant. -func (app *App) handleMergeParticipants(w http.ResponseWriter, r *http.Request) { - id, err := strconv.Atoi(r.PathValue("id")) - if err != nil { - writeError(w, "invalid id", http.StatusBadRequest) - return - } - otherID, err := strconv.Atoi(r.PathValue("other_id")) - if err != nil { - writeError(w, "invalid other_id", http.StatusBadRequest) - return - } - if err := app.mergeParticipants(id, otherID); err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - p, _ := app.getParticipant(id) - tickets, _ := app.listTickets(&id, "") - writeJSON(w, map[string]any{"participant": p, "tickets": tickets}) -} - -func (app *App) handleExportParticipants(w http.ResponseWriter, r *http.Request) { - participants, err := app.listParticipants("", "") - if err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "text/csv") - w.Header().Set("Content-Disposition", `attachment; filename="participants.csv"`) - wr := csv.NewWriter(w) - wr.Write([]string{"id", "email", "preferred_name", "phone", "pronouns", "note"}) - for _, p := range participants { - wr.Write([]string{ - strconv.Itoa(p.ID), p.Email, p.PreferredName, p.Phone, p.Pronouns, p.Note, - }) - } - wr.Flush() -} - -func (app *App) handleCreateTicket(w http.ResponseWriter, r *http.Request) { - var t Ticket - if err := json.NewDecoder(r.Body).Decode(&t); err != nil { - writeError(w, "invalid request", http.StatusBadRequest) - return - } - if t.ParticipantID == nil { - writeError(w, "participant_id is required", http.StatusBadRequest) - return - } - if t.Source == "" { - t.Source = "manual" - } - created, err := app.createTicket(t) - if err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusCreated) - writeJSON(w, created) -} - -func (app *App) handleListTickets(w http.ResponseWriter, r *http.Request) { - tickets, err := app.listTickets(nil, "") - if err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - writeJSON(w, map[string]any{"tickets": tickets}) -} - -func (app *App) handleCheckInTicket(w http.ResponseWriter, r *http.Request) { - id, err := strconv.Atoi(r.PathValue("id")) - if err != nil { - writeError(w, "invalid id", http.StatusBadRequest) - return - } - claims := claimsFromContext(r) - tk, err := app.checkInTicket(id, claims.ParticipantID) - if err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - app.broker.publish("checkin", map[string]any{"type": "ticket", "ticket": tk}) - writeJSON(w, map[string]any{"ticket": tk}) -} diff --git a/handle_settings.go b/handle_settings.go index 5da8084..9021368 100644 --- a/handle_settings.go +++ b/handle_settings.go @@ -27,14 +27,6 @@ func (app *App) handleGetSettings(w http.ResponseWriter, r *http.Request) { noteLabel = "Additional note" } - var ssoURL, ssoSecret string - app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_url'`).Scan(&ssoURL) - app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_secret'`).Scan(&ssoSecret) - maskedSSOSecret := "" - if ssoSecret != "" { - maskedSSOSecret = "***" - } - writeJSON(w, map[string]any{ "smtp_host": cfg.Host, "smtp_port": cfg.Port, @@ -46,8 +38,6 @@ func (app *App) handleGetSettings(w http.ResponseWriter, r *http.Request) { "volunteer_note_label": noteLabel, "volunteer_note_required": noteRequired == "true", "shift_signups_open": signupsOpen == "true", - "discourse_sso_url": ssoURL, - "discourse_sso_secret": maskedSSOSecret, }) } @@ -59,7 +49,7 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) { } keys := []string{"smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_from_name", "base_url", - "volunteer_note_label", "volunteer_note_required", "discourse_sso_url", "discourse_sso_secret"} + "volunteer_note_label", "volunteer_note_required"} for _, k := range keys { v, ok := body[k] if !ok { @@ -68,7 +58,7 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) { var val string switch vv := v.(type) { case string: - if (k == "smtp_password" || k == "discourse_sso_secret") && (vv == "" || vv == "***") { + if k == "smtp_password" && vv == "" { continue } val = vv @@ -89,9 +79,9 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) { app.handleGetSettings(w, r) } -func (app *App) handleResetTickets(w http.ResponseWriter, r *http.Request) { +func (app *App) handleResetAttendees(w http.ResponseWriter, r *http.Request) { ts := now() - result, err := app.db.Exec(`UPDATE tickets SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL`, ts, ts) + result, err := app.db.Exec(`UPDATE attendees SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL`, ts, ts) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return diff --git a/handle_settings_test.go b/handle_settings_test.go index 16ef59f..736696c 100644 --- a/handle_settings_test.go +++ b/handle_settings_test.go @@ -55,19 +55,17 @@ func TestUpdateSettings(t *testing.T) { } } -func TestResetTickets(t *testing.T) { +func TestResetAttendees(t *testing.T) { app := testApp(t) admin := testAdminUser(t, app) token := testToken(t, app, admin) mux := testMux(app) - p1, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) - p2, _ := app.createParticipant(Participant{PreferredName: "Oberon", Email: "oberon@example.com"}) - app.createTicket(Ticket{ParticipantID: &p1.ID, Name: "Titania", Source: "manual"}) - app.createTicket(Ticket{ParticipantID: &p2.ID, Name: "Oberon", Source: "manual"}) + app.createAttendee(Attendee{Name: "Titania", Email: "titania@example.com"}) + app.createAttendee(Attendee{Name: "Oberon", Email: "oberon@example.com"}) w := httptest.NewRecorder() - mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/reset-tickets", nil, token)) + mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/reset-attendees", nil, token)) if w.Code != 200 { t.Fatalf("status = %d: %s", w.Code, w.Body.String()) @@ -77,20 +75,20 @@ func TestResetTickets(t *testing.T) { t.Fatalf("deleted = %v, want 2", result["deleted"]) } - tickets, _ := app.listTickets(nil, "") - if len(tickets) != 0 { - t.Fatalf("tickets remaining = %d, want 0", len(tickets)) + attendees, _ := app.listAttendees("", "", "") + if len(attendees) != 0 { + t.Fatalf("attendees remaining = %d, want 0", len(attendees)) } } -func TestResetTicketsRequiresAdmin(t *testing.T) { +func TestResetAttendeesRequiresAdmin(t *testing.T) { app := testApp(t) - gate := testUserWithRoles(t, app, "Snug", []string{"gatekeeper"}, []int{}) + gate := testUserWithRole(t, app, "gate1", "gate", []int{}) token := testToken(t, app, gate) mux := testMux(app) w := httptest.NewRecorder() - mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/reset-tickets", nil, token)) + mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/reset-attendees", nil, token)) if w.Code != 403 { t.Fatalf("status = %d, want 403", w.Code) @@ -131,7 +129,7 @@ func TestResetDepartmentsCascadesShifts(t *testing.T) { func TestSettingsNonAdminRejected(t *testing.T) { app := testApp(t) - gate := testUserWithRoles(t, app, "Quince", []string{"gatekeeper"}, []int{}) + gate := testUserWithRole(t, app, "gateuser", "gate", []int{}) token := testToken(t, app, gate) mux := testMux(app) diff --git a/handle_shifts.go b/handle_shifts.go index 9299916..460ed91 100644 --- a/handle_shifts.go +++ b/handle_shifts.go @@ -8,19 +8,20 @@ import ( func (app *App) handleListShifts(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() - var deptIDs []int + var deptID *int if d := q.Get("dept"); d != "" { - if id, err := strconv.Atoi(d); err == nil { - deptIDs = []int{id} + id, err := strconv.Atoi(d) + if err == nil { + deptID = &id } } claims := claimsFromContext(r) - if isCoLeadOnly(claims) && len(deptIDs) == 0 { - deptIDs = claims.DeptIDs + if claims.Role == "volunteer_lead" && deptID == nil && len(claims.DeptIDs) > 0 { + deptID = &claims.DeptIDs[0] } - shifts, err := app.listShifts(deptIDs, q.Get("day"), q.Get("since")) + shifts, err := app.listShifts(deptID, q.Get("day"), q.Get("since")) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -39,7 +40,7 @@ func (app *App) handleCreateShift(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if isCoLeadOnly(claims) && !inSlice(s.DepartmentID, claims.DeptIDs) { + if claims.Role == "volunteer_lead" && !inSlice(s.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) return } @@ -64,7 +65,7 @@ func (app *App) handleUpdateShift(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if isCoLeadOnly(claims) { + if claims.Role == "volunteer_lead" { existing, _ := app.getShift(id) if existing == nil || !inSlice(existing.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) @@ -86,14 +87,6 @@ func (app *App) handleDeleteShift(w http.ResponseWriter, r *http.Request) { writeError(w, "invalid id", http.StatusBadRequest) return } - claims := claimsFromContext(r) - if isCoLeadOnly(claims) { - s, _ := app.getShift(id) - if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) { - writeError(w, "forbidden: outside your department", http.StatusForbidden) - return - } - } if err := app.deleteShift(id); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -118,14 +111,6 @@ func (app *App) handleAssignShiftVolunteer(w http.ResponseWriter, r *http.Reques writeError(w, "volunteer_id required", http.StatusBadRequest) return } - claims := claimsFromContext(r) - if isCoLeadOnly(claims) { - s, _ := app.getShift(shiftID) - if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) { - writeError(w, "forbidden: outside your department", http.StatusForbidden) - return - } - } if !body.Force { conflicts, err := app.checkShiftConflict(body.VolunteerID, shiftID) @@ -164,14 +149,6 @@ func (app *App) handleUnassignShiftVolunteer(w http.ResponseWriter, r *http.Requ writeError(w, "invalid volunteer id", http.StatusBadRequest) return } - claims := claimsFromContext(r) - if isCoLeadOnly(claims) { - s, _ := app.getShift(shiftID) - if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) { - writeError(w, "forbidden: outside your department", http.StatusForbidden) - return - } - } if err := app.unassignShift(volunteerID, shiftID); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -190,16 +167,6 @@ func (app *App) handleReorderShifts(w http.ResponseWriter, r *http.Request) { writeError(w, "array of {id, position} required", http.StatusBadRequest) return } - claims := claimsFromContext(r) - if isCoLeadOnly(claims) { - for _, p := range raw { - s, _ := app.getShift(p.ID) - if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) { - writeError(w, "forbidden: outside your department", http.StatusForbidden) - return - } - } - } positions := make([]struct{ ID, Position int }, len(raw)) for i, p := range raw { positions[i] = struct{ ID, Position int }{p.ID, p.Position} diff --git a/handle_shifts_test.go b/handle_shifts_test.go index 19c49bf..8c629b1 100644 --- a/handle_shifts_test.go +++ b/handle_shifts_test.go @@ -55,8 +55,7 @@ func TestShiftAssignVolunteer(t *testing.T) { dept, _ := app.createDepartment(Department{Name: "Gate"}) deptID := dept.ID app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) - p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com"}) - app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) + app.createVolunteer(Volunteer{Name: "Titania", DepartmentID: &deptID}) // Assign req := testAuthRequest("POST", "/api/shifts/1/volunteers", map[string]any{ @@ -87,8 +86,7 @@ func TestShiftAssignConflict(t *testing.T) { deptID := dept.ID app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"}) - p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com"}) - app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) + app.createVolunteer(Volunteer{Name: "Titania", DepartmentID: &deptID}) // Assign to first shift app.assignShift(1, 1) @@ -104,86 +102,6 @@ func TestShiftAssignConflict(t *testing.T) { } } -func TestCoLeadDeleteShiftOtherDept(t *testing.T) { - app := testApp(t) - mux := testMux(app) - - deptA, _ := app.createDepartment(Department{Name: "Gate"}) - deptB, _ := app.createDepartment(Department{Name: "Build"}) - colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) - tok := testToken(t, app, colead) - - s, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) - - w := httptest.NewRecorder() - mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/shifts/"+itoa(s.ID), nil, tok)) - if w.Code != http.StatusForbidden { - t.Errorf("expected 403 for other dept, got %d", w.Code) - } -} - -func TestCoLeadDeleteShiftOwnDept(t *testing.T) { - app := testApp(t) - mux := testMux(app) - - deptA, _ := app.createDepartment(Department{Name: "Gate"}) - colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) - tok := testToken(t, app, colead) - - s, _ := app.createShift(Shift{DepartmentID: deptA.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) - - w := httptest.NewRecorder() - mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/shifts/"+itoa(s.ID), nil, tok)) - if w.Code != http.StatusNoContent { - t.Errorf("expected 204 for own dept, got %d: %s", w.Code, w.Body.String()) - } -} - -func TestCoLeadAssignShiftVolunteerOtherDept(t *testing.T) { - app := testApp(t) - mux := testMux(app) - - deptA, _ := app.createDepartment(Department{Name: "Gate"}) - deptB, _ := app.createDepartment(Department{Name: "Build"}) - colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) - tok := testToken(t, app, colead) - - s, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) - deptBID := deptB.ID - p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID}) - - w := httptest.NewRecorder() - mux.ServeHTTP(w, testAuthRequest("POST", "/api/shifts/"+itoa(s.ID)+"/volunteers", map[string]any{ - "volunteer_id": v.ID, - }, tok)) - if w.Code != http.StatusForbidden { - t.Errorf("expected 403 for other dept, got %d", w.Code) - } -} - -func TestCoLeadReorderShiftsOtherDept(t *testing.T) { - app := testApp(t) - mux := testMux(app) - - deptA, _ := app.createDepartment(Department{Name: "Gate"}) - deptB, _ := app.createDepartment(Department{Name: "Build"}) - colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) - tok := testToken(t, app, colead) - - s1, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "A", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) - s2, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "B", Day: "2026-03-15", StartTime: "12:00", EndTime: "16:00"}) - - w := httptest.NewRecorder() - mux.ServeHTTP(w, testAuthRequest("POST", "/api/shifts/reorder", []map[string]int{ - {"id": s1.ID, "position": 2}, - {"id": s2.ID, "position": 1}, - }, tok)) - if w.Code != http.StatusForbidden { - t.Errorf("expected 403 for other dept reorder, got %d", w.Code) - } -} - func TestShiftReorder(t *testing.T) { app := testApp(t) admin := testAdminUser(t, app) diff --git a/handle_signup.go b/handle_signup.go index 1f0fe2e..2f8eb46 100644 --- a/handle_signup.go +++ b/handle_signup.go @@ -68,20 +68,32 @@ func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) { return } - // Find or create participant by email. - participant, _, err := app.upsertParticipant(body.Email, body.PreferredName) - if err != nil { - writeError(w, "internal error", http.StatusInternalServerError) - return + // Auto-match attendee by email or create new + var attendeeID *int + attendees, _ := app.listAttendees("", "", "") + for _, a := range attendees { + if strings.EqualFold(a.Email, body.Email) { + id := a.ID + attendeeID = &id + break + } } - // Update participant's personal details if they signed up with more info. - if body.Phone != "" || body.Pronouns != "" || body.TicketName != "" { - app.db.Exec(`UPDATE participants SET - phone = CASE WHEN phone = '' THEN ? ELSE phone END, - pronouns = CASE WHEN pronouns = '' THEN ? ELSE pronouns END, - ticket_name = CASE WHEN ticket_name = '' THEN ? ELSE ticket_name END, - updated_at = ? - WHERE id = ?`, body.Phone, body.Pronouns, body.TicketName, now(), participant.ID) + + if attendeeID == nil { + name := body.PreferredName + if body.TicketName != "" { + name = body.TicketName + } + newAttendee, err := app.createAttendee(Attendee{ + Name: name, + Email: body.Email, + Phone: body.Phone, + }) + if err != nil { + writeError(w, "internal error", http.StatusInternalServerError) + return + } + attendeeID = &newAttendee.ID } confirmToken, err := generateConfirmationToken() @@ -89,12 +101,18 @@ func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) { writeError(w, "internal error", http.StatusInternalServerError) return } - app.setParticipantConfirmationToken(participant.ID, confirmToken) vol := Volunteer{ - ParticipantID: participant.ID, - DepartmentID: body.DepartmentID, - Note: body.Note, + AttendeeID: attendeeID, + Name: body.PreferredName, + PreferredName: body.PreferredName, + TicketName: body.TicketName, + Email: body.Email, + Phone: body.Phone, + Pronouns: body.Pronouns, + DepartmentID: body.DepartmentID, + Note: body.Note, + ConfirmationToken: &confirmToken, } if _, err := app.createVolunteer(vol); err != nil { @@ -131,7 +149,7 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) { return } - if err := app.confirmParticipantEmail(vol.ParticipantID); err != nil { + if err := app.confirmVolunteerEmail(vol.ID); err != nil { writeError(w, "internal error", http.StatusInternalServerError) return } @@ -141,19 +159,24 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) { var signupsOpen string app.db.QueryRow(`SELECT value FROM config WHERE key = 'shift_signups_open'`).Scan(&signupsOpen) - if signupsOpen == "true" { - code, err := app.generateVolunteerKioskCode() - if err == nil { - if err := app.assignKioskCode(vol.ID, code); err == nil { - kioskLink := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), code) - response["kiosk_link"] = kioskLink - go func() { - if err := app.sendShiftSignupEmail(vol.Email, vol.Name, kioskLink); err != nil { - log.Printf("shift signup email to %s failed: %v", vol.Email, err) - } - }() + if signupsOpen == "true" && vol.AttendeeID != nil { + a, _ := app.getAttendee(*vol.AttendeeID) + if a != nil && a.VolunteerToken == nil { + t, err := app.generateUniqueToken() + if err == nil { + app.db.Exec(`UPDATE attendees SET volunteer_token=?, updated_at=? WHERE id=?`, t, now(), a.ID) + a.VolunteerToken = &t } } + if a != nil && a.VolunteerToken != nil { + kioskLink := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), *a.VolunteerToken) + response["kiosk_link"] = kioskLink + go func() { + if err := app.sendShiftSignupEmail(vol.Email, vol.PreferredName, kioskLink); err != nil { + log.Printf("shift signup email to %s failed: %v", vol.Email, err) + } + }() + } } writeJSON(w, response) @@ -180,29 +203,41 @@ func (app *App) handleToggleShiftSignups(w http.ResponseWriter, r *http.Request) } func (app *App) openShiftSignups() { - // Assign kiosk codes to email-confirmed volunteers that don't have one yet. - vols, _ := app.listVolunteersNeedingKioskCode() + // Generate kiosk tokens for confirmed volunteers whose attendees lack one + vols, _ := app.listConfirmedVolunteersWithoutKioskToken() for _, v := range vols { - code, err := app.generateVolunteerKioskCode() + if v.AttendeeID == nil { + continue + } + t, err := app.generateUniqueToken() if err != nil { continue } - app.assignKioskCode(v.ID, code) + app.db.Exec(`UPDATE attendees SET volunteer_token=?, updated_at=? WHERE id=?`, t, now(), *v.AttendeeID) } - // Email all email-confirmed volunteers that now have a kiosk code. + // Email all confirmed volunteers with kiosk links confirmed, _ := queryVolunteers(app.db, ` - SELECT `+volunteerSelect+` `+volunteerFrom+` - WHERE p.email_confirmed = 1 AND v.kiosk_code IS NOT NULL AND v.deleted_at IS NULL`) + SELECT `+volunteerCols+` + FROM volunteers + WHERE email_confirmed = 1 AND deleted_at IS NULL AND attendee_id IS NOT NULL`) baseURL := app.resolveBaseURL() sent := 0 for _, v := range confirmed { - if v.Email == "" || v.KioskCode == nil { + if v.AttendeeID == nil || v.Email == "" { continue } - kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *v.KioskCode) - if err := app.sendShiftSignupEmail(v.Email, v.Name, kioskLink); err == nil { + a, _ := app.getAttendee(*v.AttendeeID) + if a == nil || a.VolunteerToken == nil { + continue + } + kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *a.VolunteerToken) + name := v.PreferredName + if name == "" { + name = v.Name + } + if err := app.sendShiftSignupEmail(v.Email, name, kioskLink); err == nil { sent++ } else { log.Printf("shift signup email to %s failed: %v", v.Email, err) diff --git a/handle_signup_test.go b/handle_signup_test.go index 62f59d1..a59da69 100644 --- a/handle_signup_test.go +++ b/handle_signup_test.go @@ -58,38 +58,38 @@ func TestPublicSignup(t *testing.T) { if err != nil || vol == nil { t.Fatal("volunteer not created") } - if vol.Name != "Titania" { - t.Errorf("name = %q, want Titania", vol.Name) + if vol.PreferredName != "Titania" { + t.Errorf("preferred_name = %q, want Titania", vol.PreferredName) } if vol.Pronouns != "she/they" { t.Errorf("pronouns = %q, want she/they", vol.Pronouns) } + if vol.ConfirmationToken == nil || *vol.ConfirmationToken == "" { + t.Error("expected confirmation token to be set") + } if vol.EmailConfirmed { t.Error("should not be confirmed yet") } - // Participant should be auto-created and linked - if vol.ParticipantID == 0 { - t.Fatal("expected participant to be linked") + // Attendee should be auto-created and linked + if vol.AttendeeID == nil { + t.Fatal("expected attendee to be linked") } - p, _ := app.getParticipant(vol.ParticipantID) - if p == nil { - t.Fatal("linked participant not found") + a, _ := app.getAttendee(*vol.AttendeeID) + if a == nil { + t.Fatal("linked attendee not found") } - if p.ConfirmationToken == nil || *p.ConfirmationToken == "" { - t.Error("expected confirmation token on participant") - } - if p.Email != "titania@example.com" { - t.Errorf("participant email = %q, want titania@example.com", p.Email) + if a.Email != "titania@example.com" { + t.Errorf("attendee email = %q, want titania@example.com", a.Email) } } -func TestPublicSignupAutoMatchParticipant(t *testing.T) { +func TestPublicSignupAutoMatchAttendee(t *testing.T) { app := testApp(t) mux := testMux(app) - // Pre-existing participant - existing, _ := app.createParticipant(Participant{PreferredName: "Titania Fairweather", Email: "titania@example.com"}) + // Pre-existing attendee + existing, _ := app.createAttendee(Attendee{Name: "Titania Fairweather", Email: "titania@example.com"}) w := httptest.NewRecorder() mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{ @@ -105,8 +105,8 @@ func TestPublicSignupAutoMatchParticipant(t *testing.T) { if vol == nil { t.Fatal("volunteer not created") } - if vol.ParticipantID == 0 || vol.ParticipantID != existing.ID { - t.Errorf("expected volunteer linked to existing participant %d, got %d", existing.ID, vol.ParticipantID) + if vol.AttendeeID == nil || *vol.AttendeeID != existing.ID { + t.Errorf("expected volunteer linked to existing attendee %d, got %v", existing.ID, vol.AttendeeID) } } @@ -200,8 +200,12 @@ func TestConfirmEmail(t *testing.T) { mux := testMux(app) token := "abc123def456" - p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token}) - app.createVolunteer(Volunteer{ParticipantID: p.ID}) + app.createVolunteer(Volunteer{ + Name: "Titania", + PreferredName: "Titania", + Email: "titania@example.com", + ConfirmationToken: &token, + }) w := httptest.NewRecorder() mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) @@ -213,13 +217,12 @@ func TestConfirmEmail(t *testing.T) { t.Errorf("expected confirmed, got %v", result["status"]) } - // Verify participant is email confirmed + // Verify volunteer is confirmed vol, _ := app.getVolunteerByEmail("titania@example.com") if vol == nil || !vol.EmailConfirmed { - t.Error("volunteer should show email confirmed via participant") + t.Error("volunteer should be email confirmed") } - updatedP, _ := app.getParticipant(p.ID) - if updatedP.ConfirmationToken != nil { + if vol.ConfirmationToken != nil { t.Error("confirmation token should be cleared after confirmation") } } @@ -244,8 +247,12 @@ func TestConfirmEmailAlreadyConfirmed(t *testing.T) { mux := testMux(app) token := "abc123def456" - p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token}) - app.createVolunteer(Volunteer{ParticipantID: p.ID}) + app.createVolunteer(Volunteer{ + Name: "Titania", + PreferredName: "Titania", + Email: "titania@example.com", + ConfirmationToken: &token, + }) // Confirm first time w := httptest.NewRecorder() @@ -270,9 +277,15 @@ func TestConfirmEmailWithSignupsOpen(t *testing.T) { app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`) app.baseURL = "https://example.com" + attendee, _ := app.createAttendee(Attendee{Name: "Titania", Email: "titania@example.com"}) token := "abc123def456" - participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token}) - app.createVolunteer(Volunteer{ParticipantID: participant.ID}) + app.createVolunteer(Volunteer{ + Name: "Titania", + PreferredName: "Titania", + Email: "titania@example.com", + AttendeeID: &attendee.ID, + ConfirmationToken: &token, + }) w := httptest.NewRecorder() mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) @@ -288,80 +301,10 @@ func TestConfirmEmailWithSignupsOpen(t *testing.T) { t.Error("expected kiosk_link when signups are open") } - // Volunteer should now have a kiosk_code, no stub ticket created. - vol, _ := app.getVolunteerByEmail("titania@example.com") - if vol == nil || vol.KioskCode == nil { - t.Error("volunteer should have a kiosk_code after confirm with signups open") - } - tickets, _ := app.listTickets(&participant.ID, "") - if len(tickets) != 0 { - t.Errorf("expected no stub tickets, got %d", len(tickets)) - } -} - -func TestPublicSignupTicketNameDoesNotOverwritePreferredName(t *testing.T) { - app := testApp(t) - mux := testMux(app) - - w := httptest.NewRecorder() - mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{ - "preferred_name": "Titania", - "ticket_name": "Titania Fairweather", - "email": "titania@example.com", - })) - if w.Code != 200 { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - vol, _ := app.getVolunteerByEmail("titania@example.com") - if vol == nil || vol.ParticipantID == 0 { - t.Fatal("volunteer/participant not created") - } - p, _ := app.getParticipant(vol.ParticipantID) - if p == nil { - t.Fatal("participant not found") - } - if p.PreferredName != "Titania" { - t.Errorf("participant preferred_name = %q, want %q (not ticket_name)", p.PreferredName, "Titania") - } - if p.TicketName != "Titania Fairweather" { - t.Errorf("participant.TicketName = %q, want %q", p.TicketName, "Titania Fairweather") - } -} - -func TestConfirmEmailAssignsKioskCode(t *testing.T) { - app := testApp(t) - mux := testMux(app) - - app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`) - app.baseURL = "https://example.com" - - token := "abc123def456" - participant, _ := app.createParticipant(Participant{PreferredName: "Titania", TicketName: "Titania Fairweather", Email: "titania@example.com", ConfirmationToken: &token}) - app.createVolunteer(Volunteer{ParticipantID: participant.ID}) - - w := httptest.NewRecorder() - mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) - if w.Code != 200 { - t.Fatalf("expected 200, got %d", w.Code) - } - result := parseJSON(t, w) - if result["status"] != "confirmed" { - t.Fatalf("expected confirmed, got %v", result["status"]) - } - if result["kiosk_link"] == nil { - t.Error("expected kiosk_link in response when signups are open") - } - - // Kiosk code should be on the volunteer record, not a stub ticket. - vol, _ := app.getVolunteerByEmail("titania@example.com") - if vol == nil || vol.KioskCode == nil { - t.Fatal("expected volunteer to have a kiosk_code") - } - // No stub ticket should have been created. - tickets, _ := app.listTickets(&participant.ID, "") - if len(tickets) != 0 { - t.Errorf("expected no stub tickets, got %d", len(tickets)) + // Attendee should now have a kiosk token + a, _ := app.getAttendee(attendee.ID) + if a.VolunteerToken == nil { + t.Error("attendee should have kiosk token after confirm with signups open") } } diff --git a/handle_sso.go b/handle_sso.go deleted file mode 100644 index e97c887..0000000 --- a/handle_sso.go +++ /dev/null @@ -1,190 +0,0 @@ -package main - -import ( - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "fmt" - "net/http" - "net/url" - "strings" -) - -func (app *App) getSSOConfig() (ssoURL, ssoSecret string) { - app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_url'`).Scan(&ssoURL) - app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_secret'`).Scan(&ssoSecret) - return -} - -func (app *App) handleSSOEnabled(w http.ResponseWriter, r *http.Request) { - ssoURL, ssoSecret := app.getSSOConfig() - writeJSON(w, map[string]bool{"enabled": ssoURL != "" && ssoSecret != ""}) -} - -func (app *App) getBaseURL() string { - if app.baseURL != "" { - return app.baseURL - } - var u string - app.db.QueryRow(`SELECT value FROM config WHERE key = 'base_url'`).Scan(&u) - return u -} - -func (app *App) handleSSOInit(w http.ResponseWriter, r *http.Request) { - ssoURL, ssoSecret := app.getSSOConfig() - if ssoURL == "" || ssoSecret == "" { - writeError(w, "SSO not configured", http.StatusNotFound) - return - } - - baseURL := app.getBaseURL() - if baseURL == "" { - writeError(w, "base_url must be configured for SSO", http.StatusBadRequest) - return - } - - b := make([]byte, 32) - rand.Read(b) - nonce := hex.EncodeToString(b) - - app.cleanExpiredNonces() - if err := app.createSSONonce(nonce); err != nil { - writeError(w, "internal error", http.StatusInternalServerError) - return - } - - returnURL := strings.TrimRight(baseURL, "/") + "/api/sso/callback" - - payload := fmt.Sprintf("nonce=%s&return_sso_url=%s", url.QueryEscape(nonce), url.QueryEscape(returnURL)) - encoded := base64.StdEncoding.EncodeToString([]byte(payload)) - - mac := hmac.New(sha256.New, []byte(ssoSecret)) - mac.Write([]byte(encoded)) - sig := hex.EncodeToString(mac.Sum(nil)) - - redirect := fmt.Sprintf("%s/session/sso_provider?sso=%s&sig=%s", - strings.TrimRight(ssoURL, "/"), url.QueryEscape(encoded), url.QueryEscape(sig)) - - writeJSON(w, map[string]string{"redirect_url": redirect}) -} - -func (app *App) handleSSOCallback(w http.ResponseWriter, r *http.Request) { - baseURL := app.getBaseURL() - - ssoRedirectError := func(msg string) { - if baseURL != "" { - http.Redirect(w, r, strings.TrimRight(baseURL, "/")+"/#sso_error="+url.QueryEscape(msg), http.StatusFound) - } else { - writeError(w, msg, http.StatusBadRequest) - } - } - - _, ssoSecret := app.getSSOConfig() - if ssoSecret == "" { - ssoRedirectError("SSO not configured") - return - } - - ssoParam := r.URL.Query().Get("sso") - sigParam := r.URL.Query().Get("sig") - if ssoParam == "" || sigParam == "" { - ssoRedirectError("Invalid SSO response") - return - } - - mac := hmac.New(sha256.New, []byte(ssoSecret)) - mac.Write([]byte(ssoParam)) - expectedSig := hex.EncodeToString(mac.Sum(nil)) - if !hmac.Equal([]byte(expectedSig), []byte(sigParam)) { - ssoRedirectError("Invalid SSO signature") - return - } - - decoded, err := base64.StdEncoding.DecodeString(ssoParam) - if err != nil { - ssoRedirectError("Invalid SSO payload") - return - } - - vals, err := url.ParseQuery(string(decoded)) - if err != nil { - ssoRedirectError("Invalid SSO payload") - return - } - - nonce := vals.Get("nonce") - valid, err := app.consumeSSONonce(nonce) - if err != nil || !valid { - ssoRedirectError("SSO session expired. Please try again.") - return - } - - email := strings.ToLower(vals.Get("email")) - if email == "" { - ssoRedirectError("No email in SSO response") - return - } - - name := vals.Get("name") - if name == "" { - name = vals.Get("username") - } - - user, _, err := app.getLoginParticipant(email) - if err != nil { - ssoRedirectError("Login failed. Please try again.") - return - } - - if user == nil { - p, err := app.getParticipantByEmail(email) - if err != nil { - ssoRedirectError("Login failed. Please try again.") - return - } - if p != nil { - if _, err := app.db.Exec( - `UPDATE participants SET login_enabled = 1, updated_at = ? WHERE id = ?`, - now(), p.ID, - ); err != nil { - ssoRedirectError("Login failed. Please try again.") - return - } - user, err = app.getUser(p.ID) - if err != nil { - ssoRedirectError("Login failed. Please try again.") - return - } - } - } - - if user == nil { - if name == "" { - name = strings.Split(email, "@")[0] - } - res, err := app.db.Exec( - `INSERT INTO participants (email, preferred_name, login_enabled, updated_at) VALUES (?, ?, 1, ?)`, - email, name, now(), - ) - if err != nil { - ssoRedirectError("Login failed. Please try again.") - return - } - id, _ := res.LastInsertId() - user, err = app.getUser(int(id)) - if err != nil || user == nil { - ssoRedirectError("Login failed. Please try again.") - return - } - } - - token, err := app.signToken(user) - if err != nil { - ssoRedirectError("Login failed. Please try again.") - return - } - - http.Redirect(w, r, strings.TrimRight(baseURL, "/")+"/#sso_token="+url.QueryEscape(token), http.StatusFound) -} diff --git a/handle_sync.go b/handle_sync.go index f2171ce..78725c5 100644 --- a/handle_sync.go +++ b/handle_sync.go @@ -12,18 +12,14 @@ func (app *App) handleSyncPull(w http.ResponseWriter, r *http.Request) { since := r.URL.Query().Get("since") event, _ := app.getEvent() - participants, _ := app.listParticipants("", since) - tickets, _ := app.listTickets(nil, since) + attendees, _ := app.attendeesSince(since) departments, _ := app.listDepartments(since) volunteers, _ := app.listVolunteers("", nil, since) shifts, _ := app.listShifts(nil, "", since) volunteerShifts, _ := app.listVolunteerShifts(since) - if participants == nil { - participants = []Participant{} - } - if tickets == nil { - tickets = []Ticket{} + if attendees == nil { + attendees = []Attendee{} } if departments == nil { departments = []Department{} @@ -41,8 +37,7 @@ func (app *App) handleSyncPull(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{ "server_time": time.Now().UTC().Format("2006-01-02T15:04:05Z"), "event": event, - "participants": participants, - "tickets": tickets, + "attendees": attendees, "departments": departments, "volunteers": volunteers, "shifts": shifts, diff --git a/handle_sync_test.go b/handle_sync_test.go index 000bc60..c5d759a 100644 --- a/handle_sync_test.go +++ b/handle_sync_test.go @@ -13,10 +13,10 @@ func TestSyncPullFull(t *testing.T) { token := testToken(t, app, admin) mux := testMux(app) - p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) + app.createAttendee(Attendee{Name: "Titania"}) dept, _ := app.createDepartment(Department{Name: "Gate"}) deptID := dept.ID - app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: p.ID, DepartmentID: &deptID}) + app.createVolunteer(Volunteer{Name: "Titania", DepartmentID: &deptID}) app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) req := testAuthRequest("GET", "/api/sync/pull", nil, token) @@ -31,9 +31,9 @@ func TestSyncPullFull(t *testing.T) { if result["server_time"] == nil { t.Error("missing server_time") } - participants := result["participants"].([]any) - if len(participants) != 2 { // admin + Titania - t.Errorf("participants = %d, want 2", len(participants)) + attendees := result["attendees"].([]any) + if len(attendees) != 1 { + t.Errorf("attendees = %d, want 1", len(attendees)) } depts := result["departments"].([]any) if len(depts) != 1 { @@ -47,30 +47,29 @@ func TestSyncPullIncremental(t *testing.T) { token := testToken(t, app, admin) mux := testMux(app) - // Backdate admin participant so it falls before the "since" cutoff. - app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, admin.ID) - - p1, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) - app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, p1.ID) + app.createAttendee(Attendee{Name: "Titania"}) + // Backdate Titania so she falls before the "since" cutoff + app.db.Exec(`UPDATE attendees SET updated_at = '2026-01-01T00:00:00Z' WHERE name = 'Titania'`) since := "2026-01-01T12:00:00Z" - // Lysander created with default updated_at (now), which is after our since - app.createParticipant(Participant{PreferredName: "Lysander", Email: "lysander@example.com"}) + // Oberon created with default updated_at (now), which is after our since + app.createAttendee(Attendee{Name: "Oberon"}) req := testAuthRequest("GET", "/api/sync/pull?since="+since, nil, token) w := httptest.NewRecorder() mux.ServeHTTP(w, req) result := parseJSON(t, w) - participants := result["participants"].([]any) - if len(participants) != 1 { - t.Errorf("incremental: got %d participants, want 1", len(participants)) + attendees := result["attendees"].([]any) + // Should only include Oberon (created after `since`) + if len(attendees) != 1 { + t.Errorf("incremental: got %d attendees, want 1", len(attendees)) } - if len(participants) == 1 { - p := participants[0].(map[string]any) - if p["preferred_name"] != "Lysander" { - t.Errorf("preferred_name = %v, want Lysander", p["preferred_name"]) + if len(attendees) == 1 { + a := attendees[0].(map[string]any) + if a["name"] != "Oberon" { + t.Errorf("name = %v, want Oberon", a["name"]) } } } @@ -81,33 +80,31 @@ func TestSyncPullIncludesSoftDeleted(t *testing.T) { token := testToken(t, app, admin) mux := testMux(app) - // Backdate admin participant. - app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, admin.ID) - - p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) - app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, p.ID) + a, _ := app.createAttendee(Attendee{Name: "Titania"}) + // Backdate Titania's creation so the since cutoff is between creation and deletion + app.db.Exec(`UPDATE attendees SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, a.ID) since := "2026-01-01T12:00:00Z" // Delete updates updated_at to now(), which is after our since - app.deleteParticipant(p.ID) + app.deleteAttendee(a.ID) req := testAuthRequest("GET", "/api/sync/pull?since="+since, nil, token) w := httptest.NewRecorder() mux.ServeHTTP(w, req) var result struct { - Participants []struct { + Attendees []struct { ID int `json:"id"` DeletedAt *string `json:"deleted_at"` - } `json:"participants"` + } `json:"attendees"` } json.Unmarshal(w.Body.Bytes(), &result) - if len(result.Participants) != 1 { - t.Fatalf("got %d participants, want 1", len(result.Participants)) + if len(result.Attendees) != 1 { + t.Fatalf("got %d attendees, want 1", len(result.Attendees)) } - if result.Participants[0].DeletedAt == nil { + if result.Attendees[0].DeletedAt == nil { t.Error("deleted_at should be set for soft-deleted record") } } diff --git a/handle_tokens.go b/handle_tokens.go index 63e19a6..480d03d 100644 --- a/handle_tokens.go +++ b/handle_tokens.go @@ -8,9 +8,9 @@ import ( "strings" ) -// handleGenerateTokens creates codes for all tickets that don't have one. +// handleGenerateTokens creates volunteer_token values for all attendees that don't have one. func (app *App) handleGenerateTokens(w http.ResponseWriter, r *http.Request) { - count, err := app.generateCodesForAll() + count, err := app.generateTokensForAll() if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -21,7 +21,7 @@ func (app *App) handleGenerateTokens(w http.ResponseWriter, r *http.Request) { // handleExportTokenLinks streams a CSV download with token signup links, // compatible with MailChimp / Zeffy bulk-send workflows. func (app *App) handleExportTokenLinks(w http.ResponseWriter, r *http.Request) { - tickets, err := app.listTickets(nil, "") + attendees, err := app.listAttendees("", "", "") if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -37,62 +37,55 @@ func (app *App) handleExportTokenLinks(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Disposition", `attachment; filename="volunteer-tokens.csv"`) wr := csv.NewWriter(w) wr.Write([]string{"Email Address", "First Name", "Token", "Signup Link"}) - for _, tk := range tickets { - if tk.Code == nil || tk.ParticipantID == nil { + for _, a := range attendees { + if a.VolunteerToken == nil { continue } - p, _ := app.getParticipant(*tk.ParticipantID) - if p == nil || p.Email == "" { - continue - } - firstName := p.PreferredName - if firstName == "" { - firstName = tk.Name - } - if parts := strings.Fields(firstName); len(parts) > 0 { + firstName := a.Name + if parts := strings.Fields(a.Name); len(parts) > 0 { firstName = parts[0] } - link := fmt.Sprintf("%s/v/%s", baseURL, *tk.Code) - wr.Write([]string{p.Email, firstName, *tk.Code, link}) + link := fmt.Sprintf("%s/v/%s", baseURL, *a.VolunteerToken) + wr.Write([]string{a.Email, firstName, *a.VolunteerToken, link}) } wr.Flush() } -// handleEmailToken sends a token email to a single ticket's participant. +// handleEmailToken sends a token email to a single attendee. func (app *App) handleEmailToken(w http.ResponseWriter, r *http.Request) { id, err := strconv.Atoi(r.PathValue("id")) if err != nil { writeError(w, "invalid id", http.StatusBadRequest) return } - tk, err := app.getTicket(id) - if err != nil || tk == nil { + a, err := app.getAttendee(id) + if err != nil || a == nil { writeError(w, "not found", http.StatusNotFound) return } - if err := app.sendTicketTokenEmail(*tk); err != nil { + if err := app.sendTokenEmail(*a); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, map[string]any{"ok": true}) } -// handleEmailAllTokens bulk-sends token emails to all tickets that have a code and participant email. +// handleEmailAllTokens bulk-sends token emails to all attendees that have both a token and email. func (app *App) handleEmailAllTokens(w http.ResponseWriter, r *http.Request) { - tickets, err := app.listTickets(nil, "") + attendees, err := app.listAttendees("", "", "") if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } var sent, skipped int var errors []string - for _, tk := range tickets { - if tk.Code == nil || tk.ParticipantID == nil { + for _, a := range attendees { + if a.Email == "" || a.VolunteerToken == nil { skipped++ continue } - if err := app.sendTicketTokenEmail(tk); err != nil { - errors = append(errors, fmt.Sprintf("ticket %d: %v", tk.ID, err)) + if err := app.sendTokenEmail(a); err != nil { + errors = append(errors, fmt.Sprintf("%s: %v", a.Name, err)) skipped++ } else { sent++ diff --git a/handle_users.go b/handle_users.go index 4de6109..386e5b0 100644 --- a/handle_users.go +++ b/handle_users.go @@ -17,18 +17,17 @@ func (app *App) handleListUsers(w http.ResponseWriter, r *http.Request) { func (app *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { var body struct { - Email string `json:"email"` - PreferredName string `json:"preferred_name"` - Password string `json:"password"` - Roles []string `json:"roles"` - DepartmentIDs []int `json:"department_ids"` + Username string `json:"username"` + Password string `json:"password"` + Role string `json:"role"` + DepartmentIDs []int `json:"department_ids"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, "invalid request", http.StatusBadRequest) return } - if body.Email == "" || body.Password == "" || len(body.Roles) == 0 { - writeError(w, "email, password, and at least one role are required", http.StatusBadRequest) + if body.Username == "" || body.Password == "" || body.Role == "" { + writeError(w, "username, password, and role are required", http.StatusBadRequest) return } hash, err := hashPassword(body.Password) @@ -39,7 +38,7 @@ func (app *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { if body.DepartmentIDs == nil { body.DepartmentIDs = []int{} } - user, err := app.createUser(body.Email, body.PreferredName, hash, body.Roles, body.DepartmentIDs) + user, err := app.createUser(body.Username, hash, body.Role, body.DepartmentIDs) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -54,15 +53,10 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { writeError(w, "invalid id", http.StatusBadRequest) return } - target, _ := app.getUser(id) - if target == nil { - writeError(w, "not found", http.StatusNotFound) - return - } var body struct { - Roles []string `json:"roles"` - Password string `json:"password"` - DepartmentIDs []int `json:"department_ids"` + Role string `json:"role"` + Password string `json:"password"` + DepartmentIDs []int `json:"department_ids"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, "invalid request", http.StatusBadRequest) @@ -71,8 +65,8 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { if body.DepartmentIDs == nil { body.DepartmentIDs = []int{} } - if body.Roles != nil { - if err := app.updateUserRoles(id, body.Roles, body.DepartmentIDs); err != nil { + if body.Role != "" { + if err := app.updateUser(id, body.Role, body.DepartmentIDs); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } @@ -88,7 +82,7 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { return } } - user, _ := app.getUser(id) + user, _ := app.getUserByID(id) writeJSON(w, user) } @@ -99,11 +93,11 @@ func (app *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if claims.ParticipantID == id { + if claims.UserID == id { writeError(w, "cannot delete yourself", http.StatusBadRequest) return } - if err := app.removeUser(id); err != nil { + if err := app.deleteUser(id); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } diff --git a/handle_volunteers.go b/handle_volunteers.go index cd891d2..533428e 100644 --- a/handle_volunteers.go +++ b/handle_volunteers.go @@ -2,7 +2,6 @@ package main import ( "encoding/json" - "log" "net/http" "strconv" ) @@ -12,19 +11,20 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) { search := q.Get("search") since := q.Get("since") - var deptIDs []int + var deptID *int if d := q.Get("dept"); d != "" { - if id, err := strconv.Atoi(d); err == nil { - deptIDs = []int{id} + id, err := strconv.Atoi(d) + if err == nil { + deptID = &id } } claims := claimsFromContext(r) - if isCoLeadOnly(claims) && len(deptIDs) == 0 { - deptIDs = claims.DeptIDs + if claims.Role == "volunteer_lead" && deptID == nil && len(claims.DeptIDs) > 0 { + deptID = &claims.DeptIDs[0] } - volunteers, err := app.listVolunteers(search, deptIDs, since) + volunteers, err := app.listVolunteers(search, deptID, since) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -33,65 +33,27 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) { } func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) { - var body struct { - Name string `json:"name"` - TicketName string `json:"ticket_name"` - Email string `json:"email"` - DepartmentID *int `json:"department_id"` - IsLead bool `json:"is_lead"` - Note string `json:"note"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + var v Volunteer + if err := json.NewDecoder(r.Body).Decode(&v); err != nil { writeError(w, "invalid request", http.StatusBadRequest) return } - if body.Name == "" { + if v.Name == "" { writeError(w, "name is required", http.StatusBadRequest) return } - if body.Email == "" { - writeError(w, "email is required", http.StatusBadRequest) - return - } claims := claimsFromContext(r) - if isCoLeadOnly(claims) { - if body.DepartmentID == nil || !inSlice(*body.DepartmentID, claims.DeptIDs) { + if claims.Role == "volunteer_lead" { + if v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) return } } - p, _ := app.getParticipantByEmail(body.Email) - if p == nil { - p, _ = app.createParticipant(Participant{PreferredName: body.Name, Email: body.Email, TicketName: body.TicketName}) - } else if body.TicketName != "" && p.TicketName == "" { - app.db.Exec(`UPDATE participants SET ticket_name = ?, updated_at = ? WHERE id = ?`, body.TicketName, now(), p.ID) - } - if p == nil { - writeError(w, "failed to create participant", http.StatusInternalServerError) - return - } - confirmToken, err := generateConfirmationToken() - if err != nil { - writeError(w, "internal error", http.StatusInternalServerError) - return - } - app.setParticipantConfirmationToken(p.ID, confirmToken) - v := Volunteer{ - ParticipantID: p.ID, - DepartmentID: body.DepartmentID, - IsLead: body.IsLead, - Note: body.Note, - } created, err := app.createVolunteer(v) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } - go func() { - if err := app.sendConfirmationEmail(body.Email, body.Name, confirmToken); err != nil { - log.Printf("confirmation email to %s failed: %v", body.Email, err) - } - }() w.WriteHeader(http.StatusCreated) writeJSON(w, created) } @@ -116,40 +78,28 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) { writeError(w, "invalid id", http.StatusBadRequest) return } - var body struct { - DepartmentID *int `json:"department_id"` - IsLead bool `json:"is_lead"` - Note string `json:"note"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + var v Volunteer + if err := json.NewDecoder(r.Body).Decode(&v); err != nil { writeError(w, "invalid request", http.StatusBadRequest) return } + if v.Name == "" { + writeError(w, "name is required", http.StatusBadRequest) + return + } claims := claimsFromContext(r) - if isCoLeadOnly(claims) { + if claims.Role == "volunteer_lead" { existing, _ := app.getVolunteer(id) if existing == nil || existing.DepartmentID == nil || !inSlice(*existing.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) return } - if body.DepartmentID != nil && !inSlice(*body.DepartmentID, claims.DeptIDs) { - writeError(w, "forbidden: cannot move volunteer to that department", http.StatusForbidden) - return - } - } - v := Volunteer{ - ID: id, - DepartmentID: body.DepartmentID, - IsLead: body.IsLead, - Note: body.Note, } + v.ID = id if err := app.updateVolunteer(v); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } - if v.IsLead { - app.confirmVolunteer(id) - } updated, _ := app.getVolunteer(id) writeJSON(w, updated) } @@ -160,14 +110,6 @@ func (app *App) handleDeleteVolunteer(w http.ResponseWriter, r *http.Request) { writeError(w, "invalid id", http.StatusBadRequest) return } - claims := claimsFromContext(r) - if isCoLeadOnly(claims) { - v, _ := app.getVolunteer(id) - if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { - writeError(w, "forbidden: outside your department", http.StatusForbidden) - return - } - } if err := app.deleteVolunteer(id); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -175,21 +117,14 @@ func (app *App) handleDeleteVolunteer(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -func (app *App) handleMarkVolunteerReady(w http.ResponseWriter, r *http.Request) { +func (app *App) handleCheckInVolunteer(w http.ResponseWriter, r *http.Request) { id, err := strconv.Atoi(r.PathValue("id")) if err != nil { writeError(w, "invalid id", http.StatusBadRequest) return } claims := claimsFromContext(r) - if isCoLeadOnly(claims) { - v, _ := app.getVolunteer(id) - if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { - writeError(w, "forbidden: outside your department", http.StatusForbidden) - return - } - } - v, err := app.markVolunteerReady(id, claims.ParticipantID) + v, err := app.checkInVolunteer(id, claims.UserID) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -198,28 +133,6 @@ func (app *App) handleMarkVolunteerReady(w http.ResponseWriter, r *http.Request) writeJSON(w, v) } -func (app *App) handleConfirmVolunteer(w http.ResponseWriter, r *http.Request) { - id, err := strconv.Atoi(r.PathValue("id")) - if err != nil { - writeError(w, "invalid id", http.StatusBadRequest) - return - } - claims := claimsFromContext(r) - if isCoLeadOnly(claims) { - v, _ := app.getVolunteer(id) - if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { - writeError(w, "forbidden: outside your department", http.StatusForbidden) - return - } - } - v, err := app.confirmVolunteer(id) - if err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - writeJSON(w, v) -} - func (app *App) handleAssignShift(w http.ResponseWriter, r *http.Request) { volunteerID, err := strconv.Atoi(r.PathValue("id")) if err != nil { @@ -233,24 +146,7 @@ func (app *App) handleAssignShift(w http.ResponseWriter, r *http.Request) { writeError(w, "shift_id required", http.StatusBadRequest) return } - claims := claimsFromContext(r) - if isCoLeadOnly(claims) { - v, _ := app.getVolunteer(volunteerID) - if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { - writeError(w, "forbidden: outside your department", http.StatusForbidden) - return - } - } - shift, err := app.getShift(body.ShiftID) - if err != nil || shift == nil { - writeError(w, "shift not found", http.StatusNotFound) - return - } - if err := app.assignShiftWithCapacity(volunteerID, body.ShiftID, shift.Capacity); err != nil { - if err == errShiftFull { - writeError(w, "shift is at capacity", http.StatusConflict) - return - } + if err := app.assignShift(volunteerID, body.ShiftID); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } @@ -268,14 +164,6 @@ func (app *App) handleUnassignShift(w http.ResponseWriter, r *http.Request) { writeError(w, "invalid shift id", http.StatusBadRequest) return } - claims := claimsFromContext(r) - if isCoLeadOnly(claims) { - v, _ := app.getVolunteer(volunteerID) - if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { - writeError(w, "forbidden: outside your department", http.StatusForbidden) - return - } - } if err := app.unassignShift(volunteerID, shiftID); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -283,3 +171,11 @@ func (app *App) handleUnassignShift(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func inSlice(v int, s []int) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} diff --git a/handle_volunteers_test.go b/handle_volunteers_test.go deleted file mode 100644 index dc61f28..0000000 --- a/handle_volunteers_test.go +++ /dev/null @@ -1,264 +0,0 @@ -package main - -import ( - "net/http" - "net/http/httptest" - "testing" -) - -func TestConfirmVolunteer(t *testing.T) { - app := testApp(t) - mux := testMux(app) - admin := testAdminUser(t, app) - tok := testToken(t, app, admin) - - dept, _ := app.createDepartment(Department{Name: "Gate"}) - deptID := dept.ID - p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com", EmailConfirmed: true}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) - - w := httptest.NewRecorder() - mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - result := parseJSON(t, w) - vol := result["confirmed"] - if vol != true { - t.Error("expected confirmed=true in response") - } - - got, _ := app.getVolunteer(v.ID) - if got == nil || !got.Confirmed { - t.Error("volunteer should be confirmed in DB") - } - if got.ConfirmedAt == nil { - t.Error("confirmed_at should be set") - } -} - -func TestConfirmVolunteerIdempotent(t *testing.T) { - app := testApp(t) - mux := testMux(app) - admin := testAdminUser(t, app) - tok := testToken(t, app, admin) - - p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com", EmailConfirmed: true}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID}) - - // Confirm twice — second should be a no-op, not an error. - w := httptest.NewRecorder() - mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) - if w.Code != 200 { - t.Fatalf("first confirm: %d", w.Code) - } - - w = httptest.NewRecorder() - mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) - if w.Code != 200 { - t.Fatalf("second confirm: %d", w.Code) - } -} - -func TestConfirmVolunteerRequiresRole(t *testing.T) { - app := testApp(t) - mux := testMux(app) - - // Gatekeeper role should NOT be able to confirm volunteers. - gatekeeper := testUserWithRoles(t, app, "Egeus", []string{"gatekeeper"}, []int{}) - tok := testToken(t, app, gatekeeper) - - p, _ := app.createParticipant(Participant{PreferredName: "Helena", EmailConfirmed: true}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID}) - - w := httptest.NewRecorder() - mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) - if w.Code != http.StatusForbidden { - t.Errorf("expected 403 for gatekeeper role, got %d", w.Code) - } -} - -func TestCoLeadDeleteVolunteerOwnDept(t *testing.T) { - app := testApp(t) - mux := testMux(app) - - deptA, _ := app.createDepartment(Department{Name: "Gate"}) - colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) - tok := testToken(t, app, colead) - - deptAID := deptA.ID - p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptAID}) - - w := httptest.NewRecorder() - mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/volunteers/"+itoa(v.ID), nil, tok)) - if w.Code != http.StatusNoContent { - t.Errorf("expected 204 for own dept, got %d: %s", w.Code, w.Body.String()) - } -} - -func TestCoLeadDeleteVolunteerOtherDept(t *testing.T) { - app := testApp(t) - mux := testMux(app) - - deptA, _ := app.createDepartment(Department{Name: "Gate"}) - deptB, _ := app.createDepartment(Department{Name: "Build"}) - colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) - tok := testToken(t, app, colead) - - deptBID := deptB.ID - p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID}) - - w := httptest.NewRecorder() - mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/volunteers/"+itoa(v.ID), nil, tok)) - if w.Code != http.StatusForbidden { - t.Errorf("expected 403 for other dept, got %d", w.Code) - } -} - -func TestCoLeadConfirmVolunteerOtherDept(t *testing.T) { - app := testApp(t) - mux := testMux(app) - - deptA, _ := app.createDepartment(Department{Name: "Gate"}) - deptB, _ := app.createDepartment(Department{Name: "Build"}) - colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) - tok := testToken(t, app, colead) - - deptBID := deptB.ID - p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID}) - - w := httptest.NewRecorder() - mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) - if w.Code != http.StatusForbidden { - t.Errorf("expected 403 for other dept, got %d", w.Code) - } -} - -func TestCoLeadReadyVolunteerOtherDept(t *testing.T) { - app := testApp(t) - mux := testMux(app) - - deptA, _ := app.createDepartment(Department{Name: "Gate"}) - deptB, _ := app.createDepartment(Department{Name: "Build"}) - colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) - tok := testToken(t, app, colead) - - deptBID := deptB.ID - p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID}) - - w := httptest.NewRecorder() - mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/ready", nil, tok)) - if w.Code != http.StatusForbidden { - t.Errorf("expected 403 for other dept, got %d", w.Code) - } -} - -func TestCoLeadAssignShiftOtherDept(t *testing.T) { - app := testApp(t) - mux := testMux(app) - - deptA, _ := app.createDepartment(Department{Name: "Gate"}) - deptB, _ := app.createDepartment(Department{Name: "Build"}) - colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) - tok := testToken(t, app, colead) - - deptBID := deptB.ID - p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID}) - s, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) - - w := httptest.NewRecorder() - mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/shifts", map[string]any{ - "shift_id": s.ID, - }, tok)) - if w.Code != http.StatusForbidden { - t.Errorf("expected 403 for other dept, got %d", w.Code) - } -} - -func TestCoLeadUpdateVolunteerTargetDeptForbidden(t *testing.T) { - app := testApp(t) - mux := testMux(app) - - deptA, _ := app.createDepartment(Department{Name: "Gate"}) - deptB, _ := app.createDepartment(Department{Name: "Build"}) - colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) - tok := testToken(t, app, colead) - - deptAID := deptA.ID - p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptAID}) - - w := httptest.NewRecorder() - mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{ - "department_id": deptB.ID, - }, tok)) - if w.Code != http.StatusForbidden { - t.Errorf("expected 403 moving to other dept, got %d: %s", w.Code, w.Body.String()) - } -} - -func TestUpdateVolunteerDepartment(t *testing.T) { - app := testApp(t) - mux := testMux(app) - admin := testAdminUser(t, app) - tok := testToken(t, app, admin) - - dept, _ := app.createDepartment(Department{Name: "Gate"}) - p, _ := app.createParticipant(Participant{PreferredName: "Hermia"}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID}) - - // Assign department via update. - w := httptest.NewRecorder() - mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{ - "department_id": dept.ID, - }, tok)) - if w.Code != 200 { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - got, _ := app.getVolunteer(v.ID) - if got.DepartmentID == nil || *got.DepartmentID != dept.ID { - t.Errorf("department_id = %v, want %d", got.DepartmentID, dept.ID) - } -} - -func TestUpdateVolunteerCoLeadAutoConfirms(t *testing.T) { - app := testApp(t) - mux := testMux(app) - admin := testAdminUser(t, app) - tok := testToken(t, app, admin) - - dept, _ := app.createDepartment(Department{Name: "Build"}) - deptID := dept.ID - p, _ := app.createParticipant(Participant{PreferredName: "Lysander", Email: "lys@test.com", EmailConfirmed: true}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) - - // Verify not confirmed before update. - got, _ := app.getVolunteer(v.ID) - if got.Confirmed { - t.Fatal("should not be confirmed before update") - } - - // Update is_lead=true should auto-confirm. - w := httptest.NewRecorder() - mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{ - "department_id": deptID, "is_lead": true, - }, tok)) - if w.Code != 200 { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - got, _ = app.getVolunteer(v.ID) - if !got.IsLead { - t.Error("expected is_lead=true") - } - if !got.Confirmed { - t.Error("co-lead should be auto-confirmed") - } -} diff --git a/main.go b/main.go index 186362c..77e09c4 100644 --- a/main.go +++ b/main.go @@ -99,44 +99,39 @@ func (app *App) registerRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/event", auth(app.handleGetEvent)) mux.HandleFunc("PUT /api/event", auth(app.handleUpdateEvent, "admin")) - mux.HandleFunc("GET /api/participants", auth(app.handleListParticipants, "admin", "gatekeeper")) - mux.HandleFunc("POST /api/participants", auth(app.handleCreateParticipant, "admin")) - mux.HandleFunc("GET /api/participants/export", auth(app.handleExportParticipants, "admin")) - mux.HandleFunc("GET /api/participants/{id}", auth(app.handleGetParticipant, "admin", "gatekeeper")) - mux.HandleFunc("PUT /api/participants/{id}", auth(app.handleUpdateParticipant, "admin")) - mux.HandleFunc("DELETE /api/participants/{id}", auth(app.handleDeleteParticipant, "admin")) - mux.HandleFunc("POST /api/participants/{id}/merge/{other_id}", auth(app.handleMergeParticipants, "admin")) - - mux.HandleFunc("GET /api/tickets", auth(app.handleListTickets, "admin", "gatekeeper")) - mux.HandleFunc("POST /api/tickets", auth(app.handleCreateTicket, "admin")) - mux.HandleFunc("POST /api/tickets/{id}/checkin", auth(app.handleCheckInTicket, "admin", "gatekeeper")) - mux.HandleFunc("POST /api/tickets/generate-codes", auth(app.handleGenerateTokens, "admin")) - mux.HandleFunc("GET /api/tickets/export-links", auth(app.handleExportTokenLinks, "admin")) - mux.HandleFunc("POST /api/tickets/email-codes", auth(app.handleEmailAllTokens, "admin")) - mux.HandleFunc("POST /api/tickets/{id}/email-code", auth(app.handleEmailToken, "admin")) + mux.HandleFunc("GET /api/attendees", auth(app.handleListAttendees, "admin", "ticketing", "gate")) + mux.HandleFunc("POST /api/attendees", auth(app.handleCreateAttendee, "admin", "ticketing")) + mux.HandleFunc("GET /api/attendees/export", auth(app.handleExportAttendees, "admin", "ticketing")) + mux.HandleFunc("POST /api/attendees/generate-tokens", auth(app.handleGenerateTokens, "admin", "ticketing")) + mux.HandleFunc("GET /api/attendees/export-tokens", auth(app.handleExportTokenLinks, "admin", "ticketing")) + mux.HandleFunc("POST /api/attendees/email-tokens", auth(app.handleEmailAllTokens, "admin", "ticketing")) + mux.HandleFunc("GET /api/attendees/{id}", auth(app.handleGetAttendee, "admin", "ticketing", "gate")) + mux.HandleFunc("PUT /api/attendees/{id}", auth(app.handleUpdateAttendee, "admin", "ticketing")) + mux.HandleFunc("DELETE /api/attendees/{id}", auth(app.handleDeleteAttendee, "admin", "ticketing")) + mux.HandleFunc("POST /api/attendees/{id}/checkin", auth(app.handleCheckInAttendee, "admin", "ticketing", "gate")) + mux.HandleFunc("POST /api/attendees/{id}/email-token", auth(app.handleEmailToken, "admin", "ticketing")) mux.HandleFunc("GET /api/departments", auth(app.handleListDepartments)) - mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "staffing")) - mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "staffing")) + mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "coordinator")) + mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "coordinator")) mux.HandleFunc("DELETE /api/departments/{id}", auth(app.handleDeleteDepartment, "admin")) - mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "staffing", "colead")) - mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "staffing", "colead")) - mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "staffing", "colead")) - mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "staffing", "colead")) - mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "staffing", "colead")) - mux.HandleFunc("POST /api/volunteers/{id}/ready", auth(app.handleMarkVolunteerReady, "admin", "staffing", "colead")) - mux.HandleFunc("POST /api/volunteers/{id}/confirm", auth(app.handleConfirmVolunteer, "admin", "staffing", "colead")) - mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "staffing", "colead")) - mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "staffing", "colead")) + mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("POST /api/volunteers/{id}/checkin", auth(app.handleCheckInVolunteer, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "coordinator", "volunteer_lead")) - mux.HandleFunc("GET /api/shifts", auth(app.handleListShifts, "admin", "staffing", "colead")) - mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "staffing", "colead")) - mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "staffing", "colead")) - mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "staffing", "colead")) - mux.HandleFunc("DELETE /api/shifts/{id}", auth(app.handleDeleteShift, "admin", "staffing", "colead")) - mux.HandleFunc("POST /api/shifts/{id}/volunteers", auth(app.handleAssignShiftVolunteer, "admin", "staffing", "colead")) - mux.HandleFunc("DELETE /api/shifts/{id}/volunteers/{volunteer_id}", auth(app.handleUnassignShiftVolunteer, "admin", "staffing", "colead")) + mux.HandleFunc("GET /api/shifts", auth(app.handleListShifts, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("DELETE /api/shifts/{id}", auth(app.handleDeleteShift, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("POST /api/shifts/{id}/volunteers", auth(app.handleAssignShiftVolunteer, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("DELETE /api/shifts/{id}/volunteers/{volunteer_id}", auth(app.handleUnassignShiftVolunteer, "admin", "coordinator", "volunteer_lead")) mux.HandleFunc("GET /api/users", auth(app.handleListUsers, "admin")) mux.HandleFunc("POST /api/users", auth(app.handleCreateUser, "admin")) @@ -146,13 +141,13 @@ func (app *App) registerRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/settings", auth(app.handleGetSettings, "admin")) mux.HandleFunc("PUT /api/settings", auth(app.handleUpdateSettings, "admin")) mux.HandleFunc("POST /api/settings/test-email", auth(app.handleTestEmail, "admin")) - mux.HandleFunc("POST /api/settings/reset-tickets", auth(app.handleResetTickets, "admin")) + mux.HandleFunc("POST /api/settings/reset-attendees", auth(app.handleResetAttendees, "admin")) mux.HandleFunc("POST /api/settings/reset-volunteers", auth(app.handleResetVolunteers, "admin")) mux.HandleFunc("POST /api/settings/reset-shifts", auth(app.handleResetShifts, "admin")) mux.HandleFunc("POST /api/settings/reset-departments", auth(app.handleResetDepartments, "admin")) mux.HandleFunc("POST /api/settings/reset-volunteer-shifts", auth(app.handleResetVolunteerShifts, "admin")) - mux.HandleFunc("POST /api/import", auth(app.handleImport, "admin")) + mux.HandleFunc("POST /api/import", auth(app.handleImport, "admin", "ticketing")) mux.HandleFunc("GET /api/sync/pull", auth(app.handleSyncPull)) mux.HandleFunc("GET /api/sync/stream", auth(app.handleSyncStream)) @@ -161,12 +156,9 @@ func (app *App) registerRoutes(mux *http.ServeMux) { writeJSON(w, map[string]string{"build": buildID}) }) - mux.HandleFunc("POST /api/settings/shift-signups", auth(app.handleToggleShiftSignups, "admin", "staffing")) + mux.HandleFunc("POST /api/settings/shift-signups", auth(app.handleToggleShiftSignups, "admin", "volunteer_lead")) // Public endpoints — no JWT required. - mux.HandleFunc("GET /api/public/sso-enabled", app.handleSSOEnabled) - mux.HandleFunc("GET /api/sso/init", app.handleSSOInit) - mux.HandleFunc("GET /api/sso/callback", app.handleSSOCallback) mux.HandleFunc("GET /api/public/signup-config", app.handlePublicSignupConfig) mux.HandleFunc("POST /api/public/signup", app.handlePublicSignup) mux.HandleFunc("POST /api/public/confirm", app.handleConfirmEmail) @@ -199,9 +191,9 @@ func (app *App) registerRoutes(mux *http.ServeMux) { } func (app *App) bootstrapAdmin() error { - adminEmail := os.Getenv("TURNPIKE_ADMIN_EMAIL") + adminUser := os.Getenv("TURNPIKE_ADMIN_USER") adminPass := os.Getenv("TURNPIKE_ADMIN_PASSWORD") - if adminEmail == "" || adminPass == "" { + if adminUser == "" || adminPass == "" { return nil } n, err := app.countUsers() @@ -212,11 +204,11 @@ func (app *App) bootstrapAdmin() error { if err != nil { return err } - _, err = app.createUser(adminEmail, "Admin", hash, []string{"admin"}, []int{}) + _, err = app.createUser(adminUser, hash, "admin", []int{}) if err != nil { return err } - log.Printf("Created admin user: %s", adminEmail) + log.Printf("Created admin user: %s", adminUser) return nil } diff --git a/testutil_test.go b/testutil_test.go index 14351e5..8f58833 100644 --- a/testutil_test.go +++ b/testutil_test.go @@ -6,7 +6,6 @@ import ( "fmt" "net/http" "net/http/httptest" - "strings" "testing" ) @@ -17,6 +16,7 @@ func testApp(t *testing.T) *App { t.Fatal(err) } t.Cleanup(func() { db.Close() }) + // Ensure config table exists (normally created by getOrCreateSecret) db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`) return &App{ db: db, @@ -29,18 +29,17 @@ func testApp(t *testing.T) *App { func testAdminUser(t *testing.T, app *App) *User { t.Helper() hash, _ := hashPassword("admin123") - u, err := app.createUser("oberon@athens.example", "Oberon", hash, []string{"admin"}, []int{}) + u, err := app.createUser("admin", hash, "admin", []int{}) if err != nil { t.Fatal(err) } return u } -func testUserWithRoles(t *testing.T, app *App, name string, roles []string, deptIDs []int) *User { +func testUserWithRole(t *testing.T, app *App, username, role string, deptIDs []int) *User { t.Helper() - email := strings.ToLower(name) + "@athens.example" - hash, _ := hashPassword(name + "123") - u, err := app.createUser(email, name, hash, roles, deptIDs) + hash, _ := hashPassword(username + "123") + u, err := app.createUser(username, hash, role, deptIDs) if err != nil { t.Fatal(err) }