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", "email_confirmed INTEGER NOT NULL DEFAULT 0")
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).
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`)
@ -392,6 +394,8 @@ type Volunteer struct {
IsLead bool `json:"is_lead"`
CheckedIn bool `json:"checked_in"`
CheckedInAt *string `json:"checked_in_at,omitempty"`
Confirmed bool `json:"confirmed"`
ConfirmedAt *string `json:"confirmed_at,omitempty"`
EmailConfirmed bool `json:"email_confirmed"`
ConfirmationToken *string `json:"-"`
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.pronouns,''), v.pronouns),
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.created_at, v.updated_at, v.deleted_at`
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
}
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) {
rows, err := db.Query(q, args...)
if err != nil {
@ -1303,12 +1321,14 @@ 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, emailConfirmed int
var isLead, checkedIn, confirmed, emailConfirmed int
var confirmationToken sql.NullString
var confirmedAt sql.NullString
if err := rows.Scan(
&v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName,
&v.Email, &v.Phone, &v.Pronouns, &deptID,
&isLead, &checkedIn, &v.CheckedInAt,
&confirmed, &confirmedAt,
&emailConfirmed, &confirmationToken, &v.Note,
&v.CreatedAt, &v.UpdatedAt, &v.DeletedAt,
); err != nil {
@ -1329,8 +1349,12 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
if confirmationToken.Valid {
v.ConfirmationToken = &confirmationToken.String
}
if confirmedAt.Valid {
v.ConfirmedAt = &confirmedAt.String
}
v.IsLead = isLead == 1
v.CheckedIn = checkedIn == 1
v.Confirmed = confirmed == 1
v.EmailConfirmed = emailConfirmed == 1
result = append(result, v)
}

View file

@ -99,11 +99,14 @@ Under **Volunteers**, you can:
### Volunteer statuses
| Status | Meaning |
|--------|---------|
| **Unconfirmed** | Signed up but hasn't confirmed their email |
| **Confirmed** | Email confirmed, not yet briefed |
| **Ready** | Briefed at the volunteer station, has what they need to report for shifts |
| Status | Meaning | Who sets it |
|--------|---------|-------------|
| **Unconfirmed** | Signed up but hasn't confirmed their email | Automatic (not yet done) |
| **Registered** | Email confirmed — volunteer is in the system | Automatic (email link) |
| **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.

View file

@ -79,6 +79,7 @@ export const api = {
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' }),
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

@ -129,9 +129,10 @@ tr:hover td { background: rgba(255,255,255,0.02); }
font-size: 0.72rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.04em;
}
.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-unchecked { background: rgba(122,127,150,0.15); color: var(--c-muted); }
.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-registered { background: rgba(14,165,233,0.15); color: #0ea5e9; }
.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-role { background: rgba(99,102,241,0.15); color: var(--c-accent-h); }
.badge-lead { background: rgba(245,158,11,0.15); color: var(--c-warn); }

View file

@ -8,7 +8,7 @@
let search = $state('')
let filterDept = $state('')
let filterChecked = $state('')
let filterStatus = $state('')
let error = $state('')
let showAdd = $state(false)
let adding = $state(false)
@ -20,6 +20,7 @@
const role = $derived(session?.user?.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 ?? [])
// Auto-filter coleads to their department on mount
@ -33,6 +34,7 @@
db.volunteers.filter(v => !v.deleted_at).toArray()
)
const allParticipants = liveQuery(() => db.participants.toArray())
const allTickets = liveQuery(() => db.tickets.filter(t => !t.deleted_at).toArray())
const allDepts = liveQuery(() =>
db.departments.filter(d => !d.deleted_at).toArray()
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
@ -44,8 +46,10 @@
return list
.filter(v => {
if (filterDept && v.department_id !== parseInt(filterDept)) return false
if (filterChecked === 'true' && !v.checked_in) return false
if (filterChecked === 'false' && v.checked_in) 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 (s && !v.name.toLowerCase().includes(s) &&
!(v.email || '').toLowerCase().includes(s)) return false
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) {
e.preventDefault()
adding = true
@ -110,6 +123,11 @@
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) {
return ($allParticipants ?? []).find(p => p.id === id) ?? null
}
@ -181,10 +199,12 @@
{/each}
</select>
{/if}
<select bind:value={filterChecked} style="width:auto">
<option value="">All</option>
<option value="false">Not ready</option>
<option value="true">Ready</option>
<select bind:value={filterStatus} style="width:auto">
<option value="">All statuses</option>
<option value="unconfirmed">Unconfirmed</option>
<option value="registered">Registered</option>
<option value="confirmed">Confirmed</option>
<option value="ready">Ready</option>
</select>
<span class="text-muted" style="font-size:0.85rem;white-space:nowrap">
{filtered.length} shown
@ -219,7 +239,9 @@
<span class="badge badge-lead" style="margin-left:0.4rem">Co-Lead</span>
{/if}
{#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 v.email}
<div class="text-muted" style="font-size:0.78rem">{v.email}</div>
@ -236,9 +258,15 @@
{/if}
</td>
<td class="td-status">
<span class="badge {v.checked_in ? 'badge-checked' : v.email_confirmed ? 'badge-confirmed' : 'badge-unchecked'}">
{v.checked_in ? 'Ready' : v.email_confirmed ? 'Confirmed' : 'Unconfirmed'}
</span>
{#if v.checked_in}
<span class="badge badge-checked">Ready</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}
<div class="text-muted" style="font-size:0.75rem">
{new Date(v.checked_in_at).toLocaleTimeString()}
@ -252,6 +280,9 @@
</td>
{#if canManage}
<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)}
title={v.is_lead ? 'Remove co-lead' : 'Mark as 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)
}
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) {
volunteerID, err := strconv.Atoi(r.PathValue("id"))
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("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}/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"))