diff --git a/docs/USAGE.md b/docs/USAGE.md index 949f71b..c08ec50 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -83,7 +83,7 @@ In **Settings**, the "Volunteer Signup" card controls: In **Settings**, the "Shift Signups" card has an open/close toggle: -- **Opening** signups generates kiosk codes for all confirmed volunteers and emails them their shift signup links. A confirmation dialog warns before sending. +- **Opening** signups generates kiosk codes for all registered (email-confirmed) volunteers and emails them their shift signup links. A confirmation dialog warns before sending. - **Closing** signups prevents new kiosk links from being issued on confirmation, but existing links continue to work. If a volunteer confirms their email while signups are already open, they receive their kiosk link immediately in the confirmation response and via email. @@ -92,9 +92,9 @@ If a volunteer confirms their email while signups are already open, they receive Under **Volunteers**, you can: -- Create volunteers manually (name, email, department) -- Assign volunteers to departments -- Mark volunteers as co-leads +- Create volunteers manually (name, email, department, co-lead, note) +- Edit existing volunteers (department, co-lead, note) via the inline Edit button +- Confirm registered volunteers (admin, staffing, colead) - Mark volunteers as ready (briefed at the volunteer station) ### Volunteer statuses @@ -106,7 +106,7 @@ Under **Volunteers**, you can: | **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. +**Confirmation** is a deliberate staff action — it signals that you're expecting the volunteer for shifts. Use the **Confirm** button on a registered volunteer's row. Marking a volunteer as a co-lead (`is_lead`) automatically confirms them. 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. @@ -135,7 +135,7 @@ The Volunteer Kiosk is the public-facing flow for volunteers: signup, email conf Kiosk links are generated and distributed automatically through the volunteer signup flow: 1. Volunteers sign up via the public signup form (`/volunteer-signup`) and confirm their email. -2. In **Settings**, open shift signups. This generates kiosk codes for all confirmed volunteers and emails them their links. A confirmation dialog warns before sending. +2. In **Settings**, open shift signups. This generates kiosk codes for all registered (email-confirmed) volunteers and emails them their links. A confirmation dialog warns before sending. 3. If a volunteer confirms their email while signups are already open, they receive their kiosk link immediately. **Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Kiosk links use this URL. diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index e26ee6a..441e415 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -18,6 +18,12 @@ let newIsLead = $state(false) let newNote = $state('') + let editID = $state(null) + let editDeptID = $state('') + let editIsLead = $state(false) + let editNote = $state('') + let saving = $state(false) + const role = $derived(session?.user?.role ?? '') const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role)) const canConfirm = $derived(['admin', 'staffing', 'colead'].includes(role)) @@ -100,15 +106,6 @@ } } - async function toggleLead(v) { - try { - const updated = await api.volunteers.update(v.id, { ...v, is_lead: !v.is_lead }) - await db.volunteers.put(updated) - } catch (err) { - error = err.message - } - } - async function deleteVolunteer(v) { if (!confirm(`Delete volunteer "${v.name}"?`)) return try { @@ -119,6 +116,36 @@ } } + function startEdit(v) { + editID = v.id + editDeptID = v.department_id ? String(v.department_id) : '' + editIsLead = v.is_lead + editNote = v.note ?? '' + } + + function cancelEdit() { + editID = null + } + + async function saveVolunteer(v) { + saving = true + error = '' + try { + const updated = await api.volunteers.update(v.id, { + ...v, + department_id: editDeptID ? parseInt(editDeptID) : null, + is_lead: editIsLead, + note: editNote, + }) + await db.volunteers.put(updated) + editID = null + } catch (err) { + error = err.message + } finally { + saving = false + } + } + function deptFor(id) { return ($allDepts ?? []).find(d => d.id === id) } @@ -231,66 +258,93 @@ {#each filtered as v (v.id)} {@const dept = deptFor(v.department_id)} - {@const participant = participantFor(v.participant_id)} - - - {v.name} - {#if v.is_lead} - Co-Lead - {/if} - {#if !v.participant_id} - No ticket - {:else if !participantHasTickets(v.participant_id)} - No ticket - {/if} - {#if v.email} -
{v.email}
- {/if} - {#if v.note} -
{v.note}
- {/if} - - - {#if dept} - {dept.name} - {:else} - — - {/if} - - - {#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()} -
- {/if} - - - {#if !v.checked_in} - checkIn(v)} /> - {/if} - - {#if canManage} - - {#if canConfirm && v.email_confirmed && !v.confirmed && v.department_id} - - {/if} - - + {#if editID === v.id} + + + {v.name} + {#if v.email}
{v.email}
{/if} - {/if} - + + + + + + + + + + + + + + + {:else} + + + {v.name} + {#if v.is_lead} + Co-Lead + {/if} + {#if !v.participant_id} + No ticket + {:else if !participantHasTickets(v.participant_id)} + No ticket + {/if} + {#if v.email} +
{v.email}
+ {/if} + {#if v.note} +
{v.note}
+ {/if} + + + {#if dept} + {dept.name} + {:else} + — + {/if} + + + {#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()} +
+ {/if} + + + {#if !v.checked_in} + checkIn(v)} /> + {/if} + + {#if canManage} + + {#if canConfirm && v.email_confirmed && !v.confirmed} + + {/if} + + + + {/if} + + {/if} {/each} @@ -305,5 +359,7 @@ .td-dept { width: 100%; order: 3; } .td-status { width: 100%; order: 4; } .td-actions { width: 100%; order: 5; display: flex; justify-content: flex-end; } + .edit-row td { width: 100%; } + .td-edit-dept, .td-edit-checks, .td-edit-note { width: 100%; } } diff --git a/handle_volunteers.go b/handle_volunteers.go index 15f976f..584927b 100644 --- a/handle_volunteers.go +++ b/handle_volunteers.go @@ -109,6 +109,10 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) { writeError(w, err.Error(), http.StatusInternalServerError) return } + + if v.IsLead { + app.confirmVolunteer(id) + } updated, _ := app.getVolunteer(id) writeJSON(w, updated) } diff --git a/handle_volunteers_test.go b/handle_volunteers_test.go new file mode 100644 index 0000000..e10815c --- /dev/null +++ b/handle_volunteers_test.go @@ -0,0 +1,141 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestConfirmVolunteer(t *testing.T) { + app := testApp(t) + mux := testMux(app) + admin := testAdminUser(t, app) + tok := testToken(t, app, admin) + + dept, _ := app.createDepartment(Department{Name: "Gate"}) + deptID := dept.ID + v, _ := app.createVolunteer(Volunteer{ + Name: "Titania", Email: "titania@test.com", + DepartmentID: &deptID, EmailConfirmed: true, + }) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + result := parseJSON(t, w) + vol := result["confirmed"] + if vol != true { + t.Error("expected confirmed=true in response") + } + + got, _ := app.getVolunteer(v.ID) + if got == nil || !got.Confirmed { + t.Error("volunteer should be confirmed in DB") + } + if got.ConfirmedAt == nil { + t.Error("confirmed_at should be set") + } +} + +func TestConfirmVolunteerIdempotent(t *testing.T) { + app := testApp(t) + mux := testMux(app) + admin := testAdminUser(t, app) + tok := testToken(t, app, admin) + + v, _ := app.createVolunteer(Volunteer{Name: "Puck", Email: "puck@test.com", EmailConfirmed: true}) + + // Confirm twice — second should be a no-op, not an error. + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) + if w.Code != 200 { + t.Fatalf("first confirm: %d", w.Code) + } + + w = httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) + if w.Code != 200 { + t.Fatalf("second confirm: %d", w.Code) + } +} + +func TestConfirmVolunteerRequiresRole(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + // Ticketing role should NOT be able to confirm volunteers. + ticketing := testUserWithRole(t, app, "ticket_lead", "ticketing", nil) + tok := testToken(t, app, ticketing) + + v, _ := app.createVolunteer(Volunteer{Name: "Helena", EmailConfirmed: true}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for ticketing role, got %d", w.Code) + } +} + +func TestUpdateVolunteerDepartment(t *testing.T) { + app := testApp(t) + mux := testMux(app) + admin := testAdminUser(t, app) + tok := testToken(t, app, admin) + + dept, _ := app.createDepartment(Department{Name: "Gate"}) + v, _ := app.createVolunteer(Volunteer{Name: "Hermia"}) + + // Assign department via update. + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{ + "name": "Hermia", "department_id": dept.ID, + }, tok)) + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + got, _ := app.getVolunteer(v.ID) + if got.DepartmentID == nil || *got.DepartmentID != dept.ID { + t.Errorf("department_id = %v, want %d", got.DepartmentID, dept.ID) + } +} + +func TestUpdateVolunteerCoLeadAutoConfirms(t *testing.T) { + app := testApp(t) + mux := testMux(app) + admin := testAdminUser(t, app) + tok := testToken(t, app, admin) + + dept, _ := app.createDepartment(Department{Name: "Build"}) + deptID := dept.ID + v, _ := app.createVolunteer(Volunteer{ + Name: "Lysander", Email: "lys@test.com", + DepartmentID: &deptID, EmailConfirmed: true, + }) + + // Verify not confirmed before update. + got, _ := app.getVolunteer(v.ID) + if got.Confirmed { + t.Fatal("should not be confirmed before update") + } + + // Update is_lead=true should auto-confirm. + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{ + "name": "Lysander", "department_id": deptID, "is_lead": true, + }, tok)) + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + got, _ = app.getVolunteer(v.ID) + if !got.IsLead { + t.Error("expected is_lead=true") + } + if !got.Confirmed { + t.Error("co-lead should be auto-confirmed") + } +}