Set up Unconfirmed -> Registered -> Confirmed -> Ready flow for Volunteers

This commit is contained in:
Pen Anderson 2026-03-05 15:52:40 -06:00
parent 62b3dece84
commit 72b245d6d6
7 changed files with 95 additions and 20 deletions

26
db.go
View file

@ -174,6 +174,8 @@ func migrateV2(db *sql.DB) error {
addColumnIfMissing(db, "volunteers", "pronouns TEXT NOT NULL DEFAULT ''") addColumnIfMissing(db, "volunteers", "pronouns TEXT NOT NULL DEFAULT ''")
addColumnIfMissing(db, "volunteers", "email_confirmed INTEGER NOT NULL DEFAULT 0") addColumnIfMissing(db, "volunteers", "email_confirmed INTEGER NOT NULL DEFAULT 0")
addColumnIfMissing(db, "volunteers", "confirmation_token TEXT") addColumnIfMissing(db, "volunteers", "confirmation_token TEXT")
addColumnIfMissing(db, "volunteers", "confirmed INTEGER NOT NULL DEFAULT 0")
addColumnIfMissing(db, "volunteers", "confirmed_at TEXT")
// Widen the uniqueness constraint from name-only to (name, ticket_id). // Widen the uniqueness constraint from name-only to (name, ticket_id).
db.Exec(`DROP INDEX IF EXISTS idx_attendees_name`) 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`) db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_attendees_name_ticket ON attendees(name, ticket_id) WHERE deleted_at IS NULL`)
@ -392,6 +394,8 @@ type Volunteer struct {
IsLead bool `json:"is_lead"` IsLead bool `json:"is_lead"`
CheckedIn bool `json:"checked_in"` CheckedIn bool `json:"checked_in"`
CheckedInAt *string `json:"checked_in_at,omitempty"` CheckedInAt *string `json:"checked_in_at,omitempty"`
Confirmed bool `json:"confirmed"`
ConfirmedAt *string `json:"confirmed_at,omitempty"`
EmailConfirmed bool `json:"email_confirmed"` EmailConfirmed bool `json:"email_confirmed"`
ConfirmationToken *string `json:"-"` ConfirmationToken *string `json:"-"`
Note string `json:"note"` Note string `json:"note"`
@ -1184,6 +1188,7 @@ const volunteerSelect = `v.id, v.participant_id, v.attendee_id,
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.checked_in, v.checked_in_at,
v.confirmed, v.confirmed_at,
v.email_confirmed, v.confirmation_token, v.note, v.email_confirmed, v.confirmation_token, 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`
@ -1293,6 +1298,19 @@ func (app *App) checkInVolunteer(id, userID int) (*Volunteer, error) {
return v, nil return v, nil
} }
func (app *App) confirmVolunteer(id int) (*Volunteer, error) {
t := now()
_, err := app.db.Exec(
`UPDATE volunteers SET confirmed=1, confirmed_at=?, updated_at=?
WHERE id=? AND deleted_at IS NULL AND confirmed=0`,
t, t, id,
)
if err != nil {
return nil, err
}
return app.getVolunteer(id)
}
func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
rows, err := db.Query(q, args...) rows, err := db.Query(q, args...)
if err != nil { if err != nil {
@ -1303,12 +1321,14 @@ 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, emailConfirmed int var isLead, checkedIn, confirmed, emailConfirmed int
var confirmationToken sql.NullString var confirmationToken sql.NullString
var confirmedAt sql.NullString
if err := rows.Scan( if err := rows.Scan(
&v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName, &v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName,
&v.Email, &v.Phone, &v.Pronouns, &deptID, &v.Email, &v.Phone, &v.Pronouns, &deptID,
&isLead, &checkedIn, &v.CheckedInAt, &isLead, &checkedIn, &v.CheckedInAt,
&confirmed, &confirmedAt,
&emailConfirmed, &confirmationToken, &v.Note, &emailConfirmed, &confirmationToken, &v.Note,
&v.CreatedAt, &v.UpdatedAt, &v.DeletedAt, &v.CreatedAt, &v.UpdatedAt, &v.DeletedAt,
); err != nil { ); err != nil {
@ -1329,8 +1349,12 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
if confirmationToken.Valid { if confirmationToken.Valid {
v.ConfirmationToken = &confirmationToken.String v.ConfirmationToken = &confirmationToken.String
} }
if confirmedAt.Valid {
v.ConfirmedAt = &confirmedAt.String
}
v.IsLead = isLead == 1 v.IsLead = isLead == 1
v.CheckedIn = checkedIn == 1 v.CheckedIn = checkedIn == 1
v.Confirmed = confirmed == 1
v.EmailConfirmed = emailConfirmed == 1 v.EmailConfirmed = emailConfirmed == 1
result = append(result, v) result = append(result, v)
} }

View file

@ -99,11 +99,14 @@ Under **Volunteers**, you can:
### Volunteer statuses ### Volunteer statuses
| Status | Meaning | | Status | Meaning | Who sets it |
|--------|---------| |--------|---------|-------------|
| **Unconfirmed** | Signed up but hasn't confirmed their email | | **Unconfirmed** | Signed up but hasn't confirmed their email | Automatic (not yet done) |
| **Confirmed** | Email confirmed, not yet briefed | | **Registered** | Email confirmed — volunteer is in the system | Automatic (email link) |
| **Ready** | Briefed at the volunteer station, has what they need to report for shifts | | **Confirmed** | Staff has verified the volunteer is assigned and ready to be scheduled | Admin, staffing, or co-lead |
| **Ready** | Briefed at the volunteer station, cleared to report for shifts | Gate staff at check-in |
**Confirmation** is a deliberate staff action — it signals that a volunteer has been assigned to a department and you're expecting them. Only volunteers who have been assigned a department can be confirmed. Use the **Confirm** button on a registered volunteer's row.
Volunteers are separate from participants. A person can be both a ticket holder and a volunteer. When a volunteer signs up via the public form, they are automatically linked to their participant record by email. Volunteers are separate from participants. A person can be both a ticket holder and a volunteer. When a volunteer signs up via the public form, they are automatically linked to their participant record by email.

View file

@ -79,6 +79,7 @@ export const api = {
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' }), checkIn: (id) => apiJSON(`/api/volunteers/${id}/checkin`, { 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

@ -131,6 +131,7 @@ tr:hover td { background: rgba(255,255,255,0.02); }
} }
.badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); } .badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); }
.badge-confirmed { background: rgba(99,102,241,0.15); color: var(--c-accent-h); } .badge-confirmed { background: rgba(99,102,241,0.15); color: var(--c-accent-h); }
.badge-registered { background: rgba(14,165,233,0.15); color: #0ea5e9; }
.badge-unchecked { background: rgba(122,127,150,0.15); color: var(--c-muted); } .badge-unchecked { background: rgba(122,127,150,0.15); color: var(--c-muted); }
.badge-partial { background: rgba(245,158,11,0.15); color: var(--c-warn); } .badge-partial { background: rgba(245,158,11,0.15); color: var(--c-warn); }
.badge-role { background: rgba(99,102,241,0.15); color: var(--c-accent-h); } .badge-role { background: rgba(99,102,241,0.15); color: var(--c-accent-h); }

View file

@ -8,7 +8,7 @@
let search = $state('') let search = $state('')
let filterDept = $state('') let filterDept = $state('')
let filterChecked = $state('') let filterStatus = $state('')
let error = $state('') let error = $state('')
let showAdd = $state(false) let showAdd = $state(false)
let adding = $state(false) let adding = $state(false)
@ -20,6 +20,7 @@
const role = $derived(session?.user?.role ?? '') const role = $derived(session?.user?.role ?? '')
const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role)) const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role))
const canConfirm = $derived(['admin', 'staffing', 'colead'].includes(role))
const myDeptIDs = $derived(session?.user?.department_ids ?? []) const myDeptIDs = $derived(session?.user?.department_ids ?? [])
// Auto-filter coleads to their department on mount // Auto-filter coleads to their department on mount
@ -33,6 +34,7 @@
db.volunteers.filter(v => !v.deleted_at).toArray() db.volunteers.filter(v => !v.deleted_at).toArray()
) )
const allParticipants = liveQuery(() => db.participants.toArray()) const allParticipants = liveQuery(() => db.participants.toArray())
const allTickets = liveQuery(() => db.tickets.filter(t => !t.deleted_at).toArray())
const allDepts = liveQuery(() => const allDepts = liveQuery(() =>
db.departments.filter(d => !d.deleted_at).toArray() db.departments.filter(d => !d.deleted_at).toArray()
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name))) .then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
@ -44,8 +46,10 @@
return list return list
.filter(v => { .filter(v => {
if (filterDept && v.department_id !== parseInt(filterDept)) return false if (filterDept && v.department_id !== parseInt(filterDept)) return false
if (filterChecked === 'true' && !v.checked_in) return false if (filterStatus === 'unconfirmed' && v.email_confirmed) return false
if (filterChecked === 'false' && v.checked_in) 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 (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
@ -62,6 +66,15 @@
} }
} }
async function confirmVolunteer(v) {
try {
const updated = await api.volunteers.confirm(v.id)
await db.volunteers.put(updated)
} catch (err) {
error = err.message
}
}
async function addVolunteer(e) { async function addVolunteer(e) {
e.preventDefault() e.preventDefault()
adding = true adding = true
@ -110,6 +123,11 @@
return ($allDepts ?? []).find(d => d.id === id) return ($allDepts ?? []).find(d => d.id === id)
} }
function participantHasTickets(participantId) {
if (!participantId) return false
return ($allTickets ?? []).some(t => t.participant_id === participantId)
}
function participantFor(id) { function participantFor(id) {
return ($allParticipants ?? []).find(p => p.id === id) ?? null return ($allParticipants ?? []).find(p => p.id === id) ?? null
} }
@ -181,10 +199,12 @@
{/each} {/each}
</select> </select>
{/if} {/if}
<select bind:value={filterChecked} style="width:auto"> <select bind:value={filterStatus} style="width:auto">
<option value="">All</option> <option value="">All statuses</option>
<option value="false">Not ready</option> <option value="unconfirmed">Unconfirmed</option>
<option value="true">Ready</option> <option value="registered">Registered</option>
<option value="confirmed">Confirmed</option>
<option value="ready">Ready</option>
</select> </select>
<span class="text-muted" style="font-size:0.85rem;white-space:nowrap"> <span class="text-muted" style="font-size:0.85rem;white-space:nowrap">
{filtered.length} shown {filtered.length} shown
@ -219,7 +239,9 @@
<span class="badge badge-lead" style="margin-left:0.4rem">Co-Lead</span> <span class="badge badge-lead" style="margin-left:0.4rem">Co-Lead</span>
{/if} {/if}
{#if !v.participant_id} {#if !v.participant_id}
<span class="badge badge-unchecked" style="margin-left:0.4rem" title="Not linked to a participant — no ticket record">No ticket</span> <span class="badge badge-unchecked" style="margin-left:0.4rem" title="Not linked to a participant">No ticket</span>
{:else if !participantHasTickets(v.participant_id)}
<span class="badge badge-partial" style="margin-left:0.4rem" title="Registered as volunteer but no ticket on file">No ticket</span>
{/if} {/if}
{#if v.email} {#if v.email}
<div class="text-muted" style="font-size:0.78rem">{v.email}</div> <div class="text-muted" style="font-size:0.78rem">{v.email}</div>
@ -236,9 +258,15 @@
{/if} {/if}
</td> </td>
<td class="td-status"> <td class="td-status">
<span class="badge {v.checked_in ? 'badge-checked' : v.email_confirmed ? 'badge-confirmed' : 'badge-unchecked'}"> {#if v.checked_in}
{v.checked_in ? 'Ready' : v.email_confirmed ? 'Confirmed' : 'Unconfirmed'} <span class="badge badge-checked">Ready</span>
</span> {:else if v.confirmed}
<span class="badge badge-confirmed">Confirmed</span>
{:else if v.email_confirmed}
<span class="badge badge-registered">Registered</span>
{:else}
<span class="badge badge-unchecked">Unconfirmed</span>
{/if}
{#if v.checked_in_at} {#if v.checked_in_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.checked_in_at).toLocaleTimeString()}
@ -252,6 +280,9 @@
</td> </td>
{#if canManage} {#if canManage}
<td class="td-actions"> <td class="td-actions">
{#if canConfirm && v.email_confirmed && !v.confirmed && v.department_id}
<button class="btn btn-primary btn-sm" onclick={() => confirmVolunteer(v)}>Confirm</button>
{/if}
<button class="btn btn-ghost btn-sm" onclick={() => toggleLead(v)} <button class="btn btn-ghost btn-sm" onclick={() => toggleLead(v)}
title={v.is_lead ? 'Remove co-lead' : 'Mark as co-lead'}> title={v.is_lead ? 'Remove co-lead' : 'Mark as co-lead'}>
{v.is_lead ? ' Co-Lead' : '+ Co-Lead'} {v.is_lead ? ' Co-Lead' : '+ Co-Lead'}

View file

@ -142,6 +142,20 @@ func (app *App) handleCheckInVolunteer(w http.ResponseWriter, r *http.Request) {
writeJSON(w, v) writeJSON(w, v)
} }
func (app *App) handleConfirmVolunteer(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, "invalid id", http.StatusBadRequest)
return
}
v, err := app.confirmVolunteer(id)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, v)
}
func (app *App) handleAssignShift(w http.ResponseWriter, r *http.Request) { func (app *App) handleAssignShift(w http.ResponseWriter, r *http.Request) {
volunteerID, err := strconv.Atoi(r.PathValue("id")) volunteerID, err := strconv.Atoi(r.PathValue("id"))
if err != nil { if err != nil {

View file

@ -126,6 +126,7 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
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}/checkin", auth(app.handleCheckInVolunteer, "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("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"))