Refactored user/volunteer/participant identity.

This commit is contained in:
Pen Anderson 2026-03-10 14:08:00 -05:00
parent e640bf8bed
commit 883ebd584f
28 changed files with 450 additions and 265 deletions

271
db.go
View file

@ -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,
@ -75,7 +61,7 @@ func migrate(db *sql.DB) error {
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),
checked_in_by INTEGER REFERENCES participants(id),
note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
@ -154,7 +140,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,10 +148,80 @@ 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)
);
`)
if err != nil {
return err
}
if err := migrateAuth(db); err != nil {
return err
}
return nil
}
func migrateAuth(db *sql.DB) error {
// Add auth columns to participants (idempotent — ignore "duplicate column" errors).
db.Exec(`ALTER TABLE participants ADD COLUMN password_hash TEXT`)
db.Exec(`ALTER TABLE participants ADD COLUMN login_enabled INTEGER NOT NULL DEFAULT 0`)
// Migrate users → participants if the old users table exists.
var hasUsers int
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'`).Scan(&hasUsers); err != nil || hasUsers == 0 {
return nil
}
rows, err := db.Query(`SELECT id, username, password_hash, role FROM users`)
if err != nil {
return nil
}
defer rows.Close()
for rows.Next() {
var userID int
var username, hash, role string
if err := rows.Scan(&userID, &username, &hash, &role); err != nil {
continue
}
// Map old "ticketing" role to "admin".
if role == "ticketing" {
role = "admin"
}
// Create a participant for each user (username as preferred_name).
res, err := db.Exec(
`INSERT INTO participants (preferred_name, password_hash, login_enabled, updated_at) VALUES (?, ?, 1, ?)`,
username, hash, now(),
)
if err != nil {
continue
}
pid, _ := res.LastInsertId()
db.Exec(`INSERT OR IGNORE INTO participant_roles (participant_id, role) VALUES (?, ?)`, pid, role)
// Migrate department assignments.
deptRows, err := db.Query(`SELECT department_id FROM user_departments WHERE user_id = ?`, userID)
if err == nil {
for deptRows.Next() {
var deptID int
deptRows.Scan(&deptID)
db.Exec(`INSERT OR IGNORE INTO participant_departments (participant_id, department_id) VALUES (?, ?)`, pid, deptID)
}
deptRows.Close()
}
}
db.Exec(`DROP TABLE IF EXISTS user_departments`)
db.Exec(`DROP TABLE IF EXISTS users`)
return nil
}
@ -190,11 +246,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"`
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 Attendee struct {
@ -325,11 +382,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 +438,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 +452,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
}