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", "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) {

View file

@ -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)

View file

@ -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})

View file

@ -146,44 +146,11 @@ 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 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 signupsOpen == "true" {
code, err := app.generateVolunteerKioskCode()
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)
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 {
@ -192,6 +159,7 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) {
}()
}
}
}
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",
})
code, err := app.generateVolunteerKioskCode()
if err != nil {
continue
}
ticketID = stub.ID
}
t, err := app.generateUniqueToken()
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

View file

@ -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
// 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, "")
hasCode := false
for _, tk := range 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")
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))
}
}