Added tests, shift 'delete'. Fixed overnight shifts, sync, error handling.
This commit is contained in:
parent
c9180490a4
commit
f2aa04db15
20 changed files with 2521 additions and 39 deletions
107
db.go
107
db.go
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue