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 '#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 participants(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, 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 ); CREATE UNIQUE INDEX IF NOT EXISTS idx_volunteers_kiosk_code ON volunteers(kiosk_code) WHERE kiosk_code IS NOT NULL; 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')), deleted_at TEXT, 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 '', 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 ); 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 participants(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; CREATE TABLE IF NOT EXISTS participant_roles ( participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE, role TEXT NOT NULL CHECK(role IN ('admin','staffing','colead','gatekeeper')), PRIMARY KEY (participant_id, role) ); CREATE TABLE IF NOT EXISTS participant_departments ( participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE, department_id INTEGER NOT NULL REFERENCES departments(id) ON DELETE CASCADE, PRIMARY KEY (participant_id, department_id) ); `) if err != nil { return err } if err := migrateAuth(db); err != nil { return err } return nil } func migrateAuth(db *sql.DB) error { // Add auth columns to participants (idempotent — ignore "duplicate column" errors). db.Exec(`ALTER TABLE participants ADD COLUMN password_hash TEXT`) db.Exec(`ALTER TABLE participants ADD COLUMN login_enabled INTEGER NOT NULL DEFAULT 0`) // Migrate users → participants if the old users table exists. var hasUsers int if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'`).Scan(&hasUsers); err != nil || hasUsers == 0 { return nil } rows, err := db.Query(`SELECT id, username, password_hash, role FROM users`) if err != nil { return nil } defer rows.Close() for rows.Next() { var userID int var username, hash, role string if err := rows.Scan(&userID, &username, &hash, &role); err != nil { continue } // Map old "ticketing" role to "admin". if role == "ticketing" { role = "admin" } // Create a participant for each user (username as preferred_name). res, err := db.Exec( `INSERT INTO participants (preferred_name, password_hash, login_enabled, updated_at) VALUES (?, ?, 1, ?)`, username, hash, now(), ) if err != nil { continue } pid, _ := res.LastInsertId() db.Exec(`INSERT OR IGNORE INTO participant_roles (participant_id, role) VALUES (?, ?)`, pid, role) // Migrate department assignments. deptRows, err := db.Query(`SELECT department_id FROM user_departments WHERE user_id = ?`, userID) if err == nil { for deptRows.Next() { var deptID int deptRows.Scan(&deptID) db.Exec(`INSERT OR IGNORE INTO participant_departments (participant_id, department_id) VALUES (?, ?)`, pid, deptID) } deptRows.Close() } } db.Exec(`DROP TABLE IF EXISTS user_departments`) db.Exec(`DROP TABLE IF EXISTS users`) return nil } // --- 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"` Email string `json:"email"` PreferredName string `json:"preferred_name"` Roles []string `json:"roles"` DepartmentIDs []int `json:"department_ids"` CreatedAt string `json:"created_at"` } type Attendee struct { 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"` 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 { 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"` EmailConfirmed bool `json:"email_confirmed"` ConfirmationToken *string `json:"-"` 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 } // --- 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 } func (app *App) getUserDeptIDs(participantID int) ([]int, error) { rows, err := app.db.Query( `SELECT department_id FROM participant_departments WHERE participant_id = ? ORDER BY department_id`, participantID, ) if err != nil { return nil, err } 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(participantID int, deptIDs []int) error { if _, err := app.db.Exec(`DELETE FROM participant_departments WHERE participant_id = ?`, participantID); err != nil { return err } for _, deptID := range deptIDs { if _, err := app.db.Exec( `INSERT INTO participant_departments (participant_id, department_id) VALUES (?, ?)`, participantID, deptID, ); err != nil { return err } } return nil } func (app *App) getLoginParticipant(email string) (*User, string, error) { var s User var hash sql.NullString err := app.db.QueryRow( `SELECT id, email, preferred_name, password_hash, created_at FROM participants WHERE LOWER(email) = LOWER(?) AND login_enabled = 1 AND deleted_at IS NULL`, email, ).Scan(&s.ID, &s.Email, &s.PreferredName, &hash, &s.CreatedAt) if err == sql.ErrNoRows { return nil, "", nil } if err != nil { return nil, "", err } var hashStr string if hash.Valid { hashStr = hash.String } s.Roles, _ = app.getParticipantRoles(s.ID) s.DepartmentIDs, _ = app.getUserDeptIDs(s.ID) return &s, hashStr, nil } func (app *App) getUser(id int) (*User, error) { var s User err := app.db.QueryRow( `SELECT id, email, preferred_name, created_at FROM participants WHERE id = ? AND login_enabled = 1 AND deleted_at IS NULL`, id, ).Scan(&s.ID, &s.Email, &s.PreferredName, &s.CreatedAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } s.Roles, _ = app.getParticipantRoles(s.ID) s.DepartmentIDs, _ = app.getUserDeptIDs(s.ID) return &s, nil } func (app *App) listUsers() ([]User, error) { rows, err := app.db.Query( `SELECT id, email, preferred_name, created_at FROM participants WHERE login_enabled = 1 AND deleted_at IS NULL ORDER BY preferred_name, email`, ) if err != nil { return nil, err } defer rows.Close() var staff []User for rows.Next() { var s User if err := rows.Scan(&s.ID, &s.Email, &s.PreferredName, &s.CreatedAt); err != nil { return nil, err } s.Roles = []string{} s.DepartmentIDs = []int{} staff = append(staff, s) } if err := rows.Err(); err != nil { return nil, err } for i := range staff { staff[i].Roles, _ = app.getParticipantRoles(staff[i].ID) staff[i].DepartmentIDs, _ = app.getUserDeptIDs(staff[i].ID) } return staff, nil } func (app *App) createUser(email, preferredName, hash string, roles []string, deptIDs []int) (*User, error) { // Find or create participant by email. p, err := app.getParticipantByEmail(email) if err != nil { return nil, err } if p != nil { // Participant exists — promote to staff. if _, err := app.db.Exec( `UPDATE participants SET password_hash = ?, login_enabled = 1, updated_at = ? WHERE id = ?`, hash, now(), p.ID, ); err != nil { return nil, err } if err := app.setParticipantRoles(p.ID, roles); err != nil { return nil, err } if err := app.setUserDeptIDs(p.ID, deptIDs); err != nil { return nil, err } return app.getUser(p.ID) } // Create new participant with auth. res, err := app.db.Exec( `INSERT INTO participants (email, preferred_name, password_hash, login_enabled, updated_at) VALUES (?, ?, ?, 1, ?)`, strings.ToLower(email), preferredName, hash, now(), ) if err != nil { return nil, err } id, _ := res.LastInsertId() if err := app.setParticipantRoles(int(id), roles); err != nil { return nil, err } if err := app.setUserDeptIDs(int(id), deptIDs); err != nil { return nil, err } return app.getUser(int(id)) } func (app *App) updateUserRoles(id int, roles []string, deptIDs []int) error { var enabled int err := app.db.QueryRow(`SELECT login_enabled FROM participants WHERE id = ? AND deleted_at IS NULL`, id).Scan(&enabled) if err != nil || enabled != 1 { return fmt.Errorf("participant not found or not a staff member") } if err := app.setParticipantRoles(id, roles); err != nil { return err } return app.setUserDeptIDs(id, deptIDs) } func (app *App) updateUserPassword(id int, hash string) error { _, err := app.db.Exec( `UPDATE participants SET password_hash = ?, updated_at = ? WHERE id = ? AND login_enabled = 1`, hash, now(), id, ) return err } func (app *App) removeUser(id int) error { tx, err := app.db.Begin() if err != nil { return err } defer tx.Rollback() if _, err := tx.Exec(`DELETE FROM participant_roles WHERE participant_id = ?`, id); err != nil { return err } if _, err := tx.Exec(`DELETE FROM participant_departments WHERE participant_id = ?`, id); err != nil { return err } if _, err := tx.Exec( `UPDATE participants SET login_enabled = 0, password_hash = NULL, updated_at = ? WHERE id = ?`, now(), id, ); err != nil { return err } return tx.Commit() } func (app *App) countUsers() (int, error) { var n int err := app.db.QueryRow(`SELECT COUNT(*) FROM participants WHERE login_enabled = 1 AND deleted_at IS NULL`).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, email_confirmed, confirmation_token, 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, 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(), ) 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 var emailConfirmed int var confirmationToken sql.NullString if err := rows.Scan( &p.ID, &p.Email, &p.PreferredName, &p.TicketName, &p.Phone, &p.Pronouns, &p.Note, &emailConfirmed, &confirmationToken, &p.CreatedAt, &p.UpdatedAt, &p.DeletedAt, ); err != nil { return nil, err } p.EmailConfirmed = emailConfirmed == 1 if confirmationToken.Valid { p.ConfirmationToken = &confirmationToken.String } 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 --- const volunteerSelect = `v.id, v.participant_id, p.preferred_name, p.email, p.phone, p.pronouns, v.department_id, v.is_lead, v.ready, v.ready_at, v.confirmed, v.confirmed_at, p.email_confirmed, v.kiosk_code, v.note, v.created_at, v.updated_at, v.deleted_at` const volunteerFrom = `FROM volunteers v INNER JOIN participants p ON p.id = v.participant_id` 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 (p.preferred_name LIKE ? OR p.email LIKE ?)` s := "%" + search + "%" args = append(args, s, s) } if deptID != nil { q += ` AND v.department_id = ?` args = append(args, *deptID) } q += ` ORDER BY p.preferred_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) 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, department_id, is_lead, note, updated_at) VALUES (?, ?, ?, ?, ?)`, v.ParticipantID, v.DepartmentID, boolInt(v.IsLead), 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 department_id=?, is_lead=?, note=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, 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 } func (app *App) markVolunteerReady(id, userID int) (*Volunteer, error) { t := now() _, err := app.db.Exec( `UPDATE volunteers SET ready=1, ready_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL AND ready=0`, t, t, id, ) if err != nil { return nil, err } return app.getVolunteer(id) } 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 deptID sql.NullInt64 var isLead, ready, confirmed, emailConfirmed int var confirmedAt, kioskCode sql.NullString if err := rows.Scan( &v.ID, &v.ParticipantID, &v.Name, &v.Email, &v.Phone, &v.Pronouns, &deptID, &isLead, &ready, &v.ReadyAt, &confirmed, &confirmedAt, &emailConfirmed, &kioskCode, &v.Note, &v.CreatedAt, &v.UpdatedAt, &v.DeletedAt, ); err != nil { return nil, err } if deptID.Valid { id := int(deptID.Int64) v.DepartmentID = &id } if confirmedAt.Valid { v.ConfirmedAt = &confirmedAt.String } if kioskCode.Valid { v.KioskCode = &kioskCode.String } v.IsLead = isLead == 1 v.Ready = ready == 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(p.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 p.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) 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 { _, err := app.db.Exec( `UPDATE participants SET confirmation_token = ?, updated_at = ? WHERE id = ?`, token, now(), participantID) 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 } func (app *App) listVolunteersNeedingKioskCode() ([]Volunteer, error) { return queryVolunteers(app.db, ` SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE p.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 }