1636 lines
50 KiB
Go
1636 lines
50 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
func initDB(path string) (*sql.DB, error) {
|
|
db, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open db: %w", err)
|
|
}
|
|
|
|
db.SetMaxOpenConns(1)
|
|
db.Exec("PRAGMA journal_mode=WAL")
|
|
db.Exec("PRAGMA foreign_keys=ON")
|
|
db.Exec("PRAGMA busy_timeout=5000")
|
|
|
|
if err := migrate(db); err != nil {
|
|
return nil, fmt.Errorf("migrate: %w", err)
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
func migrate(db *sql.DB) error {
|
|
_, err := db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS event (
|
|
id INTEGER PRIMARY KEY CHECK(id = 1),
|
|
name TEXT NOT NULL,
|
|
venue TEXT NOT NULL DEFAULT '',
|
|
start_date TEXT NOT NULL DEFAULT '',
|
|
end_date TEXT NOT NULL DEFAULT '',
|
|
timezone TEXT NOT NULL DEFAULT 'America/Chicago',
|
|
description TEXT NOT NULL DEFAULT '',
|
|
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,
|
|
color TEXT NOT NULL DEFAULT '#6366f1',
|
|
description TEXT NOT NULL DEFAULT '',
|
|
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_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,
|
|
checked_in INTEGER NOT NULL DEFAULT 0,
|
|
checked_in_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,
|
|
department_id INTEGER NOT NULL REFERENCES departments(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
day TEXT NOT NULL,
|
|
start_time TEXT NOT NULL,
|
|
end_time TEXT NOT NULL,
|
|
capacity INTEGER NOT NULL DEFAULT 0,
|
|
position INTEGER NOT NULL DEFAULT 0,
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
deleted_at TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS volunteer_shifts (
|
|
volunteer_id INTEGER NOT NULL REFERENCES volunteers(id) ON DELETE CASCADE,
|
|
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')),
|
|
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 '',
|
|
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
|
|
ON participants(email) WHERE deleted_at IS NULL AND email != '';
|
|
|
|
CREATE TABLE IF NOT EXISTS tickets (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
participant_id INTEGER REFERENCES participants(id) ON DELETE SET NULL,
|
|
name TEXT NOT NULL DEFAULT '',
|
|
ticket_type TEXT NOT NULL DEFAULT '',
|
|
source TEXT NOT NULL DEFAULT 'manual',
|
|
external_id TEXT NOT NULL DEFAULT '',
|
|
order_id TEXT NOT NULL DEFAULT '',
|
|
code TEXT UNIQUE,
|
|
checked_in_at TEXT,
|
|
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
|
|
);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_source_external
|
|
ON tickets(source, external_id) WHERE external_id != '' AND deleted_at IS NULL;
|
|
`)
|
|
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")
|
|
// 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)")
|
|
|
|
// 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)
|
|
}
|
|
|
|
// --- 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`
|
|
|
|
type Event struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
Venue string `json:"venue"`
|
|
StartDate string `json:"start_date"`
|
|
EndDate string `json:"end_date"`
|
|
Timezone string `json:"timezone"`
|
|
Description string `json:"description"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
type Department struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
Color string `json:"color"`
|
|
Description string `json:"description"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
DeletedAt *string `json:"deleted_at,omitempty"`
|
|
}
|
|
|
|
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"`
|
|
TicketName string `json:"ticket_name"`
|
|
Email string `json:"email"`
|
|
Phone string `json:"phone"`
|
|
Pronouns string `json:"pronouns"`
|
|
DepartmentID *int `json:"department_id,omitempty"`
|
|
IsLead bool `json:"is_lead"`
|
|
CheckedIn bool `json:"checked_in"`
|
|
CheckedInAt *string `json:"checked_in_at,omitempty"`
|
|
EmailConfirmed bool `json:"email_confirmed"`
|
|
ConfirmationToken *string `json:"-"`
|
|
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"`
|
|
Email string `json:"email"`
|
|
PreferredName string `json:"preferred_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"`
|
|
}
|
|
|
|
type Ticket struct {
|
|
ID int `json:"id"`
|
|
ParticipantID *int `json:"participant_id,omitempty"`
|
|
Name string `json:"name"`
|
|
TicketType string `json:"ticket_type"`
|
|
Source string `json:"source"`
|
|
ExternalID string `json:"external_id"`
|
|
OrderID string `json:"order_id"`
|
|
Code *string `json:"code,omitempty"`
|
|
CheckedInAt *string `json:"checked_in_at,omitempty"`
|
|
CheckedInBy *int `json:"checked_in_by,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
DeletedAt *string `json:"deleted_at,omitempty"`
|
|
}
|
|
|
|
type Shift struct {
|
|
ID int `json:"id"`
|
|
DepartmentID int `json:"department_id"`
|
|
Name string `json:"name"`
|
|
Day string `json:"day"`
|
|
StartTime string `json:"start_time"`
|
|
EndTime string `json:"end_time"`
|
|
Capacity int `json:"capacity"`
|
|
Position int `json:"position"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
DeletedAt *string `json:"deleted_at,omitempty"`
|
|
}
|
|
|
|
type VolunteerShift struct {
|
|
VolunteerID int `json:"volunteer_id"`
|
|
ShiftID int `json:"shift_id"`
|
|
Confirmed bool `json:"confirmed"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
DeletedAt *string `json:"deleted_at"`
|
|
}
|
|
|
|
// --- Event ---
|
|
|
|
func (app *App) getEvent() (*Event, error) {
|
|
var e Event
|
|
err := app.db.QueryRow(
|
|
`SELECT id, name, venue, start_date, end_date, timezone, description, updated_at FROM event WHERE id = 1`,
|
|
).Scan(&e.ID, &e.Name, &e.Venue, &e.StartDate, &e.EndDate, &e.Timezone, &e.Description, &e.UpdatedAt)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return &e, err
|
|
}
|
|
|
|
func (app *App) upsertEvent(e Event) error {
|
|
_, err := app.db.Exec(`
|
|
INSERT INTO event (id, name, venue, start_date, end_date, timezone, description, updated_at)
|
|
VALUES (1, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
name=excluded.name, venue=excluded.venue,
|
|
start_date=excluded.start_date, end_date=excluded.end_date,
|
|
timezone=excluded.timezone, description=excluded.description,
|
|
updated_at=excluded.updated_at
|
|
`, e.Name, e.Venue, e.StartDate, e.EndDate, e.Timezone, e.Description, now())
|
|
return err
|
|
}
|
|
|
|
// --- Users ---
|
|
|
|
func (app *App) getUserDeptIDs(userID int) ([]int, error) {
|
|
rows, err := app.db.Query(
|
|
`SELECT department_id FROM user_departments WHERE user_id = ? ORDER BY department_id`, userID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var ids []int
|
|
for rows.Next() {
|
|
var id int
|
|
rows.Scan(&id)
|
|
ids = append(ids, id)
|
|
}
|
|
if ids == nil {
|
|
ids = []int{}
|
|
}
|
|
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 {
|
|
return err
|
|
}
|
|
for _, deptID := range deptIDs {
|
|
if _, err := app.db.Exec(
|
|
`INSERT INTO user_departments (user_id, department_id) VALUES (?, ?)`, userID, deptID,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (app *App) getUserByUsername(username string) (*User, string, error) {
|
|
var u User
|
|
var hash string
|
|
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)
|
|
if err == sql.ErrNoRows {
|
|
return nil, "", nil
|
|
}
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
u.DepartmentIDs, err = app.getUserDeptIDs(u.ID)
|
|
return &u, hash, err
|
|
}
|
|
|
|
func (app *App) getUserByID(id int) (*User, error) {
|
|
var u 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)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
u.DepartmentIDs, err = app.getUserDeptIDs(u.ID)
|
|
return &u, err
|
|
}
|
|
|
|
func (app *App) listUsers() ([]User, error) {
|
|
rows, err := app.db.Query(
|
|
`SELECT id, username, role, created_at FROM users ORDER BY username`,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var users []User
|
|
for rows.Next() {
|
|
var u User
|
|
if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
u.DepartmentIDs = []int{}
|
|
users = append(users, u)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range users {
|
|
users[i].DepartmentIDs, _ = app.getUserDeptIDs(users[i].ID)
|
|
}
|
|
return users, nil
|
|
}
|
|
|
|
func (app *App) createUser(username, hash, role string, deptIDs []int) (*User, error) {
|
|
res, err := app.db.Exec(
|
|
`INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)`,
|
|
username, hash, role,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
if err := app.setUserDeptIDs(int(id), deptIDs); err != nil {
|
|
return nil, err
|
|
}
|
|
return app.getUserByID(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 {
|
|
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)
|
|
return err
|
|
}
|
|
|
|
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 users`).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
// --- Tokens ---
|
|
|
|
const tokenChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
|
|
|
func generateToken() (string, error) {
|
|
b := make([]byte, 8)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", fmt.Errorf("read random: %w", err)
|
|
}
|
|
result := make([]byte, 8)
|
|
for i, v := range b {
|
|
result[i] = tokenChars[int(v)%len(tokenChars)]
|
|
}
|
|
return string(result), nil
|
|
}
|
|
|
|
func (app *App) generateUniqueToken() (string, error) {
|
|
for range 10 {
|
|
t, err := generateToken()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var count int
|
|
if err := app.db.QueryRow(`SELECT COUNT(*) FROM tickets WHERE code = ?`, t).Scan(&count); err != nil {
|
|
return "", fmt.Errorf("check token uniqueness: %w", err)
|
|
}
|
|
if count == 0 {
|
|
return t, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("failed to generate unique token")
|
|
}
|
|
|
|
// generateCodesForAll generates codes for every ticket that doesn't have one yet.
|
|
func (app *App) generateCodesForAll() (int, error) {
|
|
rows, err := app.db.Query(
|
|
`SELECT id FROM tickets WHERE code IS NULL AND deleted_at IS NULL`,
|
|
)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer rows.Close()
|
|
var ids []int
|
|
for rows.Next() {
|
|
var id int
|
|
if err := rows.Scan(&id); err != nil {
|
|
return 0, fmt.Errorf("scan ticket id: %w", err)
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
|
|
count := 0
|
|
for _, id := range ids {
|
|
t, err := app.generateUniqueToken()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
app.db.Exec(`UPDATE tickets SET code=?, updated_at=? WHERE id=?`, t, now(), id)
|
|
count++
|
|
}
|
|
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, phone, pronouns, note, created_at, updated_at, deleted_at`
|
|
|
|
func (app *App) listParticipants(search, since string) ([]Participant, error) {
|
|
var q string
|
|
var args []any
|
|
if since != "" {
|
|
q = `SELECT ` + participantCols + ` FROM participants WHERE updated_at > ? ORDER BY preferred_name, email`
|
|
args = append(args, since)
|
|
} else {
|
|
q = `SELECT ` + participantCols + ` FROM participants WHERE deleted_at IS NULL`
|
|
if search != "" {
|
|
q += ` AND (preferred_name LIKE ? OR email LIKE ?)`
|
|
s := "%" + search + "%"
|
|
args = append(args, s, s)
|
|
}
|
|
q += ` ORDER BY preferred_name, email`
|
|
}
|
|
return queryParticipants(app.db, q, args...)
|
|
}
|
|
|
|
func (app *App) getParticipant(id int) (*Participant, error) {
|
|
rows, err := queryParticipants(app.db,
|
|
`SELECT `+participantCols+` FROM participants WHERE id = ?`, id)
|
|
if err != nil || len(rows) == 0 {
|
|
return nil, err
|
|
}
|
|
return &rows[0], nil
|
|
}
|
|
|
|
func (app *App) getParticipantByEmail(email string) (*Participant, error) {
|
|
rows, err := queryParticipants(app.db,
|
|
`SELECT `+participantCols+` FROM participants WHERE LOWER(email) = LOWER(?) AND deleted_at IS NULL LIMIT 1`, email)
|
|
if err != nil || len(rows) == 0 {
|
|
return nil, err
|
|
}
|
|
return &rows[0], nil
|
|
}
|
|
|
|
func (app *App) createParticipant(p Participant) (*Participant, error) {
|
|
res, err := app.db.Exec(
|
|
`INSERT INTO participants (email, preferred_name, phone, pronouns, note, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
strings.ToLower(p.Email), p.PreferredName, p.Phone, p.Pronouns, p.Note, now(),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return app.getParticipant(int(id))
|
|
}
|
|
|
|
func (app *App) updateParticipant(p Participant) error {
|
|
_, err := app.db.Exec(
|
|
`UPDATE participants SET email=?, preferred_name=?, phone=?, pronouns=?, note=?, updated_at=?
|
|
WHERE id=? AND deleted_at IS NULL`,
|
|
strings.ToLower(p.Email), p.PreferredName, p.Phone, p.Pronouns, p.Note, now(), p.ID,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (app *App) deleteParticipant(id int) error {
|
|
_, err := app.db.Exec(
|
|
`UPDATE participants SET deleted_at=?, updated_at=? WHERE id=?`, now(), now(), id,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// mergeParticipants reassigns all tickets and volunteers from other → canonical, then soft-deletes other.
|
|
func (app *App) mergeParticipants(canonicalID, otherID int) error {
|
|
ts := now()
|
|
if _, err := app.db.Exec(
|
|
`UPDATE tickets SET participant_id=?, updated_at=? WHERE participant_id=? AND deleted_at IS NULL`,
|
|
canonicalID, ts, otherID,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
if _, err := app.db.Exec(
|
|
`UPDATE volunteers SET participant_id=?, updated_at=? WHERE participant_id=? AND deleted_at IS NULL`,
|
|
canonicalID, ts, otherID,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
_, err := app.db.Exec(
|
|
`UPDATE participants SET deleted_at=?, updated_at=? WHERE id=?`, ts, ts, otherID,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func queryParticipants(db *sql.DB, q string, args ...any) ([]Participant, error) {
|
|
rows, err := db.Query(q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var result []Participant
|
|
for rows.Next() {
|
|
var p Participant
|
|
if err := rows.Scan(
|
|
&p.ID, &p.Email, &p.PreferredName, &p.Phone, &p.Pronouns, &p.Note,
|
|
&p.CreatedAt, &p.UpdatedAt, &p.DeletedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, p)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// upsertParticipant finds a participant by email or creates one.
|
|
// Returns the participant and whether it was newly created.
|
|
func (app *App) upsertParticipant(email, name string) (*Participant, bool, error) {
|
|
p, err := app.getParticipantByEmail(email)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if p != nil {
|
|
return p, false, nil
|
|
}
|
|
created, err := app.createParticipant(Participant{
|
|
Email: email,
|
|
PreferredName: name,
|
|
})
|
|
return created, true, err
|
|
}
|
|
|
|
// --- Tickets ---
|
|
|
|
const ticketCols = `id, participant_id, name, ticket_type, source, external_id, order_id, code, checked_in_at, checked_in_by, created_at, updated_at, deleted_at`
|
|
|
|
func (app *App) listTickets(participantID *int, since string) ([]Ticket, error) {
|
|
q := `SELECT ` + ticketCols + ` FROM tickets WHERE 1=1`
|
|
var args []any
|
|
if since != "" {
|
|
q += ` AND updated_at > ?`
|
|
args = append(args, since)
|
|
} else {
|
|
q += ` AND deleted_at IS NULL`
|
|
}
|
|
if participantID != nil {
|
|
q += ` AND participant_id = ?`
|
|
args = append(args, *participantID)
|
|
}
|
|
q += ` ORDER BY created_at`
|
|
return queryTickets(app.db, q, args...)
|
|
}
|
|
|
|
func (app *App) getTicket(id int) (*Ticket, error) {
|
|
rows, err := queryTickets(app.db,
|
|
`SELECT `+ticketCols+` FROM tickets WHERE id = ?`, id)
|
|
if err != nil || len(rows) == 0 {
|
|
return nil, err
|
|
}
|
|
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)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
t.ParticipantID, t.Name, t.TicketType, t.Source, t.ExternalID, t.OrderID, t.Code, now(),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return app.getTicket(int(id))
|
|
}
|
|
|
|
func (app *App) checkInTicket(id, userID int) (*Ticket, error) {
|
|
t := now()
|
|
_, err := app.db.Exec(`
|
|
UPDATE tickets SET
|
|
checked_in_at = CASE WHEN checked_in_at IS NULL THEN ? ELSE checked_in_at END,
|
|
checked_in_by = CASE WHEN checked_in_at IS NULL THEN ? ELSE checked_in_by END,
|
|
updated_at = ?
|
|
WHERE id = ? AND deleted_at IS NULL`,
|
|
t, userID, t, id,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return app.getTicket(id)
|
|
}
|
|
|
|
func (app *App) deleteTicket(id int) error {
|
|
_, err := app.db.Exec(
|
|
`UPDATE tickets SET deleted_at=?, updated_at=? WHERE id=?`, now(), now(), id,
|
|
)
|
|
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 {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var result []Ticket
|
|
for rows.Next() {
|
|
var t Ticket
|
|
var participantID, checkedInBy sql.NullInt64
|
|
var code sql.NullString
|
|
if err := rows.Scan(
|
|
&t.ID, &participantID, &t.Name, &t.TicketType, &t.Source, &t.ExternalID, &t.OrderID,
|
|
&code, &t.CheckedInAt, &checkedInBy, &t.CreatedAt, &t.UpdatedAt, &t.DeletedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
if participantID.Valid {
|
|
id := int(participantID.Int64)
|
|
t.ParticipantID = &id
|
|
}
|
|
if checkedInBy.Valid {
|
|
id := int(checkedInBy.Int64)
|
|
t.CheckedInBy = &id
|
|
}
|
|
if code.Valid && code.String != "" {
|
|
t.Code = &code.String
|
|
}
|
|
result = append(result, t)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// ticketCounts returns total and checked-in ticket counts for participants page.
|
|
func (app *App) ticketCounts() (total, checkedIn int, err error) {
|
|
app.db.QueryRow(`SELECT COUNT(*) FROM tickets WHERE deleted_at IS NULL`).Scan(&total)
|
|
app.db.QueryRow(`SELECT COUNT(*) FROM tickets WHERE checked_in_at IS NOT NULL AND deleted_at IS NULL`).Scan(&checkedIn)
|
|
return
|
|
}
|
|
|
|
func (app *App) ticketTypes() ([]string, error) {
|
|
rows, err := app.db.Query(
|
|
`SELECT DISTINCT ticket_type FROM tickets 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()
|
|
}
|
|
|
|
// --- Departments ---
|
|
|
|
func (app *App) listDepartments(since string) ([]Department, error) {
|
|
var q string
|
|
var args []any
|
|
if since != "" {
|
|
q = `SELECT id, name, color, description, updated_at, deleted_at FROM departments WHERE updated_at > ? ORDER BY name`
|
|
args = append(args, since)
|
|
} else {
|
|
q = `SELECT id, name, color, description, updated_at, deleted_at FROM departments WHERE deleted_at IS NULL ORDER BY name`
|
|
}
|
|
return queryDepartments(app.db, q, args...)
|
|
}
|
|
|
|
func (app *App) getDepartment(id int) (*Department, error) {
|
|
rows, err := queryDepartments(app.db,
|
|
`SELECT id, name, color, description, updated_at, deleted_at FROM departments WHERE id = ?`, id)
|
|
if err != nil || len(rows) == 0 {
|
|
return nil, err
|
|
}
|
|
return &rows[0], nil
|
|
}
|
|
|
|
func (app *App) createDepartment(d Department) (*Department, error) {
|
|
res, err := app.db.Exec(
|
|
`INSERT INTO departments (name, color, description, updated_at) VALUES (?, ?, ?, ?)`,
|
|
d.Name, d.Color, d.Description, now(),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return app.getDepartment(int(id))
|
|
}
|
|
|
|
func (app *App) updateDepartment(d Department) error {
|
|
_, err := app.db.Exec(
|
|
`UPDATE departments SET name=?, color=?, description=?, updated_at=? WHERE id=? AND deleted_at IS NULL`,
|
|
d.Name, d.Color, d.Description, now(), d.ID,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (app *App) deleteDepartment(id int) error {
|
|
_, err := app.db.Exec(
|
|
`UPDATE departments SET deleted_at=?, updated_at=? WHERE id=?`, now(), now(), id,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func queryDepartments(db *sql.DB, q string, args ...any) ([]Department, error) {
|
|
rows, err := db.Query(q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var result []Department
|
|
for rows.Next() {
|
|
var d Department
|
|
rows.Scan(&d.ID, &d.Name, &d.Color, &d.Description, &d.UpdatedAt, &d.DeletedAt)
|
|
result = append(result, d)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// --- Volunteers ---
|
|
|
|
// 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),
|
|
v.ticket_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.checked_in, v.checked_in_at,
|
|
v.email_confirmed, v.confirmation_token, v.note,
|
|
v.created_at, v.updated_at, v.deleted_at`
|
|
const volunteerFrom = `FROM volunteers v LEFT JOIN participants p ON p.id = v.participant_id`
|
|
|
|
// volunteerCols is kept for backward-compat references that expect unqualified column names.
|
|
const volunteerCols = `id, attendee_id, name, preferred_name, ticket_name, email, phone, pronouns, department_id, is_lead, checked_in, checked_in_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 != "" {
|
|
q += ` AND v.updated_at > ?`
|
|
args = append(args, since)
|
|
} else {
|
|
q += ` AND v.deleted_at IS NULL`
|
|
}
|
|
if search != "" {
|
|
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, s, s)
|
|
}
|
|
if deptID != nil {
|
|
q += ` AND v.department_id = ?`
|
|
args = append(args, *deptID)
|
|
}
|
|
q += ` ORDER BY COALESCE(NULLIF(p.preferred_name,''), v.name)`
|
|
return queryVolunteers(app.db, q, args...)
|
|
}
|
|
|
|
func (app *App) getVolunteer(id int) (*Volunteer, error) {
|
|
rows, err := queryVolunteers(app.db,
|
|
`SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.id = ?`, id)
|
|
if err != nil || len(rows) == 0 {
|
|
return nil, err
|
|
}
|
|
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, attendee_id, name, preferred_name, ticket_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.TicketName, 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
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return app.getVolunteer(int(id))
|
|
}
|
|
|
|
func (app *App) updateVolunteer(v Volunteer) error {
|
|
_, err := app.db.Exec(
|
|
`UPDATE volunteers SET participant_id=?, attendee_id=?, name=?, preferred_name=?, ticket_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.TicketName, v.Email, v.Phone, v.Pronouns,
|
|
v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (app *App) deleteVolunteer(id int) error {
|
|
_, err := app.db.Exec(
|
|
`UPDATE volunteers SET deleted_at=?, updated_at=? WHERE id=?`, now(), now(), id,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// checkInVolunteer marks the volunteer as checked in and, if linked to an attendee,
|
|
// also increments the attendee's checked_in_count.
|
|
func (app *App) checkInVolunteer(id, userID int) (*Volunteer, error) {
|
|
t := now()
|
|
_, err := app.db.Exec(
|
|
`UPDATE volunteers SET checked_in=1, checked_in_at=?, updated_at=?
|
|
WHERE id=? AND deleted_at IS NULL AND checked_in=0`,
|
|
t, t, id,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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 queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
|
|
rows, err := db.Query(q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var result []Volunteer
|
|
for rows.Next() {
|
|
var v Volunteer
|
|
var participantID, attendeeID, deptID sql.NullInt64
|
|
var isLead, checkedIn, emailConfirmed int
|
|
var confirmationToken sql.NullString
|
|
if err := rows.Scan(
|
|
&v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName,
|
|
&v.Email, &v.Phone, &v.Pronouns, &deptID,
|
|
&isLead, &checkedIn, &v.CheckedInAt,
|
|
&emailConfirmed, &confirmationToken, &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
|
|
}
|
|
v.IsLead = isLead == 1
|
|
v.CheckedIn = checkedIn == 1
|
|
v.EmailConfirmed = emailConfirmed == 1
|
|
result = append(result, v)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
func (app *App) getVolunteerByEmail(email string) (*Volunteer, error) {
|
|
rows, err := queryVolunteers(app.db,
|
|
`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
|
|
}
|
|
return &rows[0], nil
|
|
}
|
|
|
|
func (app *App) getVolunteerByConfirmationToken(token string) (*Volunteer, error) {
|
|
rows, err := queryVolunteers(app.db,
|
|
`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) confirmVolunteerEmail(id int) error {
|
|
_, err := app.db.Exec(
|
|
`UPDATE volunteers SET email_confirmed = 1, confirmation_token = NULL, updated_at = ? WHERE id = ?`,
|
|
now(), id)
|
|
return err
|
|
}
|
|
|
|
// listConfirmedVolunteersNeedingCode returns confirmed volunteers whose participant
|
|
// has no ticket with a code yet.
|
|
func (app *App) listConfirmedVolunteersNeedingCode() ([]Volunteer, error) {
|
|
return queryVolunteers(app.db, `
|
|
SELECT `+volunteerSelect+` `+volunteerFrom+`
|
|
WHERE v.email_confirmed = 1 AND v.deleted_at IS NULL
|
|
AND v.participant_id IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM tickets t
|
|
WHERE t.participant_id = v.participant_id
|
|
AND t.code IS NOT NULL
|
|
AND t.deleted_at IS NULL
|
|
)`)
|
|
}
|
|
|
|
func generateConfirmationToken() (string, error) {
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", fmt.Errorf("read random: %w", err)
|
|
}
|
|
return fmt.Sprintf("%x", b), nil
|
|
}
|
|
|
|
// --- Shifts ---
|
|
|
|
func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) {
|
|
q := `SELECT ` + shiftCols + ` FROM shifts WHERE 1=1`
|
|
var args []any
|
|
if since != "" {
|
|
q += ` AND updated_at > ?`
|
|
args = append(args, since)
|
|
} else {
|
|
q += ` AND deleted_at IS NULL`
|
|
}
|
|
if deptID != nil {
|
|
q += ` AND department_id = ?`
|
|
args = append(args, *deptID)
|
|
}
|
|
if day != "" {
|
|
q += ` AND day = ?`
|
|
args = append(args, day)
|
|
}
|
|
q += ` ORDER BY day, position, start_time`
|
|
return queryShifts(app.db, q, args...)
|
|
}
|
|
|
|
func (app *App) getShift(id int) (*Shift, error) {
|
|
rows, err := queryShifts(app.db,
|
|
`SELECT `+shiftCols+` FROM shifts WHERE id = ?`, id)
|
|
if err != nil || len(rows) == 0 {
|
|
return nil, err
|
|
}
|
|
return &rows[0], nil
|
|
}
|
|
|
|
func (app *App) createShift(s Shift) (*Shift, error) {
|
|
res, err := app.db.Exec(
|
|
`INSERT INTO shifts (department_id, name, day, start_time, end_time, capacity, position, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
s.DepartmentID, s.Name, s.Day, s.StartTime, s.EndTime, s.Capacity, s.Position, now(),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return app.getShift(int(id))
|
|
}
|
|
|
|
func (app *App) updateShift(s Shift) error {
|
|
_, err := app.db.Exec(
|
|
`UPDATE shifts SET department_id=?, name=?, day=?, start_time=?, end_time=?, capacity=?, position=?, updated_at=?
|
|
WHERE id=? AND deleted_at IS NULL`,
|
|
s.DepartmentID, s.Name, s.Day, s.StartTime, s.EndTime, s.Capacity, s.Position, now(), s.ID,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (app *App) deleteShift(id int) error {
|
|
_, err := app.db.Exec(`UPDATE shifts SET deleted_at=?, updated_at=? WHERE id=?`, now(), now(), id)
|
|
return err
|
|
}
|
|
|
|
func queryShifts(db *sql.DB, q string, args ...any) ([]Shift, error) {
|
|
rows, err := db.Query(q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var result []Shift
|
|
for rows.Next() {
|
|
var s Shift
|
|
rows.Scan(&s.ID, &s.DepartmentID, &s.Name, &s.Day, &s.StartTime, &s.EndTime,
|
|
&s.Capacity, &s.Position, &s.UpdatedAt, &s.DeletedAt)
|
|
result = append(result, s)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// shiftAssignedCount returns the number of volunteers currently assigned to a shift.
|
|
func (app *App) shiftAssignedCount(shiftID int) (int, error) {
|
|
var count int
|
|
err := app.db.QueryRow(`SELECT COUNT(*) FROM volunteer_shifts WHERE shift_id = ? AND deleted_at IS NULL`, shiftID).Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
// checkShiftConflict returns any of the volunteer's existing shifts that overlap
|
|
// on the same day as the target shift.
|
|
func (app *App) checkShiftConflict(volunteerID, shiftID int) ([]Shift, error) {
|
|
target, err := app.getShift(shiftID)
|
|
if err != nil || target == nil {
|
|
return nil, err
|
|
}
|
|
existing, err := queryShifts(app.db, `
|
|
SELECT `+shiftColsS+`
|
|
FROM shifts s
|
|
JOIN volunteer_shifts vs ON vs.shift_id = s.id
|
|
WHERE vs.volunteer_id = ? AND vs.deleted_at IS NULL AND s.day = ? AND s.id != ? AND s.deleted_at IS NULL`,
|
|
volunteerID, target.Day, shiftID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var conflicts []Shift
|
|
for _, s := range existing {
|
|
if timesOverlap(s.StartTime, s.EndTime, target.StartTime, target.EndTime) {
|
|
conflicts = append(conflicts, s)
|
|
}
|
|
}
|
|
return conflicts, nil
|
|
}
|
|
|
|
// timesOverlap checks whether two time ranges (HH:MM) overlap,
|
|
// correctly handling ranges that span midnight (e.g. 22:00-02:00).
|
|
func timesOverlap(startA, endA, startB, endB string) bool {
|
|
// A shift spans midnight when its end time is <= its start time.
|
|
spansMidnightA := endA <= startA
|
|
spansMidnightB := endB <= startB
|
|
|
|
switch {
|
|
case !spansMidnightA && !spansMidnightB:
|
|
return startA < endB && startB < endA
|
|
case spansMidnightA && !spansMidnightB:
|
|
return startB < endA || startB >= startA
|
|
case !spansMidnightA && spansMidnightB:
|
|
return startA < endB || startA >= startB
|
|
default:
|
|
// Both span midnight — they always overlap
|
|
return true
|
|
}
|
|
}
|
|
|
|
// reorderShifts updates the position field for each given shift.
|
|
func (app *App) reorderShifts(positions []struct{ ID, Position int }) error {
|
|
for _, p := range positions {
|
|
if _, err := app.db.Exec(
|
|
`UPDATE shifts SET position=?, updated_at=? WHERE id=?`, p.Position, now(), p.ID,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- Volunteer Shifts ---
|
|
|
|
func (app *App) assignShift(volunteerID, shiftID int) error {
|
|
_, err := app.db.Exec(
|
|
`INSERT INTO volunteer_shifts (volunteer_id, shift_id, updated_at) VALUES (?, ?, ?)
|
|
ON CONFLICT(volunteer_id, shift_id) DO UPDATE SET confirmed=1, deleted_at=NULL, updated_at=excluded.updated_at`,
|
|
volunteerID, shiftID, now(),
|
|
)
|
|
return err
|
|
}
|
|
|
|
// assignShiftWithCapacity atomically checks capacity and assigns.
|
|
// Returns errShiftFull if the shift is at capacity.
|
|
func (app *App) assignShiftWithCapacity(volunteerID, shiftID, capacity int) error {
|
|
tx, err := app.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if capacity > 0 {
|
|
var count int
|
|
if err := tx.QueryRow(`SELECT COUNT(*) FROM volunteer_shifts WHERE shift_id = ? AND deleted_at IS NULL`, shiftID).Scan(&count); err != nil {
|
|
return err
|
|
}
|
|
if count >= capacity {
|
|
return errShiftFull
|
|
}
|
|
}
|
|
|
|
if _, err := tx.Exec(
|
|
`INSERT INTO volunteer_shifts (volunteer_id, shift_id, updated_at) VALUES (?, ?, ?)
|
|
ON CONFLICT(volunteer_id, shift_id) DO UPDATE SET confirmed=1, deleted_at=NULL, updated_at=excluded.updated_at`,
|
|
volunteerID, shiftID, now(),
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
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=?`,
|
|
now(), now(), volunteerID, shiftID,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (app *App) listVolunteerShifts(since string) ([]VolunteerShift, error) {
|
|
var q string
|
|
var args []any
|
|
if since != "" {
|
|
q = `SELECT volunteer_id, shift_id, confirmed, updated_at, deleted_at FROM volunteer_shifts WHERE updated_at > ?`
|
|
args = append(args, since)
|
|
} else {
|
|
q = `SELECT volunteer_id, shift_id, confirmed, updated_at, deleted_at FROM volunteer_shifts WHERE deleted_at IS NULL`
|
|
}
|
|
rows, err := app.db.Query(q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var result []VolunteerShift
|
|
for rows.Next() {
|
|
var vs VolunteerShift
|
|
var confirmed int
|
|
rows.Scan(&vs.VolunteerID, &vs.ShiftID, &confirmed, &vs.UpdatedAt, &vs.DeletedAt)
|
|
vs.Confirmed = confirmed == 1
|
|
result = append(result, vs)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// listShiftsForVolunteer returns all shifts the volunteer is assigned to.
|
|
func (app *App) listShiftsForVolunteer(volunteerID int) ([]Shift, error) {
|
|
return queryShifts(app.db, `
|
|
SELECT `+shiftColsS+`
|
|
FROM shifts s
|
|
JOIN volunteer_shifts vs ON vs.shift_id = s.id
|
|
WHERE vs.volunteer_id = ? AND vs.deleted_at IS NULL AND s.deleted_at IS NULL
|
|
ORDER BY s.day, s.position, s.start_time`, volunteerID)
|
|
}
|
|
|
|
// listOpenShiftsForDept returns shifts in a department that still have capacity.
|
|
func (app *App) listOpenShiftsForDept(deptID int) ([]Shift, error) {
|
|
return queryShifts(app.db, `
|
|
SELECT `+shiftCols+`
|
|
FROM shifts s
|
|
WHERE s.department_id = ? AND s.deleted_at IS NULL
|
|
AND (s.capacity = 0 OR (
|
|
SELECT COUNT(*) FROM volunteer_shifts vs WHERE vs.shift_id = s.id AND vs.deleted_at IS NULL
|
|
) < s.capacity)
|
|
ORDER BY s.day, s.position, s.start_time`, deptID)
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
func now() string {
|
|
return time.Now().UTC().Format("2006-01-02T15:04:05Z")
|
|
}
|
|
|
|
func boolInt(b bool) int {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|