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") addColumnIfMissing(db, "volunteers", "confirmed INTEGER NOT NULL DEFAULT 0") addColumnIfMissing(db, "volunteers", "confirmed_at TEXT") addColumnIfMissing(db, "volunteers", "kiosk_code TEXT") db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_volunteers_kiosk_code ON volunteers(kiosk_code) WHERE kiosk_code IS NOT NULL`) // Migrate kiosk codes from tickets to volunteers (idempotent). db.Exec(` UPDATE volunteers SET kiosk_code = ( SELECT t.code FROM tickets t WHERE t.participant_id = volunteers.participant_id AND t.code IS NOT NULL AND t.deleted_at IS NULL LIMIT 1 ) WHERE kiosk_code IS NULL AND participant_id IS NOT NULL`) // Delete stub tickets whose code has been migrated to the volunteer. db.Exec(` DELETE FROM tickets WHERE source = 'manual' AND external_id = '' AND code IS NOT NULL AND participant_id IN (SELECT id FROM volunteers WHERE kiosk_code IS NOT NULL)`) // 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)") addColumnIfMissing(db, "participants", "ticket_name TEXT NOT NULL DEFAULT ''") // 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"` 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"` Confirmed bool `json:"confirmed"` ConfirmedAt *string `json:"confirmed_at,omitempty"` EmailConfirmed bool `json:"email_confirmed"` ConfirmationToken *string `json:"-"` 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"` } type Participant struct { ID int `json:"id"` Email string `json:"email"` PreferredName string `json:"preferred_name"` TicketName string `json:"ticket_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, ticket_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, ticket_name, phone, pronouns, note, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, strings.ToLower(p.Email), p.PreferredName, p.TicketName, 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=?, ticket_name=?, phone=?, pronouns=?, note=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, strings.ToLower(p.Email), p.PreferredName, p.TicketName, 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.TicketName, &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), 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.confirmed, v.confirmed_at, v.email_confirmed, v.confirmation_token, v.kiosk_code, 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, 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, email, phone, pronouns, department_id, is_lead, email_confirmed, confirmation_token, note, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, v.ParticipantID, v.AttendeeID, v.Name, v.PreferredName, 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=?, 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.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 (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) } 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, confirmed, emailConfirmed int var confirmationToken, confirmedAt, kioskCode sql.NullString if err := rows.Scan( &v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.Email, &v.Phone, &v.Pronouns, &deptID, &isLead, &checkedIn, &v.CheckedInAt, &confirmed, &confirmedAt, &emailConfirmed, &confirmationToken, &kioskCode, &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 } if confirmedAt.Valid { v.ConfirmedAt = &confirmedAt.String } if kioskCode.Valid { v.KioskCode = &kioskCode.String } v.IsLead = isLead == 1 v.CheckedIn = checkedIn == 1 v.Confirmed = confirmed == 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 } 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 } // listVolunteersNeedingKioskCode returns email-confirmed volunteers without a kiosk code. func (app *App) listVolunteersNeedingKioskCode() ([]Volunteer, error) { return queryVolunteers(app.db, ` SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.email_confirmed = 1 AND v.kiosk_code IS NULL AND v.deleted_at IS NULL`) } 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") } 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 }