Added tests, shift 'delete'. Fixed overnight shifts, sync, error handling.

This commit is contained in:
Pen Anderson 2026-03-03 12:50:24 -06:00
parent 9d0fa1f0af
commit f9c4facad6
21 changed files with 2522 additions and 40 deletions

107
db.go
View file

@ -134,6 +134,7 @@ func migrateV2(db *sql.DB) error {
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`)
@ -245,10 +246,11 @@ type Shift struct {
}
type VolunteerShift struct {
VolunteerID int `json:"volunteer_id"`
ShiftID int `json:"shift_id"`
Confirmed bool `json:"confirmed"`
UpdatedAt string `json:"updated_at"`
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 ---
@ -413,21 +415,28 @@ func (app *App) countUsers() (int, error) {
const tokenChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
func generateToken() string {
func generateToken() (string, error) {
b := make([]byte, 8)
rand.Read(b)
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)
return string(result), nil
}
func (app *App) generateUniqueToken() (string, error) {
for range 10 {
t := generateToken()
t, err := generateToken()
if err != nil {
return "", err
}
var count int
app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE volunteer_token = ?`, t).Scan(&count)
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
}
@ -452,13 +461,15 @@ func (app *App) generateTokensForAll() (int, error) {
if err != nil {
return 0, err
}
defer rows.Close()
var ids []int
for rows.Next() {
var id int
rows.Scan(&id)
if err := rows.Scan(&id); err != nil {
return 0, fmt.Errorf("scan attendee id: %w", err)
}
ids = append(ids, id)
}
rows.Close()
count := 0
for _, id := range ids {
@ -912,7 +923,7 @@ func queryShifts(db *sql.DB, q string, args ...any) ([]Shift, error) {
// 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)
err := app.db.QueryRow(`SELECT COUNT(*) FROM volunteer_shifts WHERE shift_id = ? AND deleted_at IS NULL`, shiftID).Scan(&count)
return count, err
}
@ -927,21 +938,40 @@ func (app *App) checkShiftConflict(volunteerID, shiftID int) ([]Shift, error) {
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`,
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 {
// Overlap: one starts before the other ends (HH:MM string comparison works for same-day)
if s.StartTime < target.EndTime && target.StartTime < s.EndTime {
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 {
@ -959,15 +989,48 @@ func (app *App) reorderShifts(positions []struct{ ID, Position int }) error {
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`,
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(
`DELETE FROM volunteer_shifts WHERE volunteer_id=? AND shift_id=?`, volunteerID, shiftID,
`UPDATE volunteer_shifts SET deleted_at = ?, updated_at = ? WHERE volunteer_id=? AND shift_id=?`,
now(), now(), volunteerID, shiftID,
)
return err
}
@ -976,10 +1039,10 @@ 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 > ?`
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 FROM volunteer_shifts`
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 {
@ -990,7 +1053,7 @@ func (app *App) listVolunteerShifts(since string) ([]VolunteerShift, error) {
for rows.Next() {
var vs VolunteerShift
var confirmed int
rows.Scan(&vs.VolunteerID, &vs.ShiftID, &confirmed, &vs.UpdatedAt)
rows.Scan(&vs.VolunteerID, &vs.ShiftID, &confirmed, &vs.UpdatedAt, &vs.DeletedAt)
vs.Confirmed = confirmed == 1
result = append(result, vs)
}
@ -1003,7 +1066,7 @@ func (app *App) listShiftsForVolunteer(volunteerID int) ([]Shift, error) {
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
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)
}
@ -1014,7 +1077,7 @@ func (app *App) listOpenShiftsForDept(deptID int) ([]Shift, error) {
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
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)
}