diff --git a/db.go b/db.go index be830db..9129489 100644 --- a/db.go +++ b/db.go @@ -176,6 +176,21 @@ func migrateV2(db *sql.DB) error { addColumnIfMissing(db, "volunteers", "confirmation_token TEXT") addColumnIfMissing(db, "volunteers", "confirmed INTEGER NOT NULL DEFAULT 0") addColumnIfMissing(db, "volunteers", "confirmed_at TEXT") + addColumnIfMissing(db, "volunteers", "kiosk_code TEXT") + db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_volunteers_kiosk_code ON volunteers(kiosk_code) WHERE kiosk_code IS NOT NULL`) + // Migrate kiosk codes from tickets to volunteers (idempotent). + db.Exec(` + UPDATE volunteers SET kiosk_code = ( + SELECT t.code FROM tickets t + WHERE t.participant_id = volunteers.participant_id + AND t.code IS NOT NULL AND t.deleted_at IS NULL + LIMIT 1 + ) WHERE kiosk_code IS NULL AND participant_id IS NOT NULL`) + // Delete stub tickets whose code has been migrated to the volunteer. + db.Exec(` + DELETE FROM tickets + WHERE source = 'manual' AND external_id = '' AND code IS NOT NULL + AND participant_id IN (SELECT id FROM volunteers WHERE kiosk_code IS NOT NULL)`) // 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`) @@ -398,6 +413,7 @@ type Volunteer struct { ConfirmedAt *string `json:"confirmed_at,omitempty"` EmailConfirmed bool `json:"email_confirmed"` ConfirmationToken *string `json:"-"` + KioskCode *string `json:"kiosk_code,omitempty"` Note string `json:"note"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` @@ -1189,7 +1205,7 @@ const volunteerSelect = `v.id, v.participant_id, v.attendee_id, COALESCE(NULLIF(p.pronouns,''), v.pronouns), v.department_id, v.is_lead, v.checked_in, v.checked_in_at, v.confirmed, v.confirmed_at, - v.email_confirmed, v.confirmation_token, v.note, + v.email_confirmed, v.confirmation_token, v.kiosk_code, v.note, v.created_at, v.updated_at, v.deleted_at` const volunteerFrom = `FROM volunteers v LEFT JOIN participants p ON p.id = v.participant_id` @@ -1322,14 +1338,13 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { var v Volunteer var participantID, attendeeID, deptID sql.NullInt64 var isLead, checkedIn, confirmed, emailConfirmed int - var confirmationToken sql.NullString - var confirmedAt sql.NullString + var confirmationToken, confirmedAt, kioskCode sql.NullString if err := rows.Scan( &v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName, &v.Email, &v.Phone, &v.Pronouns, &deptID, &isLead, &checkedIn, &v.CheckedInAt, &confirmed, &confirmedAt, - &emailConfirmed, &confirmationToken, &v.Note, + &emailConfirmed, &confirmationToken, &kioskCode, &v.Note, &v.CreatedAt, &v.UpdatedAt, &v.DeletedAt, ); err != nil { return nil, err @@ -1352,6 +1367,9 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { if confirmedAt.Valid { v.ConfirmedAt = &confirmedAt.String } + if kioskCode.Valid { + v.KioskCode = &kioskCode.String + } v.IsLead = isLead == 1 v.CheckedIn = checkedIn == 1 v.Confirmed = confirmed == 1 @@ -1386,19 +1404,43 @@ func (app *App) confirmVolunteerEmail(id int) error { return err } -// listConfirmedVolunteersNeedingCode returns confirmed volunteers whose participant -// has no ticket with a code yet. -func (app *App) listConfirmedVolunteersNeedingCode() ([]Volunteer, error) { +func (app *App) getVolunteerByKioskCode(code string) (*Volunteer, error) { + rows, err := queryVolunteers(app.db, + `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.kiosk_code = ? AND v.deleted_at IS NULL LIMIT 1`, code) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +func (app *App) assignKioskCode(id int, code string) error { + _, err := app.db.Exec( + `UPDATE volunteers SET kiosk_code=?, updated_at=? WHERE id=?`, code, now(), id) + return err +} + +// listVolunteersNeedingKioskCode returns email-confirmed volunteers without a kiosk code. +func (app *App) listVolunteersNeedingKioskCode() ([]Volunteer, error) { return queryVolunteers(app.db, ` SELECT `+volunteerSelect+` `+volunteerFrom+` - WHERE v.email_confirmed = 1 AND v.deleted_at IS NULL - AND v.participant_id IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM tickets t - WHERE t.participant_id = v.participant_id - AND t.code IS NOT NULL - AND t.deleted_at IS NULL - )`) + WHERE v.email_confirmed = 1 AND v.kiosk_code IS NULL AND v.deleted_at IS NULL`) +} + +func (app *App) generateVolunteerKioskCode() (string, error) { + for range 10 { + t, err := generateToken() + if err != nil { + return "", err + } + var count int + if err := app.db.QueryRow(`SELECT COUNT(*) FROM volunteers WHERE kiosk_code = ?`, t).Scan(&count); err != nil { + return "", fmt.Errorf("check kiosk code uniqueness: %w", err) + } + if count == 0 { + return t, nil + } + } + return "", fmt.Errorf("failed to generate unique kiosk code") } func generateConfirmationToken() (string, error) { diff --git a/handle_kiosk.go b/handle_kiosk.go index 646db43..773ccfe 100644 --- a/handle_kiosk.go +++ b/handle_kiosk.go @@ -6,26 +6,21 @@ import ( "strconv" ) +func (app *App) volunteerFromKioskToken(token string) (*Volunteer, error) { + return app.getVolunteerByKioskCode(token) +} + // handleKioskGet returns the volunteer's profile, current shift assignments, and -// available open shifts in their department. Authenticated by ticket code only — +// available open shifts in their department. Authenticated by kiosk code only — // no JWT required. func (app *App) handleKioskGet(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") - t, err := app.getTicketByCode(token) - if err != nil || t == nil { + v, err := app.volunteerFromKioskToken(token) + if err != nil || v == nil { writeError(w, "not found", http.StatusNotFound) return } - var v *Volunteer - if t.ParticipantID != nil { - v, _ = app.getVolunteerByParticipantID(*t.ParticipantID) - } - if v == nil { - writeError(w, "no volunteer record linked to this token", http.StatusNotFound) - return - } - assigned, _ := app.listShiftsForVolunteer(v.ID) if assigned == nil { assigned = []Shift{} @@ -56,19 +51,11 @@ func (app *App) handleKioskClaim(w http.ResponseWriter, r *http.Request) { return } - t, err := app.getTicketByCode(token) - if err != nil || t == nil { + v, err := app.volunteerFromKioskToken(token) + if err != nil || v == nil { writeError(w, "not found", http.StatusNotFound) return } - var v *Volunteer - if t.ParticipantID != nil { - v, _ = app.getVolunteerByParticipantID(*t.ParticipantID) - } - if v == nil { - writeError(w, "no volunteer linked to this token", http.StatusNotFound) - return - } force := r.URL.Query().Get("force") == "true" @@ -116,19 +103,11 @@ func (app *App) handleKioskUnclaim(w http.ResponseWriter, r *http.Request) { return } - t, err := app.getTicketByCode(token) - if err != nil || t == nil { + v, err := app.volunteerFromKioskToken(token) + if err != nil || v == nil { writeError(w, "not found", http.StatusNotFound) return } - var v *Volunteer - if t.ParticipantID != nil { - v, _ = app.getVolunteerByParticipantID(*t.ParticipantID) - } - if v == nil { - writeError(w, "no volunteer linked to this token", http.StatusNotFound) - return - } if err := app.unassignShift(v.ID, shiftID); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) diff --git a/handle_kiosk_test.go b/handle_kiosk_test.go index 1e3c682..e385ad3 100644 --- a/handle_kiosk_test.go +++ b/handle_kiosk_test.go @@ -14,14 +14,11 @@ func setupKiosk(t *testing.T) (*App, *http.ServeMux, string) { dept, _ := app.createDepartment(Department{Name: "Gate"}) deptID := dept.ID - // Create participant + ticket with code + // Create volunteer with a kiosk_code directly on the volunteer record p, _ := app.createParticipant(Participant{Email: "titania@test.com", PreferredName: "Titania"}) - token, _ := app.generateUniqueToken() - tk, _ := app.createTicket(Ticket{ParticipantID: &p.ID, Name: "Titania", Source: "manual", Code: &token}) - _ = tk - - // Create linked volunteer via participant_id - app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: &p.ID, DepartmentID: &deptID}) + v, _ := app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: &p.ID, DepartmentID: &deptID}) + token, _ := app.generateVolunteerKioskCode() + app.assignKioskCode(v.ID, token) // Create shifts app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2}) diff --git a/handle_signup.go b/handle_signup.go index 2373e9c..bd9f091 100644 --- a/handle_signup.go +++ b/handle_signup.go @@ -146,51 +146,19 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) { var signupsOpen string app.db.QueryRow(`SELECT value FROM config WHERE key = 'shift_signups_open'`).Scan(&signupsOpen) - if signupsOpen == "true" && vol.ParticipantID != nil { - // Find a ticket with a code, or create/assign one. - tickets, _ := app.listTickets(vol.ParticipantID, "") - var code *string - for _, tk := range tickets { - if tk.Code != nil { - code = tk.Code - break + if signupsOpen == "true" { + code, err := app.generateVolunteerKioskCode() + if err == nil { + if err := app.assignKioskCode(vol.ID, code); err == nil { + kioskLink := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), code) + 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) + } + }() } } - if code == nil { - // No coded ticket — find any ticket or create a stub, then generate code. - var ticketID int - if len(tickets) > 0 { - ticketID = tickets[0].ID - } else { - tkName := vol.TicketName - if tkName == "" { - tkName = vol.PreferredName - } - stub, err := app.createTicket(Ticket{ - ParticipantID: vol.ParticipantID, - Name: tkName, - Source: "manual", - }) - if err == nil { - ticketID = stub.ID - } - } - if ticketID > 0 { - if t, err := app.generateUniqueToken(); err == nil { - app.db.Exec(`UPDATE tickets SET code=?, updated_at=? WHERE id=?`, t, now(), ticketID) - code = &t - } - } - } - if code != nil { - kioskLink := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), *code) - 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) @@ -217,62 +185,28 @@ func (app *App) handleToggleShiftSignups(w http.ResponseWriter, r *http.Request) } func (app *App) openShiftSignups() { - // Generate codes for tickets belonging to confirmed volunteers that have no code yet. - vols, _ := app.listConfirmedVolunteersNeedingCode() + // Assign kiosk codes to email-confirmed volunteers that don't have one yet. + vols, _ := app.listVolunteersNeedingKioskCode() for _, v := range vols { - if v.ParticipantID == nil { - continue - } - // Find any ticket for this participant, or create a stub one. - tickets, _ := app.listTickets(v.ParticipantID, "") - var ticketID int - if len(tickets) > 0 { - ticketID = tickets[0].ID - } else { - tkName := v.TicketName - if tkName == "" { - tkName = v.PreferredName - } - stub, err := app.createTicket(Ticket{ - ParticipantID: v.ParticipantID, - Name: tkName, - Source: "manual", - }) - if err != nil { - continue - } - ticketID = stub.ID - } - t, err := app.generateUniqueToken() + code, err := app.generateVolunteerKioskCode() if err != nil { continue } - app.db.Exec(`UPDATE tickets SET code=?, updated_at=? WHERE id=?`, t, now(), ticketID) + app.assignKioskCode(v.ID, code) } - // Email all confirmed volunteers that now have a ticket with a code. + // Email all email-confirmed volunteers that now have a kiosk code. confirmed, _ := queryVolunteers(app.db, ` SELECT `+volunteerSelect+` `+volunteerFrom+` - WHERE v.email_confirmed = 1 AND v.deleted_at IS NULL AND v.participant_id IS NOT NULL`) + WHERE v.email_confirmed = 1 AND v.kiosk_code IS NOT NULL AND v.deleted_at IS NULL`) baseURL := app.resolveBaseURL() sent := 0 for _, v := range confirmed { - if v.ParticipantID == nil || v.Email == "" { + if v.Email == "" || v.KioskCode == nil { continue } - tickets, _ := app.listTickets(v.ParticipantID, "") - var code *string - for _, tk := range tickets { - if tk.Code != nil { - code = tk.Code - break - } - } - if code == nil { - continue - } - kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *code) + kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *v.KioskCode) name := v.PreferredName if name == "" { name = v.Name diff --git a/handle_signup_test.go b/handle_signup_test.go index 86bcfe5..c240596 100644 --- a/handle_signup_test.go +++ b/handle_signup_test.go @@ -301,17 +301,14 @@ func TestConfirmEmailWithSignupsOpen(t *testing.T) { t.Error("expected kiosk_link when signups are open") } - // Ticket for participant should now have a code - tickets, _ := app.listTickets(&participant.ID, "") - hasCode := false - for _, tk := range tickets { - if tk.Code != nil && *tk.Code != "" { - hasCode = true - break - } + // Volunteer should now have a kiosk_code, no stub ticket created. + vol, _ := app.getVolunteerByEmail("titania@example.com") + if vol == nil || vol.KioskCode == nil { + t.Error("volunteer should have a kiosk_code after confirm with signups open") } - if !hasCode { - t.Error("participant should have a ticket with code after confirm with signups open") + tickets, _ := app.listTickets(&participant.ID, "") + if len(tickets) != 0 { + t.Errorf("expected no stub tickets, got %d", len(tickets)) } } @@ -345,14 +342,13 @@ func TestPublicSignupTicketNameDoesNotOverwritePreferredName(t *testing.T) { } } -func TestConfirmEmailStubTicketHasName(t *testing.T) { +func TestConfirmEmailAssignsKioskCode(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" - // Volunteer with a ticket_name but no pre-existing ticket participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) token := "abc123def456" app.createVolunteer(Volunteer{ @@ -373,47 +369,19 @@ func TestConfirmEmailStubTicketHasName(t *testing.T) { if result["status"] != "confirmed" { t.Fatalf("expected confirmed, got %v", result["status"]) } + if result["kiosk_link"] == nil { + t.Error("expected kiosk_link in response when signups are open") + } - // Stub ticket should have been created with TicketName as its name + // Kiosk code should be on the volunteer record, not a stub ticket. + vol, _ := app.getVolunteerByEmail("titania@example.com") + if vol == nil || vol.KioskCode == nil { + t.Fatal("expected volunteer to have a kiosk_code") + } + // No stub ticket should have been created. tickets, _ := app.listTickets(&participant.ID, "") - if len(tickets) == 0 { - t.Fatal("expected stub ticket to be created") - } - if tickets[0].Name != "Titania Fairweather" { - t.Errorf("stub ticket name = %q, want %q", tickets[0].Name, "Titania Fairweather") - } -} - -func TestConfirmEmailStubTicketFallsBackToPreferredName(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" - - // Volunteer with no ticket_name — stub should use preferred_name - participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) - token := "abc123def456" - app.createVolunteer(Volunteer{ - Name: "Titania", - PreferredName: "Titania", - Email: "titania@example.com", - ParticipantID: &participant.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) - } - - tickets, _ := app.listTickets(&participant.ID, "") - if len(tickets) == 0 { - t.Fatal("expected stub ticket to be created") - } - if tickets[0].Name != "Titania" { - t.Errorf("stub ticket name = %q, want %q (preferred_name fallback)", tickets[0].Name, "Titania") + if len(tickets) != 0 { + t.Errorf("expected no stub tickets, got %d", len(tickets)) } }