2026-03-03 11:27:07 -06:00
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 departments (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
name TEXT NOT NULL UNIQUE ,
color TEXT NOT NULL DEFAULT ' # 6366 f1 ' ,
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 ,
2026-03-10 14:08:00 -05:00
checked_in_by INTEGER REFERENCES participants ( id ) ,
2026-03-03 11:27:07 -06:00
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 (
2026-03-06 07:11:19 -06:00
id INTEGER PRIMARY KEY AUTOINCREMENT ,
participant_id INTEGER NOT NULL REFERENCES participants ( id ) ON DELETE CASCADE ,
department_id INTEGER REFERENCES departments ( id ) ON DELETE SET NULL ,
is_lead INTEGER NOT NULL DEFAULT 0 ,
ready INTEGER NOT NULL DEFAULT 0 ,
ready_at TEXT ,
confirmed INTEGER NOT NULL DEFAULT 0 ,
confirmed_at TEXT ,
kiosk_code 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
2026-03-03 11:27:07 -06:00
) ;
2026-03-06 07:11:19 -06:00
CREATE UNIQUE INDEX IF NOT EXISTS idx_volunteers_kiosk_code
ON volunteers ( kiosk_code ) WHERE kiosk_code IS NOT NULL ;
2026-03-03 11:27:07 -06:00
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 ' ) ) ,
2026-03-06 07:11:19 -06:00
deleted_at TEXT ,
2026-03-03 11:27:07 -06:00
PRIMARY KEY ( volunteer_id , shift_id )
) ;
2026-03-04 10:53:42 -06:00
CREATE TABLE IF NOT EXISTS participants (
2026-03-06 07:11:19 -06:00
id INTEGER PRIMARY KEY AUTOINCREMENT ,
email TEXT NOT NULL DEFAULT ' ' ,
preferred_name TEXT NOT NULL DEFAULT ' ' ,
ticket_name TEXT NOT NULL DEFAULT ' ' ,
phone TEXT NOT NULL DEFAULT ' ' ,
pronouns TEXT NOT NULL DEFAULT ' ' ,
note TEXT NOT NULL DEFAULT ' ' ,
email_confirmed INTEGER NOT NULL DEFAULT 0 ,
confirmation_token TEXT ,
created_at TEXT NOT NULL DEFAULT ( datetime ( ' now ' ) ) ,
updated_at TEXT NOT NULL DEFAULT ( datetime ( ' now ' ) ) ,
deleted_at TEXT
2026-03-04 10:53:42 -06:00
) ;
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 ,
2026-03-10 14:08:00 -05:00
checked_in_by INTEGER REFERENCES participants ( id ) ,
2026-03-04 10:53:42 -06:00
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 ;
2026-03-10 14:08:00 -05:00
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 )
) ;
2026-03-03 11:27:07 -06:00
` )
if err != nil {
return err
}
2026-03-10 14:08:00 -05:00
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
}
// Collect all users first (single connection — can't query and exec concurrently).
type oldUser struct {
id int
name string
hash string
role string
}
rows , err := db . Query ( ` SELECT id, username, password_hash, role FROM users ` )
if err != nil {
return nil
}
var users [ ] oldUser
for rows . Next ( ) {
var u oldUser
if err := rows . Scan ( & u . id , & u . name , & u . hash , & u . role ) ; err != nil {
continue
}
if u . role == "ticketing" {
u . role = "admin"
}
users = append ( users , u )
}
rows . Close ( )
// Collect department assignments.
type deptAssign struct {
userID int
deptID int
}
deptRows , err := db . Query ( ` SELECT user_id, department_id FROM user_departments ` )
var deptAssigns [ ] deptAssign
if err == nil {
for deptRows . Next ( ) {
var da deptAssign
deptRows . Scan ( & da . userID , & da . deptID )
deptAssigns = append ( deptAssigns , da )
}
deptRows . Close ( )
}
// Now insert with the connection free.
for _ , u := range users {
res , err := db . Exec (
` INSERT INTO participants (preferred_name, password_hash, login_enabled, updated_at) VALUES (?, ?, 1, ?) ` ,
u . name , u . hash , now ( ) ,
)
if err != nil {
continue
}
pid , _ := res . LastInsertId ( )
db . Exec ( ` INSERT OR IGNORE INTO participant_roles (participant_id, role) VALUES (?, ?) ` , pid , u . role )
for _ , da := range deptAssigns {
if da . userID == u . id {
db . Exec ( ` INSERT OR IGNORE INTO participant_departments (participant_id, department_id) VALUES (?, ?) ` , pid , da . deptID )
}
}
}
db . Exec ( ` DROP TABLE IF EXISTS user_departments ` )
db . Exec ( ` DROP TABLE IF EXISTS users ` )
2026-03-03 11:27:07 -06:00
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 `
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 {
2026-03-10 14:08:00 -05:00
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" `
2026-03-03 11:27:07 -06:00
}
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 {
2026-03-06 07:11:19 -06:00
ID int ` json:"id" `
ParticipantID int ` json:"participant_id" `
DepartmentID * int ` json:"department_id,omitempty" `
IsLead bool ` json:"is_lead" `
Ready bool ` json:"ready" `
ReadyAt * string ` json:"ready_at,omitempty" `
Confirmed bool ` json:"confirmed" `
ConfirmedAt * string ` json:"confirmed_at,omitempty" `
KioskCode * string ` json:"kiosk_code,omitempty" `
Note string ` json:"note" `
CreatedAt string ` json:"created_at" `
UpdatedAt string ` json:"updated_at" `
DeletedAt * string ` json:"deleted_at,omitempty" `
// Populated via JOIN from participant, not stored on volunteers table:
Name string ` json:"name" `
Email string ` json:"email" `
Phone string ` json:"phone" `
Pronouns string ` json:"pronouns" `
EmailConfirmed bool ` json:"email_confirmed" `
}
type Participant struct {
2026-03-03 17:59:35 -06:00
ID int ` json:"id" `
Email string ` json:"email" `
2026-03-06 07:11:19 -06:00
PreferredName string ` json:"preferred_name" `
TicketName string ` json:"ticket_name" `
2026-03-03 17:59:35 -06:00
Phone string ` json:"phone" `
Pronouns string ` json:"pronouns" `
2026-03-06 07:11:19 -06:00
Note string ` json:"note" `
2026-03-03 17:59:35 -06:00
EmailConfirmed bool ` json:"email_confirmed" `
ConfirmationToken * string ` json:"-" `
CreatedAt string ` json:"created_at" `
UpdatedAt string ` json:"updated_at" `
DeletedAt * string ` json:"deleted_at,omitempty" `
2026-03-03 11:27:07 -06:00
}
2026-03-04 10:53:42 -06:00
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" `
}
2026-03-03 11:27:07 -06:00
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 {
2026-03-03 12:50:24 -06:00
VolunteerID int ` json:"volunteer_id" `
ShiftID int ` json:"shift_id" `
Confirmed bool ` json:"confirmed" `
UpdatedAt string ` json:"updated_at" `
DeletedAt * string ` json:"deleted_at" `
2026-03-03 11:27:07 -06:00
}
// --- 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
}
2026-03-10 14:08:00 -05:00
// --- Staff (participants with login_enabled) ---
func ( app * App ) getParticipantRoles ( participantID int ) ( [ ] string , 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
}
2026-03-03 11:27:07 -06:00
2026-03-10 14:08:00 -05:00
func ( app * App ) getUserDeptIDs ( participantID int ) ( [ ] int , error ) {
2026-03-03 11:27:07 -06:00
rows , err := app . db . Query (
2026-03-10 14:08:00 -05:00
` SELECT department_id FROM participant_departments WHERE participant_id = ? ORDER BY department_id ` , participantID ,
2026-03-03 11:27:07 -06:00
)
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 ( )
}
2026-03-10 14:08:00 -05:00
func ( app * App ) setUserDeptIDs ( participantID int , deptIDs [ ] int ) error {
if _ , err := app . db . Exec ( ` DELETE FROM participant_departments WHERE participant_id = ? ` , participantID ) ; err != nil {
2026-03-03 11:27:07 -06:00
return err
}
for _ , deptID := range deptIDs {
if _ , err := app . db . Exec (
2026-03-10 14:08:00 -05:00
` INSERT INTO participant_departments (participant_id, department_id) VALUES (?, ?) ` , participantID , deptID ,
2026-03-03 11:27:07 -06:00
) ; err != nil {
return err
}
}
return nil
}
2026-03-10 14:08:00 -05:00
func ( app * App ) getLoginParticipant ( email string ) ( * User , string , error ) {
var s User
var hash sql . NullString
2026-03-03 11:27:07 -06:00
err := app . db . QueryRow (
2026-03-10 14:08:00 -05:00
` 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 )
2026-03-03 11:27:07 -06:00
if err == sql . ErrNoRows {
return nil , "" , nil
}
if err != nil {
return nil , "" , err
}
2026-03-10 14:08:00 -05:00
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
2026-03-03 11:27:07 -06:00
}
2026-03-10 14:08:00 -05:00
func ( app * App ) getUser ( id int ) ( * User , error ) {
var s User
2026-03-03 11:27:07 -06:00
err := app . db . QueryRow (
2026-03-10 14:08:00 -05:00
` 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 )
2026-03-03 11:27:07 -06:00
if err == sql . ErrNoRows {
return nil , nil
}
if err != nil {
return nil , err
}
2026-03-10 14:08:00 -05:00
s . Roles , _ = app . getParticipantRoles ( s . ID )
s . DepartmentIDs , _ = app . getUserDeptIDs ( s . ID )
return & s , nil
2026-03-03 11:27:07 -06:00
}
func ( app * App ) listUsers ( ) ( [ ] User , error ) {
rows , err := app . db . Query (
2026-03-10 14:08:00 -05:00
` SELECT id , email , preferred_name , created_at
FROM participants WHERE login_enabled = 1 AND deleted_at IS NULL ORDER BY preferred_name , email ` ,
2026-03-03 11:27:07 -06:00
)
if err != nil {
return nil , err
}
defer rows . Close ( )
2026-03-10 14:08:00 -05:00
var staff [ ] User
2026-03-03 11:27:07 -06:00
for rows . Next ( ) {
2026-03-10 14:08:00 -05:00
var s User
if err := rows . Scan ( & s . ID , & s . Email , & s . PreferredName , & s . CreatedAt ) ; err != nil {
2026-03-03 11:27:07 -06:00
return nil , err
}
2026-03-10 14:08:00 -05:00
s . Roles = [ ] string { }
s . DepartmentIDs = [ ] int { }
staff = append ( staff , s )
2026-03-03 11:27:07 -06:00
}
if err := rows . Err ( ) ; err != nil {
return nil , err
}
2026-03-10 14:08:00 -05:00
for i := range staff {
staff [ i ] . Roles , _ = app . getParticipantRoles ( staff [ i ] . ID )
staff [ i ] . DepartmentIDs , _ = app . getUserDeptIDs ( staff [ i ] . ID )
2026-03-03 11:27:07 -06:00
}
2026-03-10 14:08:00 -05:00
return staff , nil
2026-03-03 11:27:07 -06:00
}
2026-03-10 14:08:00 -05:00
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.
2026-03-03 11:27:07 -06:00
res , err := app . db . Exec (
2026-03-10 14:08:00 -05:00
` INSERT INTO participants ( email , preferred_name , password_hash , login_enabled , updated_at )
VALUES ( ? , ? , ? , 1 , ? ) ` ,
strings . ToLower ( email ) , preferredName , hash , now ( ) ,
2026-03-03 11:27:07 -06:00
)
if err != nil {
return nil , err
}
id , _ := res . LastInsertId ( )
2026-03-10 14:08:00 -05:00
if err := app . setParticipantRoles ( int ( id ) , roles ) ; err != nil {
return nil , err
}
2026-03-03 11:27:07 -06:00
if err := app . setUserDeptIDs ( int ( id ) , deptIDs ) ; err != nil {
return nil , err
}
2026-03-10 14:08:00 -05:00
return app . getUser ( int ( id ) )
2026-03-03 11:27:07 -06:00
}
2026-03-10 14:08:00 -05:00
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 {
2026-03-03 11:27:07 -06:00
return err
}
return app . setUserDeptIDs ( id , deptIDs )
}
func ( app * App ) updateUserPassword ( id int , hash string ) error {
2026-03-10 14:08:00 -05:00
_ , err := app . db . Exec (
` UPDATE participants SET password_hash = ?, updated_at = ? WHERE id = ? AND login_enabled = 1 ` , hash , now ( ) , id ,
)
2026-03-03 11:27:07 -06:00
return err
}
2026-03-10 14:08:00 -05:00
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 ( )
2026-03-03 11:27:07 -06:00
}
func ( app * App ) countUsers ( ) ( int , error ) {
var n int
2026-03-10 14:08:00 -05:00
err := app . db . QueryRow ( ` SELECT COUNT(*) FROM participants WHERE login_enabled = 1 AND deleted_at IS NULL ` ) . Scan ( & n )
2026-03-03 11:27:07 -06:00
return n , err
}
// --- Tokens ---
const tokenChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
2026-03-03 12:50:24 -06:00
func generateToken ( ) ( string , error ) {
2026-03-03 11:27:07 -06:00
b := make ( [ ] byte , 8 )
2026-03-03 12:50:24 -06:00
if _ , err := rand . Read ( b ) ; err != nil {
return "" , fmt . Errorf ( "read random: %w" , err )
}
2026-03-03 11:27:07 -06:00
result := make ( [ ] byte , 8 )
for i , v := range b {
result [ i ] = tokenChars [ int ( v ) % len ( tokenChars ) ]
}
2026-03-03 12:50:24 -06:00
return string ( result ) , nil
2026-03-03 11:27:07 -06:00
}
func ( app * App ) generateUniqueToken ( ) ( string , error ) {
for range 10 {
2026-03-03 12:50:24 -06:00
t , err := generateToken ( )
if err != nil {
return "" , err
}
2026-03-03 11:27:07 -06:00
var count int
2026-03-04 10:53:42 -06:00
if err := app . db . QueryRow ( ` SELECT COUNT(*) FROM tickets WHERE code = ? ` , t ) . Scan ( & count ) ; err != nil {
2026-03-03 12:50:24 -06:00
return "" , fmt . Errorf ( "check token uniqueness: %w" , err )
}
2026-03-03 11:27:07 -06:00
if count == 0 {
return t , nil
}
}
return "" , fmt . Errorf ( "failed to generate unique token" )
}
2026-03-04 10:53:42 -06:00
// generateCodesForAll generates codes for every ticket that doesn't have one yet.
func ( app * App ) generateCodesForAll ( ) ( int , error ) {
2026-03-03 11:27:07 -06:00
rows , err := app . db . Query (
2026-03-04 10:53:42 -06:00
` SELECT id FROM tickets WHERE code IS NULL AND deleted_at IS NULL ` ,
2026-03-03 11:27:07 -06:00
)
if err != nil {
return 0 , err
}
2026-03-03 12:50:24 -06:00
defer rows . Close ( )
2026-03-03 11:27:07 -06:00
var ids [ ] int
for rows . Next ( ) {
var id int
2026-03-03 12:50:24 -06:00
if err := rows . Scan ( & id ) ; err != nil {
2026-03-04 10:53:42 -06:00
return 0 , fmt . Errorf ( "scan ticket id: %w" , err )
2026-03-03 12:50:24 -06:00
}
2026-03-03 11:27:07 -06:00
ids = append ( ids , id )
}
count := 0
for _ , id := range ids {
t , err := app . generateUniqueToken ( )
if err != nil {
continue
}
2026-03-04 10:53:42 -06:00
app . db . Exec ( ` UPDATE tickets SET code=?, updated_at=? WHERE id=? ` , t , now ( ) , id )
2026-03-03 11:27:07 -06:00
count ++
}
return count , nil
}
2026-03-04 10:53:42 -06:00
// incrementPartySize is kept for backward compatibility with existing tests.
2026-03-03 11:27:07 -06:00
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
}
2026-03-04 10:53:42 -06:00
// --- Participants ---
2026-03-06 07:11:19 -06:00
const participantCols = ` id, email, preferred_name, ticket_name, phone, pronouns, note, email_confirmed, confirmation_token, created_at, updated_at, deleted_at `
2026-03-04 10:53:42 -06:00
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 (
2026-03-06 07:11:19 -06:00
` INSERT INTO participants (email, preferred_name, ticket_name, phone, pronouns, note, email_confirmed, confirmation_token, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ` ,
strings . ToLower ( p . Email ) , p . PreferredName , p . TicketName , p . Phone , p . Pronouns , p . Note , boolInt ( p . EmailConfirmed ) , p . ConfirmationToken , now ( ) ,
2026-03-04 10:53:42 -06:00
)
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 (
2026-03-05 17:15:41 -06:00
` UPDATE participants SET email = ? , preferred_name = ? , ticket_name = ? , phone = ? , pronouns = ? , note = ? , updated_at = ?
2026-03-04 10:53:42 -06:00
WHERE id = ? AND deleted_at IS NULL ` ,
2026-03-05 17:15:41 -06:00
strings . ToLower ( p . Email ) , p . PreferredName , p . TicketName , p . Phone , p . Pronouns , p . Note , now ( ) , p . ID ,
2026-03-04 10:53:42 -06:00
)
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
}
2026-03-10 15:14:36 -05:00
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 )
2026-03-04 10:53:42 -06:00
_ , 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
2026-03-06 07:11:19 -06:00
var emailConfirmed int
var confirmationToken sql . NullString
2026-03-04 10:53:42 -06:00
if err := rows . Scan (
2026-03-05 17:15:41 -06:00
& p . ID , & p . Email , & p . PreferredName , & p . TicketName , & p . Phone , & p . Pronouns , & p . Note ,
2026-03-06 07:11:19 -06:00
& emailConfirmed , & confirmationToken ,
2026-03-04 10:53:42 -06:00
& p . CreatedAt , & p . UpdatedAt , & p . DeletedAt ,
) ; err != nil {
return nil , err
}
2026-03-06 07:11:19 -06:00
p . EmailConfirmed = emailConfirmed == 1
if confirmationToken . Valid {
p . ConfirmationToken = & confirmationToken . String
}
2026-03-04 10:53:42 -06:00
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 ( )
}
2026-03-03 11:27:07 -06:00
// --- 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 ---
2026-03-06 07:11:19 -06:00
const volunteerSelect = ` v . id , v . participant_id ,
p . preferred_name , p . email , p . phone , p . pronouns ,
2026-03-05 17:34:50 -06:00
v . department_id , v . is_lead , v . ready , v . ready_at ,
2026-03-05 15:52:40 -06:00
v . confirmed , v . confirmed_at ,
2026-03-06 07:11:19 -06:00
p . email_confirmed , v . kiosk_code , v . note ,
2026-03-04 10:53:42 -06:00
v . created_at , v . updated_at , v . deleted_at `
2026-03-06 07:11:19 -06:00
const volunteerFrom = ` FROM volunteers v INNER JOIN participants p ON p.id = v.participant_id `
2026-03-03 11:27:07 -06:00
2026-03-10 15:14:36 -05:00
func ( app * App ) listVolunteers ( search string , deptIDs [ ] int , since string ) ( [ ] Volunteer , error ) {
2026-03-04 10:53:42 -06:00
q := ` SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1 `
2026-03-03 11:27:07 -06:00
var args [ ] any
if since != "" {
2026-03-04 10:53:42 -06:00
q += ` AND v.updated_at > ? `
2026-03-03 11:27:07 -06:00
args = append ( args , since )
} else {
2026-03-04 10:53:42 -06:00
q += ` AND v.deleted_at IS NULL `
2026-03-03 11:27:07 -06:00
}
if search != "" {
2026-03-06 07:11:19 -06:00
q += ` AND (p.preferred_name LIKE ? OR p.email LIKE ?) `
2026-03-03 11:27:07 -06:00
s := "%" + search + "%"
2026-03-06 07:11:19 -06:00
args = append ( args , s , s )
2026-03-03 11:27:07 -06:00
}
2026-03-10 15:14:36 -05:00
if len ( deptIDs ) == 1 {
2026-03-04 10:53:42 -06:00
q += ` AND v.department_id = ? `
2026-03-10 15:14:36 -05:00
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 )
}
2026-03-03 11:27:07 -06:00
}
2026-03-06 07:11:19 -06:00
q += ` ORDER BY p.preferred_name `
2026-03-03 11:27:07 -06:00
return queryVolunteers ( app . db , q , args ... )
}
func ( app * App ) getVolunteer ( id int ) ( * Volunteer , error ) {
rows , err := queryVolunteers ( app . db ,
2026-03-04 10:53:42 -06:00
` SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE v.id = ? ` , id )
2026-03-03 11:27:07 -06:00
if err != nil || len ( rows ) == 0 {
return nil , err
}
return & rows [ 0 ] , nil
}
2026-03-04 10:53:42 -06:00
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 )
2026-03-03 11:27:07 -06:00
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 (
2026-03-06 07:11:19 -06:00
` INSERT INTO volunteers ( participant_id , department_id , is_lead , note , updated_at )
VALUES ( ? , ? , ? , ? , ? ) ` ,
v . ParticipantID , v . DepartmentID , boolInt ( v . IsLead ) , v . Note , now ( ) ,
2026-03-03 11:27:07 -06:00
)
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 (
2026-03-06 07:11:19 -06:00
` UPDATE volunteers SET department_id = ? , is_lead = ? , note = ? , updated_at = ?
2026-03-03 11:27:07 -06:00
WHERE id = ? AND deleted_at IS NULL ` ,
2026-03-03 17:59:35 -06:00
v . DepartmentID , boolInt ( v . IsLead ) , v . Note , now ( ) , v . ID ,
2026-03-03 11:27:07 -06:00
)
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
}
2026-03-05 17:34:50 -06:00
func ( app * App ) markVolunteerReady ( id , userID int ) ( * Volunteer , error ) {
2026-03-03 11:27:07 -06:00
t := now ( )
_ , err := app . db . Exec (
2026-03-05 17:34:50 -06:00
` UPDATE volunteers SET ready = 1 , ready_at = ? , updated_at = ?
WHERE id = ? AND deleted_at IS NULL AND ready = 0 ` ,
2026-03-03 11:27:07 -06:00
t , t , id ,
)
if err != nil {
return nil , err
}
2026-03-06 07:11:19 -06:00
return app . getVolunteer ( id )
2026-03-03 11:27:07 -06:00
}
2026-03-05 15:52:40 -06:00
func ( app * App ) confirmVolunteer ( id int ) ( * Volunteer , error ) {
t := now ( )
_ , err := app . db . Exec (
` UPDATE volunteers SET confirmed = 1 , confirmed_at = ? , updated_at = ?
WHERE id = ? AND deleted_at IS NULL AND confirmed = 0 ` ,
t , t , id ,
)
if err != nil {
return nil , err
}
return app . getVolunteer ( id )
}
2026-03-03 11:27:07 -06:00
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
2026-03-06 07:11:19 -06:00
var deptID sql . NullInt64
2026-03-05 17:34:50 -06:00
var isLead , ready , confirmed , emailConfirmed int
2026-03-06 07:11:19 -06:00
var confirmedAt , kioskCode sql . NullString
2026-03-03 11:27:07 -06:00
if err := rows . Scan (
2026-03-06 07:11:19 -06:00
& v . ID , & v . ParticipantID ,
& v . Name , & v . Email , & v . Phone , & v . Pronouns ,
& deptID , & isLead , & ready , & v . ReadyAt ,
2026-03-05 15:52:40 -06:00
& confirmed , & confirmedAt ,
2026-03-06 07:11:19 -06:00
& emailConfirmed , & kioskCode , & v . Note ,
2026-03-03 11:27:07 -06:00
& v . CreatedAt , & v . UpdatedAt , & v . DeletedAt ,
) ; err != nil {
return nil , err
}
if deptID . Valid {
id := int ( deptID . Int64 )
v . DepartmentID = & id
}
2026-03-05 15:52:40 -06:00
if confirmedAt . Valid {
v . ConfirmedAt = & confirmedAt . String
}
2026-03-05 16:31:08 -06:00
if kioskCode . Valid {
v . KioskCode = & kioskCode . String
}
2026-03-03 11:27:07 -06:00
v . IsLead = isLead == 1
2026-03-05 17:34:50 -06:00
v . Ready = ready == 1
2026-03-05 15:52:40 -06:00
v . Confirmed = confirmed == 1
2026-03-03 17:59:35 -06:00
v . EmailConfirmed = emailConfirmed == 1
2026-03-03 11:27:07 -06:00
result = append ( result , v )
}
return result , rows . Err ( )
}
2026-03-03 17:59:35 -06:00
func ( app * App ) getVolunteerByEmail ( email string ) ( * Volunteer , error ) {
rows , err := queryVolunteers ( app . db ,
2026-03-06 07:11:19 -06:00
` SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE LOWER(p.email) = LOWER(?) AND v.deleted_at IS NULL LIMIT 1 ` , email )
2026-03-03 17:59:35 -06:00
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 ,
2026-03-06 07:11:19 -06:00
` SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE p.confirmation_token = ? AND v.deleted_at IS NULL LIMIT 1 ` , token )
2026-03-03 17:59:35 -06:00
if err != nil || len ( rows ) == 0 {
return nil , err
}
return & rows [ 0 ] , nil
}
2026-03-06 07:11:19 -06:00
func ( app * App ) confirmParticipantEmail ( participantID int ) error {
_ , err := app . db . Exec (
` UPDATE participants SET email_confirmed = 1, confirmation_token = NULL, updated_at = ? WHERE id = ? ` ,
now ( ) , participantID )
return err
}
func ( app * App ) setParticipantConfirmationToken ( participantID int , token string ) error {
2026-03-03 17:59:35 -06:00
_ , err := app . db . Exec (
2026-03-06 07:11:19 -06:00
` UPDATE participants SET confirmation_token = ?, updated_at = ? WHERE id = ? ` ,
token , now ( ) , participantID )
2026-03-03 17:59:35 -06:00
return err
}
2026-03-05 16:31:08 -06:00
func ( app * App ) getVolunteerByKioskCode ( code string ) ( * Volunteer , error ) {
rows , err := queryVolunteers ( app . db ,
` SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE v.kiosk_code = ? AND v.deleted_at IS NULL LIMIT 1 ` , code )
if err != nil || len ( rows ) == 0 {
return nil , err
}
return & rows [ 0 ] , nil
}
func ( app * App ) assignKioskCode ( id int , code string ) error {
_ , err := app . db . Exec (
` UPDATE volunteers SET kiosk_code=?, updated_at=? WHERE id=? ` , code , now ( ) , id )
return err
}
func ( app * App ) listVolunteersNeedingKioskCode ( ) ( [ ] Volunteer , error ) {
2026-03-03 17:59:35 -06:00
return queryVolunteers ( app . db , `
2026-03-04 10:53:42 -06:00
SELECT ` +volunteerSelect+ ` ` +volunteerFrom+ `
2026-03-06 07:11:19 -06:00
WHERE p . email_confirmed = 1 AND v . kiosk_code IS NULL AND v . deleted_at IS NULL ` )
2026-03-05 16:31:08 -06:00
}
func ( app * App ) generateVolunteerKioskCode ( ) ( string , error ) {
for range 10 {
t , err := generateToken ( )
if err != nil {
return "" , err
}
var count int
if err := app . db . QueryRow ( ` SELECT COUNT(*) FROM volunteers WHERE kiosk_code = ? ` , t ) . Scan ( & count ) ; err != nil {
return "" , fmt . Errorf ( "check kiosk code uniqueness: %w" , err )
}
if count == 0 {
return t , nil
}
}
return "" , fmt . Errorf ( "failed to generate unique kiosk code" )
2026-03-03 17:59:35 -06:00
}
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
}
2026-03-03 11:27:07 -06:00
// --- Shifts ---
2026-03-10 15:14:36 -05:00
func ( app * App ) listShifts ( deptIDs [ ] int , day , since string ) ( [ ] Shift , error ) {
2026-03-03 11:27:07 -06:00
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 `
}
2026-03-10 15:14:36 -05:00
if len ( deptIDs ) == 1 {
2026-03-03 11:27:07 -06:00
q += ` AND department_id = ? `
2026-03-10 15:14:36 -05:00
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 )
}
2026-03-03 11:27:07 -06:00
}
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
2026-03-03 12:50:24 -06:00
err := app . db . QueryRow ( ` SELECT COUNT(*) FROM volunteer_shifts WHERE shift_id = ? AND deleted_at IS NULL ` , shiftID ) . Scan ( & count )
2026-03-03 11:27:07 -06:00
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
2026-03-03 12:50:24 -06:00
WHERE vs . volunteer_id = ? AND vs . deleted_at IS NULL AND s . day = ? AND s . id != ? AND s . deleted_at IS NULL ` ,
2026-03-03 11:27:07 -06:00
volunteerID , target . Day , shiftID )
if err != nil {
return nil , err
}
var conflicts [ ] Shift
for _ , s := range existing {
2026-03-03 12:50:24 -06:00
if timesOverlap ( s . StartTime , s . EndTime , target . StartTime , target . EndTime ) {
2026-03-03 11:27:07 -06:00
conflicts = append ( conflicts , s )
}
}
return conflicts , nil
}
2026-03-03 12:50:24 -06:00
// 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
}
}
2026-03-03 11:27:07 -06:00
// 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 ( ? , ? , ? )
2026-03-03 12:50:24 -06:00
ON CONFLICT ( volunteer_id , shift_id ) DO UPDATE SET confirmed = 1 , deleted_at = NULL , updated_at = excluded . updated_at ` ,
2026-03-03 11:27:07 -06:00
volunteerID , shiftID , now ( ) ,
)
return err
}
2026-03-03 12:50:24 -06:00
// 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" )
2026-03-03 11:27:07 -06:00
func ( app * App ) unassignShift ( volunteerID , shiftID int ) error {
_ , err := app . db . Exec (
2026-03-06 07:11:19 -06:00
` UPDATE volunteer_shifts SET deleted_at=?, updated_at=? WHERE volunteer_id=? AND shift_id=? ` ,
2026-03-03 12:50:24 -06:00
now ( ) , now ( ) , volunteerID , shiftID ,
2026-03-03 11:27:07 -06:00
)
return err
}
func ( app * App ) listVolunteerShifts ( since string ) ( [ ] VolunteerShift , error ) {
var q string
var args [ ] any
if since != "" {
2026-03-03 12:50:24 -06:00
q = ` SELECT volunteer_id, shift_id, confirmed, updated_at, deleted_at FROM volunteer_shifts WHERE updated_at > ? `
2026-03-03 11:27:07 -06:00
args = append ( args , since )
} else {
2026-03-03 12:50:24 -06:00
q = ` SELECT volunteer_id, shift_id, confirmed, updated_at, deleted_at FROM volunteer_shifts WHERE deleted_at IS NULL `
2026-03-03 11:27:07 -06:00
}
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
2026-03-03 12:50:24 -06:00
rows . Scan ( & vs . VolunteerID , & vs . ShiftID , & confirmed , & vs . UpdatedAt , & vs . DeletedAt )
2026-03-03 11:27:07 -06:00
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
2026-03-03 12:50:24 -06:00
WHERE vs . volunteer_id = ? AND vs . deleted_at IS NULL AND s . deleted_at IS NULL
2026-03-03 11:27:07 -06:00
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 (
2026-03-03 12:50:24 -06:00
SELECT COUNT ( * ) FROM volunteer_shifts vs WHERE vs . shift_id = s . id AND vs . deleted_at IS NULL
2026-03-03 11:27:07 -06:00
) < 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
}
2026-03-10 15:14:36 -05:00
func placeholders ( n int ) string {
if n <= 0 {
return ""
}
return strings . Repeat ( "?," , n - 1 ) + "?"
}