Refactored ticket code into kiosk code.
This commit is contained in:
parent
72b245d6d6
commit
3eec81af7f
5 changed files with 110 additions and 190 deletions
72
db.go
72
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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
104
handle_signup.go
104
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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue