Refactored ticket code into kiosk code.

This commit is contained in:
Pen Anderson 2026-03-05 16:31:08 -06:00
parent 72b245d6d6
commit 3eec81af7f
5 changed files with 110 additions and 190 deletions

72
db.go
View file

@ -176,6 +176,21 @@ func migrateV2(db *sql.DB) error {
addColumnIfMissing(db, "volunteers", "confirmation_token TEXT") addColumnIfMissing(db, "volunteers", "confirmation_token TEXT")
addColumnIfMissing(db, "volunteers", "confirmed INTEGER NOT NULL DEFAULT 0") addColumnIfMissing(db, "volunteers", "confirmed INTEGER NOT NULL DEFAULT 0")
addColumnIfMissing(db, "volunteers", "confirmed_at TEXT") 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). // 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`)
@ -398,6 +413,7 @@ type Volunteer struct {
ConfirmedAt *string `json:"confirmed_at,omitempty"` ConfirmedAt *string `json:"confirmed_at,omitempty"`
EmailConfirmed bool `json:"email_confirmed"` EmailConfirmed bool `json:"email_confirmed"`
ConfirmationToken *string `json:"-"` ConfirmationToken *string `json:"-"`
KioskCode *string `json:"kiosk_code,omitempty"`
Note string `json:"note"` Note string `json:"note"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_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), COALESCE(NULLIF(p.pronouns,''), v.pronouns),
v.department_id, v.is_lead, v.checked_in, v.checked_in_at, v.department_id, v.is_lead, v.checked_in, v.checked_in_at,
v.confirmed, v.confirmed_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` v.created_at, v.updated_at, v.deleted_at`
const volunteerFrom = `FROM volunteers v LEFT JOIN participants p ON p.id = v.participant_id` 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 v Volunteer
var participantID, attendeeID, deptID sql.NullInt64 var participantID, attendeeID, deptID sql.NullInt64
var isLead, checkedIn, confirmed, emailConfirmed int var isLead, checkedIn, confirmed, emailConfirmed int
var confirmationToken sql.NullString var confirmationToken, confirmedAt, kioskCode sql.NullString
var confirmedAt sql.NullString
if err := rows.Scan( if err := rows.Scan(
&v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName, &v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName,
&v.Email, &v.Phone, &v.Pronouns, &deptID, &v.Email, &v.Phone, &v.Pronouns, &deptID,
&isLead, &checkedIn, &v.CheckedInAt, &isLead, &checkedIn, &v.CheckedInAt,
&confirmed, &confirmedAt, &confirmed, &confirmedAt,
&emailConfirmed, &confirmationToken, &v.Note, &emailConfirmed, &confirmationToken, &kioskCode, &v.Note,
&v.CreatedAt, &v.UpdatedAt, &v.DeletedAt, &v.CreatedAt, &v.UpdatedAt, &v.DeletedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
@ -1352,6 +1367,9 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
if confirmedAt.Valid { if confirmedAt.Valid {
v.ConfirmedAt = &confirmedAt.String v.ConfirmedAt = &confirmedAt.String
} }
if kioskCode.Valid {
v.KioskCode = &kioskCode.String
}
v.IsLead = isLead == 1 v.IsLead = isLead == 1
v.CheckedIn = checkedIn == 1 v.CheckedIn = checkedIn == 1
v.Confirmed = confirmed == 1 v.Confirmed = confirmed == 1
@ -1386,19 +1404,43 @@ func (app *App) confirmVolunteerEmail(id int) error {
return err return err
} }
// listConfirmedVolunteersNeedingCode returns confirmed volunteers whose participant func (app *App) getVolunteerByKioskCode(code string) (*Volunteer, error) {
// has no ticket with a code yet. rows, err := queryVolunteers(app.db,
func (app *App) listConfirmedVolunteersNeedingCode() ([]Volunteer, error) { `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, ` return queryVolunteers(app.db, `
SELECT `+volunteerSelect+` `+volunteerFrom+` SELECT `+volunteerSelect+` `+volunteerFrom+`
WHERE v.email_confirmed = 1 AND v.deleted_at IS NULL WHERE v.email_confirmed = 1 AND v.kiosk_code IS NULL AND v.deleted_at IS NULL`)
AND v.participant_id IS NOT NULL }
AND NOT EXISTS (
SELECT 1 FROM tickets t func (app *App) generateVolunteerKioskCode() (string, error) {
WHERE t.participant_id = v.participant_id for range 10 {
AND t.code IS NOT NULL t, err := generateToken()
AND t.deleted_at IS NULL 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) { func generateConfirmationToken() (string, error) {

View file

@ -6,26 +6,21 @@ import (
"strconv" "strconv"
) )
func (app *App) volunteerFromKioskToken(token string) (*Volunteer, error) {
return app.getVolunteerByKioskCode(token)
}
// handleKioskGet returns the volunteer's profile, current shift assignments, and // 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. // no JWT required.
func (app *App) handleKioskGet(w http.ResponseWriter, r *http.Request) { func (app *App) handleKioskGet(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token") token := r.PathValue("token")
t, err := app.getTicketByCode(token) v, err := app.volunteerFromKioskToken(token)
if err != nil || t == nil { if err != nil || v == nil {
writeError(w, "not found", http.StatusNotFound) writeError(w, "not found", http.StatusNotFound)
return 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) assigned, _ := app.listShiftsForVolunteer(v.ID)
if assigned == nil { if assigned == nil {
assigned = []Shift{} assigned = []Shift{}
@ -56,19 +51,11 @@ func (app *App) handleKioskClaim(w http.ResponseWriter, r *http.Request) {
return return
} }
t, err := app.getTicketByCode(token) v, err := app.volunteerFromKioskToken(token)
if err != nil || t == nil { if err != nil || v == nil {
writeError(w, "not found", http.StatusNotFound) writeError(w, "not found", http.StatusNotFound)
return 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" force := r.URL.Query().Get("force") == "true"
@ -116,19 +103,11 @@ func (app *App) handleKioskUnclaim(w http.ResponseWriter, r *http.Request) {
return return
} }
t, err := app.getTicketByCode(token) v, err := app.volunteerFromKioskToken(token)
if err != nil || t == nil { if err != nil || v == nil {
writeError(w, "not found", http.StatusNotFound) writeError(w, "not found", http.StatusNotFound)
return 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 { if err := app.unassignShift(v.ID, shiftID); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)

View file

@ -14,14 +14,11 @@ func setupKiosk(t *testing.T) (*App, *http.ServeMux, string) {
dept, _ := app.createDepartment(Department{Name: "Gate"}) dept, _ := app.createDepartment(Department{Name: "Gate"})
deptID := dept.ID 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"}) p, _ := app.createParticipant(Participant{Email: "titania@test.com", PreferredName: "Titania"})
token, _ := app.generateUniqueToken() v, _ := app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: &p.ID, DepartmentID: &deptID})
tk, _ := app.createTicket(Ticket{ParticipantID: &p.ID, Name: "Titania", Source: "manual", Code: &token}) token, _ := app.generateVolunteerKioskCode()
_ = tk app.assignKioskCode(v.ID, token)
// Create linked volunteer via participant_id
app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: &p.ID, DepartmentID: &deptID})
// Create shifts // Create shifts
app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2}) app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2})

View file

@ -146,44 +146,11 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) {
var signupsOpen string var signupsOpen string
app.db.QueryRow(`SELECT value FROM config WHERE key = 'shift_signups_open'`).Scan(&signupsOpen) app.db.QueryRow(`SELECT value FROM config WHERE key = 'shift_signups_open'`).Scan(&signupsOpen)
if signupsOpen == "true" && vol.ParticipantID != nil { if signupsOpen == "true" {
// Find a ticket with a code, or create/assign one. code, err := app.generateVolunteerKioskCode()
tickets, _ := app.listTickets(vol.ParticipantID, "")
var code *string
for _, tk := range tickets {
if tk.Code != nil {
code = tk.Code
break
}
}
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 { if err == nil {
ticketID = stub.ID if err := app.assignKioskCode(vol.ID, code); err == nil {
} kioskLink := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), code)
}
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 response["kiosk_link"] = kioskLink
go func() { go func() {
if err := app.sendShiftSignupEmail(vol.Email, vol.PreferredName, kioskLink); err != nil { if err := app.sendShiftSignupEmail(vol.Email, vol.PreferredName, kioskLink); err != nil {
@ -192,6 +159,7 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) {
}() }()
} }
} }
}
writeJSON(w, response) writeJSON(w, response)
} }
@ -217,62 +185,28 @@ func (app *App) handleToggleShiftSignups(w http.ResponseWriter, r *http.Request)
} }
func (app *App) openShiftSignups() { func (app *App) openShiftSignups() {
// Generate codes for tickets belonging to confirmed volunteers that have no code yet. // Assign kiosk codes to email-confirmed volunteers that don't have one yet.
vols, _ := app.listConfirmedVolunteersNeedingCode() vols, _ := app.listVolunteersNeedingKioskCode()
for _, v := range vols { for _, v := range vols {
if v.ParticipantID == nil { code, err := app.generateVolunteerKioskCode()
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 { if err != nil {
continue continue
} }
ticketID = stub.ID app.assignKioskCode(v.ID, code)
}
t, err := app.generateUniqueToken()
if err != nil {
continue
}
app.db.Exec(`UPDATE tickets SET code=?, updated_at=? WHERE id=?`, t, now(), ticketID)
} }
// 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, ` confirmed, _ := queryVolunteers(app.db, `
SELECT `+volunteerSelect+` `+volunteerFrom+` 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() baseURL := app.resolveBaseURL()
sent := 0 sent := 0
for _, v := range confirmed { for _, v := range confirmed {
if v.ParticipantID == nil || v.Email == "" { if v.Email == "" || v.KioskCode == nil {
continue continue
} }
tickets, _ := app.listTickets(v.ParticipantID, "") kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *v.KioskCode)
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)
name := v.PreferredName name := v.PreferredName
if name == "" { if name == "" {
name = v.Name name = v.Name

View file

@ -301,17 +301,14 @@ func TestConfirmEmailWithSignupsOpen(t *testing.T) {
t.Error("expected kiosk_link when signups are open") t.Error("expected kiosk_link when signups are open")
} }
// Ticket for participant should now have a code // 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")
}
tickets, _ := app.listTickets(&participant.ID, "") tickets, _ := app.listTickets(&participant.ID, "")
hasCode := false if len(tickets) != 0 {
for _, tk := range tickets { t.Errorf("expected no stub tickets, got %d", len(tickets))
if tk.Code != nil && *tk.Code != "" {
hasCode = true
break
}
}
if !hasCode {
t.Error("participant should have a ticket with code after confirm with signups open")
} }
} }
@ -345,14 +342,13 @@ func TestPublicSignupTicketNameDoesNotOverwritePreferredName(t *testing.T) {
} }
} }
func TestConfirmEmailStubTicketHasName(t *testing.T) { func TestConfirmEmailAssignsKioskCode(t *testing.T) {
app := testApp(t) app := testApp(t)
mux := testMux(app) mux := testMux(app)
app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`) app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`)
app.baseURL = "https://example.com" 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"}) participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"})
token := "abc123def456" token := "abc123def456"
app.createVolunteer(Volunteer{ app.createVolunteer(Volunteer{
@ -373,47 +369,19 @@ func TestConfirmEmailStubTicketHasName(t *testing.T) {
if result["status"] != "confirmed" { if result["status"] != "confirmed" {
t.Fatalf("expected confirmed, got %v", result["status"]) 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, "") tickets, _ := app.listTickets(&participant.ID, "")
if len(tickets) == 0 { if len(tickets) != 0 {
t.Fatal("expected stub ticket to be created") t.Errorf("expected no stub tickets, got %d", len(tickets))
}
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")
} }
} }