Added volunteer signup.

This commit is contained in:
Pen Anderson 2026-03-03 17:59:35 -06:00
parent ace7f11a60
commit 8dc5d3ed01
12 changed files with 1258 additions and 49 deletions

247
handle_signup.go Normal file
View 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(&noteLabel)
app.db.QueryRow(`SELECT value FROM config WHERE key = 'volunteer_note_required'`).Scan(&noteRequired)
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(&noteRequired)
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)
}