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..bda44ed 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,6 +63,28 @@ 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,
@@ -100,8 +136,6 @@ 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
@@ -120,7 +154,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 +162,19 @@ 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 nil
}
// --- 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 +190,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 {
@@ -283,45 +325,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 +347,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 +362,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,6 +520,174 @@ 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`
@@ -653,8 +771,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,
)
@@ -735,6 +851,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 +896,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 {
@@ -903,7 +1038,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, deptIDs []int, since string) ([]Volunteer, error) {
+func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) {
q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1`
var args []any
if since != "" {
@@ -917,14 +1052,9 @@ func (app *App) listVolunteers(search string, deptIDs []int, since string) ([]Vo
s := "%" + search + "%"
args = append(args, 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`
return queryVolunteers(app.db, q, args...)
@@ -939,6 +1069,15 @@ 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)
@@ -1115,7 +1254,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 +1263,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 = ?`
@@ -1355,27 +1489,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 +1501,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..c9e8b68 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 {
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}