Turnpike/db.go

1034 lines
30 KiB
Go
Raw Normal View History

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")
// 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, &notNull, &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"`
}
// --- 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 {
b := make([]byte, 8)
rand.Read(b)
result := make([]byte, 8)
for i, v := range b {
result[i] = tokenChars[int(v)%len(tokenChars)]
}
return string(result)
}
func (app *App) generateUniqueToken() (string, error) {
for range 10 {
t := generateToken()
var count int
app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE volunteer_token = ?`, t).Scan(&count)
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
}
var ids []int
for rows.Next() {
var id int
rows.Scan(&id)
ids = append(ids, id)
}
rows.Close()
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 = ?`, 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 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 {
// Overlap: one starts before the other ends (HH:MM string comparison works for same-day)
if s.StartTime < target.EndTime && target.StartTime < s.EndTime {
conflicts = append(conflicts, s)
}
}
return conflicts, nil
}
// 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, updated_at=excluded.updated_at`,
volunteerID, shiftID, now(),
)
return err
}
func (app *App) unassignShift(volunteerID, shiftID int) error {
_, err := app.db.Exec(
`DELETE FROM volunteer_shifts WHERE volunteer_id=? AND shift_id=?`, 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 FROM volunteer_shifts WHERE updated_at > ?`
args = append(args, since)
} else {
q = `SELECT volunteer_id, shift_id, confirmed, updated_at FROM volunteer_shifts`
}
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.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 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
) < 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
}