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 @@ + + +
+
+
Turnpike  Email Confirmation
+
+ +
+ {#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

@@ -147,5 +173,44 @@
+ + +
+

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 @@ + + +
+
+
Turnpike  Volunteer Signup
+
+ +
+ {#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'} + + {/if} + + {#if error} +
{error}
+ {/if} + +
+
+ + + + + + + + + {#if config?.departments?.length > 0} + + {/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)