Refactored volunteer check_in as ready status.

This commit is contained in:
Pen Anderson 2026-03-05 17:34:50 -06:00
parent e722ef055e
commit 4c462c9d47
7 changed files with 51 additions and 28 deletions

49
db.go
View file

@ -93,8 +93,8 @@ func migrate(db *sql.DB) error {
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,
ready INTEGER NOT NULL DEFAULT 0,
ready_at TEXT,
note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
@ -177,6 +177,8 @@ func migrateV2(db *sql.DB) error {
addColumnIfMissing(db, "volunteers", "confirmed INTEGER NOT NULL DEFAULT 0")
addColumnIfMissing(db, "volunteers", "confirmed_at TEXT")
addColumnIfMissing(db, "volunteers", "kiosk_code TEXT")
renameColumnIfExists(db, "volunteers", "checked_in", "ready")
renameColumnIfExists(db, "volunteers", "checked_in_at", "ready_at")
db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_volunteers_kiosk_code ON volunteers(kiosk_code) WHERE kiosk_code IS NOT NULL`)
// Migrate kiosk codes from tickets to volunteers (idempotent).
db.Exec(`
@ -340,6 +342,27 @@ func addColumnIfMissing(db *sql.DB, table, colDef string) {
db.Exec(`ALTER TABLE "` + table + `" ADD COLUMN ` + colDef)
}
func renameColumnIfExists(db *sql.DB, table, oldName, newName string) {
found := false
rows, err := db.Query(`PRAGMA table_info("` + table + `")`)
if err != nil {
return
}
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 == oldName {
found = true
}
}
rows.Close()
if found {
db.Exec(`ALTER TABLE "` + table + `" RENAME COLUMN "` + oldName + `" TO "` + newName + `"`)
}
}
// --- Types ---
const attendeeCols = `id, name, email, phone, ticket_id, ticket_type, volunteer_token,
@ -407,8 +430,8 @@ type Volunteer struct {
Pronouns string `json:"pronouns"`
DepartmentID *int `json:"department_id,omitempty"`
IsLead bool `json:"is_lead"`
CheckedIn bool `json:"checked_in"`
CheckedInAt *string `json:"checked_in_at,omitempty"`
Ready bool `json:"ready"`
ReadyAt *string `json:"ready_at,omitempty"`
Confirmed bool `json:"confirmed"`
ConfirmedAt *string `json:"confirmed_at,omitempty"`
EmailConfirmed bool `json:"email_confirmed"`
@ -1203,14 +1226,14 @@ const volunteerSelect = `v.id, v.participant_id, v.attendee_id,
COALESCE(NULLIF(p.email,''), v.email),
COALESCE(NULLIF(p.phone,''), v.phone),
COALESCE(NULLIF(p.pronouns,''), v.pronouns),
v.department_id, v.is_lead, v.checked_in, v.checked_in_at,
v.department_id, v.is_lead, v.ready, v.ready_at,
v.confirmed, v.confirmed_at,
v.email_confirmed, v.confirmation_token, v.kiosk_code, v.note,
v.created_at, v.updated_at, v.deleted_at`
const volunteerFrom = `FROM volunteers v LEFT JOIN participants p ON p.id = v.participant_id`
// volunteerCols is kept for backward-compat references that expect unqualified column names.
const volunteerCols = `id, attendee_id, name, preferred_name, email, phone, pronouns, department_id, is_lead, checked_in, checked_in_at, email_confirmed, confirmation_token, note, created_at, updated_at, deleted_at`
const volunteerCols = `id, attendee_id, name, preferred_name, email, phone, pronouns, department_id, is_lead, ready, ready_at, email_confirmed, confirmation_token, note, created_at, updated_at, deleted_at`
func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) {
q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1`
@ -1292,13 +1315,13 @@ func (app *App) deleteVolunteer(id int) error {
return err
}
// checkInVolunteer marks the volunteer as checked in and, if linked to an attendee,
// markVolunteerReady marks the volunteer as ready and, if linked to an attendee,
// also increments the attendee's checked_in_count.
func (app *App) checkInVolunteer(id, userID int) (*Volunteer, error) {
func (app *App) markVolunteerReady(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`,
`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 {
@ -1337,12 +1360,12 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
for rows.Next() {
var v Volunteer
var participantID, attendeeID, deptID sql.NullInt64
var isLead, checkedIn, confirmed, emailConfirmed int
var isLead, ready, confirmed, emailConfirmed int
var confirmationToken, confirmedAt, kioskCode sql.NullString
if err := rows.Scan(
&v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName,
&v.Email, &v.Phone, &v.Pronouns, &deptID,
&isLead, &checkedIn, &v.CheckedInAt,
&isLead, &ready, &v.ReadyAt,
&confirmed, &confirmedAt,
&emailConfirmed, &confirmationToken, &kioskCode, &v.Note,
&v.CreatedAt, &v.UpdatedAt, &v.DeletedAt,
@ -1371,7 +1394,7 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
v.KioskCode = &kioskCode.String
}
v.IsLead = isLead == 1
v.CheckedIn = checkedIn == 1
v.Ready = ready == 1
v.Confirmed = confirmed == 1
v.EmailConfirmed = emailConfirmed == 1
result = append(result, v)

View file

@ -78,7 +78,7 @@ export const api = {
create: (data) => apiJSON('/api/volunteers', { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => apiJSON(`/api/volunteers/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id) => apiFetch(`/api/volunteers/${id}`, { method: 'DELETE' }),
checkIn: (id) => apiJSON(`/api/volunteers/${id}/checkin`, { method: 'POST' }),
markReady: (id) => apiJSON(`/api/volunteers/${id}/ready`, { method: 'POST' }),
confirm: (id) => apiJSON(`/api/volunteers/${id}/confirm`, { method: 'POST' }),
assignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts`, { method: 'POST', body: JSON.stringify({ shift_id: shiftId }) }),
unassignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts/${shiftId}`, { method: 'DELETE' }),

View file

@ -9,5 +9,5 @@
</script>
<button class="btn btn-success btn-sm" onclick={handle} disabled={loading}>
{loading ? '…' : '✓ Ready'}
{loading ? '…' : 'Mark ready'}
</button>

View file

@ -31,7 +31,7 @@
return vols
})
const volTotal = $derived(volunteers.length)
const volCheckedIn = $derived(volunteers.filter(v => v.checked_in).length)
const volCheckedIn = $derived(volunteers.filter(v => v.ready).length)
const volLeads = $derived(volunteers.filter(v => v.is_lead).length)
// Shift stats (scoped for colead)

View file

@ -54,8 +54,8 @@
if (filterDept && v.department_id !== parseInt(filterDept)) return false
if (filterStatus === 'unconfirmed' && v.email_confirmed) return false
if (filterStatus === 'registered' && (!v.email_confirmed || v.confirmed)) return false
if (filterStatus === 'confirmed' && (!v.confirmed || v.checked_in)) return false
if (filterStatus === 'ready' && !v.checked_in) return false
if (filterStatus === 'confirmed' && (!v.confirmed || v.ready)) return false
if (filterStatus === 'ready' && !v.ready) return false
if (s && !v.name.toLowerCase().includes(s) &&
!(v.email || '').toLowerCase().includes(s)) return false
return true
@ -63,9 +63,9 @@
.sort((a, b) => a.name.localeCompare(b.name))
})
async function checkIn(v) {
async function markReady(v) {
try {
const updated = await api.volunteers.checkIn(v.id)
const updated = await api.volunteers.markReady(v.id)
await db.volunteers.put(updated)
} catch (err) {
error = err.message
@ -314,7 +314,7 @@
{/if}
</td>
<td class="td-status">
{#if v.checked_in}
{#if v.ready}
<span class="badge badge-checked">Ready</span>
{:else if v.confirmed}
<span class="badge badge-confirmed">Confirmed</span>
@ -323,15 +323,15 @@
{:else}
<span class="badge badge-unchecked">Unconfirmed</span>
{/if}
{#if v.checked_in_at}
{#if v.ready_at}
<div class="text-muted" style="font-size:0.75rem">
{new Date(v.checked_in_at).toLocaleTimeString()}
{new Date(v.ready_at).toLocaleTimeString()}
</div>
{/if}
</td>
<td class="td-ready">
{#if !v.checked_in}
<CheckInButton onclick={() => checkIn(v)} />
{#if v.confirmed && !v.ready}
<CheckInButton onclick={() => markReady(v)} />
{/if}
</td>
{#if canManage}

View file

@ -130,14 +130,14 @@ func (app *App) handleDeleteVolunteer(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
func (app *App) handleCheckInVolunteer(w http.ResponseWriter, r *http.Request) {
func (app *App) handleMarkVolunteerReady(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, "invalid id", http.StatusBadRequest)
return
}
claims := claimsFromContext(r)
v, err := app.checkInVolunteer(id, claims.UserID)
v, err := app.markVolunteerReady(id, claims.UserID)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return

View file

@ -125,7 +125,7 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "ticketing", "staffing", "colead"))
mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "ticketing", "staffing", "colead"))
mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "ticketing", "staffing", "colead"))
mux.HandleFunc("POST /api/volunteers/{id}/checkin", auth(app.handleCheckInVolunteer, "admin", "ticketing", "staffing", "colead"))
mux.HandleFunc("POST /api/volunteers/{id}/ready", auth(app.handleMarkVolunteerReady, "admin", "ticketing", "staffing", "colead"))
mux.HandleFunc("POST /api/volunteers/{id}/confirm", auth(app.handleConfirmVolunteer, "admin", "staffing", "colead"))
mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "ticketing", "staffing", "colead"))
mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "ticketing", "staffing", "colead"))