Added volunteer signup.
This commit is contained in:
parent
ace7f11a60
commit
8dc5d3ed01
12 changed files with 1258 additions and 49 deletions
105
db.go
105
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, "attendees", "checked_in_count INTEGER NOT NULL DEFAULT 0")
|
||||||
addColumnIfMissing(db, "shifts", "position INTEGER NOT NULL DEFAULT 0")
|
addColumnIfMissing(db, "shifts", "position INTEGER NOT NULL DEFAULT 0")
|
||||||
addColumnIfMissing(db, "volunteer_shifts", "deleted_at TEXT")
|
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).
|
// Widen the uniqueness constraint from name-only to (name, ticket_id).
|
||||||
db.Exec(`DROP INDEX IF EXISTS idx_attendees_name`)
|
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`)
|
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 {
|
type Volunteer struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
AttendeeID *int `json:"attendee_id,omitempty"`
|
AttendeeID *int `json:"attendee_id,omitempty"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Email string `json:"email"`
|
PreferredName string `json:"preferred_name"`
|
||||||
Phone string `json:"phone"`
|
TicketName string `json:"ticket_name"`
|
||||||
DepartmentID *int `json:"department_id,omitempty"`
|
Email string `json:"email"`
|
||||||
IsLead bool `json:"is_lead"`
|
Phone string `json:"phone"`
|
||||||
CheckedIn bool `json:"checked_in"`
|
Pronouns string `json:"pronouns"`
|
||||||
CheckedInAt *string `json:"checked_in_at,omitempty"`
|
DepartmentID *int `json:"department_id,omitempty"`
|
||||||
Note string `json:"note"`
|
IsLead bool `json:"is_lead"`
|
||||||
CreatedAt string `json:"created_at"`
|
CheckedIn bool `json:"checked_in"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
CheckedInAt *string `json:"checked_in_at,omitempty"`
|
||||||
DeletedAt *string `json:"deleted_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 {
|
type Shift struct {
|
||||||
|
|
@ -719,7 +729,7 @@ func queryDepartments(db *sql.DB, q string, args ...any) ([]Department, error) {
|
||||||
|
|
||||||
// --- Volunteers ---
|
// --- 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) {
|
func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) {
|
||||||
q := `SELECT ` + volunteerCols + ` FROM volunteers WHERE 1=1`
|
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) {
|
func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) {
|
||||||
res, err := app.db.Exec(
|
res, err := app.db.Exec(
|
||||||
`INSERT INTO volunteers (attendee_id, name, email, phone, department_id, is_lead, note, updated_at)
|
`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 (?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
v.AttendeeID, v.Name, v.Email, v.Phone, v.DepartmentID, boolInt(v.IsLead), v.Note, now(),
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -776,9 +787,10 @@ func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) {
|
||||||
|
|
||||||
func (app *App) updateVolunteer(v Volunteer) error {
|
func (app *App) updateVolunteer(v Volunteer) error {
|
||||||
_, err := app.db.Exec(
|
_, 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`,
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -822,10 +834,13 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var v Volunteer
|
var v Volunteer
|
||||||
var attendeeID, deptID sql.NullInt64
|
var attendeeID, deptID sql.NullInt64
|
||||||
var isLead, checkedIn int
|
var isLead, checkedIn, emailConfirmed int
|
||||||
|
var confirmationToken sql.NullString
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&v.ID, &attendeeID, &v.Name, &v.Email, &v.Phone, &deptID,
|
&v.ID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName,
|
||||||
&isLead, &checkedIn, &v.CheckedInAt, &v.Note,
|
&v.Email, &v.Phone, &v.Pronouns, &deptID,
|
||||||
|
&isLead, &checkedIn, &v.CheckedInAt,
|
||||||
|
&emailConfirmed, &confirmationToken, &v.Note,
|
||||||
&v.CreatedAt, &v.UpdatedAt, &v.DeletedAt,
|
&v.CreatedAt, &v.UpdatedAt, &v.DeletedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -838,13 +853,59 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
|
||||||
id := int(deptID.Int64)
|
id := int(deptID.Int64)
|
||||||
v.DepartmentID = &id
|
v.DepartmentID = &id
|
||||||
}
|
}
|
||||||
|
if confirmationToken.Valid {
|
||||||
|
v.ConfirmationToken = &confirmationToken.String
|
||||||
|
}
|
||||||
v.IsLead = isLead == 1
|
v.IsLead = isLead == 1
|
||||||
v.CheckedIn = checkedIn == 1
|
v.CheckedIn = checkedIn == 1
|
||||||
|
v.EmailConfirmed = emailConfirmed == 1
|
||||||
result = append(result, v)
|
result = append(result, v)
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
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 ---
|
// --- Shifts ---
|
||||||
|
|
||||||
func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) {
|
func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) {
|
||||||
|
|
|
||||||
55
email.go
55
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))
|
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.
|
// sendTokenEmail sends a volunteer token link to the attendee's email address.
|
||||||
func (app *App) sendTokenEmail(a Attendee) error {
|
func (app *App) sendTokenEmail(a Attendee) error {
|
||||||
if a.Email == "" {
|
if a.Email == "" {
|
||||||
|
|
@ -116,20 +132,8 @@ func (app *App) sendTokenEmail(a Attendee) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := app.loadSMTPConfig()
|
cfg := app.loadSMTPConfig()
|
||||||
|
eventName := app.eventName()
|
||||||
baseURL := app.baseURL
|
link := fmt.Sprintf("%s/#/v/%s", app.resolveBaseURL(), *a.VolunteerToken)
|
||||||
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)
|
|
||||||
subject := fmt.Sprintf("Your volunteer link for %s", eventName)
|
subject := fmt.Sprintf("Your volunteer link for %s", eventName)
|
||||||
body := fmt.Sprintf(
|
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",
|
"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)
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@
|
||||||
import Users from './pages/Users.svelte'
|
import Users from './pages/Users.svelte'
|
||||||
import Import from './pages/Import.svelte'
|
import Import from './pages/Import.svelte'
|
||||||
import Kiosk from './pages/Kiosk.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 GateUI from './pages/GateUI.svelte'
|
||||||
import ScheduleBoard from './pages/ScheduleBoard.svelte'
|
import ScheduleBoard from './pages/ScheduleBoard.svelte'
|
||||||
import Settings from './pages/Settings.svelte'
|
import Settings from './pages/Settings.svelte'
|
||||||
|
|
@ -25,8 +27,11 @@
|
||||||
let updateAvailable = $state(false)
|
let updateAvailable = $state(false)
|
||||||
let mobileNavOpen = $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 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() {
|
async function checkVersion() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -39,8 +44,8 @@
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
checkVersion()
|
checkVersion()
|
||||||
|
|
||||||
// Kiosk pages don't need auth
|
// Public pages don't need auth
|
||||||
if (kioskToken) {
|
if (isPublicPage) {
|
||||||
loading = false
|
loading = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -87,6 +92,10 @@
|
||||||
<!-- checking session -->
|
<!-- checking session -->
|
||||||
{:else if kioskToken}
|
{:else if kioskToken}
|
||||||
<Kiosk />
|
<Kiosk />
|
||||||
|
{:else if isVolunteerSignup}
|
||||||
|
<VolunteerSignup />
|
||||||
|
{:else if isConfirmEmail}
|
||||||
|
<ConfirmEmail />
|
||||||
{:else if !session}
|
{:else if !session}
|
||||||
<Login onlogin={onLogin} />
|
<Login onlogin={onLogin} />
|
||||||
{:else if role === 'gate'}
|
{:else if role === 'gate'}
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,12 @@ export const api = {
|
||||||
get: () => apiJSON('/api/settings'),
|
get: () => apiJSON('/api/settings'),
|
||||||
update: (data) => apiJSON('/api/settings', { method: 'PUT', body: JSON.stringify(data) }),
|
update: (data) => apiJSON('/api/settings', { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
testEmail: (to) => apiJSON('/api/settings/test-email', { method: 'POST', body: JSON.stringify({ to }) }),
|
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) => {
|
import: async (formData) => {
|
||||||
const res = await apiFetch('/api/import', { method: 'POST', body: formData })
|
const res = await apiFetch('/api/import', { method: 'POST', body: formData })
|
||||||
|
|
|
||||||
|
|
@ -96,3 +96,50 @@ describe('api methods', () => {
|
||||||
expect(f.mock.calls[0][0]).toBe('/api/sync/pull')
|
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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
148
frontend/src/pages/ConfirmEmail.svelte
Normal file
148
frontend/src/pages/ConfirmEmail.svelte
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { api } from '../api.js'
|
||||||
|
|
||||||
|
let status = $state('loading')
|
||||||
|
let kioskLink = $state('')
|
||||||
|
let error = $state('')
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const match = window.location.hash.match(/^#\/confirm\/(.+)/)
|
||||||
|
const token = match?.[1] ?? ''
|
||||||
|
if (!token) {
|
||||||
|
status = 'invalid'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await api.signup.confirm(token)
|
||||||
|
status = result.status ?? 'invalid'
|
||||||
|
if (result.kiosk_link) kioskLink = result.kiosk_link
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
status = 'error'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="kiosk">
|
||||||
|
<div class="kiosk-header">
|
||||||
|
<div class="kiosk-brand">Turn<span>pike</span> <span class="kiosk-role">Email Confirmation</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kiosk-body">
|
||||||
|
{#if status === 'loading'}
|
||||||
|
<div class="kiosk-center">Confirming...</div>
|
||||||
|
{:else if status === 'confirmed'}
|
||||||
|
<div class="kiosk-card" style="text-align:center">
|
||||||
|
<div class="confirm-icon">✓</div>
|
||||||
|
<h2 style="font-size:1.2rem;font-weight:700;margin-bottom:0.5rem">Email Confirmed</h2>
|
||||||
|
<p style="color:var(--c-muted);line-height:1.6;margin:0">
|
||||||
|
Your email address has been verified. Thank you for signing up!
|
||||||
|
</p>
|
||||||
|
{#if kioskLink}
|
||||||
|
<div class="kiosk-link-box">
|
||||||
|
<p style="color:var(--c-text);font-weight:600;margin-bottom:0.5rem">Shift signups are open!</p>
|
||||||
|
<a href={kioskLink} class="kbtn kbtn-primary">Choose Your Shifts</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if status === 'already_confirmed'}
|
||||||
|
<div class="kiosk-card" style="text-align:center">
|
||||||
|
<h2 style="font-size:1.2rem;font-weight:700;margin-bottom:0.5rem">Already Confirmed</h2>
|
||||||
|
<p style="color:var(--c-muted);line-height:1.6;margin:0">
|
||||||
|
This email address was already confirmed. No further action needed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else if status === 'error'}
|
||||||
|
<div class="kiosk-error">{error}</div>
|
||||||
|
{:else}
|
||||||
|
<div class="kiosk-card" style="text-align:center">
|
||||||
|
<h2 style="font-size:1.2rem;font-weight:700;margin-bottom:0.5rem">Invalid Link</h2>
|
||||||
|
<p style="color:var(--c-muted);line-height:1.6;margin:0">
|
||||||
|
This confirmation link is not valid or has already been used.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.kiosk {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--c-bg);
|
||||||
|
color: var(--c-text);
|
||||||
|
font-family: var(--font);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.kiosk-header {
|
||||||
|
background: var(--c-surface);
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.kiosk-brand {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
.kiosk-brand span:first-of-type { color: var(--c-accent); }
|
||||||
|
.kiosk-role {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--c-muted);
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.kiosk-body {
|
||||||
|
max-width: 540px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.kiosk-center { display: flex; align-items: center; justify-content: center; padding: 2rem 0; }
|
||||||
|
.kiosk-card {
|
||||||
|
background: var(--c-surface);
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
}
|
||||||
|
.kiosk-error {
|
||||||
|
background: rgba(239,68,68,0.12);
|
||||||
|
border: 1px solid rgba(239,68,68,0.3);
|
||||||
|
color: #fca5a5;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.confirm-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(34,197,94,0.15);
|
||||||
|
color: #4ade80;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
.kiosk-link-box {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
.kbtn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
padding: 0.55rem 1.25rem; border-radius: 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: 0.875rem; font-weight: 500; cursor: pointer;
|
||||||
|
font-family: var(--font);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 150ms;
|
||||||
|
}
|
||||||
|
.kbtn-primary { background: var(--c-accent); color: #fff; }
|
||||||
|
.kbtn-primary:hover { background: var(--c-accent-h); }
|
||||||
|
</style>
|
||||||
|
|
@ -16,6 +16,10 @@
|
||||||
let smtpFromName = $state('')
|
let smtpFromName = $state('')
|
||||||
let baseURL = $state('')
|
let baseURL = $state('')
|
||||||
let testEmail = $state('')
|
let testEmail = $state('')
|
||||||
|
let noteLabel = $state('Additional note')
|
||||||
|
let noteRequired = $state(false)
|
||||||
|
let shiftSignupsOpen = $state(false)
|
||||||
|
let togglingSignups = $state(false)
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -27,6 +31,9 @@
|
||||||
smtpFrom = s.smtp_from ?? ''
|
smtpFrom = s.smtp_from ?? ''
|
||||||
smtpFromName = s.smtp_from_name ?? ''
|
smtpFromName = s.smtp_from_name ?? ''
|
||||||
baseURL = s.base_url ?? ''
|
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) {
|
} catch (err) {
|
||||||
error = err.message
|
error = err.message
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -48,6 +55,8 @@
|
||||||
smtp_from: smtpFrom,
|
smtp_from: smtpFrom,
|
||||||
smtp_from_name: smtpFromName,
|
smtp_from_name: smtpFromName,
|
||||||
base_url: baseURL,
|
base_url: baseURL,
|
||||||
|
volunteer_note_label: noteLabel,
|
||||||
|
volunteer_note_required: noteRequired,
|
||||||
})
|
})
|
||||||
smtpPassword = ''
|
smtpPassword = ''
|
||||||
success = 'Settings saved.'
|
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() {
|
async function sendTest() {
|
||||||
if (!testEmail) return
|
if (!testEmail) return
|
||||||
testing = true
|
testing = true
|
||||||
|
|
@ -135,7 +161,7 @@
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Test email -->
|
<!-- Test email -->
|
||||||
<div class="card">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Test Email</h2>
|
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Test Email</h2>
|
||||||
<div style="display:flex;gap:0.5rem;align-items:flex-end">
|
<div style="display:flex;gap:0.5rem;align-items:flex-end">
|
||||||
<div class="form-group" style="flex:1;margin-bottom:0">
|
<div class="form-group" style="flex:1;margin-bottom:0">
|
||||||
|
|
@ -147,5 +173,44 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Volunteer Signup -->
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Volunteer Signup</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="s-note-label">Note Field Label</label>
|
||||||
|
<input id="s-note-label" bind:value={noteLabel} placeholder="Additional note" />
|
||||||
|
</div>
|
||||||
|
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;cursor:pointer">
|
||||||
|
<input type="checkbox" bind:checked={noteRequired} />
|
||||||
|
Note field is required
|
||||||
|
</label>
|
||||||
|
<p class="text-muted" style="font-size:0.78rem;margin-top:0.75rem">
|
||||||
|
Signup form: <a href="/#/volunteer-signup" target="_blank" style="color:var(--c-accent)">/#/volunteer-signup</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shift Signups -->
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Shift Signups</h2>
|
||||||
|
<div style="display:flex;align-items:center;gap:1rem">
|
||||||
|
<span style="font-size:0.875rem">
|
||||||
|
Status: <strong>{shiftSignupsOpen ? 'Open' : 'Closed'}</strong>
|
||||||
|
</span>
|
||||||
|
<button class="btn {shiftSignupsOpen ? 'btn-ghost' : 'btn-primary'}"
|
||||||
|
onclick={toggleSignups} disabled={togglingSignups}>
|
||||||
|
{#if togglingSignups}
|
||||||
|
Working…
|
||||||
|
{:else}
|
||||||
|
{shiftSignupsOpen ? 'Close Signups' : 'Open Signups'}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if !shiftSignupsOpen}
|
||||||
|
<p class="text-muted" style="font-size:0.78rem;margin-top:0.75rem">
|
||||||
|
Opening signups will email all confirmed volunteers their shift signup links.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
241
frontend/src/pages/VolunteerSignup.svelte
Normal file
241
frontend/src/pages/VolunteerSignup.svelte
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { api } from '../api.js'
|
||||||
|
|
||||||
|
let loading = $state(true)
|
||||||
|
let submitting = $state(false)
|
||||||
|
let error = $state('')
|
||||||
|
let submitted = $state(false)
|
||||||
|
|
||||||
|
let config = $state(null)
|
||||||
|
let preferredName = $state('')
|
||||||
|
let ticketName = $state('')
|
||||||
|
let email = $state('')
|
||||||
|
let pronouns = $state('')
|
||||||
|
let phone = $state('')
|
||||||
|
let departmentId = $state('')
|
||||||
|
let note = $state('')
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
config = await api.signup.config()
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
submitting = true
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
preferred_name: preferredName.trim(),
|
||||||
|
email: email.trim(),
|
||||||
|
}
|
||||||
|
if (ticketName.trim()) data.ticket_name = ticketName.trim()
|
||||||
|
if (pronouns.trim()) data.pronouns = pronouns.trim()
|
||||||
|
if (phone.trim()) data.phone = phone.trim()
|
||||||
|
if (departmentId) data.department_id = Number(departmentId)
|
||||||
|
if (note.trim()) data.note = note.trim()
|
||||||
|
await api.signup.submit(data)
|
||||||
|
submitted = true
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
submitting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="kiosk">
|
||||||
|
<div class="kiosk-header">
|
||||||
|
<div class="kiosk-brand">Turn<span>pike</span> <span class="kiosk-role">Volunteer Signup</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kiosk-body">
|
||||||
|
{#if loading}
|
||||||
|
<div class="kiosk-center">Loading...</div>
|
||||||
|
{:else if submitted}
|
||||||
|
<div class="kiosk-card" style="text-align:center">
|
||||||
|
<h2 style="font-size:1.3rem;font-weight:700;margin-bottom:0.75rem">Thank you!</h2>
|
||||||
|
<p style="color:var(--c-muted);line-height:1.6;margin:0">
|
||||||
|
We've sent a confirmation email to <strong style="color:var(--c-text)">{email}</strong>.
|
||||||
|
Please check your inbox and click the link to confirm your signup.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if config?.event_name && config.event_name !== 'the event'}
|
||||||
|
<h2 class="signup-event-name">{config.event_name}</h2>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="kiosk-alert">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form onsubmit={submit}>
|
||||||
|
<div class="kiosk-card">
|
||||||
|
<div class="signup-field">
|
||||||
|
<label for="s-name">Preferred Name <span class="req">*</span></label>
|
||||||
|
<input id="s-name" bind:value={preferredName} required placeholder="What should we call you?" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="signup-field">
|
||||||
|
<label for="s-ticket">Ticket Name</label>
|
||||||
|
<input id="s-ticket" bind:value={ticketName} placeholder="Name on your ticket (if different)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="signup-field">
|
||||||
|
<label for="s-email">Email <span class="req">*</span></label>
|
||||||
|
<input id="s-email" type="email" bind:value={email} required placeholder="you@example.com" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="signup-row">
|
||||||
|
<div class="signup-field">
|
||||||
|
<label for="s-pronouns">Pronouns</label>
|
||||||
|
<input id="s-pronouns" bind:value={pronouns} placeholder="e.g. she/her" />
|
||||||
|
</div>
|
||||||
|
<div class="signup-field">
|
||||||
|
<label for="s-phone">Phone</label>
|
||||||
|
<input id="s-phone" type="tel" bind:value={phone} placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if config?.departments?.length > 0}
|
||||||
|
<div class="signup-field">
|
||||||
|
<label for="s-dept">Department Preference</label>
|
||||||
|
<select id="s-dept" bind:value={departmentId}>
|
||||||
|
<option value="">No preference</option>
|
||||||
|
{#each config.departments as dept}
|
||||||
|
<option value={dept.id}>{dept.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="signup-field">
|
||||||
|
<label for="s-note">
|
||||||
|
{config?.volunteer_note_label ?? 'Additional note'}
|
||||||
|
{#if config?.volunteer_note_required}<span class="req">*</span>{/if}
|
||||||
|
</label>
|
||||||
|
<textarea id="s-note" bind:value={note} rows="3"
|
||||||
|
required={config?.volunteer_note_required}
|
||||||
|
placeholder=""></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="kbtn kbtn-primary" style="width:100%;margin-top:0.5rem" disabled={submitting}>
|
||||||
|
{submitting ? 'Submitting...' : 'Sign Up'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.kiosk {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--c-bg);
|
||||||
|
color: var(--c-text);
|
||||||
|
font-family: var(--font);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.kiosk-header {
|
||||||
|
background: var(--c-surface);
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.kiosk-brand {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
.kiosk-brand span:first-of-type { color: var(--c-accent); }
|
||||||
|
.kiosk-role {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--c-muted);
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.kiosk-body {
|
||||||
|
max-width: 540px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.kiosk-center { display: flex; align-items: center; justify-content: center; }
|
||||||
|
.kiosk-alert {
|
||||||
|
background: rgba(239,68,68,0.1);
|
||||||
|
border: 1px solid rgba(239,68,68,0.25);
|
||||||
|
color: #fca5a5;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.kiosk-card {
|
||||||
|
background: var(--c-surface);
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
.signup-event-name {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
.signup-field {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.signup-field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--c-muted);
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
.signup-field input,
|
||||||
|
.signup-field select,
|
||||||
|
.signup-field textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--c-bg);
|
||||||
|
color: var(--c-text);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.signup-field input:focus,
|
||||||
|
.signup-field select:focus,
|
||||||
|
.signup-field textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--c-accent);
|
||||||
|
}
|
||||||
|
.signup-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.req { color: var(--c-accent); }
|
||||||
|
.kbtn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
padding: 0.55rem 1rem; border-radius: 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: 0.875rem; font-weight: 500; cursor: pointer;
|
||||||
|
font-family: var(--font);
|
||||||
|
transition: background 150ms;
|
||||||
|
}
|
||||||
|
.kbtn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.kbtn-primary { background: var(--c-accent); color: #fff; }
|
||||||
|
.kbtn-primary:hover:not(:disabled) { background: var(--c-accent-h); }
|
||||||
|
</style>
|
||||||
|
|
@ -19,14 +19,25 @@ func (app *App) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
pass = "***"
|
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{
|
writeJSON(w, map[string]any{
|
||||||
"smtp_host": cfg.Host,
|
"smtp_host": cfg.Host,
|
||||||
"smtp_port": cfg.Port,
|
"smtp_port": cfg.Port,
|
||||||
"smtp_user": cfg.User,
|
"smtp_user": cfg.User,
|
||||||
"smtp_password": pass,
|
"smtp_password": pass,
|
||||||
"smtp_from": cfg.From,
|
"smtp_from": cfg.From,
|
||||||
"smtp_from_name": cfg.FromName,
|
"smtp_from_name": cfg.FromName,
|
||||||
"base_url": baseURL,
|
"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
|
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 {
|
for _, k := range keys {
|
||||||
v, ok := body[k]
|
v, ok := body[k]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -47,11 +59,17 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
switch vv := v.(type) {
|
switch vv := v.(type) {
|
||||||
case string:
|
case string:
|
||||||
if k == "smtp_password" && vv == "" {
|
if k == "smtp_password" && vv == "" {
|
||||||
continue // don't erase the stored password with an empty value
|
continue
|
||||||
}
|
}
|
||||||
val = vv
|
val = vv
|
||||||
case float64:
|
case float64:
|
||||||
val = strconv.Itoa(int(vv))
|
val = strconv.Itoa(int(vv))
|
||||||
|
case bool:
|
||||||
|
if vv {
|
||||||
|
val = "true"
|
||||||
|
} else {
|
||||||
|
val = "false"
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
247
handle_signup.go
Normal file
247
handle_signup.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
333
handle_signup_test.go
Normal file
333
handle_signup_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
main.go
7
main.go
|
|
@ -151,6 +151,13 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
|
||||||
writeJSON(w, map[string]string{"build": buildID})
|
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.
|
// Kiosk — authenticated by volunteer token, no JWT required.
|
||||||
mux.HandleFunc("GET /api/v/{token}", app.handleKioskGet)
|
mux.HandleFunc("GET /api/v/{token}", app.handleKioskGet)
|
||||||
mux.HandleFunc("POST /api/v/{token}/shifts/{id}", app.handleKioskClaim)
|
mux.HandleFunc("POST /api/v/{token}/shifts/{id}", app.handleKioskClaim)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue