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','coordinator','gate','ticketing','volunteer_lead')), 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) ); `) 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") // 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 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"` AttendeeID *int `json:"attendee_id,omitempty"` Name string `json:"name"` Email string `json:"email"` Phone string `json:"phone"` DepartmentID *int `json:"department_id,omitempty"` IsLead bool `json:"is_lead"` CheckedIn bool `json:"checked_in"` CheckedInAt *string `json:"checked_in_at,omitempty"` Note string `json:"note"` 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 attendees WHERE volunteer_token = ?`, 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") } func (app *App) getAttendeeByToken(token string) (*Attendee, error) { rows, err := queryAttendees(app.db, `SELECT `+attendeeCols+` FROM attendees WHERE volunteer_token = ? AND deleted_at IS NULL`, token) if err != nil || len(rows) == 0 { return nil, err } return &rows[0], nil } // generateTokensForAll creates tokens for every attendee that doesn't have one yet. func (app *App) generateTokensForAll() (int, error) { rows, err := app.db.Query( `SELECT id FROM attendees WHERE volunteer_token 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 attendee 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 attendees SET volunteer_token=?, updated_at=? WHERE id=?`, t, now(), id) count++ } return count, nil } // incrementPartySize bumps party_size for an existing attendee matched by name+ticket_id. // Used during import to handle duplicate ticket rows from the same order. 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 } // --- 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 volunteerCols = `id, attendee_id, name, email, phone, department_id, is_lead, checked_in, checked_in_at, note, created_at, updated_at, deleted_at` func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) { q := `SELECT ` + volunteerCols + ` FROM volunteers WHERE 1=1` var args []any if since != "" { q += ` AND updated_at > ?` args = append(args, since) } else { q += ` AND deleted_at IS NULL` } if search != "" { q += ` AND (name LIKE ? OR email LIKE ?)` s := "%" + search + "%" args = append(args, s, s) } if deptID != nil { q += ` AND department_id = ?` args = append(args, *deptID) } q += ` ORDER BY name` return queryVolunteers(app.db, q, args...) } func (app *App) getVolunteer(id int) (*Volunteer, error) { rows, err := queryVolunteers(app.db, `SELECT `+volunteerCols+` FROM volunteers WHERE 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 `+volunteerCols+` FROM volunteers WHERE attendee_id = ? AND deleted_at IS NULL LIMIT 1`, attendeeID) 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 (attendee_id, name, email, phone, department_id, is_lead, note, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, v.AttendeeID, v.Name, v.Email, v.Phone, 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 attendee_id=?, name=?, email=?, phone=?, department_id=?, is_lead=?, note=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, v.AttendeeID, v.Name, v.Email, v.Phone, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID, ) return err } func (app *App) deleteVolunteer(id int) error { _, err := app.db.Exec( `UPDATE volunteers SET deleted_at=?, updated_at=? WHERE id=?`, now(), now(), id, ) return err } // checkInVolunteer marks the volunteer as checked in and, if linked to an attendee, // also increments the attendee's checked_in_count. func (app *App) checkInVolunteer(id, userID int) (*Volunteer, error) { t := now() _, err := app.db.Exec( `UPDATE volunteers SET checked_in=1, checked_in_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL AND checked_in=0`, t, t, id, ) if err != nil { return nil, err } v, err := app.getVolunteer(id) if err != nil || v == nil { return v, err } if v.AttendeeID != nil { app.checkInAttendee(*v.AttendeeID, userID, 1) } return v, nil } func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { rows, err := db.Query(q, args...) if err != nil { return nil, err } defer rows.Close() var result []Volunteer for rows.Next() { var v Volunteer var attendeeID, deptID sql.NullInt64 var isLead, checkedIn int if err := rows.Scan( &v.ID, &attendeeID, &v.Name, &v.Email, &v.Phone, &deptID, &isLead, &checkedIn, &v.CheckedInAt, &v.Note, &v.CreatedAt, &v.UpdatedAt, &v.DeletedAt, ); err != nil { return nil, err } if attendeeID.Valid { id := int(attendeeID.Int64) v.AttendeeID = &id } if deptID.Valid { id := int(deptID.Int64) v.DepartmentID = &id } v.IsLead = isLead == 1 v.CheckedIn = checkedIn == 1 result = append(result, v) } return result, rows.Err() } // --- 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 }