Compare commits
2 commits
46e4fbb0a2
...
cc4dd76438
| Author | SHA1 | Date | |
|---|---|---|---|
| cc4dd76438 | |||
| ab3d9a0409 |
4 changed files with 278 additions and 74 deletions
|
|
@ -83,7 +83,7 @@ In **Settings**, the "Volunteer Signup" card controls:
|
||||||
|
|
||||||
In **Settings**, the "Shift Signups" card has an open/close toggle:
|
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.
|
- **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.
|
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:
|
Under **Volunteers**, you can:
|
||||||
|
|
||||||
- Create volunteers manually (name, email, department)
|
- Create volunteers manually (name, email, department, co-lead, note)
|
||||||
- Assign volunteers to departments
|
- Edit existing volunteers (department, co-lead, note) via the inline Edit button
|
||||||
- Mark volunteers as co-leads
|
- Confirm registered volunteers (admin, staffing, colead)
|
||||||
- Mark volunteers as ready (briefed at the volunteer station)
|
- Mark volunteers as ready (briefed at the volunteer station)
|
||||||
|
|
||||||
### Volunteer statuses
|
### 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 |
|
| **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 |
|
| **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.
|
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:
|
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.
|
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.
|
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.
|
**Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Kiosk links use this URL.
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,12 @@
|
||||||
let newIsLead = $state(false)
|
let newIsLead = $state(false)
|
||||||
let newNote = $state('')
|
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 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 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) {
|
async function deleteVolunteer(v) {
|
||||||
if (!confirm(`Delete volunteer "${v.name}"?`)) return
|
if (!confirm(`Delete volunteer "${v.name}"?`)) return
|
||||||
try {
|
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) {
|
function deptFor(id) {
|
||||||
return ($allDepts ?? []).find(d => d.id === id)
|
return ($allDepts ?? []).find(d => d.id === id)
|
||||||
}
|
}
|
||||||
|
|
@ -231,7 +258,36 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each filtered as v (v.id)}
|
{#each filtered as v (v.id)}
|
||||||
{@const dept = deptFor(v.department_id)}
|
{@const dept = deptFor(v.department_id)}
|
||||||
{@const participant = participantFor(v.participant_id)}
|
{#if editID === v.id}
|
||||||
|
<tr class="edit-row">
|
||||||
|
<td class="td-name" style="width:100%">
|
||||||
|
<strong>{v.name}</strong>
|
||||||
|
{#if v.email}<div class="text-muted" style="font-size:0.78rem">{v.email}</div>{/if}
|
||||||
|
</td>
|
||||||
|
<td class="td-edit-dept">
|
||||||
|
<select bind:value={editDeptID} style="margin:0">
|
||||||
|
<option value="">No department</option>
|
||||||
|
{#each $allDepts ?? [] as d}
|
||||||
|
<option value={String(d.id)}>{d.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="td-edit-checks">
|
||||||
|
<label style="display:flex;align-items:center;gap:0.4rem;cursor:pointer;white-space:nowrap">
|
||||||
|
<input type="checkbox" style="width:auto" bind:checked={editIsLead} /> Co-Lead
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td class="td-edit-note">
|
||||||
|
<input bind:value={editNote} placeholder="Note" style="margin:0" />
|
||||||
|
</td>
|
||||||
|
<td class="td-actions">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick={() => saveVolunteer(v)} disabled={saving}>
|
||||||
|
{saving ? '…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick={cancelEdit}>Cancel</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="td-name">
|
<td class="td-name">
|
||||||
<strong>{v.name}</strong>
|
<strong>{v.name}</strong>
|
||||||
|
|
@ -241,7 +297,10 @@
|
||||||
{#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</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)}
|
{: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>
|
<span class="badge badge-partial" style="margin-left:0.4rem" title="No ticket on file">No ticket</span>
|
||||||
|
{/if}
|
||||||
|
{#if v.ticket_name && v.ticket_name !== v.name}
|
||||||
|
<div class="text-muted" style="font-size:0.78rem">Ticket: {v.ticket_name}</div>
|
||||||
{/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>
|
||||||
|
|
@ -280,17 +339,15 @@
|
||||||
</td>
|
</td>
|
||||||
{#if canManage}
|
{#if canManage}
|
||||||
<td class="td-actions">
|
<td class="td-actions">
|
||||||
{#if canConfirm && v.email_confirmed && !v.confirmed && v.department_id}
|
{#if canConfirm && v.email_confirmed && !v.confirmed}
|
||||||
<button class="btn btn-primary btn-sm" onclick={() => confirmVolunteer(v)}>Confirm</button>
|
<button class="btn btn-primary btn-sm" onclick={() => confirmVolunteer(v)}>Confirm</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="btn btn-ghost btn-sm" onclick={() => toggleLead(v)}
|
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(v)}>Edit</button>
|
||||||
title={v.is_lead ? 'Remove co-lead' : 'Mark as co-lead'}>
|
|
||||||
{v.is_lead ? '− Co-Lead' : '+ Co-Lead'}
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-danger btn-sm" onclick={() => deleteVolunteer(v)}>Delete</button>
|
<button class="btn btn-danger btn-sm" onclick={() => deleteVolunteer(v)}>Delete</button>
|
||||||
</td>
|
</td>
|
||||||
{/if}
|
{/if}
|
||||||
</tr>
|
</tr>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
@ -305,5 +362,7 @@
|
||||||
.td-dept { width: 100%; order: 3; }
|
.td-dept { width: 100%; order: 3; }
|
||||||
.td-status { width: 100%; order: 4; }
|
.td-status { width: 100%; order: 4; }
|
||||||
.td-actions { width: 100%; order: 5; display: flex; justify-content: flex-end; }
|
.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%; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,10 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if v.IsLead {
|
||||||
|
app.confirmVolunteer(id)
|
||||||
|
}
|
||||||
updated, _ := app.getVolunteer(id)
|
updated, _ := app.getVolunteer(id)
|
||||||
writeJSON(w, updated)
|
writeJSON(w, updated)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
141
handle_volunteers_test.go
Normal file
141
handle_volunteers_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue