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 40ebaf07bf
7 changed files with 48 additions and 28 deletions

46
db.go
View file

@ -93,8 +93,8 @@ func migrate(db *sql.DB) error {
phone TEXT NOT NULL DEFAULT '', phone TEXT NOT NULL DEFAULT '',
department_id INTEGER REFERENCES departments(id) ON DELETE SET NULL, department_id INTEGER REFERENCES departments(id) ON DELETE SET NULL,
is_lead INTEGER NOT NULL DEFAULT 0, is_lead INTEGER NOT NULL DEFAULT 0,
checked_in INTEGER NOT NULL DEFAULT 0, ready INTEGER NOT NULL DEFAULT 0,
checked_in_at TEXT, ready_at TEXT,
note TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_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 INTEGER NOT NULL DEFAULT 0")
addColumnIfMissing(db, "volunteers", "confirmed_at TEXT") addColumnIfMissing(db, "volunteers", "confirmed_at TEXT")
addColumnIfMissing(db, "volunteers", "kiosk_code 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`) 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). // Migrate kiosk codes from tickets to volunteers (idempotent).
db.Exec(` db.Exec(`
@ -340,6 +342,24 @@ func addColumnIfMissing(db *sql.DB, table, colDef string) {
db.Exec(`ALTER TABLE "` + table + `" ADD COLUMN ` + colDef) db.Exec(`ALTER TABLE "` + table + `" ADD COLUMN ` + colDef)
} }
func renameColumnIfExists(db *sql.DB, table, oldName, newName string) {
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 == oldName {
db.Exec(`ALTER TABLE "` + table + `" RENAME COLUMN "` + oldName + `" TO "` + newName + `"`)
return
}
}
}
// --- Types --- // --- Types ---
const attendeeCols = `id, name, email, phone, ticket_id, ticket_type, volunteer_token, const attendeeCols = `id, name, email, phone, ticket_id, ticket_type, volunteer_token,
@ -407,8 +427,8 @@ type Volunteer struct {
Pronouns string `json:"pronouns"` Pronouns string `json:"pronouns"`
DepartmentID *int `json:"department_id,omitempty"` DepartmentID *int `json:"department_id,omitempty"`
IsLead bool `json:"is_lead"` IsLead bool `json:"is_lead"`
CheckedIn bool `json:"checked_in"` Ready bool `json:"ready"`
CheckedInAt *string `json:"checked_in_at,omitempty"` ReadyAt *string `json:"ready_at,omitempty"`
Confirmed bool `json:"confirmed"` Confirmed bool `json:"confirmed"`
ConfirmedAt *string `json:"confirmed_at,omitempty"` ConfirmedAt *string `json:"confirmed_at,omitempty"`
EmailConfirmed bool `json:"email_confirmed"` EmailConfirmed bool `json:"email_confirmed"`
@ -1203,14 +1223,14 @@ const volunteerSelect = `v.id, v.participant_id, v.attendee_id,
COALESCE(NULLIF(p.email,''), v.email), COALESCE(NULLIF(p.email,''), v.email),
COALESCE(NULLIF(p.phone,''), v.phone), COALESCE(NULLIF(p.phone,''), v.phone),
COALESCE(NULLIF(p.pronouns,''), v.pronouns), 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.confirmed, v.confirmed_at,
v.email_confirmed, v.confirmation_token, v.kiosk_code, v.note, v.email_confirmed, v.confirmation_token, v.kiosk_code, v.note,
v.created_at, v.updated_at, v.deleted_at` v.created_at, v.updated_at, v.deleted_at`
const volunteerFrom = `FROM volunteers v LEFT JOIN participants p ON p.id = v.participant_id` 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. // 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) { func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) {
q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1` q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1`
@ -1292,13 +1312,13 @@ func (app *App) deleteVolunteer(id int) error {
return err 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. // 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() t := now()
_, err := app.db.Exec( _, err := app.db.Exec(
`UPDATE volunteers SET checked_in=1, checked_in_at=?, updated_at=? `UPDATE volunteers SET ready=1, ready_at=?, updated_at=?
WHERE id=? AND deleted_at IS NULL AND checked_in=0`, WHERE id=? AND deleted_at IS NULL AND ready=0`,
t, t, id, t, t, id,
) )
if err != nil { if err != nil {
@ -1337,12 +1357,12 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
for rows.Next() { for rows.Next() {
var v Volunteer var v Volunteer
var participantID, attendeeID, deptID sql.NullInt64 var participantID, attendeeID, deptID sql.NullInt64
var isLead, checkedIn, confirmed, emailConfirmed int var isLead, ready, confirmed, emailConfirmed int
var confirmationToken, confirmedAt, kioskCode sql.NullString var confirmationToken, confirmedAt, kioskCode sql.NullString
if err := rows.Scan( if err := rows.Scan(
&v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName,
&v.Email, &v.Phone, &v.Pronouns, &deptID, &v.Email, &v.Phone, &v.Pronouns, &deptID,
&isLead, &checkedIn, &v.CheckedInAt, &isLead, &ready, &v.ReadyAt,
&confirmed, &confirmedAt, &confirmed, &confirmedAt,
&emailConfirmed, &confirmationToken, &kioskCode, &v.Note, &emailConfirmed, &confirmationToken, &kioskCode, &v.Note,
&v.CreatedAt, &v.UpdatedAt, &v.DeletedAt, &v.CreatedAt, &v.UpdatedAt, &v.DeletedAt,
@ -1371,7 +1391,7 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
v.KioskCode = &kioskCode.String v.KioskCode = &kioskCode.String
} }
v.IsLead = isLead == 1 v.IsLead = isLead == 1
v.CheckedIn = checkedIn == 1 v.Ready = ready == 1
v.Confirmed = confirmed == 1 v.Confirmed = confirmed == 1
v.EmailConfirmed = emailConfirmed == 1 v.EmailConfirmed = emailConfirmed == 1
result = append(result, v) result = append(result, v)

View file

@ -78,7 +78,7 @@ export const api = {
create: (data) => apiJSON('/api/volunteers', { method: 'POST', body: JSON.stringify(data) }), create: (data) => apiJSON('/api/volunteers', { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => apiJSON(`/api/volunteers/${id}`, { method: 'PUT', 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' }), 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' }), 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 }) }), 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' }), unassignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts/${shiftId}`, { method: 'DELETE' }),

View file

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

View file

@ -31,7 +31,7 @@
return vols return vols
}) })
const volTotal = $derived(volunteers.length) 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) const volLeads = $derived(volunteers.filter(v => v.is_lead).length)
// Shift stats (scoped for colead) // Shift stats (scoped for colead)

View file

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

View file

@ -130,14 +130,14 @@ func (app *App) handleDeleteVolunteer(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) 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")) id, err := strconv.Atoi(r.PathValue("id"))
if err != nil { if err != nil {
writeError(w, "invalid id", http.StatusBadRequest) writeError(w, "invalid id", http.StatusBadRequest)
return return
} }
claims := claimsFromContext(r) claims := claimsFromContext(r)
v, err := app.checkInVolunteer(id, claims.UserID) v, err := app.markVolunteerReady(id, claims.UserID)
if err != nil { if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return 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("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("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("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}/confirm", auth(app.handleConfirmVolunteer, "admin", "staffing", "colead"))
mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "ticketing", "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")) mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "ticketing", "staffing", "colead"))