Refactored user/volunteer/participant identity.
This commit is contained in:
parent
e640bf8bed
commit
883ebd584f
28 changed files with 450 additions and 265 deletions
271
db.go
271
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,
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue