From 72b245d6d6e01b3a29b921f8ec4d8cda553448e4 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 15:52:40 -0600 Subject: [PATCH] Set up Unconfirmed -> Registered -> Confirmed -> Ready flow for Volunteers --- db.go | 26 +++++++++++++- docs/USAGE.md | 13 ++++--- frontend/src/api.js | 1 + frontend/src/app.css | 7 ++-- frontend/src/pages/Volunteers.svelte | 53 ++++++++++++++++++++++------ handle_volunteers.go | 14 ++++++++ main.go | 1 + 7 files changed, 95 insertions(+), 20 deletions(-) diff --git a/db.go b/db.go index d93807d..be830db 100644 --- a/db.go +++ b/db.go @@ -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) } diff --git a/docs/USAGE.md b/docs/USAGE.md index 23572eb..949f71b 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -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. diff --git a/frontend/src/api.js b/frontend/src/api.js index b0767e6..6700d4b 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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' }), }, diff --git a/frontend/src/app.css b/frontend/src/app.css index d36f2f7..0cd0ee8 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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); } diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index ade758b..e26ee6a 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -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} {/if} - + + + + + {filtered.length} shown @@ -219,7 +239,9 @@ Co-Lead {/if} {#if !v.participant_id} - No ticket + No ticket + {:else if !participantHasTickets(v.participant_id)} + No ticket {/if} {#if v.email}
{v.email}
@@ -236,9 +258,15 @@ {/if} - - {v.checked_in ? 'Ready' : v.email_confirmed ? 'Confirmed' : 'Unconfirmed'} - + {#if v.checked_in} + Ready + {:else if v.confirmed} + Confirmed + {:else if v.email_confirmed} + Registered + {:else} + Unconfirmed + {/if} {#if v.checked_in_at}
{new Date(v.checked_in_at).toLocaleTimeString()} @@ -252,6 +280,9 @@ {#if canManage} + {#if canConfirm && v.email_confirmed && !v.confirmed && v.department_id} + + {/if}