2026-03-03 17:59:35 -06:00
|
|
|
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 {
|
2026-03-03 19:55:35 -06:00
|
|
|
kioskLink := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), *a.VolunteerToken)
|
2026-03-03 17:59:35 -06:00
|
|
|
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
|
|
|
|
|
}
|
2026-03-03 19:55:35 -06:00
|
|
|
kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *a.VolunteerToken)
|
2026-03-03 17:59:35 -06:00
|
|
|
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)
|
|
|
|
|
}
|