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/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..f611bc1 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", "gatekeeper", []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..cef0e8f 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','ticketing','staffing','colead','gatekeeper')),
+ 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,
+ ready INTEGER NOT NULL DEFAULT 0,
+ ready_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,25 +119,19 @@ 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
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ email TEXT NOT NULL DEFAULT '',
+ preferred_name TEXT NOT NULL DEFAULT '',
+ phone TEXT NOT NULL DEFAULT '',
+ pronouns TEXT NOT NULL DEFAULT '',
+ 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_participants_email
@@ -120,7 +147,7 @@ func migrate(db *sql.DB) error {
order_id TEXT NOT NULL DEFAULT '',
code TEXT UNIQUE,
checked_in_at TEXT,
- checked_in_by INTEGER REFERENCES participants(id),
+ checked_in_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT
@@ -128,29 +155,217 @@ func migrate(db *sql.DB) error {
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")
+ addColumnIfMissing(db, "volunteers", "confirmed INTEGER NOT NULL DEFAULT 0")
+ addColumnIfMissing(db, "volunteers", "confirmed_at TEXT")
+ addColumnIfMissing(db, "volunteers", "kiosk_code TEXT")
+ renameColumnIfExists(db, "volunteers", "checked_in", "ready")
+ renameColumnIfExists(db, "volunteers", "checked_in_at", "ready_at")
+ db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_volunteers_kiosk_code ON volunteers(kiosk_code) WHERE kiosk_code IS NOT NULL`)
+ // Migrate kiosk codes from tickets to volunteers (idempotent).
+ db.Exec(`
+ UPDATE volunteers SET kiosk_code = (
+ SELECT t.code FROM tickets t
+ WHERE t.participant_id = volunteers.participant_id
+ AND t.code IS NOT NULL AND t.deleted_at IS NULL
+ LIMIT 1
+ ) WHERE kiosk_code IS NULL AND participant_id IS NOT NULL`)
+ // Delete stub tickets whose code has been migrated to the volunteer.
+ db.Exec(`
+ DELETE FROM tickets
+ WHERE source = 'manual' AND external_id = '' AND code IS NOT NULL
+ AND participant_id IN (SELECT id FROM volunteers WHERE kiosk_code IS NOT NULL)`)
+ // Widen the uniqueness constraint from name-only to (name, ticket_id).
+ 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 migrateV3(db)
+}
+
+// migrateV3 populates participants + tickets from attendees/volunteers,
+// and links volunteers to participants via participant_id.
+func migrateV3(db *sql.DB) error {
+ addColumnIfMissing(db, "volunteers", "participant_id INTEGER REFERENCES participants(id)")
+ addColumnIfMissing(db, "participants", "ticket_name TEXT NOT NULL DEFAULT ''")
+
+ // Seed participants from volunteers first (better name data: preferred_name).
+ db.Exec(`
+ INSERT OR IGNORE INTO participants (email, preferred_name, phone, pronouns, created_at, updated_at)
+ SELECT
+ LOWER(email),
+ CASE WHEN preferred_name != '' THEN preferred_name ELSE name END,
+ phone,
+ pronouns,
+ created_at,
+ created_at
+ FROM volunteers
+ WHERE email != '' AND deleted_at IS NULL`)
+
+ // Fill in from attendees for emails not yet in participants.
+ db.Exec(`
+ INSERT OR IGNORE INTO participants (email, preferred_name, phone, created_at, updated_at)
+ SELECT LOWER(email), name, phone, created_at, created_at
+ FROM attendees
+ WHERE email != '' AND deleted_at IS NULL`)
+
+ // Attendees with no email: create a placeholder participant so tickets aren't orphaned.
+ rows, _ := db.Query(`SELECT id, name, created_at FROM attendees WHERE email = '' AND deleted_at IS NULL`)
+ if rows != nil {
+ type stub struct {
+ id, name, createdAt string
+ }
+ var stubs []stub
+ for rows.Next() {
+ var s stub
+ rows.Scan(&s.id, &s.name, &s.createdAt)
+ stubs = append(stubs, s)
+ }
+ rows.Close()
+ for _, s := range stubs {
+ placeholder := fmt.Sprintf("ticket-%s@unknown", s.id)
+ db.Exec(`INSERT OR IGNORE INTO participants (email, preferred_name, created_at, updated_at) VALUES (?, ?, ?, ?)`,
+ placeholder, s.name, s.createdAt, s.createdAt)
+ }
+ }
+
+ // Link volunteers to participants via email.
+ db.Exec(`
+ UPDATE volunteers SET participant_id = (
+ SELECT p.id FROM participants p WHERE LOWER(p.email) = LOWER(volunteers.email)
+ )
+ WHERE participant_id IS NULL AND email != ''`)
+
+ // Seed tickets from attendees (1 ticket per attendee row).
+ db.Exec(`
+ INSERT OR IGNORE INTO tickets (participant_id, name, ticket_type, source, external_id, order_id, code, checked_in_at, checked_in_by, created_at, updated_at, deleted_at)
+ SELECT
+ p.id,
+ a.name,
+ a.ticket_type,
+ CASE WHEN a.ticket_id != '' THEN 'crowdwork' ELSE 'manual' END,
+ a.ticket_id,
+ a.ticket_id,
+ a.volunteer_token,
+ a.checked_in_at,
+ a.checked_in_by,
+ a.created_at,
+ a.updated_at,
+ a.deleted_at
+ FROM attendees a
+ JOIN participants p ON LOWER(p.email) = LOWER(a.email) OR p.email = 'ticket-' || a.id || '@unknown'`)
+
+ // Volunteers whose participant has no ticket: create a stub ticket so they can get a kiosk code.
+ db.Exec(`
+ INSERT OR IGNORE INTO tickets (participant_id, source, created_at, updated_at)
+ SELECT DISTINCT v.participant_id, 'manual', v.created_at, v.created_at
+ FROM volunteers v
+ WHERE v.participant_id IS NOT NULL
+ AND v.deleted_at IS NULL
+ AND NOT EXISTS (SELECT 1 FROM tickets t WHERE t.participant_id = v.participant_id AND t.deleted_at IS NULL)`)
+
+ return migrateV4(db)
+}
+
+// migrateV4 renames roles: volunteer_lead→colead, coordinator→staffing, gate→gatekeeper.
+func migrateV4(db *sql.DB) error {
+ var count int
+ if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE role IN ('volunteer_lead','coordinator','gate')`).Scan(&count); err != nil || count == 0 {
+ return nil
+ }
+ if _, err := db.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
+ return err
+ }
+ stmts := []string{
+ `CREATE TABLE users_v4 (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL,
+ role TEXT NOT NULL CHECK(role IN ('admin','ticketing','staffing','colead','gatekeeper')),
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )`,
+ `INSERT INTO users_v4 (id, username, password_hash, role, created_at)
+ SELECT id, username, password_hash,
+ CASE role
+ WHEN 'volunteer_lead' THEN 'colead'
+ WHEN 'coordinator' THEN 'staffing'
+ WHEN 'gate' THEN 'gatekeeper'
+ ELSE role
+ END,
+ created_at
+ FROM users`,
+ `DROP TABLE users`,
+ `ALTER TABLE users_v4 RENAME TO users`,
+ `PRAGMA foreign_keys = ON`,
+ }
+ for _, s := range stmts {
+ if _, err := db.Exec(s); err != nil {
+ db.Exec(`PRAGMA foreign_keys = ON`)
+ return fmt.Errorf("migrateV4: %w", err)
+ }
+ }
+ 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)
+}
+
+func renameColumnIfExists(db *sql.DB, table, oldName, newName string) {
+ 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 == oldName {
+ db.Exec(`ALTER TABLE "` + table + `" RENAME COLUMN "` + oldName + `" TO "` + newName + `"`)
+ return
+ }
+ }
}
// --- 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 +381,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,40 +417,40 @@ type Department struct {
}
type Volunteer struct {
+ ID int `json:"id"`
+ ParticipantID *int `json:"participant_id,omitempty"`
+ AttendeeID *int `json:"attendee_id,omitempty"` // deprecated; kept for migration compat
+ Name string `json:"name"`
+ PreferredName string `json:"preferred_name"`
+ Email string `json:"email"`
+ Phone string `json:"phone"`
+ Pronouns string `json:"pronouns"`
+ 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"`
+ EmailConfirmed bool `json:"email_confirmed"`
+ ConfirmationToken *string `json:"-"`
+ 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"`
+}
+
+type Participant 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"`
+ Email string `json:"email"`
+ PreferredName string `json:"preferred_name"`
+ TicketName string `json:"ticket_name"`
+ Phone string `json:"phone"`
+ Pronouns string `json:"pronouns"`
Note string `json:"note"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
DeletedAt *string `json:"deleted_at,omitempty"`
- // Populated via JOIN from participant, not stored on volunteers table:
- Name string `json:"name"`
- Email string `json:"email"`
- Phone string `json:"phone"`
- Pronouns string `json:"pronouns"`
- EmailConfirmed bool `json:"email_confirmed"`
-}
-
-type Participant struct {
- ID int `json:"id"`
- Email string `json:"email"`
- PreferredName string `json:"preferred_name"`
- TicketName string `json:"ticket_name"`
- Phone string `json:"phone"`
- Pronouns string `json:"pronouns"`
- Note string `json:"note"`
- EmailConfirmed bool `json:"email_confirmed"`
- ConfirmationToken *string `json:"-"`
- CreatedAt string `json:"created_at"`
- UpdatedAt string `json:"updated_at"`
- DeletedAt *string `json:"deleted_at,omitempty"`
}
type Ticket struct {
@@ -283,45 +516,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 +538,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 +553,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
}
@@ -570,9 +711,177 @@ func (app *App) generateCodesForAll() (int, error) {
return count, nil
}
+// incrementPartySize is kept for backward compatibility with existing tests.
+func (app *App) incrementPartySize(name, ticketID string) (bool, error) {
+ res, err := app.db.Exec(
+ `UPDATE attendees SET party_size = party_size + 1, updated_at = ?
+ WHERE name = ? AND ticket_id = ? AND deleted_at IS NULL`,
+ now(), name, ticketID,
+ )
+ if err != nil {
+ return false, err
+ }
+ n, _ := res.RowsAffected()
+ return n > 0, nil
+}
+
+// --- 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.getAttendee(int(id))
+}
+
+func (app *App) updateAttendee(a Attendee) error {
+ _, err := app.db.Exec(
+ `UPDATE attendees SET name=?, email=?, phone=?, ticket_id=?, ticket_type=?, note=?, updated_at=?
+ WHERE id = ? AND deleted_at IS NULL`,
+ a.Name, a.Email, a.Phone, a.TicketID, a.TicketType, a.Note, now(), a.ID,
+ )
+ return err
+}
+
+func (app *App) deleteAttendee(id int) error {
+ _, err := app.db.Exec(
+ `UPDATE attendees SET deleted_at = ?, updated_at = ? WHERE id = ?`, now(), now(), id,
+ )
+ return err
+}
+
+// checkInAttendee increments checked_in_count by count (capped at party_size).
+// Sets checked_in and checked_in_at on the first check-in.
+func (app *App) checkInAttendee(id, userID, count int) (*Attendee, error) {
+ if count < 1 {
+ count = 1
+ }
+ a, err := app.getAttendee(id)
+ if err != nil || a == nil {
+ return nil, err
+ }
+ remaining := a.PartySize - a.CheckedInCount
+ if count > remaining {
+ count = remaining
+ }
+ if count <= 0 {
+ return a, nil
+ }
+ t := now()
+ _, err = app.db.Exec(`
+ UPDATE attendees SET
+ checked_in_count = checked_in_count + ?,
+ checked_in = CASE WHEN checked_in = 0 THEN 1 ELSE checked_in END,
+ checked_in_at = CASE WHEN checked_in = 0 THEN ? ELSE checked_in_at END,
+ checked_in_by = CASE WHEN checked_in = 0 THEN ? ELSE checked_in_by END,
+ updated_at = ?
+ WHERE id = ? AND deleted_at IS NULL`,
+ count, t, userID, t, id,
+ )
+ if err != nil {
+ return nil, err
+ }
+ return app.getAttendee(id)
+}
+
+func (app *App) attendeesSince(since string) ([]Attendee, error) {
+ return queryAttendees(app.db,
+ `SELECT `+attendeeCols+` FROM attendees WHERE updated_at > ? ORDER BY updated_at ASC`, since)
+}
+
+func queryAttendees(db *sql.DB, q string, args ...any) ([]Attendee, error) {
+ rows, err := db.Query(q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var result []Attendee
+ for rows.Next() {
+ var a Attendee
+ var checkedIn int
+ var token sql.NullString
+ if err := rows.Scan(
+ &a.ID, &a.Name, &a.Email, &a.Phone, &a.TicketID, &a.TicketType,
+ &token, &a.PartySize, &checkedIn, &a.CheckedInCount,
+ &a.CheckedInAt, &a.CheckedInBy, &a.Note,
+ &a.CreatedAt, &a.UpdatedAt, &a.DeletedAt,
+ ); err != nil {
+ return nil, err
+ }
+ if token.Valid && token.String != "" {
+ a.VolunteerToken = &token.String
+ }
+ a.CheckedIn = checkedIn == 1
+ if a.PartySize < 1 {
+ a.PartySize = 1
+ }
+ result = append(result, a)
+ }
+ return result, rows.Err()
+}
+
+func (app *App) attendeeTicketTypes() ([]string, error) {
+ rows, err := app.db.Query(
+ `SELECT DISTINCT ticket_type FROM attendees WHERE ticket_type != '' AND deleted_at IS NULL ORDER BY ticket_type`,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var types []string
+ for rows.Next() {
+ var t string
+ rows.Scan(&t)
+ types = append(types, t)
+ }
+ 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
+}
+
// --- Participants ---
-const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, email_confirmed, confirmation_token, created_at, updated_at, deleted_at`
+const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, created_at, updated_at, deleted_at`
func (app *App) listParticipants(search, since string) ([]Participant, error) {
var q string
@@ -612,8 +921,8 @@ func (app *App) getParticipantByEmail(email string) (*Participant, error) {
func (app *App) createParticipant(p Participant) (*Participant, 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(),
+ `INSERT INTO participants (email, preferred_name, ticket_name, phone, pronouns, note, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ strings.ToLower(p.Email), p.PreferredName, p.TicketName, p.Phone, p.Pronouns, p.Note, now(),
)
if err != nil {
return nil, err
@@ -653,8 +962,6 @@ func (app *App) mergeParticipants(canonicalID, otherID int) error {
); 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,
)
@@ -670,19 +977,12 @@ func queryParticipants(db *sql.DB, q string, args ...any) ([]Participant, error)
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)
}
return result, rows.Err()
@@ -735,6 +1035,15 @@ func (app *App) getTicket(id int) (*Ticket, error) {
return &rows[0], nil
}
+func (app *App) getTicketByCode(code string) (*Ticket, error) {
+ rows, err := queryTickets(app.db,
+ `SELECT `+ticketCols+` FROM tickets WHERE code = ? AND deleted_at IS NULL`, code)
+ 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)
@@ -771,6 +1080,16 @@ func (app *App) deleteTicket(id int) error {
return err
}
+func (app *App) ticketsSince(since string) ([]Ticket, error) {
+ return queryTickets(app.db,
+ `SELECT `+ticketCols+` FROM tickets WHERE updated_at > ? ORDER BY updated_at ASC`, since)
+}
+
+func (app *App) participantsSince(since string) ([]Participant, error) {
+ return queryParticipants(app.db,
+ `SELECT `+participantCols+` FROM participants WHERE updated_at > ? ORDER BY updated_at ASC`, since)
+}
+
func queryTickets(db *sql.DB, q string, args ...any) ([]Ticket, error) {
rows, err := db.Query(q, args...)
if err != nil {
@@ -895,15 +1214,25 @@ 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,
+// volunteerSelect / volunteerFrom are used together for all volunteer queries.
+// Personal fields (name, email, phone, pronouns) come from the joined participant when available,
+// falling back to the volunteer's own columns for legacy rows.
+const volunteerSelect = `v.id, v.participant_id, v.attendee_id,
+ COALESCE(NULLIF(p.preferred_name,''), NULLIF(v.preferred_name,''), v.name),
+ COALESCE(NULLIF(p.preferred_name,''), NULLIF(v.preferred_name,''), v.name),
+ COALESCE(NULLIF(p.email,''), v.email),
+ COALESCE(NULLIF(p.phone,''), v.phone),
+ COALESCE(NULLIF(p.pronouns,''), v.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.email_confirmed, v.confirmation_token, 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 volunteerFrom = `FROM volunteers v LEFT JOIN participants p ON p.id = v.participant_id`
-func (app *App) listVolunteers(search string, deptIDs []int, since string) ([]Volunteer, error) {
+// volunteerCols is kept for backward-compat references that expect unqualified column names.
+const volunteerCols = `id, attendee_id, name, preferred_name, email, phone, pronouns, department_id, is_lead, ready, ready_at, email_confirmed, confirmation_token, note, created_at, updated_at, deleted_at`
+
+func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) {
q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1`
var args []any
if since != "" {
@@ -913,20 +1242,15 @@ func (app *App) listVolunteers(search string, deptIDs []int, since string) ([]Vo
q += ` AND v.deleted_at IS NULL`
}
if search != "" {
- q += ` AND (p.preferred_name LIKE ? OR p.email LIKE ?)`
+ q += ` AND (v.name LIKE ? OR v.email LIKE ? OR p.preferred_name LIKE ? OR p.email LIKE ?)`
s := "%" + search + "%"
- args = append(args, s, s)
+ args = append(args, s, s, s, s)
}
- if len(deptIDs) == 1 {
+ if deptID != nil {
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)
- }
+ args = append(args, *deptID)
}
- q += ` ORDER BY p.preferred_name`
+ q += ` ORDER BY COALESCE(NULLIF(p.preferred_name,''), v.name)`
return queryVolunteers(app.db, q, args...)
}
@@ -939,11 +1263,30 @@ func (app *App) getVolunteer(id int) (*Volunteer, error) {
return &rows[0], nil
}
+func (app *App) getVolunteerByAttendeeID(attendeeID int) (*Volunteer, error) {
+ rows, err := queryVolunteers(app.db,
+ `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.attendee_id = ? AND v.deleted_at IS NULL LIMIT 1`, attendeeID)
+ if err != nil || len(rows) == 0 {
+ return nil, err
+ }
+ return &rows[0], nil
+}
+
+func (app *App) getVolunteerByParticipantID(participantID int) (*Volunteer, error) {
+ rows, err := queryVolunteers(app.db,
+ `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.participant_id = ? AND v.deleted_at IS NULL LIMIT 1`, participantID)
+ if err != nil || len(rows) == 0 {
+ return nil, err
+ }
+ return &rows[0], nil
+}
+
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 (participant_id, attendee_id, name, preferred_name, email, phone, pronouns, department_id, is_lead, email_confirmed, confirmation_token, note, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ v.ParticipantID, v.AttendeeID, v.Name, v.PreferredName, 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 +1297,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 participant_id=?, attendee_id=?, name=?, preferred_name=?, email=?, phone=?, pronouns=?, department_id=?, is_lead=?, note=?, updated_at=?
WHERE id=? AND deleted_at IS NULL`,
+ v.ParticipantID, v.AttendeeID, v.Name, v.PreferredName, v.Email, v.Phone, v.Pronouns,
v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID,
)
return err
@@ -968,6 +1312,8 @@ func (app *App) deleteVolunteer(id int) error {
return err
}
+// markVolunteerReady marks the volunteer as ready and, if linked to an attendee,
+// also increments the attendee's checked_in_count.
func (app *App) markVolunteerReady(id, userID int) (*Volunteer, error) {
t := now()
_, err := app.db.Exec(
@@ -978,7 +1324,14 @@ func (app *App) markVolunteerReady(id, userID int) (*Volunteer, error) {
if err != nil {
return nil, err
}
- return app.getVolunteer(id)
+ v, err := app.getVolunteer(id)
+ if err != nil || v == nil {
+ return v, err
+ }
+ if v.AttendeeID != nil {
+ app.checkInAttendee(*v.AttendeeID, userID, 1)
+ }
+ return v, nil
}
func (app *App) confirmVolunteer(id int) (*Volunteer, error) {
@@ -1003,23 +1356,34 @@ 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 participantID, attendeeID, deptID sql.NullInt64
var isLead, ready, confirmed, emailConfirmed int
- var confirmedAt, kioskCode sql.NullString
+ var confirmationToken, confirmedAt, kioskCode sql.NullString
if err := rows.Scan(
- &v.ID, &v.ParticipantID,
- &v.Name, &v.Email, &v.Phone, &v.Pronouns,
- &deptID, &isLead, &ready, &v.ReadyAt,
+ &v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName,
+ &v.Email, &v.Phone, &v.Pronouns, &deptID,
+ &isLead, &ready, &v.ReadyAt,
&confirmed, &confirmedAt,
- &emailConfirmed, &kioskCode, &v.Note,
+ &emailConfirmed, &confirmationToken, &kioskCode, &v.Note,
&v.CreatedAt, &v.UpdatedAt, &v.DeletedAt,
); err != nil {
return nil, err
}
+ if participantID.Valid {
+ id := int(participantID.Int64)
+ v.ParticipantID = &id
+ }
+ if attendeeID.Valid {
+ id := int(attendeeID.Int64)
+ v.AttendeeID = &id
+ }
if deptID.Valid {
id := int(deptID.Int64)
v.DepartmentID = &id
}
+ if confirmationToken.Valid {
+ v.ConfirmationToken = &confirmationToken.String
+ }
if confirmedAt.Valid {
v.ConfirmedAt = &confirmedAt.String
}
@@ -1037,7 +1401,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 `+volunteerSelect+` `+volunteerFrom+` WHERE LOWER(v.email) = LOWER(?) AND v.deleted_at IS NULL LIMIT 1`, email)
if err != nil || len(rows) == 0 {
return nil, err
}
@@ -1046,24 +1410,17 @@ 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 `+volunteerSelect+` `+volunteerFrom+` WHERE v.confirmation_token = ? AND v.deleted_at IS NULL LIMIT 1`, token)
if err != nil || len(rows) == 0 {
return nil, err
}
return &rows[0], nil
}
-func (app *App) confirmParticipantEmail(participantID int) error {
+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)
- 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)
+ `UPDATE volunteers SET email_confirmed = 1, confirmation_token = NULL, updated_at = ? WHERE id = ?`,
+ now(), id)
return err
}
@@ -1082,10 +1439,11 @@ func (app *App) assignKioskCode(id int, code string) error {
return err
}
+// listVolunteersNeedingKioskCode returns email-confirmed volunteers without a kiosk code.
func (app *App) listVolunteersNeedingKioskCode() ([]Volunteer, error) {
return queryVolunteers(app.db, `
SELECT `+volunteerSelect+` `+volunteerFrom+`
- WHERE p.email_confirmed = 1 AND v.kiosk_code IS NULL AND v.deleted_at IS NULL`)
+ WHERE v.email_confirmed = 1 AND v.kiosk_code IS NULL AND v.deleted_at IS NULL`)
}
func (app *App) generateVolunteerKioskCode() (string, error) {
@@ -1115,7 +1473,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 +1482,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 +1655,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 +1708,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 +1720,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/frontend/src/App.svelte b/frontend/src/App.svelte
index ac0957e..7ad6bc9 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -1,6 +1,6 @@
{#if updateAvailable}
@@ -129,9 +102,9 @@
{:else if isConfirmEmail}
- Welcome, {session?.user?.preferred_name} - · {#each roles as r}{r}{/each} + Welcome, {session?.user?.username} + · {session?.user?.role}