diff --git a/db.go b/db.go
index ef3a339..bc97c91 100644
--- a/db.go
+++ b/db.go
@@ -135,6 +135,11 @@ func migrateV2(db *sql.DB) error {
addColumnIfMissing(db, "attendees", "checked_in_count INTEGER NOT NULL DEFAULT 0")
addColumnIfMissing(db, "shifts", "position INTEGER NOT NULL DEFAULT 0")
addColumnIfMissing(db, "volunteer_shifts", "deleted_at TEXT")
+ addColumnIfMissing(db, "volunteers", "preferred_name TEXT NOT NULL DEFAULT ''")
+ addColumnIfMissing(db, "volunteers", "ticket_name 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", "confirmation_token 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`)
@@ -217,19 +222,24 @@ type Department struct {
}
type Volunteer struct {
- ID int `json:"id"`
- AttendeeID *int `json:"attendee_id,omitempty"`
- Name string `json:"name"`
- Email string `json:"email"`
- Phone string `json:"phone"`
- DepartmentID *int `json:"department_id,omitempty"`
- IsLead bool `json:"is_lead"`
- CheckedIn bool `json:"checked_in"`
- CheckedInAt *string `json:"checked_in_at,omitempty"`
- Note string `json:"note"`
- CreatedAt string `json:"created_at"`
- UpdatedAt string `json:"updated_at"`
- DeletedAt *string `json:"deleted_at,omitempty"`
+ ID int `json:"id"`
+ AttendeeID *int `json:"attendee_id,omitempty"`
+ Name string `json:"name"`
+ PreferredName string `json:"preferred_name"`
+ TicketName string `json:"ticket_name"`
+ Email string `json:"email"`
+ Phone string `json:"phone"`
+ Pronouns string `json:"pronouns"`
+ DepartmentID *int `json:"department_id,omitempty"`
+ IsLead bool `json:"is_lead"`
+ CheckedIn bool `json:"checked_in"`
+ CheckedInAt *string `json:"checked_in_at,omitempty"`
+ EmailConfirmed bool `json:"email_confirmed"`
+ ConfirmationToken *string `json:"-"`
+ Note string `json:"note"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+ DeletedAt *string `json:"deleted_at,omitempty"`
}
type Shift struct {
@@ -719,7 +729,7 @@ func queryDepartments(db *sql.DB, q string, args ...any) ([]Department, error) {
// --- Volunteers ---
-const volunteerCols = `id, attendee_id, name, email, phone, department_id, is_lead, checked_in, checked_in_at, note, created_at, updated_at, deleted_at`
+const volunteerCols = `id, attendee_id, name, preferred_name, ticket_name, email, phone, pronouns, department_id, is_lead, checked_in, checked_in_at, email_confirmed, confirmation_token, note, created_at, updated_at, deleted_at`
func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) {
q := `SELECT ` + volunteerCols + ` FROM volunteers WHERE 1=1`
@@ -763,9 +773,10 @@ func (app *App) getVolunteerByAttendeeID(attendeeID int) (*Volunteer, error) {
func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) {
res, err := app.db.Exec(
- `INSERT INTO volunteers (attendee_id, name, email, phone, department_id, is_lead, note, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
- v.AttendeeID, v.Name, v.Email, v.Phone, v.DepartmentID, boolInt(v.IsLead), v.Note, now(),
+ `INSERT INTO volunteers (attendee_id, name, preferred_name, ticket_name, email, phone, pronouns, department_id, is_lead, email_confirmed, confirmation_token, note, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ v.AttendeeID, v.Name, v.PreferredName, v.TicketName, v.Email, v.Phone, v.Pronouns,
+ v.DepartmentID, boolInt(v.IsLead), boolInt(v.EmailConfirmed), v.ConfirmationToken, v.Note, now(),
)
if err != nil {
return nil, err
@@ -776,9 +787,10 @@ func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) {
func (app *App) updateVolunteer(v Volunteer) error {
_, err := app.db.Exec(
- `UPDATE volunteers SET attendee_id=?, name=?, email=?, phone=?, department_id=?, is_lead=?, note=?, updated_at=?
+ `UPDATE volunteers SET attendee_id=?, name=?, preferred_name=?, ticket_name=?, email=?, phone=?, pronouns=?, department_id=?, is_lead=?, note=?, updated_at=?
WHERE id=? AND deleted_at IS NULL`,
- v.AttendeeID, v.Name, v.Email, v.Phone, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID,
+ v.AttendeeID, v.Name, v.PreferredName, v.TicketName, v.Email, v.Phone, v.Pronouns,
+ v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID,
)
return err
}
@@ -822,10 +834,13 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
for rows.Next() {
var v Volunteer
var attendeeID, deptID sql.NullInt64
- var isLead, checkedIn int
+ var isLead, checkedIn, emailConfirmed int
+ var confirmationToken sql.NullString
if err := rows.Scan(
- &v.ID, &attendeeID, &v.Name, &v.Email, &v.Phone, &deptID,
- &isLead, &checkedIn, &v.CheckedInAt, &v.Note,
+ &v.ID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName,
+ &v.Email, &v.Phone, &v.Pronouns, &deptID,
+ &isLead, &checkedIn, &v.CheckedInAt,
+ &emailConfirmed, &confirmationToken, &v.Note,
&v.CreatedAt, &v.UpdatedAt, &v.DeletedAt,
); err != nil {
return nil, err
@@ -838,13 +853,59 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
id := int(deptID.Int64)
v.DepartmentID = &id
}
+ if confirmationToken.Valid {
+ v.ConfirmationToken = &confirmationToken.String
+ }
v.IsLead = isLead == 1
v.CheckedIn = checkedIn == 1
+ v.EmailConfirmed = emailConfirmed == 1
result = append(result, v)
}
return result, rows.Err()
}
+func (app *App) getVolunteerByEmail(email string) (*Volunteer, error) {
+ rows, err := queryVolunteers(app.db,
+ `SELECT `+volunteerCols+` FROM volunteers WHERE LOWER(email) = LOWER(?) AND deleted_at IS NULL LIMIT 1`, email)
+ if err != nil || len(rows) == 0 {
+ return nil, err
+ }
+ return &rows[0], nil
+}
+
+func (app *App) getVolunteerByConfirmationToken(token string) (*Volunteer, error) {
+ rows, err := queryVolunteers(app.db,
+ `SELECT `+volunteerCols+` FROM volunteers WHERE confirmation_token = ? AND deleted_at IS NULL LIMIT 1`, token)
+ if err != nil || len(rows) == 0 {
+ return nil, err
+ }
+ return &rows[0], nil
+}
+
+func (app *App) confirmVolunteerEmail(id int) error {
+ _, err := app.db.Exec(
+ `UPDATE volunteers SET email_confirmed = 1, confirmation_token = NULL, updated_at = ? WHERE id = ?`,
+ now(), id)
+ return err
+}
+
+func (app *App) listConfirmedVolunteersWithoutKioskToken() ([]Volunteer, error) {
+ return queryVolunteers(app.db, `
+ SELECT `+volunteerCols+`
+ FROM volunteers
+ WHERE email_confirmed = 1 AND deleted_at IS NULL
+ AND attendee_id IS NOT NULL
+ AND (SELECT a.volunteer_token FROM attendees a WHERE a.id = volunteers.attendee_id) IS NULL`)
+}
+
+func generateConfirmationToken() (string, error) {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ return "", fmt.Errorf("read random: %w", err)
+ }
+ return fmt.Sprintf("%x", b), nil
+}
+
// --- Shifts ---
func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) {
diff --git a/email.go b/email.go
index 05c94f0..8d1d463 100644
--- a/email.go
+++ b/email.go
@@ -106,6 +106,22 @@ func sendEmail(cfg SMTPConfig, to, subject, body string) error {
return smtp.SendMail(addr, auth, cfg.From, []string{to}, []byte(msg))
}
+func (app *App) resolveBaseURL() string {
+ baseURL := app.baseURL
+ if baseURL == "" {
+ app.db.QueryRow(`SELECT value FROM config WHERE key = 'base_url'`).Scan(&baseURL)
+ }
+ return strings.TrimRight(baseURL, "/")
+}
+
+func (app *App) eventName() string {
+ event, _ := app.getEvent()
+ if event != nil && event.Name != "" {
+ return event.Name
+ }
+ return "the event"
+}
+
// sendTokenEmail sends a volunteer token link to the attendee's email address.
func (app *App) sendTokenEmail(a Attendee) error {
if a.Email == "" {
@@ -116,20 +132,8 @@ func (app *App) sendTokenEmail(a Attendee) error {
}
cfg := app.loadSMTPConfig()
-
- baseURL := app.baseURL
- if baseURL == "" {
- app.db.QueryRow(`SELECT value FROM config WHERE key = 'base_url'`).Scan(&baseURL)
- }
- baseURL = strings.TrimRight(baseURL, "/")
-
- event, _ := app.getEvent()
- eventName := "the event"
- if event != nil && event.Name != "" {
- eventName = event.Name
- }
-
- link := fmt.Sprintf("%s/#/v/%s", baseURL, *a.VolunteerToken)
+ eventName := app.eventName()
+ link := fmt.Sprintf("%s/#/v/%s", app.resolveBaseURL(), *a.VolunteerToken)
subject := fmt.Sprintf("Your volunteer link for %s", eventName)
body := fmt.Sprintf(
"Hi %s,\n\nThank you for volunteering at %s!\n\nYour volunteer token: %s\nYour signup link: %s\n\nUse this link to sign up for available shifts in your department.\n\nSee you there!\n",
@@ -138,3 +142,26 @@ func (app *App) sendTokenEmail(a Attendee) error {
return sendEmail(cfg, a.Email, subject, body)
}
+
+func (app *App) sendConfirmationEmail(to, name, confirmToken string) error {
+ cfg := app.loadSMTPConfig()
+ eventName := app.eventName()
+ link := fmt.Sprintf("%s/#/confirm/%s", app.resolveBaseURL(), confirmToken)
+ subject := fmt.Sprintf("Please confirm your email for %s", eventName)
+ body := fmt.Sprintf(
+ "Hi %s,\n\nThank you for signing up to volunteer at %s!\n\nPlease confirm your email address by visiting:\n%s\n\nIf you did not sign up, you can safely ignore this email.\n",
+ name, eventName, link,
+ )
+ return sendEmail(cfg, to, subject, body)
+}
+
+func (app *App) sendShiftSignupEmail(to, name, kioskLink string) error {
+ cfg := app.loadSMTPConfig()
+ eventName := app.eventName()
+ subject := fmt.Sprintf("Shift signups are open for %s!", eventName)
+ body := fmt.Sprintf(
+ "Hi %s,\n\nShift signups are now open for %s!\n\nUse this link to sign up for available shifts:\n%s\n\nSee you there!\n",
+ name, eventName, kioskLink,
+ )
+ return sendEmail(cfg, to, subject, body)
+}
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 52ed392..179cc2c 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -11,6 +11,8 @@
import Users from './pages/Users.svelte'
import Import from './pages/Import.svelte'
import Kiosk from './pages/Kiosk.svelte'
+ import VolunteerSignup from './pages/VolunteerSignup.svelte'
+ import ConfirmEmail from './pages/ConfirmEmail.svelte'
import GateUI from './pages/GateUI.svelte'
import ScheduleBoard from './pages/ScheduleBoard.svelte'
import Settings from './pages/Settings.svelte'
@@ -25,8 +27,11 @@
let updateAvailable = $state(false)
let mobileNavOpen = $state(false)
- // Check if this is a kiosk token URL before doing anything else
+ // Check if this is a public page (no auth needed)
const kioskToken = $derived(window.location.hash.match(/^#\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
+ const isVolunteerSignup = $derived(window.location.hash.startsWith('#/volunteer-signup'))
+ const isConfirmEmail = $derived(window.location.hash.startsWith('#/confirm/'))
+ const isPublicPage = $derived(!!kioskToken || isVolunteerSignup || isConfirmEmail)
async function checkVersion() {
try {
@@ -39,8 +44,8 @@
onMount(async () => {
checkVersion()
- // Kiosk pages don't need auth
- if (kioskToken) {
+ // Public pages don't need auth
+ if (isPublicPage) {
loading = false
return
}
@@ -87,6 +92,10 @@
{:else if kioskToken}
+{:else if isVolunteerSignup}
+
+{:else if isConfirmEmail}
+
{:else if !session}
{:else if role === 'gate'}
diff --git a/frontend/src/api.js b/frontend/src/api.js
index c6f6e11..d7ea497 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -109,6 +109,12 @@ export const api = {
get: () => apiJSON('/api/settings'),
update: (data) => apiJSON('/api/settings', { method: 'PUT', body: JSON.stringify(data) }),
testEmail: (to) => apiJSON('/api/settings/test-email', { method: 'POST', body: JSON.stringify({ to }) }),
+ toggleShiftSignups: (open) => apiJSON('/api/settings/shift-signups', { method: 'POST', body: JSON.stringify({ open }) }),
+ },
+ signup: {
+ config: () => kioskFetch('/api/public/signup-config'),
+ submit: (data) => kioskFetch('/api/public/signup', { method: 'POST', body: JSON.stringify(data) }),
+ confirm: (token) => kioskFetch('/api/public/confirm', { method: 'POST', body: JSON.stringify({ token }) }),
},
import: async (formData) => {
const res = await apiFetch('/api/import', { method: 'POST', body: formData })
diff --git a/frontend/src/api.test.js b/frontend/src/api.test.js
index 25e0dd9..f6527f5 100644
--- a/frontend/src/api.test.js
+++ b/frontend/src/api.test.js
@@ -96,3 +96,50 @@ describe('api methods', () => {
expect(f.mock.calls[0][0]).toBe('/api/sync/pull')
})
})
+
+describe('signup methods', () => {
+ it('signup.config fetches config without auth', async () => {
+ const f = mockFetch({ departments: [], volunteer_note_label: 'Note' })
+ await api.signup.config()
+ const [url, opts] = f.mock.calls[0]
+ expect(url).toBe('/api/public/signup-config')
+ expect(opts.headers['Authorization']).toBeUndefined()
+ })
+
+ it('signup.submit posts form data without auth', async () => {
+ const f = mockFetch({ ok: true })
+ await api.signup.submit({ preferred_name: 'Titania', email: 'titania@example.com' })
+ const [url, opts] = f.mock.calls[0]
+ expect(url).toBe('/api/public/signup')
+ expect(opts.method).toBe('POST')
+ expect(JSON.parse(opts.body)).toEqual({ preferred_name: 'Titania', email: 'titania@example.com' })
+ expect(opts.headers['Authorization']).toBeUndefined()
+ })
+
+ it('signup.confirm posts token without auth', async () => {
+ const f = mockFetch({ status: 'confirmed' })
+ await api.signup.confirm('abc123')
+ const [url, opts] = f.mock.calls[0]
+ expect(url).toBe('/api/public/confirm')
+ expect(opts.method).toBe('POST')
+ expect(JSON.parse(opts.body)).toEqual({ token: 'abc123' })
+ expect(opts.headers['Authorization']).toBeUndefined()
+ })
+
+ it('signup.submit throws on 400', async () => {
+ mockFetch({ error: 'preferred name and email are required' }, 400)
+ await expect(api.signup.submit({})).rejects.toThrow('preferred name and email are required')
+ })
+})
+
+describe('settings shift signups', () => {
+ it('toggleShiftSignups posts open flag', async () => {
+ await saveSession('tok', { id: 1 })
+ const f = mockFetch({ shift_signups_open: true })
+ await api.settings.toggleShiftSignups(true)
+ const [url, opts] = f.mock.calls[0]
+ expect(url).toBe('/api/settings/shift-signups')
+ expect(opts.method).toBe('POST')
+ expect(JSON.parse(opts.body)).toEqual({ open: true })
+ })
+})
diff --git a/frontend/src/pages/ConfirmEmail.svelte b/frontend/src/pages/ConfirmEmail.svelte
new file mode 100644
index 0000000..39babea
--- /dev/null
+++ b/frontend/src/pages/ConfirmEmail.svelte
@@ -0,0 +1,148 @@
+
+
+
+
+
+
+ {#if status === 'loading'}
+
Confirming...
+ {:else if status === 'confirmed'}
+
+
✓
+
Email Confirmed
+
+ Your email address has been verified. Thank you for signing up!
+
+ {#if kioskLink}
+
+ {/if}
+
+ {:else if status === 'already_confirmed'}
+
+
Already Confirmed
+
+ This email address was already confirmed. No further action needed.
+
+
+ {:else if status === 'error'}
+
{error}
+ {:else}
+
+
Invalid Link
+
+ This confirmation link is not valid or has already been used.
+
+
+ {/if}
+
+
+
+
diff --git a/frontend/src/pages/Settings.svelte b/frontend/src/pages/Settings.svelte
index 060aefd..28193e4 100644
--- a/frontend/src/pages/Settings.svelte
+++ b/frontend/src/pages/Settings.svelte
@@ -16,6 +16,10 @@
let smtpFromName = $state('')
let baseURL = $state('')
let testEmail = $state('')
+ let noteLabel = $state('Additional note')
+ let noteRequired = $state(false)
+ let shiftSignupsOpen = $state(false)
+ let togglingSignups = $state(false)
onMount(async () => {
try {
@@ -27,6 +31,9 @@
smtpFrom = s.smtp_from ?? ''
smtpFromName = s.smtp_from_name ?? ''
baseURL = s.base_url ?? ''
+ noteLabel = s.volunteer_note_label ?? 'Additional note'
+ noteRequired = s.volunteer_note_required ?? false
+ shiftSignupsOpen = s.shift_signups_open ?? false
} catch (err) {
error = err.message
} finally {
@@ -48,6 +55,8 @@
smtp_from: smtpFrom,
smtp_from_name: smtpFromName,
base_url: baseURL,
+ volunteer_note_label: noteLabel,
+ volunteer_note_required: noteRequired,
})
smtpPassword = ''
success = 'Settings saved.'
@@ -58,6 +67,23 @@
}
}
+ async function toggleSignups() {
+ const opening = !shiftSignupsOpen
+ if (opening && !confirm('This will email all confirmed volunteers their shift signup links. Continue?')) return
+ togglingSignups = true
+ error = ''
+ success = ''
+ try {
+ const result = await api.settings.toggleShiftSignups(opening)
+ shiftSignupsOpen = result.shift_signups_open
+ success = opening ? 'Shift signups opened. Emails are being sent.' : 'Shift signups closed.'
+ } catch (err) {
+ error = err.message
+ } finally {
+ togglingSignups = false
+ }
+ }
+
async function sendTest() {
if (!testEmail) return
testing = true
@@ -135,7 +161,7 @@
-
+
Test Email
+
+
+
+
Volunteer Signup
+
+
+
+
+
+
+ Signup form: /#/volunteer-signup
+
+
+
+
+
+
Shift Signups
+
+
+ Status: {shiftSignupsOpen ? 'Open' : 'Closed'}
+
+
+
+ {#if !shiftSignupsOpen}
+
+ Opening signups will email all confirmed volunteers their shift signup links.
+
+ {/if}
+
{/if}
diff --git a/frontend/src/pages/VolunteerSignup.svelte b/frontend/src/pages/VolunteerSignup.svelte
new file mode 100644
index 0000000..8681265
--- /dev/null
+++ b/frontend/src/pages/VolunteerSignup.svelte
@@ -0,0 +1,241 @@
+
+
+
+
+
+
+ {#if loading}
+
Loading...
+ {:else if submitted}
+
+
Thank you!
+
+ We've sent a confirmation email to {email}.
+ Please check your inbox and click the link to confirm your signup.
+
+
+ {:else}
+ {#if config?.event_name && config.event_name !== 'the event'}
+
{config.event_name}
+ {/if}
+
+ {#if error}
+
{error}
+ {/if}
+
+
+ {/if}
+
+
+
+
diff --git a/handle_settings.go b/handle_settings.go
index bf19d13..27420fa 100644
--- a/handle_settings.go
+++ b/handle_settings.go
@@ -19,14 +19,25 @@ func (app *App) handleGetSettings(w http.ResponseWriter, r *http.Request) {
pass = "***"
}
+ var noteLabel, noteRequired, signupsOpen string
+ app.db.QueryRow(`SELECT value FROM config WHERE key = 'volunteer_note_label'`).Scan(¬eLabel)
+ app.db.QueryRow(`SELECT value FROM config WHERE key = 'volunteer_note_required'`).Scan(¬eRequired)
+ app.db.QueryRow(`SELECT value FROM config WHERE key = 'shift_signups_open'`).Scan(&signupsOpen)
+ if noteLabel == "" {
+ noteLabel = "Additional note"
+ }
+
writeJSON(w, map[string]any{
- "smtp_host": cfg.Host,
- "smtp_port": cfg.Port,
- "smtp_user": cfg.User,
- "smtp_password": pass,
- "smtp_from": cfg.From,
- "smtp_from_name": cfg.FromName,
- "base_url": baseURL,
+ "smtp_host": cfg.Host,
+ "smtp_port": cfg.Port,
+ "smtp_user": cfg.User,
+ "smtp_password": pass,
+ "smtp_from": cfg.From,
+ "smtp_from_name": cfg.FromName,
+ "base_url": baseURL,
+ "volunteer_note_label": noteLabel,
+ "volunteer_note_required": noteRequired == "true",
+ "shift_signups_open": signupsOpen == "true",
})
}
@@ -37,7 +48,8 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
return
}
- keys := []string{"smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_from_name", "base_url"}
+ keys := []string{"smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_from_name", "base_url",
+ "volunteer_note_label", "volunteer_note_required"}
for _, k := range keys {
v, ok := body[k]
if !ok {
@@ -47,11 +59,17 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
switch vv := v.(type) {
case string:
if k == "smtp_password" && vv == "" {
- continue // don't erase the stored password with an empty value
+ continue
}
val = vv
case float64:
val = strconv.Itoa(int(vv))
+ case bool:
+ if vv {
+ val = "true"
+ } else {
+ val = "false"
+ }
default:
continue
}
diff --git a/handle_signup.go b/handle_signup.go
new file mode 100644
index 0000000..62ab6c2
--- /dev/null
+++ b/handle_signup.go
@@ -0,0 +1,247 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "strings"
+)
+
+func (app *App) handlePublicSignupConfig(w http.ResponseWriter, r *http.Request) {
+ var noteLabel, noteRequired string
+ app.db.QueryRow(`SELECT value FROM config WHERE key = 'volunteer_note_label'`).Scan(¬eLabel)
+ app.db.QueryRow(`SELECT value FROM config WHERE key = 'volunteer_note_required'`).Scan(¬eRequired)
+ if noteLabel == "" {
+ noteLabel = "Additional note"
+ }
+
+ depts, _ := app.listDepartments("")
+ deptList := []map[string]any{}
+ for _, d := range depts {
+ deptList = append(deptList, map[string]any{"id": d.ID, "name": d.Name, "color": d.Color})
+ }
+
+ eventName := app.eventName()
+
+ writeJSON(w, map[string]any{
+ "event_name": eventName,
+ "departments": deptList,
+ "volunteer_note_label": noteLabel,
+ "volunteer_note_required": noteRequired == "true",
+ })
+}
+
+func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ PreferredName string `json:"preferred_name"`
+ TicketName string `json:"ticket_name"`
+ Email string `json:"email"`
+ Pronouns string `json:"pronouns"`
+ Phone string `json:"phone"`
+ DepartmentID *int `json:"department_id"`
+ Note string `json:"note"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeError(w, "invalid request", http.StatusBadRequest)
+ return
+ }
+
+ body.PreferredName = strings.TrimSpace(body.PreferredName)
+ body.Email = strings.TrimSpace(body.Email)
+ if body.PreferredName == "" || body.Email == "" {
+ writeError(w, "preferred name and email are required", http.StatusBadRequest)
+ return
+ }
+
+ var noteRequired string
+ app.db.QueryRow(`SELECT value FROM config WHERE key = 'volunteer_note_required'`).Scan(¬eRequired)
+ if noteRequired == "true" && strings.TrimSpace(body.Note) == "" {
+ writeError(w, "note field is required", http.StatusBadRequest)
+ return
+ }
+
+ // Don't reveal whether email is already registered
+ existing, _ := app.getVolunteerByEmail(body.Email)
+ if existing != nil {
+ writeJSON(w, map[string]any{"ok": true})
+ return
+ }
+
+ // Auto-match attendee by email or create new
+ var attendeeID *int
+ attendees, _ := app.listAttendees("", "", "")
+ for _, a := range attendees {
+ if strings.EqualFold(a.Email, body.Email) {
+ id := a.ID
+ attendeeID = &id
+ break
+ }
+ }
+
+ if attendeeID == nil {
+ name := body.PreferredName
+ if body.TicketName != "" {
+ name = body.TicketName
+ }
+ newAttendee, err := app.createAttendee(Attendee{
+ Name: name,
+ Email: body.Email,
+ Phone: body.Phone,
+ })
+ if err != nil {
+ writeError(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ attendeeID = &newAttendee.ID
+ }
+
+ confirmToken, err := generateConfirmationToken()
+ if err != nil {
+ writeError(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ vol := Volunteer{
+ AttendeeID: attendeeID,
+ Name: body.PreferredName,
+ PreferredName: body.PreferredName,
+ TicketName: body.TicketName,
+ Email: body.Email,
+ Phone: body.Phone,
+ Pronouns: body.Pronouns,
+ DepartmentID: body.DepartmentID,
+ Note: body.Note,
+ ConfirmationToken: &confirmToken,
+ }
+
+ if _, err := app.createVolunteer(vol); err != nil {
+ writeError(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ go func() {
+ if err := app.sendConfirmationEmail(body.Email, body.PreferredName, confirmToken); err != nil {
+ log.Printf("confirmation email to %s failed: %v", body.Email, err)
+ }
+ }()
+
+ writeJSON(w, map[string]any{"ok": true})
+}
+
+func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Token == "" {
+ writeError(w, "invalid request", http.StatusBadRequest)
+ return
+ }
+
+ vol, err := app.getVolunteerByConfirmationToken(body.Token)
+ if err != nil || vol == nil {
+ writeJSON(w, map[string]any{"status": "invalid"})
+ return
+ }
+
+ if vol.EmailConfirmed {
+ writeJSON(w, map[string]any{"status": "already_confirmed"})
+ return
+ }
+
+ if err := app.confirmVolunteerEmail(vol.ID); err != nil {
+ writeError(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ response := map[string]any{"status": "confirmed"}
+
+ var signupsOpen string
+ app.db.QueryRow(`SELECT value FROM config WHERE key = 'shift_signups_open'`).Scan(&signupsOpen)
+
+ if signupsOpen == "true" && vol.AttendeeID != nil {
+ a, _ := app.getAttendee(*vol.AttendeeID)
+ if a != nil && a.VolunteerToken == nil {
+ t, err := app.generateUniqueToken()
+ if err == nil {
+ app.db.Exec(`UPDATE attendees SET volunteer_token=?, updated_at=? WHERE id=?`, t, now(), a.ID)
+ a.VolunteerToken = &t
+ }
+ }
+ if a != nil && a.VolunteerToken != nil {
+ kioskLink := fmt.Sprintf("%s/#/v/%s", app.resolveBaseURL(), *a.VolunteerToken)
+ response["kiosk_link"] = kioskLink
+ go func() {
+ if err := app.sendShiftSignupEmail(vol.Email, vol.PreferredName, kioskLink); err != nil {
+ log.Printf("shift signup email to %s failed: %v", vol.Email, err)
+ }
+ }()
+ }
+ }
+
+ writeJSON(w, response)
+}
+
+func (app *App) handleToggleShiftSignups(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Open bool `json:"open"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeError(w, "invalid request", http.StatusBadRequest)
+ return
+ }
+ val := "false"
+ if body.Open {
+ val = "true"
+ }
+ app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', ?)`, val)
+
+ if body.Open {
+ go app.openShiftSignups()
+ }
+ writeJSON(w, map[string]any{"shift_signups_open": body.Open})
+}
+
+func (app *App) openShiftSignups() {
+ // Generate kiosk tokens for confirmed volunteers whose attendees lack one
+ vols, _ := app.listConfirmedVolunteersWithoutKioskToken()
+ for _, v := range vols {
+ if v.AttendeeID == nil {
+ continue
+ }
+ t, err := app.generateUniqueToken()
+ if err != nil {
+ continue
+ }
+ app.db.Exec(`UPDATE attendees SET volunteer_token=?, updated_at=? WHERE id=?`, t, now(), *v.AttendeeID)
+ }
+
+ // Email all confirmed volunteers with kiosk links
+ confirmed, _ := queryVolunteers(app.db, `
+ SELECT `+volunteerCols+`
+ FROM volunteers
+ WHERE email_confirmed = 1 AND deleted_at IS NULL AND attendee_id IS NOT NULL`)
+ baseURL := app.resolveBaseURL()
+ sent := 0
+
+ for _, v := range confirmed {
+ if v.AttendeeID == nil || v.Email == "" {
+ continue
+ }
+ a, _ := app.getAttendee(*v.AttendeeID)
+ if a == nil || a.VolunteerToken == nil {
+ continue
+ }
+ kioskLink := fmt.Sprintf("%s/#/v/%s", baseURL, *a.VolunteerToken)
+ name := v.PreferredName
+ if name == "" {
+ name = v.Name
+ }
+ if err := app.sendShiftSignupEmail(v.Email, name, kioskLink); err == nil {
+ sent++
+ } else {
+ log.Printf("shift signup email to %s failed: %v", v.Email, err)
+ }
+ }
+ log.Printf("Shift signups opened: sent %d emails", sent)
+}
diff --git a/handle_signup_test.go b/handle_signup_test.go
new file mode 100644
index 0000000..a59da69
--- /dev/null
+++ b/handle_signup_test.go
@@ -0,0 +1,333 @@
+package main
+
+import (
+ "net/http/httptest"
+ "testing"
+)
+
+func TestPublicSignupConfig(t *testing.T) {
+ app := testApp(t)
+ mux := testMux(app)
+
+ app.createDepartment(Department{Name: "Setup", Color: "#ff0000"})
+ app.createDepartment(Department{Name: "Teardown", Color: "#00ff00"})
+ app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('volunteer_note_label', 'Who sent you?')`)
+ app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('volunteer_note_required', 'true')`)
+
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, testRequest("GET", "/api/public/signup-config", nil))
+ if w.Code != 200 {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+ result := parseJSON(t, w)
+ depts, ok := result["departments"].([]any)
+ if !ok || len(depts) != 2 {
+ t.Fatalf("expected 2 departments, got %v", result["departments"])
+ }
+ if result["volunteer_note_label"] != "Who sent you?" {
+ t.Errorf("expected 'Who sent you?', got %v", result["volunteer_note_label"])
+ }
+ if result["volunteer_note_required"] != true {
+ t.Errorf("expected note required true, got %v", result["volunteer_note_required"])
+ }
+}
+
+func TestPublicSignup(t *testing.T) {
+ app := testApp(t)
+ mux := testMux(app)
+ app.createDepartment(Department{Name: "Setup", Color: "#ff0000"})
+
+ w := httptest.NewRecorder()
+ deptID := 1
+ mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{
+ "preferred_name": "Titania",
+ "email": "titania@example.com",
+ "pronouns": "she/they",
+ "department_id": deptID,
+ }))
+ if w.Code != 200 {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ result := parseJSON(t, w)
+ if result["ok"] != true {
+ t.Fatalf("expected ok true, got %v", result)
+ }
+
+ // Volunteer should exist
+ vol, err := app.getVolunteerByEmail("titania@example.com")
+ if err != nil || vol == nil {
+ t.Fatal("volunteer not created")
+ }
+ if vol.PreferredName != "Titania" {
+ t.Errorf("preferred_name = %q, want Titania", vol.PreferredName)
+ }
+ if vol.Pronouns != "she/they" {
+ t.Errorf("pronouns = %q, want she/they", vol.Pronouns)
+ }
+ if vol.ConfirmationToken == nil || *vol.ConfirmationToken == "" {
+ t.Error("expected confirmation token to be set")
+ }
+ if vol.EmailConfirmed {
+ t.Error("should not be confirmed yet")
+ }
+
+ // Attendee should be auto-created and linked
+ if vol.AttendeeID == nil {
+ t.Fatal("expected attendee to be linked")
+ }
+ a, _ := app.getAttendee(*vol.AttendeeID)
+ if a == nil {
+ t.Fatal("linked attendee not found")
+ }
+ if a.Email != "titania@example.com" {
+ t.Errorf("attendee email = %q, want titania@example.com", a.Email)
+ }
+}
+
+func TestPublicSignupAutoMatchAttendee(t *testing.T) {
+ app := testApp(t)
+ mux := testMux(app)
+
+ // Pre-existing attendee
+ existing, _ := app.createAttendee(Attendee{Name: "Titania Fairweather", Email: "titania@example.com"})
+
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{
+ "preferred_name": "Titania",
+ "ticket_name": "Titania Fairweather",
+ "email": "titania@example.com",
+ }))
+ if w.Code != 200 {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+
+ vol, _ := app.getVolunteerByEmail("titania@example.com")
+ if vol == nil {
+ t.Fatal("volunteer not created")
+ }
+ if vol.AttendeeID == nil || *vol.AttendeeID != existing.ID {
+ t.Errorf("expected volunteer linked to existing attendee %d, got %v", existing.ID, vol.AttendeeID)
+ }
+}
+
+func TestPublicSignupDuplicateEmail(t *testing.T) {
+ app := testApp(t)
+ mux := testMux(app)
+
+ // First signup
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{
+ "preferred_name": "Titania",
+ "email": "titania@example.com",
+ }))
+ if w.Code != 200 {
+ t.Fatalf("first signup: expected 200, got %d", w.Code)
+ }
+
+ // Second signup with same email — should silently succeed
+ w = httptest.NewRecorder()
+ mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{
+ "preferred_name": "Puck",
+ "email": "titania@example.com",
+ }))
+ if w.Code != 200 {
+ t.Fatalf("duplicate signup: expected 200, got %d", w.Code)
+ }
+ result := parseJSON(t, w)
+ if result["ok"] != true {
+ t.Fatalf("expected ok true for duplicate, got %v", result)
+ }
+
+ // Should still be only one volunteer
+ vols, _ := app.listVolunteers("", nil, "")
+ if len(vols) != 1 {
+ t.Errorf("expected 1 volunteer, got %d", len(vols))
+ }
+}
+
+func TestPublicSignupMissingFields(t *testing.T) {
+ app := testApp(t)
+ mux := testMux(app)
+
+ tests := []struct {
+ name string
+ body map[string]any
+ }{
+ {"no name", map[string]any{"email": "a@b.com"}},
+ {"no email", map[string]any{"preferred_name": "Titania"}},
+ {"empty both", map[string]any{}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", tt.body))
+ if w.Code != 400 {
+ t.Errorf("expected 400, got %d", w.Code)
+ }
+ })
+ }
+}
+
+func TestPublicSignupNoteRequired(t *testing.T) {
+ app := testApp(t)
+ mux := testMux(app)
+ app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('volunteer_note_required', 'true')`)
+
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{
+ "preferred_name": "Titania",
+ "email": "titania@example.com",
+ "note": "",
+ }))
+ if w.Code != 400 {
+ t.Fatalf("expected 400 when note required but empty, got %d", w.Code)
+ }
+
+ // With note provided
+ w = httptest.NewRecorder()
+ mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{
+ "preferred_name": "Titania",
+ "email": "titania@example.com",
+ "note": "A friend sent me",
+ }))
+ if w.Code != 200 {
+ t.Fatalf("expected 200 with note, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestConfirmEmail(t *testing.T) {
+ app := testApp(t)
+ mux := testMux(app)
+
+ token := "abc123def456"
+ app.createVolunteer(Volunteer{
+ Name: "Titania",
+ PreferredName: "Titania",
+ Email: "titania@example.com",
+ ConfirmationToken: &token,
+ })
+
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token}))
+ if w.Code != 200 {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+ result := parseJSON(t, w)
+ if result["status"] != "confirmed" {
+ t.Errorf("expected confirmed, got %v", result["status"])
+ }
+
+ // Verify volunteer is confirmed
+ vol, _ := app.getVolunteerByEmail("titania@example.com")
+ if vol == nil || !vol.EmailConfirmed {
+ t.Error("volunteer should be email confirmed")
+ }
+ if vol.ConfirmationToken != nil {
+ t.Error("confirmation token should be cleared after confirmation")
+ }
+}
+
+func TestConfirmEmailInvalid(t *testing.T) {
+ app := testApp(t)
+ mux := testMux(app)
+
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": "nonexistent"}))
+ if w.Code != 200 {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+ result := parseJSON(t, w)
+ if result["status"] != "invalid" {
+ t.Errorf("expected invalid, got %v", result["status"])
+ }
+}
+
+func TestConfirmEmailAlreadyConfirmed(t *testing.T) {
+ app := testApp(t)
+ mux := testMux(app)
+
+ token := "abc123def456"
+ app.createVolunteer(Volunteer{
+ Name: "Titania",
+ PreferredName: "Titania",
+ Email: "titania@example.com",
+ ConfirmationToken: &token,
+ })
+
+ // Confirm first time
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token}))
+ if parseJSON(t, w)["status"] != "confirmed" {
+ t.Fatal("first confirm should succeed")
+ }
+
+ // Second confirm with same token should be invalid (token cleared)
+ w = httptest.NewRecorder()
+ mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token}))
+ result := parseJSON(t, w)
+ if result["status"] != "invalid" {
+ t.Errorf("expected invalid after token cleared, got %v", result["status"])
+ }
+}
+
+func TestConfirmEmailWithSignupsOpen(t *testing.T) {
+ app := testApp(t)
+ mux := testMux(app)
+
+ app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`)
+ app.baseURL = "https://example.com"
+
+ attendee, _ := app.createAttendee(Attendee{Name: "Titania", Email: "titania@example.com"})
+ token := "abc123def456"
+ app.createVolunteer(Volunteer{
+ Name: "Titania",
+ PreferredName: "Titania",
+ Email: "titania@example.com",
+ AttendeeID: &attendee.ID,
+ ConfirmationToken: &token,
+ })
+
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token}))
+ if w.Code != 200 {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+ result := parseJSON(t, w)
+ if result["status"] != "confirmed" {
+ t.Fatalf("expected confirmed, got %v", result["status"])
+ }
+ kioskLink, ok := result["kiosk_link"].(string)
+ if !ok || kioskLink == "" {
+ t.Error("expected kiosk_link when signups are open")
+ }
+
+ // Attendee should now have a kiosk token
+ a, _ := app.getAttendee(attendee.ID)
+ if a.VolunteerToken == nil {
+ t.Error("attendee should have kiosk token after confirm with signups open")
+ }
+}
+
+func TestToggleShiftSignups(t *testing.T) {
+ app := testApp(t)
+ mux := testMux(app)
+ admin := testAdminUser(t, app)
+ tok := testToken(t, app, admin)
+
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/shift-signups", map[string]any{"open": true}, tok))
+ if w.Code != 200 {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ result := parseJSON(t, w)
+ if result["shift_signups_open"] != true {
+ t.Errorf("expected shift_signups_open true, got %v", result)
+ }
+
+ // Check config stored
+ var val string
+ app.db.QueryRow(`SELECT value FROM config WHERE key = 'shift_signups_open'`).Scan(&val)
+ if val != "true" {
+ t.Errorf("config not stored, got %q", val)
+ }
+}
diff --git a/main.go b/main.go
index aca8086..fa05ac6 100644
--- a/main.go
+++ b/main.go
@@ -151,6 +151,13 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
writeJSON(w, map[string]string{"build": buildID})
})
+ mux.HandleFunc("POST /api/settings/shift-signups", auth(app.handleToggleShiftSignups, "admin", "volunteer_lead"))
+
+ // Public endpoints — no JWT required.
+ mux.HandleFunc("GET /api/public/signup-config", app.handlePublicSignupConfig)
+ mux.HandleFunc("POST /api/public/signup", app.handlePublicSignup)
+ mux.HandleFunc("POST /api/public/confirm", app.handleConfirmEmail)
+
// Kiosk — authenticated by volunteer token, no JWT required.
mux.HandleFunc("GET /api/v/{token}", app.handleKioskGet)
mux.HandleFunc("POST /api/v/{token}/shifts/{id}", app.handleKioskClaim)