diff --git a/Makefile b/Makefile
index 30bca30..a8de0d3 100644
--- a/Makefile
+++ b/Makefile
@@ -1,9 +1,9 @@
.PHONY: build frontend-build dev clean test patch minor major
LAST_TAG := $(shell git tag --sort=-v:refname | head -1)
-MAJOR := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f1)
-MINOR := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f2)
-PATCH := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f3)
+MAJOR := $(shell echo $(LAST_TAG) | cut -d. -f1)
+MINOR := $(shell echo $(LAST_TAG) | cut -d. -f2)
+PATCH := $(shell echo $(LAST_TAG) | cut -d. -f3)
build: frontend-build
CGO_ENABLED=0 go build -ldflags "-X main.buildID=$$(git rev-parse --short HEAD)" -o turnpike .
@@ -25,13 +25,13 @@ clean:
rm -rf frontend/dist
patch:
- git tag v$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))
- @echo "Tagged v$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))"
+ git tag $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))
+ @echo "Tagged $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))"
minor:
- git tag v$(MAJOR).$(shell echo $$(($(MINOR)+1))).0
- @echo "Tagged v$(MAJOR).$(shell echo $$(($(MINOR)+1))).0"
+ git tag $(MAJOR).$(shell echo $$(($(MINOR)+1))).0
+ @echo "Tagged $(MAJOR).$(shell echo $$(($(MINOR)+1))).0"
major:
- git tag v$(shell echo $$(($(MAJOR)+1))).0.0
- @echo "Tagged v$(shell echo $$(($(MAJOR)+1))).0.0"
+ git tag $(shell echo $$(($(MAJOR)+1))).0.0
+ @echo "Tagged $(shell echo $$(($(MAJOR)+1))).0.0"
diff --git a/auth.go b/auth.go
index c2d11af..b675e6f 100644
--- a/auth.go
+++ b/auth.go
@@ -12,10 +12,10 @@ import (
)
type Claims struct {
- UserID int `json:"uid"`
- Username string `json:"sub"`
- Role string `json:"role"`
- DeptIDs []int `json:"dept_ids,omitempty"`
+ ParticipantID int `json:"pid"`
+ Email string `json:"sub"`
+ Roles []string `json:"roles"`
+ DeptIDs []int `json:"dept_ids,omitempty"`
jwt.RegisteredClaims
}
@@ -28,13 +28,13 @@ func checkPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
-func (app *App) signToken(u *User) (string, error) {
+func (app *App) signToken(s *User) (string, error) {
expiry := time.Duration(app.tokenExpiry) * time.Hour
claims := Claims{
- UserID: u.ID,
- Username: u.Username,
- Role: u.Role,
- DeptIDs: u.DepartmentIDs,
+ ParticipantID: s.ID,
+ Email: s.Email,
+ Roles: s.Roles,
+ DeptIDs: s.DepartmentIDs,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
IssuedAt: jwt.NewNumericDate(time.Now()),
@@ -88,7 +88,7 @@ func (app *App) requireAuth(next http.HandlerFunc, roles ...string) http.Handler
writeError(w, "unauthorized", http.StatusUnauthorized)
return
}
- if len(roles) > 0 && !hasRole(claims.Role, roles) {
+ if len(roles) > 0 && !hasAnyRole(claims.Roles, roles) {
writeError(w, "forbidden", http.StatusForbidden)
return
}
@@ -97,9 +97,25 @@ func (app *App) requireAuth(next http.HandlerFunc, roles ...string) http.Handler
}
}
-func hasRole(role string, allowed []string) bool {
- for _, r := range allowed {
- if r == role {
+func hasAnyRole(roles []string, allowed []string) bool {
+ for _, r := range roles {
+ for _, a := range allowed {
+ if r == a {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func isCoLeadOnly(claims *Claims) bool {
+ return hasAnyRole(claims.Roles, []string{"colead"}) &&
+ !hasAnyRole(claims.Roles, []string{"admin", "staffing"})
+}
+
+func inSlice(v int, s []int) bool {
+ for _, x := range s {
+ if x == v {
return true
}
}
diff --git a/auth_test.go b/auth_test.go
index f611bc1..602c6cf 100644
--- a/auth_test.go
+++ b/auth_test.go
@@ -12,7 +12,7 @@ func TestLoginValid(t *testing.T) {
mux := testMux(app)
req := testRequest("POST", "/api/login", map[string]string{
- "username": admin.Username,
+ "email": admin.Email,
"password": "admin123",
})
w := httptest.NewRecorder()
@@ -26,7 +26,7 @@ func TestLoginValid(t *testing.T) {
t.Error("missing token in response")
}
user, ok := result["user"].(map[string]any)
- if !ok || user["username"] != "admin" {
+ if !ok || user["email"] != "oberon@athens.example" {
t.Errorf("user = %v", result["user"])
}
}
@@ -37,7 +37,7 @@ func TestLoginWrongPassword(t *testing.T) {
mux := testMux(app)
req := testRequest("POST", "/api/login", map[string]string{
- "username": "admin",
+ "email": "oberon@athens.example",
"password": "wrong",
})
w := httptest.NewRecorder()
@@ -53,7 +53,7 @@ func TestLoginNonexistentUser(t *testing.T) {
mux := testMux(app)
req := testRequest("POST", "/api/login", map[string]string{
- "username": "nobody",
+ "email": "nobody@test.com",
"password": "test",
})
w := httptest.NewRecorder()
@@ -94,8 +94,7 @@ func TestAuthMiddlewareRoleEnforcement(t *testing.T) {
app := testApp(t)
mux := testMux(app)
- // Create a gate user — should not be able to access /api/users (admin only)
- gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{})
+ gate := testUserWithRoles(t, app, "Starveling", []string{"gatekeeper"}, []int{})
token := testToken(t, app, gate)
req := testAuthRequest("GET", "/api/users", nil, token)
@@ -121,7 +120,7 @@ func TestMeEndpoint(t *testing.T) {
t.Fatalf("status = %d", w.Code)
}
result := parseJSON(t, w)
- if result["username"] != "admin" {
- t.Errorf("username = %v", result["username"])
+ if result["email"] != "oberon@athens.example" {
+ t.Errorf("email = %v", result["email"])
}
}
diff --git a/db.go b/db.go
index bda44ed..0ec6716 100644
--- a/db.go
+++ b/db.go
@@ -40,20 +40,6 @@ func migrate(db *sql.DB) error {
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
- CREATE TABLE IF NOT EXISTS users (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- username TEXT NOT NULL UNIQUE,
- password_hash TEXT NOT NULL,
- role TEXT NOT NULL CHECK(role IN ('admin','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,
@@ -63,28 +49,6 @@ func migrate(db *sql.DB) error {
deleted_at TEXT
);
- CREATE TABLE IF NOT EXISTS attendees (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT NOT NULL,
- email TEXT NOT NULL DEFAULT '',
- phone TEXT NOT NULL DEFAULT '',
- ticket_id TEXT NOT NULL DEFAULT '',
- ticket_type TEXT NOT NULL DEFAULT '',
- volunteer_token TEXT UNIQUE,
- party_size INTEGER NOT NULL DEFAULT 1,
- checked_in INTEGER NOT NULL DEFAULT 0,
- checked_in_count INTEGER NOT NULL DEFAULT 0,
- checked_in_at TEXT,
- checked_in_by INTEGER REFERENCES users(id),
- note TEXT NOT NULL DEFAULT '',
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
- updated_at TEXT NOT NULL DEFAULT (datetime('now')),
- deleted_at TEXT
- );
-
- CREATE UNIQUE INDEX IF NOT EXISTS idx_attendees_name_ticket
- ON attendees(name, ticket_id) WHERE deleted_at IS NULL;
-
CREATE TABLE IF NOT EXISTS volunteers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
@@ -136,6 +100,8 @@ func migrate(db *sql.DB) error {
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
@@ -154,7 +120,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 users(id),
+ 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
@@ -162,19 +128,29 @@ 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'))
+ );
`)
- if err != nil {
- return err
- }
- return nil
+ return err
}
// --- Types ---
-const attendeeCols = `id, name, email, phone, ticket_id, ticket_type, volunteer_token,
- party_size, checked_in, checked_in_count, checked_in_at, checked_in_by,
- note, created_at, updated_at, deleted_at`
-
const shiftCols = `id, department_id, name, day, start_time, end_time, capacity, position, updated_at, deleted_at`
const shiftColsS = `s.id, s.department_id, s.name, s.day, s.start_time, s.end_time, s.capacity, s.position, s.updated_at, s.deleted_at`
@@ -190,30 +166,12 @@ type Event struct {
}
type User struct {
- ID int `json:"id"`
- Username string `json:"username"`
- Role string `json:"role"`
- DepartmentIDs []int `json:"department_ids"`
- CreatedAt string `json:"created_at"`
-}
-
-type Attendee struct {
- ID int `json:"id"`
- Name string `json:"name"`
- Email string `json:"email"`
- Phone string `json:"phone"`
- TicketID string `json:"ticket_id"`
- TicketType string `json:"ticket_type"`
- VolunteerToken *string `json:"volunteer_token,omitempty"`
- PartySize int `json:"party_size"`
- CheckedIn bool `json:"checked_in"`
- CheckedInCount int `json:"checked_in_count"`
- CheckedInAt *string `json:"checked_in_at,omitempty"`
- CheckedInBy *int `json:"checked_in_by,omitempty"`
- Note string `json:"note"`
- CreatedAt string `json:"created_at"`
- UpdatedAt string `json:"updated_at"`
- DeletedAt *string `json:"deleted_at,omitempty"`
+ ID int `json:"id"`
+ Email string `json:"email"`
+ PreferredName string `json:"preferred_name"`
+ Roles []string `json:"roles"`
+ DepartmentIDs []int `json:"department_ids"`
+ CreatedAt string `json:"created_at"`
}
type Department struct {
@@ -325,11 +283,45 @@ func (app *App) upsertEvent(e Event) error {
return err
}
-// --- Users ---
+// --- Staff (participants with login_enabled) ---
-func (app *App) getUserDeptIDs(userID int) ([]int, error) {
+func (app *App) getParticipantRoles(participantID int) ([]string, error) {
rows, err := app.db.Query(
- `SELECT department_id FROM user_departments WHERE user_id = ? ORDER BY department_id`, userID,
+ `SELECT role FROM participant_roles WHERE participant_id = ? ORDER BY role`, participantID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var roles []string
+ for rows.Next() {
+ var r string
+ rows.Scan(&r)
+ roles = append(roles, r)
+ }
+ if roles == nil {
+ roles = []string{}
+ }
+ return roles, rows.Err()
+}
+
+func (app *App) setParticipantRoles(participantID int, roles []string) error {
+ if _, err := app.db.Exec(`DELETE FROM participant_roles WHERE participant_id = ?`, participantID); err != nil {
+ return err
+ }
+ for _, role := range roles {
+ if _, err := app.db.Exec(
+ `INSERT INTO participant_roles (participant_id, role) VALUES (?, ?)`, participantID, role,
+ ); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (app *App) getUserDeptIDs(participantID int) ([]int, error) {
+ rows, err := app.db.Query(
+ `SELECT department_id FROM participant_departments WHERE participant_id = ? ORDER BY department_id`, participantID,
)
if err != nil {
return nil, err
@@ -347,14 +339,13 @@ func (app *App) getUserDeptIDs(userID int) ([]int, error) {
return ids, rows.Err()
}
-func (app *App) setUserDeptIDs(userID int, deptIDs []int) error {
- _, err := app.db.Exec(`DELETE FROM user_departments WHERE user_id = ?`, userID)
- if err != nil {
+func (app *App) setUserDeptIDs(participantID int, deptIDs []int) error {
+ if _, err := app.db.Exec(`DELETE FROM participant_departments WHERE participant_id = ?`, participantID); err != nil {
return err
}
for _, deptID := range deptIDs {
if _, err := app.db.Exec(
- `INSERT INTO user_departments (user_id, department_id) VALUES (?, ?)`, userID, deptID,
+ `INSERT INTO participant_departments (participant_id, department_id) VALUES (?, ?)`, participantID, deptID,
); err != nil {
return err
}
@@ -362,98 +353,157 @@ func (app *App) setUserDeptIDs(userID int, deptIDs []int) error {
return nil
}
-func (app *App) getUserByUsername(username string) (*User, string, error) {
- var u User
- var hash string
+func (app *App) getLoginParticipant(email string) (*User, string, error) {
+ var s User
+ var hash sql.NullString
err := app.db.QueryRow(
- `SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?`, username,
- ).Scan(&u.ID, &u.Username, &hash, &u.Role, &u.CreatedAt)
+ `SELECT id, email, preferred_name, password_hash, created_at
+ FROM participants WHERE LOWER(email) = LOWER(?) AND login_enabled = 1 AND deleted_at IS NULL`, email,
+ ).Scan(&s.ID, &s.Email, &s.PreferredName, &hash, &s.CreatedAt)
if err == sql.ErrNoRows {
return nil, "", nil
}
if err != nil {
return nil, "", err
}
- u.DepartmentIDs, err = app.getUserDeptIDs(u.ID)
- return &u, hash, err
+ var hashStr string
+ if hash.Valid {
+ hashStr = hash.String
+ }
+ s.Roles, _ = app.getParticipantRoles(s.ID)
+ s.DepartmentIDs, _ = app.getUserDeptIDs(s.ID)
+ return &s, hashStr, nil
}
-func (app *App) getUserByID(id int) (*User, error) {
- var u User
+func (app *App) getUser(id int) (*User, error) {
+ var s User
err := app.db.QueryRow(
- `SELECT id, username, role, created_at FROM users WHERE id = ?`, id,
- ).Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt)
+ `SELECT id, email, preferred_name, created_at
+ FROM participants WHERE id = ? AND login_enabled = 1 AND deleted_at IS NULL`, id,
+ ).Scan(&s.ID, &s.Email, &s.PreferredName, &s.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
- u.DepartmentIDs, err = app.getUserDeptIDs(u.ID)
- return &u, err
+ s.Roles, _ = app.getParticipantRoles(s.ID)
+ s.DepartmentIDs, _ = app.getUserDeptIDs(s.ID)
+ return &s, nil
}
func (app *App) listUsers() ([]User, error) {
rows, err := app.db.Query(
- `SELECT id, username, role, created_at FROM users ORDER BY username`,
+ `SELECT id, email, preferred_name, created_at
+ FROM participants WHERE login_enabled = 1 AND deleted_at IS NULL ORDER BY preferred_name, email`,
)
if err != nil {
return nil, err
}
defer rows.Close()
- var users []User
+ var staff []User
for rows.Next() {
- var u User
- if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt); err != nil {
+ var s User
+ if err := rows.Scan(&s.ID, &s.Email, &s.PreferredName, &s.CreatedAt); err != nil {
return nil, err
}
- u.DepartmentIDs = []int{}
- users = append(users, u)
+ s.Roles = []string{}
+ s.DepartmentIDs = []int{}
+ staff = append(staff, s)
}
if err := rows.Err(); err != nil {
return nil, err
}
- for i := range users {
- users[i].DepartmentIDs, _ = app.getUserDeptIDs(users[i].ID)
+ for i := range staff {
+ staff[i].Roles, _ = app.getParticipantRoles(staff[i].ID)
+ staff[i].DepartmentIDs, _ = app.getUserDeptIDs(staff[i].ID)
}
- return users, nil
+ return staff, nil
}
-func (app *App) createUser(username, hash, role string, deptIDs []int) (*User, error) {
+func (app *App) createUser(email, preferredName, hash string, roles []string, deptIDs []int) (*User, error) {
+ // Find or create participant by email.
+ p, err := app.getParticipantByEmail(email)
+ if err != nil {
+ return nil, err
+ }
+ if p != nil {
+ // Participant exists — promote to staff.
+ if _, err := app.db.Exec(
+ `UPDATE participants SET password_hash = ?, login_enabled = 1, updated_at = ? WHERE id = ?`,
+ hash, now(), p.ID,
+ ); err != nil {
+ return nil, err
+ }
+ if err := app.setParticipantRoles(p.ID, roles); err != nil {
+ return nil, err
+ }
+ if err := app.setUserDeptIDs(p.ID, deptIDs); err != nil {
+ return nil, err
+ }
+ return app.getUser(p.ID)
+ }
+ // Create new participant with auth.
res, err := app.db.Exec(
- `INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)`,
- username, hash, role,
+ `INSERT INTO participants (email, preferred_name, password_hash, login_enabled, updated_at)
+ VALUES (?, ?, ?, 1, ?)`,
+ strings.ToLower(email), preferredName, hash, now(),
)
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
+ if err := app.setParticipantRoles(int(id), roles); err != nil {
+ return nil, err
+ }
if err := app.setUserDeptIDs(int(id), deptIDs); err != nil {
return nil, err
}
- return app.getUserByID(int(id))
+ return app.getUser(int(id))
}
-func (app *App) updateUser(id int, role string, deptIDs []int) error {
- if _, err := app.db.Exec(`UPDATE users SET role = ? WHERE id = ?`, role, id); err != nil {
+func (app *App) updateUserRoles(id int, roles []string, deptIDs []int) error {
+ var enabled int
+ err := app.db.QueryRow(`SELECT login_enabled FROM participants WHERE id = ? AND deleted_at IS NULL`, id).Scan(&enabled)
+ if err != nil || enabled != 1 {
+ return fmt.Errorf("participant not found or not a staff member")
+ }
+ if err := app.setParticipantRoles(id, roles); err != nil {
return err
}
return app.setUserDeptIDs(id, deptIDs)
}
func (app *App) updateUserPassword(id int, hash string) error {
- _, err := app.db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, hash, id)
+ _, err := app.db.Exec(
+ `UPDATE participants SET password_hash = ?, updated_at = ? WHERE id = ? AND login_enabled = 1`, hash, now(), id,
+ )
return err
}
-func (app *App) deleteUser(id int) error {
- _, err := app.db.Exec(`DELETE FROM users WHERE id = ?`, id)
- return err
+func (app *App) removeUser(id int) error {
+ tx, err := app.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ if _, err := tx.Exec(`DELETE FROM participant_roles WHERE participant_id = ?`, id); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(`DELETE FROM participant_departments WHERE participant_id = ?`, id); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(
+ `UPDATE participants SET login_enabled = 0, password_hash = NULL, updated_at = ? WHERE id = ?`, now(), id,
+ ); err != nil {
+ return err
+ }
+ return tx.Commit()
}
func (app *App) countUsers() (int, error) {
var n int
- err := app.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
+ err := app.db.QueryRow(`SELECT COUNT(*) FROM participants WHERE login_enabled = 1 AND deleted_at IS NULL`).Scan(&n)
return n, err
}
@@ -520,174 +570,6 @@ 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`
@@ -771,6 +653,8 @@ 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,
)
@@ -851,15 +735,6 @@ 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)
@@ -896,16 +771,6 @@ 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 {
@@ -1038,7 +903,7 @@ const volunteerSelect = `v.id, v.participant_id,
v.created_at, v.updated_at, v.deleted_at`
const volunteerFrom = `FROM volunteers v INNER JOIN participants p ON p.id = v.participant_id`
-func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) {
+func (app *App) listVolunteers(search string, deptIDs []int, since string) ([]Volunteer, error) {
q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1`
var args []any
if since != "" {
@@ -1052,9 +917,14 @@ func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volu
s := "%" + search + "%"
args = append(args, s, s)
}
- if deptID != nil {
+ if len(deptIDs) == 1 {
q += ` AND v.department_id = ?`
- args = append(args, *deptID)
+ args = append(args, deptIDs[0])
+ } else if len(deptIDs) > 1 {
+ q += ` AND v.department_id IN (` + placeholders(len(deptIDs)) + `)`
+ for _, id := range deptIDs {
+ args = append(args, id)
+ }
}
q += ` ORDER BY p.preferred_name`
return queryVolunteers(app.db, q, args...)
@@ -1069,15 +939,6 @@ func (app *App) getVolunteer(id int) (*Volunteer, error) {
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)
@@ -1254,7 +1115,7 @@ func generateConfirmationToken() (string, error) {
// --- Shifts ---
-func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) {
+func (app *App) listShifts(deptIDs []int, day, since string) ([]Shift, error) {
q := `SELECT ` + shiftCols + ` FROM shifts WHERE 1=1`
var args []any
if since != "" {
@@ -1263,9 +1124,14 @@ func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) {
} else {
q += ` AND deleted_at IS NULL`
}
- if deptID != nil {
+ if len(deptIDs) == 1 {
q += ` AND department_id = ?`
- args = append(args, *deptID)
+ args = append(args, deptIDs[0])
+ } else if len(deptIDs) > 1 {
+ q += ` AND department_id IN (` + placeholders(len(deptIDs)) + `)`
+ for _, id := range deptIDs {
+ args = append(args, id)
+ }
}
if day != "" {
q += ` AND day = ?`
@@ -1489,6 +1355,27 @@ func (app *App) listOpenShiftsForDept(deptID int) ([]Shift, error) {
ORDER BY s.day, s.position, s.start_time`, deptID)
}
+// --- SSO Nonces ---
+
+func (app *App) createSSONonce(nonce string) error {
+ _, err := app.db.Exec(`INSERT INTO sso_nonces (nonce) VALUES (?)`, nonce)
+ return err
+}
+
+func (app *App) consumeSSONonce(nonce string) (bool, error) {
+ res, err := app.db.Exec(
+ `DELETE FROM sso_nonces WHERE nonce = ? AND created_at > datetime('now', '-10 minutes')`, nonce)
+ if err != nil {
+ return false, err
+ }
+ n, _ := res.RowsAffected()
+ return n > 0, nil
+}
+
+func (app *App) cleanExpiredNonces() {
+ app.db.Exec(`DELETE FROM sso_nonces WHERE created_at < datetime('now', '-10 minutes')`)
+}
+
// --- Helpers ---
func now() string {
@@ -1501,3 +1388,10 @@ func boolInt(b bool) int {
}
return 0
}
+
+func placeholders(n int) string {
+ if n <= 0 {
+ return ""
+ }
+ return strings.Repeat("?,", n-1) + "?"
+}
diff --git a/db_test.go b/db_test.go
index c9e8b68..5755d08 100644
--- a/db_test.go
+++ b/db_test.go
@@ -7,7 +7,7 @@ import (
func TestMigrate(t *testing.T) {
app := testApp(t)
// Verify tables exist by querying each one
- tables := []string{"event", "users", "attendees", "departments", "volunteers", "shifts", "volunteer_shifts"}
+ tables := []string{"event", "participants", "participant_roles", "departments", "volunteers", "shifts", "volunteer_shifts"}
for _, table := range tables {
var count int
err := app.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count)
@@ -17,98 +17,6 @@ func TestMigrate(t *testing.T) {
}
}
-func TestAttendeesCRUD(t *testing.T) {
- app := testApp(t)
-
- a, err := app.createAttendee(Attendee{Name: "Titania", Email: "titania@test.com", TicketType: "GA"})
- if err != nil {
- t.Fatal(err)
- }
- if a.ID == 0 || a.Name != "Titania" {
- t.Errorf("create: got %+v", a)
- }
-
- got, err := app.getAttendee(a.ID)
- if err != nil || got == nil {
- t.Fatal("get: not found")
- }
- if got.Email != "titania@test.com" {
- t.Errorf("get: email = %q", got.Email)
- }
-
- got.Name = "Titania Fairweather"
- if err := app.updateAttendee(*got); err != nil {
- t.Fatal(err)
- }
- got2, _ := app.getAttendee(a.ID)
- if got2.Name != "Titania Fairweather" {
- t.Errorf("update: name = %q", got2.Name)
- }
-
- if err := app.deleteAttendee(a.ID); err != nil {
- t.Fatal(err)
- }
- // getAttendee returns soft-deleted records; listAttendees filters them
- attendees, _ := app.listAttendees("", "", "")
- for _, at := range attendees {
- if at.ID == a.ID {
- t.Error("delete: still visible in list")
- }
- }
-}
-
-func TestIncrementPartySize(t *testing.T) {
- app := testApp(t)
-
- app.createAttendee(Attendee{Name: "Oberon", TicketID: "ORD-100"})
-
- merged, err := app.incrementPartySize("Oberon", "ORD-100")
- if err != nil || !merged {
- t.Fatalf("increment: merged=%v, err=%v", merged, err)
- }
-
- a, _ := app.getAttendee(1)
- if a.PartySize != 2 {
- t.Errorf("party_size = %d, want 2", a.PartySize)
- }
-
- // Different ticket_id should not merge
- merged2, _ := app.incrementPartySize("Oberon", "ORD-200")
- if merged2 {
- t.Error("should not merge different ticket_id")
- }
-}
-
-func TestCheckInAttendee(t *testing.T) {
- app := testApp(t)
- admin := testAdminUser(t, app)
-
- app.createAttendee(Attendee{Name: "Puck"})
- // Set party_size directly since createAttendee defaults to 1
- app.db.Exec(`UPDATE attendees SET party_size = 3 WHERE id = 1`)
-
- // Check in 1
- a, err := app.checkInAttendee(1, admin.ID, 1)
- if err != nil {
- t.Fatal(err)
- }
- if a.CheckedInCount != 1 || !a.CheckedIn {
- t.Errorf("after 1: count=%d, checked_in=%v", a.CheckedInCount, a.CheckedIn)
- }
-
- // Check in 2 more (should cap at party_size=3)
- a, _ = app.checkInAttendee(1, admin.ID, 5)
- if a.CheckedInCount != 3 {
- t.Errorf("after cap: count=%d, want 3", a.CheckedInCount)
- }
-
- // Check in again — already full, should stay at 3
- a, _ = app.checkInAttendee(1, admin.ID, 1)
- if a.CheckedInCount != 3 {
- t.Errorf("after full: count=%d, want 3", a.CheckedInCount)
- }
-}
-
func TestGenerateToken(t *testing.T) {
token, err := generateToken()
if err != nil {
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 7ad6bc9..ac0957e 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -1,6 +1,6 @@
{#if updateAvailable}
@@ -102,9 +129,9 @@
{:else if isConfirmEmail}
- Welcome, {session?.user?.username} - · {session?.user?.role} + Welcome, {session?.user?.preferred_name} + · {#each roles as r}{r}{/each}