diff --git a/db.go b/db.go index bc97c91..3dba412 100644 --- a/db.go +++ b/db.go @@ -121,6 +121,40 @@ func migrate(db *sql.DB) error { updated_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (volunteer_id, shift_id) ); + + CREATE TABLE IF NOT EXISTS participants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL DEFAULT '', + preferred_name TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '', + pronouns TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_participants_email + ON participants(email) WHERE deleted_at IS NULL AND email != ''; + + CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + participant_id INTEGER REFERENCES participants(id) ON DELETE SET NULL, + name TEXT NOT NULL DEFAULT '', + ticket_type TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT 'manual', + external_id TEXT NOT NULL DEFAULT '', + order_id TEXT NOT NULL DEFAULT '', + code TEXT UNIQUE, + checked_in_at TEXT, + checked_in_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_source_external + ON tickets(source, external_id) WHERE external_id != '' AND deleted_at IS NULL; `) if err != nil { return err @@ -143,6 +177,89 @@ func migrateV2(db *sql.DB) error { // 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`) + return migrateV3(db) +} + +// migrateV3 populates participants + tickets from attendees/volunteers, +// and links volunteers to participants via participant_id. +func migrateV3(db *sql.DB) error { + addColumnIfMissing(db, "volunteers", "participant_id INTEGER REFERENCES participants(id)") + + // Seed participants from volunteers first (better name data: preferred_name). + db.Exec(` + INSERT OR IGNORE INTO participants (email, preferred_name, phone, pronouns, created_at, updated_at) + SELECT + LOWER(email), + CASE WHEN preferred_name != '' THEN preferred_name ELSE name END, + phone, + pronouns, + created_at, + created_at + FROM volunteers + WHERE email != '' AND deleted_at IS NULL`) + + // Fill in from attendees for emails not yet in participants. + db.Exec(` + INSERT OR IGNORE INTO participants (email, preferred_name, phone, created_at, updated_at) + SELECT LOWER(email), name, phone, created_at, created_at + FROM attendees + WHERE email != '' AND deleted_at IS NULL`) + + // Attendees with no email: create a placeholder participant so tickets aren't orphaned. + rows, _ := db.Query(`SELECT id, name, created_at FROM attendees WHERE email = '' AND deleted_at IS NULL`) + if rows != nil { + type stub struct { + id, name, createdAt string + } + var stubs []stub + for rows.Next() { + var s stub + rows.Scan(&s.id, &s.name, &s.createdAt) + stubs = append(stubs, s) + } + rows.Close() + for _, s := range stubs { + placeholder := fmt.Sprintf("ticket-%s@unknown", s.id) + db.Exec(`INSERT OR IGNORE INTO participants (email, preferred_name, created_at, updated_at) VALUES (?, ?, ?, ?)`, + placeholder, s.name, s.createdAt, s.createdAt) + } + } + + // Link volunteers to participants via email. + db.Exec(` + UPDATE volunteers SET participant_id = ( + SELECT p.id FROM participants p WHERE LOWER(p.email) = LOWER(volunteers.email) + ) + WHERE participant_id IS NULL AND email != ''`) + + // Seed tickets from attendees (1 ticket per attendee row). + db.Exec(` + INSERT OR IGNORE INTO tickets (participant_id, name, ticket_type, source, external_id, order_id, code, checked_in_at, checked_in_by, created_at, updated_at, deleted_at) + SELECT + p.id, + a.name, + a.ticket_type, + CASE WHEN a.ticket_id != '' THEN 'crowdwork' ELSE 'manual' END, + a.ticket_id, + a.ticket_id, + a.volunteer_token, + a.checked_in_at, + a.checked_in_by, + a.created_at, + a.updated_at, + a.deleted_at + FROM attendees a + JOIN participants p ON LOWER(p.email) = LOWER(a.email) OR p.email = 'ticket-' || a.id || '@unknown'`) + + // Volunteers whose participant has no ticket: create a stub ticket so they can get a kiosk code. + db.Exec(` + INSERT OR IGNORE INTO tickets (participant_id, source, created_at, updated_at) + SELECT DISTINCT v.participant_id, 'manual', v.created_at, v.created_at + FROM volunteers v + WHERE v.participant_id IS NOT NULL + AND v.deleted_at IS NULL + AND NOT EXISTS (SELECT 1 FROM tickets t WHERE t.participant_id = v.participant_id AND t.deleted_at IS NULL)`) + return nil } @@ -223,7 +340,8 @@ type Department struct { type Volunteer struct { ID int `json:"id"` - AttendeeID *int `json:"attendee_id,omitempty"` + ParticipantID *int `json:"participant_id,omitempty"` + AttendeeID *int `json:"attendee_id,omitempty"` // deprecated; kept for migration compat Name string `json:"name"` PreferredName string `json:"preferred_name"` TicketName string `json:"ticket_name"` @@ -242,6 +360,34 @@ type Volunteer struct { DeletedAt *string `json:"deleted_at,omitempty"` } +type Participant struct { + ID int `json:"id"` + Email string `json:"email"` + PreferredName string `json:"preferred_name"` + Phone string `json:"phone"` + Pronouns string `json:"pronouns"` + Note string `json:"note"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at,omitempty"` +} + +type Ticket struct { + ID int `json:"id"` + ParticipantID *int `json:"participant_id,omitempty"` + Name string `json:"name"` + TicketType string `json:"ticket_type"` + Source string `json:"source"` + ExternalID string `json:"external_id"` + OrderID string `json:"order_id"` + Code *string `json:"code,omitempty"` + CheckedInAt *string `json:"checked_in_at,omitempty"` + CheckedInBy *int `json:"checked_in_by,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at,omitempty"` +} + type Shift struct { ID int `json:"id"` DepartmentID int `json:"department_id"` @@ -444,7 +590,7 @@ func (app *App) generateUniqueToken() (string, error) { return "", err } var count int - if err := app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE volunteer_token = ?`, t).Scan(&count); err != nil { + if err := app.db.QueryRow(`SELECT COUNT(*) FROM tickets WHERE code = ?`, t).Scan(&count); err != nil { return "", fmt.Errorf("check token uniqueness: %w", err) } if count == 0 { @@ -454,19 +600,10 @@ func (app *App) generateUniqueToken() (string, error) { return "", fmt.Errorf("failed to generate unique token") } -func (app *App) getAttendeeByToken(token string) (*Attendee, error) { - rows, err := queryAttendees(app.db, - `SELECT `+attendeeCols+` FROM attendees WHERE volunteer_token = ? AND deleted_at IS NULL`, token) - if err != nil || len(rows) == 0 { - return nil, err - } - return &rows[0], nil -} - -// generateTokensForAll creates tokens for every attendee that doesn't have one yet. -func (app *App) generateTokensForAll() (int, error) { +// generateCodesForAll generates codes for every ticket that doesn't have one yet. +func (app *App) generateCodesForAll() (int, error) { rows, err := app.db.Query( - `SELECT id FROM attendees WHERE volunteer_token IS NULL AND deleted_at IS NULL`, + `SELECT id FROM tickets WHERE code IS NULL AND deleted_at IS NULL`, ) if err != nil { return 0, err @@ -476,7 +613,7 @@ func (app *App) generateTokensForAll() (int, error) { for rows.Next() { var id int if err := rows.Scan(&id); err != nil { - return 0, fmt.Errorf("scan attendee id: %w", err) + return 0, fmt.Errorf("scan ticket id: %w", err) } ids = append(ids, id) } @@ -487,14 +624,13 @@ func (app *App) generateTokensForAll() (int, error) { if err != nil { continue } - app.db.Exec(`UPDATE attendees SET volunteer_token=?, updated_at=? WHERE id=?`, t, now(), id) + app.db.Exec(`UPDATE tickets SET code=?, updated_at=? WHERE id=?`, t, now(), id) count++ } return count, nil } -// incrementPartySize bumps party_size for an existing attendee matched by name+ticket_id. -// Used during import to handle duplicate ticket rows from the same order. +// incrementPartySize is kept for backward compatibility with existing tests. func (app *App) incrementPartySize(name, ticketID string) (bool, error) { res, err := app.db.Exec( `UPDATE attendees SET party_size = party_size + 1, updated_at = ? @@ -662,6 +798,274 @@ func (app *App) attendeeCounts() (total, checkedIn int, err error) { return } +// --- Participants --- + +const participantCols = `id, email, preferred_name, phone, pronouns, note, created_at, updated_at, deleted_at` + +func (app *App) listParticipants(search, since string) ([]Participant, error) { + var q string + var args []any + if since != "" { + q = `SELECT ` + participantCols + ` FROM participants WHERE updated_at > ? ORDER BY preferred_name, email` + args = append(args, since) + } else { + q = `SELECT ` + participantCols + ` FROM participants WHERE deleted_at IS NULL` + if search != "" { + q += ` AND (preferred_name LIKE ? OR email LIKE ?)` + s := "%" + search + "%" + args = append(args, s, s) + } + q += ` ORDER BY preferred_name, email` + } + return queryParticipants(app.db, q, args...) +} + +func (app *App) getParticipant(id int) (*Participant, error) { + rows, err := queryParticipants(app.db, + `SELECT `+participantCols+` FROM participants WHERE id = ?`, id) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +func (app *App) getParticipantByEmail(email string) (*Participant, error) { + rows, err := queryParticipants(app.db, + `SELECT `+participantCols+` FROM participants WHERE LOWER(email) = LOWER(?) AND deleted_at IS NULL LIMIT 1`, email) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +func (app *App) createParticipant(p Participant) (*Participant, error) { + res, err := app.db.Exec( + `INSERT INTO participants (email, preferred_name, phone, pronouns, note, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, + strings.ToLower(p.Email), p.PreferredName, p.Phone, p.Pronouns, p.Note, now(), + ) + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + return app.getParticipant(int(id)) +} + +func (app *App) updateParticipant(p Participant) error { + _, err := app.db.Exec( + `UPDATE participants SET email=?, preferred_name=?, phone=?, pronouns=?, note=?, updated_at=? + WHERE id=? AND deleted_at IS NULL`, + strings.ToLower(p.Email), p.PreferredName, p.Phone, p.Pronouns, p.Note, now(), p.ID, + ) + return err +} + +func (app *App) deleteParticipant(id int) error { + _, err := app.db.Exec( + `UPDATE participants SET deleted_at=?, updated_at=? WHERE id=?`, now(), now(), id, + ) + return err +} + +// mergeParticipants reassigns all tickets and volunteers from other → canonical, then soft-deletes other. +func (app *App) mergeParticipants(canonicalID, otherID int) error { + ts := now() + if _, err := app.db.Exec( + `UPDATE tickets SET participant_id=?, updated_at=? WHERE participant_id=? AND deleted_at IS NULL`, + canonicalID, ts, otherID, + ); err != nil { + return err + } + if _, err := app.db.Exec( + `UPDATE volunteers SET participant_id=?, updated_at=? WHERE participant_id=? AND deleted_at IS NULL`, + canonicalID, ts, otherID, + ); err != nil { + return err + } + _, err := app.db.Exec( + `UPDATE participants SET deleted_at=?, updated_at=? WHERE id=?`, ts, ts, otherID, + ) + return err +} + +func queryParticipants(db *sql.DB, q string, args ...any) ([]Participant, error) { + rows, err := db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var result []Participant + for rows.Next() { + var p Participant + if err := rows.Scan( + &p.ID, &p.Email, &p.PreferredName, &p.Phone, &p.Pronouns, &p.Note, + &p.CreatedAt, &p.UpdatedAt, &p.DeletedAt, + ); err != nil { + return nil, err + } + result = append(result, p) + } + return result, rows.Err() +} + +// upsertParticipant finds a participant by email or creates one. +// Returns the participant and whether it was newly created. +func (app *App) upsertParticipant(email, name string) (*Participant, bool, error) { + p, err := app.getParticipantByEmail(email) + if err != nil { + return nil, false, err + } + if p != nil { + return p, false, nil + } + created, err := app.createParticipant(Participant{ + Email: email, + PreferredName: name, + }) + return created, true, err +} + +// --- Tickets --- + +const ticketCols = `id, participant_id, name, ticket_type, source, external_id, order_id, code, checked_in_at, checked_in_by, created_at, updated_at, deleted_at` + +func (app *App) listTickets(participantID *int, since string) ([]Ticket, error) { + q := `SELECT ` + ticketCols + ` FROM tickets WHERE 1=1` + var args []any + if since != "" { + q += ` AND updated_at > ?` + args = append(args, since) + } else { + q += ` AND deleted_at IS NULL` + } + if participantID != nil { + q += ` AND participant_id = ?` + args = append(args, *participantID) + } + q += ` ORDER BY created_at` + return queryTickets(app.db, q, args...) +} + +func (app *App) getTicket(id int) (*Ticket, error) { + rows, err := queryTickets(app.db, + `SELECT `+ticketCols+` FROM tickets WHERE id = ?`, id) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +func (app *App) getTicketByCode(code string) (*Ticket, error) { + rows, err := queryTickets(app.db, + `SELECT `+ticketCols+` FROM tickets WHERE code = ? AND deleted_at IS NULL`, code) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +func (app *App) createTicket(t Ticket) (*Ticket, error) { + res, err := app.db.Exec( + `INSERT INTO tickets (participant_id, name, ticket_type, source, external_id, order_id, code, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + t.ParticipantID, t.Name, t.TicketType, t.Source, t.ExternalID, t.OrderID, t.Code, now(), + ) + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + return app.getTicket(int(id)) +} + +func (app *App) checkInTicket(id, userID int) (*Ticket, error) { + t := now() + _, err := app.db.Exec(` + UPDATE tickets SET + checked_in_at = CASE WHEN checked_in_at IS NULL THEN ? ELSE checked_in_at END, + checked_in_by = CASE WHEN checked_in_at IS NULL THEN ? ELSE checked_in_by END, + updated_at = ? + WHERE id = ? AND deleted_at IS NULL`, + t, userID, t, id, + ) + if err != nil { + return nil, err + } + return app.getTicket(id) +} + +func (app *App) deleteTicket(id int) error { + _, err := app.db.Exec( + `UPDATE tickets SET deleted_at=?, updated_at=? WHERE id=?`, now(), now(), id, + ) + return err +} + +func (app *App) ticketsSince(since string) ([]Ticket, error) { + return queryTickets(app.db, + `SELECT `+ticketCols+` FROM tickets WHERE updated_at > ? ORDER BY updated_at ASC`, since) +} + +func (app *App) participantsSince(since string) ([]Participant, error) { + return queryParticipants(app.db, + `SELECT `+participantCols+` FROM participants WHERE updated_at > ? ORDER BY updated_at ASC`, since) +} + +func queryTickets(db *sql.DB, q string, args ...any) ([]Ticket, error) { + rows, err := db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var result []Ticket + for rows.Next() { + var t Ticket + var participantID, checkedInBy sql.NullInt64 + var code sql.NullString + if err := rows.Scan( + &t.ID, &participantID, &t.Name, &t.TicketType, &t.Source, &t.ExternalID, &t.OrderID, + &code, &t.CheckedInAt, &checkedInBy, &t.CreatedAt, &t.UpdatedAt, &t.DeletedAt, + ); err != nil { + return nil, err + } + if participantID.Valid { + id := int(participantID.Int64) + t.ParticipantID = &id + } + if checkedInBy.Valid { + id := int(checkedInBy.Int64) + t.CheckedInBy = &id + } + if code.Valid && code.String != "" { + t.Code = &code.String + } + result = append(result, t) + } + return result, rows.Err() +} + +// ticketCounts returns total and checked-in ticket counts for participants page. +func (app *App) ticketCounts() (total, checkedIn int, err error) { + app.db.QueryRow(`SELECT COUNT(*) FROM tickets WHERE deleted_at IS NULL`).Scan(&total) + app.db.QueryRow(`SELECT COUNT(*) FROM tickets WHERE checked_in_at IS NOT NULL AND deleted_at IS NULL`).Scan(&checkedIn) + return +} + +func (app *App) ticketTypes() ([]string, error) { + rows, err := app.db.Query( + `SELECT DISTINCT ticket_type FROM tickets WHERE ticket_type != '' AND deleted_at IS NULL ORDER BY ticket_type`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var types []string + for rows.Next() { + var t string + rows.Scan(&t) + types = append(types, t) + } + return types, rows.Err() +} + // --- Departments --- func (app *App) listDepartments(since string) ([]Department, error) { @@ -729,33 +1133,49 @@ func queryDepartments(db *sql.DB, q string, args ...any) ([]Department, error) { // --- Volunteers --- +// volunteerSelect / volunteerFrom are used together for all volunteer queries. +// Personal fields (name, email, phone, pronouns) come from the joined participant when available, +// falling back to the volunteer's own columns for legacy rows. +const volunteerSelect = `v.id, v.participant_id, v.attendee_id, + COALESCE(NULLIF(p.preferred_name,''), NULLIF(v.preferred_name,''), v.name), + COALESCE(NULLIF(p.preferred_name,''), NULLIF(v.preferred_name,''), v.name), + v.ticket_name, + COALESCE(NULLIF(p.email,''), v.email), + COALESCE(NULLIF(p.phone,''), v.phone), + COALESCE(NULLIF(p.pronouns,''), v.pronouns), + v.department_id, v.is_lead, v.checked_in, v.checked_in_at, + v.email_confirmed, v.confirmation_token, 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` + +// volunteerCols is kept for backward-compat references that expect unqualified column names. const volunteerCols = `id, attendee_id, name, preferred_name, ticket_name, email, phone, pronouns, department_id, is_lead, checked_in, checked_in_at, email_confirmed, confirmation_token, note, created_at, updated_at, deleted_at` func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) { - q := `SELECT ` + volunteerCols + ` FROM volunteers WHERE 1=1` + q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1` var args []any if since != "" { - q += ` AND updated_at > ?` + q += ` AND v.updated_at > ?` args = append(args, since) } else { - q += ` AND deleted_at IS NULL` + q += ` AND v.deleted_at IS NULL` } if search != "" { - q += ` AND (name LIKE ? OR email LIKE ?)` + q += ` AND (v.name LIKE ? OR v.email LIKE ? OR p.preferred_name LIKE ? OR p.email LIKE ?)` s := "%" + search + "%" - args = append(args, s, s) + args = append(args, s, s, s, s) } if deptID != nil { - q += ` AND department_id = ?` + q += ` AND v.department_id = ?` args = append(args, *deptID) } - q += ` ORDER BY name` + q += ` ORDER BY COALESCE(NULLIF(p.preferred_name,''), v.name)` return queryVolunteers(app.db, q, args...) } func (app *App) getVolunteer(id int) (*Volunteer, error) { rows, err := queryVolunteers(app.db, - `SELECT `+volunteerCols+` FROM volunteers WHERE id = ?`, id) + `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.id = ?`, id) if err != nil || len(rows) == 0 { return nil, err } @@ -764,7 +1184,16 @@ func (app *App) getVolunteer(id int) (*Volunteer, error) { func (app *App) getVolunteerByAttendeeID(attendeeID int) (*Volunteer, error) { rows, err := queryVolunteers(app.db, - `SELECT `+volunteerCols+` FROM volunteers WHERE attendee_id = ? AND deleted_at IS NULL LIMIT 1`, attendeeID) + `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.attendee_id = ? AND v.deleted_at IS NULL LIMIT 1`, attendeeID) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +func (app *App) getVolunteerByParticipantID(participantID int) (*Volunteer, error) { + rows, err := queryVolunteers(app.db, + `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.participant_id = ? AND v.deleted_at IS NULL LIMIT 1`, participantID) if err != nil || len(rows) == 0 { return nil, err } @@ -773,9 +1202,9 @@ func (app *App) getVolunteerByAttendeeID(attendeeID int) (*Volunteer, error) { func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { res, err := app.db.Exec( - `INSERT INTO volunteers (attendee_id, name, preferred_name, ticket_name, email, phone, pronouns, department_id, is_lead, email_confirmed, confirmation_token, note, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - v.AttendeeID, v.Name, v.PreferredName, v.TicketName, v.Email, v.Phone, v.Pronouns, + `INSERT INTO volunteers (participant_id, attendee_id, name, preferred_name, ticket_name, email, phone, pronouns, department_id, is_lead, email_confirmed, confirmation_token, note, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + v.ParticipantID, v.AttendeeID, v.Name, v.PreferredName, v.TicketName, v.Email, v.Phone, v.Pronouns, v.DepartmentID, boolInt(v.IsLead), boolInt(v.EmailConfirmed), v.ConfirmationToken, v.Note, now(), ) if err != nil { @@ -787,9 +1216,9 @@ func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { func (app *App) updateVolunteer(v Volunteer) error { _, err := app.db.Exec( - `UPDATE volunteers SET attendee_id=?, name=?, preferred_name=?, ticket_name=?, email=?, phone=?, pronouns=?, department_id=?, is_lead=?, note=?, updated_at=? + `UPDATE volunteers SET participant_id=?, attendee_id=?, name=?, preferred_name=?, ticket_name=?, email=?, phone=?, pronouns=?, department_id=?, is_lead=?, note=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, - v.AttendeeID, v.Name, v.PreferredName, v.TicketName, v.Email, v.Phone, v.Pronouns, + v.ParticipantID, v.AttendeeID, v.Name, v.PreferredName, v.TicketName, v.Email, v.Phone, v.Pronouns, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID, ) return err @@ -833,11 +1262,11 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { var result []Volunteer for rows.Next() { var v Volunteer - var attendeeID, deptID sql.NullInt64 + var participantID, attendeeID, deptID sql.NullInt64 var isLead, checkedIn, emailConfirmed int var confirmationToken sql.NullString if err := rows.Scan( - &v.ID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName, + &v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName, &v.Email, &v.Phone, &v.Pronouns, &deptID, &isLead, &checkedIn, &v.CheckedInAt, &emailConfirmed, &confirmationToken, &v.Note, @@ -845,6 +1274,10 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { ); err != nil { return nil, err } + if participantID.Valid { + id := int(participantID.Int64) + v.ParticipantID = &id + } if attendeeID.Valid { id := int(attendeeID.Int64) v.AttendeeID = &id @@ -866,7 +1299,7 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { func (app *App) getVolunteerByEmail(email string) (*Volunteer, error) { rows, err := queryVolunteers(app.db, - `SELECT `+volunteerCols+` FROM volunteers WHERE LOWER(email) = LOWER(?) AND deleted_at IS NULL LIMIT 1`, email) + `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE LOWER(v.email) = LOWER(?) AND v.deleted_at IS NULL LIMIT 1`, email) if err != nil || len(rows) == 0 { return nil, err } @@ -875,7 +1308,7 @@ func (app *App) getVolunteerByEmail(email string) (*Volunteer, error) { func (app *App) getVolunteerByConfirmationToken(token string) (*Volunteer, error) { rows, err := queryVolunteers(app.db, - `SELECT `+volunteerCols+` FROM volunteers WHERE confirmation_token = ? AND deleted_at IS NULL LIMIT 1`, token) + `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.confirmation_token = ? AND v.deleted_at IS NULL LIMIT 1`, token) if err != nil || len(rows) == 0 { return nil, err } @@ -889,13 +1322,19 @@ func (app *App) confirmVolunteerEmail(id int) error { return err } -func (app *App) listConfirmedVolunteersWithoutKioskToken() ([]Volunteer, error) { +// listConfirmedVolunteersNeedingCode returns confirmed volunteers whose participant +// has no ticket with a code yet. +func (app *App) listConfirmedVolunteersNeedingCode() ([]Volunteer, error) { return queryVolunteers(app.db, ` - SELECT `+volunteerCols+` - FROM volunteers - WHERE email_confirmed = 1 AND deleted_at IS NULL - AND attendee_id IS NOT NULL - AND (SELECT a.volunteer_token FROM attendees a WHERE a.id = volunteers.attendee_id) IS NULL`) + 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 + )`) } func generateConfirmationToken() (string, error) { diff --git a/email.go b/email.go index 0a1c65d..41a7a55 100644 --- a/email.go +++ b/email.go @@ -122,25 +122,36 @@ func (app *App) eventName() string { return "the event" } -// sendTokenEmail sends a volunteer token link to the attendee's email address. -func (app *App) sendTokenEmail(a Attendee) error { - if a.Email == "" { - return fmt.Errorf("attendee has no email address") +// sendTicketTokenEmail sends a volunteer token link for a ticket to its participant's email. +func (app *App) sendTicketTokenEmail(tk Ticket) error { + if tk.Code == nil || *tk.Code == "" { + return fmt.Errorf("ticket has no code") } - if a.VolunteerToken == nil || *a.VolunteerToken == "" { - return fmt.Errorf("attendee has no volunteer token") + if tk.ParticipantID == nil { + return fmt.Errorf("ticket has no participant") + } + p, err := app.getParticipant(*tk.ParticipantID) + if err != nil || p == nil { + return fmt.Errorf("participant not found") + } + if p.Email == "" { + return fmt.Errorf("participant has no email address") } cfg := app.loadSMTPConfig() eventName := app.eventName() - link := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), *a.VolunteerToken) + link := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), *tk.Code) + name := p.PreferredName + if name == "" { + name = tk.Name + } subject := fmt.Sprintf("Your volunteer link for %s", eventName) body := fmt.Sprintf( "Hi %s,\n\nThank you for volunteering at %s!\n\nYour volunteer token: %s\nYour signup link: %s\n\nUse this link to sign up for available shifts in your department.\n\nSee you there!\n", - a.Name, eventName, *a.VolunteerToken, link, + name, eventName, *tk.Code, link, ) - return sendEmail(cfg, a.Email, subject, body) + return sendEmail(cfg, p.Email, subject, body) } func (app *App) sendConfirmationEmail(to, name, confirmToken string) error { diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 0fed314..ba26931 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -5,6 +5,7 @@ import Login from './pages/Login.svelte' import Dashboard from './pages/Dashboard.svelte' import Attendees from './pages/Attendees.svelte' + import Participants from './pages/Participants.svelte' import Volunteers from './pages/Volunteers.svelte' import Departments from './pages/Departments.svelte' import Users from './pages/Users.svelte' @@ -128,6 +129,8 @@ {/if} {:else if path.startsWith('/attendees')} + {:else if path.startsWith('/participants')} + {:else if path.startsWith('/volunteers')} {:else if path.startsWith('/departments')} diff --git a/frontend/src/api.js b/frontend/src/api.js index e288308..bf6fd0a 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -56,6 +56,21 @@ export const api = { get: () => apiJSON('/api/event'), update: (data) => apiJSON('/api/event', { method: 'PUT', body: JSON.stringify(data) }), }, + participants: { + list: (params = {}) => apiJSON('/api/participants?' + new URLSearchParams(params)), + get: (id) => apiJSON(`/api/participants/${id}`), + create: (data) => apiJSON('/api/participants', { method: 'POST', body: JSON.stringify(data) }), + update: (id, data) => apiJSON(`/api/participants/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + delete: (id) => apiFetch(`/api/participants/${id}`, { method: 'DELETE' }), + merge: (id, otherId) => apiJSON(`/api/participants/${id}/merge/${otherId}`, { method: 'POST' }), + }, + tickets: { + list: () => apiJSON('/api/tickets'), + checkIn: (id) => apiJSON(`/api/tickets/${id}/checkin`, { method: 'POST' }), + generateCodes: () => apiJSON('/api/tickets/generate-codes', { method: 'POST' }), + emailCode: (id) => apiJSON(`/api/tickets/${id}/email-code`, { method: 'POST' }), + emailAllCodes: () => apiJSON('/api/tickets/email-codes', { method: 'POST' }), + }, attendees: { list: (params = {}) => apiJSON('/api/attendees?' + new URLSearchParams(params)), get: (id) => apiJSON(`/api/attendees/${id}`), @@ -111,6 +126,7 @@ export const api = { testEmail: (to) => apiJSON('/api/settings/test-email', { method: 'POST', body: JSON.stringify({ to }) }), toggleShiftSignups: (open) => apiJSON('/api/settings/shift-signups', { method: 'POST', body: JSON.stringify({ open }) }), resetAttendees: () => apiJSON('/api/settings/reset-attendees', { method: 'POST' }), + resetTickets: () => apiJSON('/api/settings/reset-tickets', { method: 'POST' }), resetVolunteers: () => apiJSON('/api/settings/reset-volunteers', { method: 'POST' }), resetShifts: () => apiJSON('/api/settings/reset-shifts', { method: 'POST' }), resetDepartments: () => apiJSON('/api/settings/reset-departments', { method: 'POST' }), diff --git a/frontend/src/components/Nav.svelte b/frontend/src/components/Nav.svelte index cef8d7c..d7ccc63 100644 --- a/frontend/src/components/Nav.svelte +++ b/frontend/src/components/Nav.svelte @@ -1,5 +1,5 @@ + +
+ + + {#if mergeMode && mergeSource} +
+
+ Merge: "{mergeSource.preferred_name || mergeSource.email}" will be merged into the participant you select below. + All their tickets and volunteer records will move to the target. +
+ {#if mergeTarget} +
+ Target: {mergeTarget.preferred_name || mergeTarget.email} ({mergeTarget.email}) +
+
+ + +
+ {:else} +
Click a participant row below to select as merge target.
+
+ +
+ {/if} +
+ {/if} + + {#if error} +
{error}
+ {/if} + {#if success} +
{success}
+ {/if} + + + + {#if ($allParticipants ?? []).length === 0} +
+ No participants yet +

Import a CSV or wait for volunteer signups.

+
+ {:else} +
+ + + + + + + + {#if canManage}{/if} + + + + {#each filtered as p (p.id)} + {@const pts = ticketsFor(p.id)} + {@const ci = checkedInCount(p.id)} + {@const isExpanded = expandedId === p.id} + {@const isMergeTarget = mergeMode && mergeSource?.id !== p.id} + { mergeTarget = p } : null} + style={mergeMode && mergeSource?.id !== p.id ? 'cursor:pointer' : ''} + > + + + + + {#if canManage} + + {/if} + + {#if isExpanded && pts.length > 0} + + + + {/if} + {/each} + +
NameEmailTicketsStatus
+ {p.preferred_name || '—'} + {#if p.pronouns} + · {p.pronouns} + {/if} + {#if p.note} +
{p.note}
+ {/if} +
{p.email || '—'} + {#if pts.length > 0} + + {:else} + + {/if} + + {#if pts.length > 0} + + {ci}/{pts.length} in + + {:else} + No ticket + {/if} + + {#if !mergeMode} + + {/if} +
+
+ {#each pts as tk (tk.id)} +
+
+ {tk.name || '(unnamed)'} + {#if tk.ticket_type} + · {tk.ticket_type} + {/if} + {#if tk.external_id} + · #{tk.external_id} + {/if} + {#if tk.code} +
+ {tk.code} + {#if p.email && canManage} + + {/if} +
+ {/if} +
+
+ {#if tk.checked_in_at} + In {fmtTime(tk.checked_in_at)} + {:else} + Pending + {/if} +
{tk.source}
+
+
+ {/each} +
+
+
+ {/if} +
+ + diff --git a/frontend/src/sync.js b/frontend/src/sync.js index 05b16c0..e8f2d1a 100644 --- a/frontend/src/sync.js +++ b/frontend/src/sync.js @@ -12,7 +12,7 @@ export async function syncPull() { const data = await api.sync.pull(since) await db.transaction('rw', - [db.event, db.attendees, db.departments, db.volunteers, db.shifts, db.volunteer_shifts], + [db.event, db.attendees, db.participants, db.tickets, db.departments, db.volunteers, db.shifts, db.volunteer_shifts], async () => { if (data.event) { await db.event.put(data.event) @@ -23,6 +23,16 @@ export async function syncPull() { const deleted = data.attendees.filter(a => a.deleted_at).map(a => a.id) if (deleted.length) await db.attendees.bulkDelete(deleted) } + if (data.participants?.length) { + await db.participants.bulkPut(data.participants) + const deleted = data.participants.filter(p => p.deleted_at).map(p => p.id) + if (deleted.length) await db.participants.bulkDelete(deleted) + } + if (data.tickets?.length) { + await db.tickets.bulkPut(data.tickets) + const deleted = data.tickets.filter(t => t.deleted_at).map(t => t.id) + if (deleted.length) await db.tickets.bulkDelete(deleted) + } if (data.departments?.length) { await db.departments.bulkPut(data.departments) const deleted = data.departments.filter(d => d.deleted_at).map(d => d.id) @@ -75,6 +85,9 @@ export function startSSE(onEvent) { if (payload.data?.type === 'attendee' && payload.data?.attendee) { await db.attendees.put(payload.data.attendee) } + if (payload.data?.type === 'ticket' && payload.data?.ticket) { + await db.tickets.put(payload.data.ticket) + } if (payload.data?.type === 'volunteer' && payload.data?.volunteer) { await db.volunteers.put(payload.data.volunteer) } diff --git a/handle_attendees.go b/handle_attendees.go index 5e732ba..0ee5628 100644 --- a/handle_attendees.go +++ b/handle_attendees.go @@ -125,7 +125,17 @@ func (app *App) handleCheckInAttendee(w http.ResponseWriter, r *http.Request) { result := map[string]any{"attendee": a} if body.AlsoVolunteer { - v, _ := app.getVolunteerByAttendeeID(id) + // Try to find volunteer via participant_id first (new model), fall back to attendee_id (legacy). + var v *Volunteer + if a != nil { + p, _ := app.getParticipantByEmail(a.Email) + if p != nil { + v, _ = app.getVolunteerByParticipantID(p.ID) + } + } + if v == nil { + v, _ = app.getVolunteerByAttendeeID(id) + } if v != nil { if !v.CheckedIn { if v2, err := app.checkInVolunteer(v.ID, claims.UserID); err == nil { diff --git a/handle_import.go b/handle_import.go index 359d8f9..7870e7a 100644 --- a/handle_import.go +++ b/handle_import.go @@ -10,7 +10,6 @@ import ( type ImportResult struct { Inserted int `json:"inserted"` - Grouped int `json:"grouped"` Skipped int `json:"skipped"` Errors []string `json:"errors"` } @@ -57,12 +56,14 @@ func (app *App) importCSV(r io.Reader) (ImportResult, error) { } var ( - nameIdx, emailIdx, ticketIDIdx, ticketTypeIdx, noteIdx int - hasEmail, hasTicketID, hasTicketType, hasNote bool + nameIdx, emailIdx, ticketIDIdx, ticketTypeIdx int + hasEmail, hasTicketID, hasTicketType bool + isCrowdWork bool ) if idx, ok := colIndex["patron name"]; ok { // CrowdWork / ticketing platform format + isCrowdWork = true nameIdx = idx if idx, ok := colIndex["patron email"]; ok { emailIdx, hasEmail = idx, true @@ -85,9 +86,6 @@ func (app *App) importCSV(r io.Reader) (ImportResult, error) { if idx, ok := colIndex["ticket_type"]; ok { ticketTypeIdx, hasTicketType = idx, true } - if idx, ok := colIndex["note"]; ok { - noteIdx, hasNote = idx, true - } } else { return ImportResult{}, fmt.Errorf("CSV must have a 'name' or 'patron name' column") } @@ -111,33 +109,49 @@ func (app *App) importCSV(r io.Reader) (ImportResult, error) { continue } - a := Attendee{Name: name} + email := "" if hasEmail { - a.Email = strings.TrimSpace(csvGet(record, emailIdx)) + email = strings.TrimSpace(csvGet(record, emailIdx)) } + externalID := "" if hasTicketID { - a.TicketID = strings.TrimSpace(csvGet(record, ticketIDIdx)) + externalID = strings.TrimSpace(csvGet(record, ticketIDIdx)) } + ticketType := "" if hasTicketType { - a.TicketType = strings.TrimSpace(csvGet(record, ticketTypeIdx)) - } - if hasNote { - a.Note = strings.TrimSpace(csvGet(record, noteIdx)) + ticketType = strings.TrimSpace(csvGet(record, ticketTypeIdx)) } - _, err = app.createAttendee(a) + source := "manual" + orderID := "" + if isCrowdWork { + source = "crowdwork" + orderID = externalID + } + + // Find or create participant when email is present. + var participantID *int + if email != "" { + p, _, err := app.upsertParticipant(email, name) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("line %d (%s): participant: %v", lineNum, name, err)) + continue + } + if p != nil { + participantID = &p.ID + } + } + + _, err = app.createTicket(Ticket{ + ParticipantID: participantID, + Name: name, + TicketType: ticketType, + Source: source, + ExternalID: externalID, + OrderID: orderID, + }) if err != nil { if strings.Contains(err.Error(), "UNIQUE constraint failed") { - // CrowdWork exports one row per ticket under the purchaser's name. - // If we have a ticket_id and the same (name, ticket_id) already exists, - // increment party_size instead of skipping. - if hasTicketID && a.TicketID != "" { - merged, mergeErr := app.incrementPartySize(a.Name, a.TicketID) - if mergeErr == nil && merged { - result.Grouped++ - continue - } - } result.Skipped++ } else { result.Errors = append(result.Errors, fmt.Sprintf("line %d (%s): %v", lineNum, name, err)) diff --git a/handle_import_test.go b/handle_import_test.go index a062c53..2397ca2 100644 --- a/handle_import_test.go +++ b/handle_import_test.go @@ -61,13 +61,13 @@ func TestImportGenericFormat(t *testing.T) { } } -func TestImportPartySizeDedup(t *testing.T) { +func TestImportDedup(t *testing.T) { app := testApp(t) admin := testAdminUser(t, app) token := testToken(t, app, admin) mux := testMux(app) - // 3 rows same name+order = 1 record, party_size=3 + // 3 rows with same order number: first inserts, remaining 2 skip (same external_id) csv := "Patron Name,Patron Email,Order Number,Tier Name\nTitania,titania@test.com,ORD-1,GA\nTitania,titania@test.com,ORD-1,GA\nTitania,titania@test.com,ORD-1,GA\n" w := postCSV(t, mux, token, csv) @@ -75,16 +75,16 @@ func TestImportPartySizeDedup(t *testing.T) { if result["inserted"] != float64(1) { t.Errorf("inserted = %v, want 1", result["inserted"]) } - if result["grouped"] != float64(2) { - t.Errorf("grouped = %v, want 2", result["grouped"]) + if result["skipped"] != float64(2) { + t.Errorf("skipped = %v, want 2", result["skipped"]) } - attendees, _ := app.listAttendees("", "", "") - if len(attendees) != 1 { - t.Fatalf("attendee count = %d, want 1", len(attendees)) + tickets, _ := app.listTickets(nil, "") + if len(tickets) != 1 { + t.Fatalf("ticket count = %d, want 1", len(tickets)) } - if attendees[0].PartySize != 3 { - t.Errorf("party_size = %d, want 3", attendees[0].PartySize) + if tickets[0].Source != "crowdwork" { + t.Errorf("source = %q, want crowdwork", tickets[0].Source) } } @@ -94,7 +94,8 @@ func TestImportReimportSkips(t *testing.T) { token := testToken(t, app, admin) mux := testMux(app) - csv := "name\nTitania\nOberon\n" + // Use ticket_ids so re-import dedup works via UNIQUE(source, external_id) + csv := "name,email,ticket_id\nTitania,titania@test.com,T001\nOberon,oberon@test.com,T002\n" postCSV(t, mux, token, csv) // Re-import same data diff --git a/handle_kiosk.go b/handle_kiosk.go index c782afb..646db43 100644 --- a/handle_kiosk.go +++ b/handle_kiosk.go @@ -7,17 +7,20 @@ import ( ) // handleKioskGet returns the volunteer's profile, current shift assignments, and -// available open shifts in their department. Authenticated by volunteer token only — +// available open shifts in their department. Authenticated by ticket code only — // no JWT required. func (app *App) handleKioskGet(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") - a, err := app.getAttendeeByToken(token) - if err != nil || a == nil { + t, err := app.getTicketByCode(token) + if err != nil || t == nil { writeError(w, "not found", http.StatusNotFound) return } - v, _ := app.getVolunteerByAttendeeID(a.ID) + 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 @@ -53,12 +56,15 @@ func (app *App) handleKioskClaim(w http.ResponseWriter, r *http.Request) { return } - a, err := app.getAttendeeByToken(token) - if err != nil || a == nil { + t, err := app.getTicketByCode(token) + if err != nil || t == nil { writeError(w, "not found", http.StatusNotFound) return } - v, _ := app.getVolunteerByAttendeeID(a.ID) + 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 @@ -110,12 +116,15 @@ func (app *App) handleKioskUnclaim(w http.ResponseWriter, r *http.Request) { return } - a, err := app.getAttendeeByToken(token) - if err != nil || a == nil { + t, err := app.getTicketByCode(token) + if err != nil || t == nil { writeError(w, "not found", http.StatusNotFound) return } - v, _ := app.getVolunteerByAttendeeID(a.ID) + 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 diff --git a/handle_kiosk_test.go b/handle_kiosk_test.go index 0eb252b..1e3c682 100644 --- a/handle_kiosk_test.go +++ b/handle_kiosk_test.go @@ -14,13 +14,14 @@ func setupKiosk(t *testing.T) (*App, *http.ServeMux, string) { dept, _ := app.createDepartment(Department{Name: "Gate"}) deptID := dept.ID - // Create attendee with token - a, _ := app.createAttendee(Attendee{Name: "Titania", Email: "titania@test.com"}) + // Create participant + ticket with code + p, _ := app.createParticipant(Participant{Email: "titania@test.com", PreferredName: "Titania"}) token, _ := app.generateUniqueToken() - app.db.Exec(`UPDATE attendees SET volunteer_token = ? WHERE id = ?`, token, a.ID) + tk, _ := app.createTicket(Ticket{ParticipantID: &p.ID, Name: "Titania", Source: "manual", Code: &token}) + _ = tk - // Create linked volunteer - app.createVolunteer(Volunteer{Name: "Titania", AttendeeID: &a.ID, DepartmentID: &deptID}) + // Create linked volunteer via participant_id + app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: &p.ID, DepartmentID: &deptID}) // 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_participants.go b/handle_participants.go new file mode 100644 index 0000000..3a5b290 --- /dev/null +++ b/handle_participants.go @@ -0,0 +1,157 @@ +package main + +import ( + "encoding/csv" + "encoding/json" + "net/http" + "strconv" +) + +func (app *App) handleListParticipants(w http.ResponseWriter, r *http.Request) { + search := r.URL.Query().Get("search") + participants, err := app.listParticipants(search, "") + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + total, checkedIn, _ := app.ticketCounts() + types, _ := app.ticketTypes() + writeJSON(w, map[string]any{ + "participants": participants, + "total": total, + "checked_in": checkedIn, + "ticket_types": types, + }) +} + +func (app *App) handleGetParticipant(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + p, err := app.getParticipant(id) + if err != nil || p == nil { + writeError(w, "not found", http.StatusNotFound) + return + } + tickets, _ := app.listTickets(&id, "") + writeJSON(w, map[string]any{"participant": p, "tickets": tickets}) +} + +func (app *App) handleCreateParticipant(w http.ResponseWriter, r *http.Request) { + var p Participant + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + writeError(w, "invalid request", http.StatusBadRequest) + return + } + if p.PreferredName == "" && p.Email == "" { + writeError(w, "name or email is required", http.StatusBadRequest) + return + } + created, err := app.createParticipant(p) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + writeJSON(w, created) +} + +func (app *App) handleUpdateParticipant(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + var p Participant + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + writeError(w, "invalid request", http.StatusBadRequest) + return + } + p.ID = id + if err := app.updateParticipant(p); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + updated, _ := app.getParticipant(id) + writeJSON(w, updated) +} + +func (app *App) handleDeleteParticipant(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + if err := app.deleteParticipant(id); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// handleMergeParticipants reassigns all tickets and volunteers from otherID to +// canonicalID, then soft-deletes the other participant. +func (app *App) handleMergeParticipants(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + otherID, err := strconv.Atoi(r.PathValue("other_id")) + if err != nil { + writeError(w, "invalid other_id", http.StatusBadRequest) + return + } + if err := app.mergeParticipants(id, otherID); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + p, _ := app.getParticipant(id) + tickets, _ := app.listTickets(&id, "") + writeJSON(w, map[string]any{"participant": p, "tickets": tickets}) +} + +func (app *App) handleExportParticipants(w http.ResponseWriter, r *http.Request) { + participants, err := app.listParticipants("", "") + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Content-Disposition", `attachment; filename="participants.csv"`) + wr := csv.NewWriter(w) + wr.Write([]string{"id", "email", "preferred_name", "phone", "pronouns", "note"}) + for _, p := range participants { + wr.Write([]string{ + strconv.Itoa(p.ID), p.Email, p.PreferredName, p.Phone, p.Pronouns, p.Note, + }) + } + wr.Flush() +} + +func (app *App) handleListTickets(w http.ResponseWriter, r *http.Request) { + tickets, err := app.listTickets(nil, "") + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]any{"tickets": tickets}) +} + +func (app *App) handleCheckInTicket(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + claims := claimsFromContext(r) + tk, err := app.checkInTicket(id, claims.UserID) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + app.broker.publish("checkin", map[string]any{"type": "ticket", "ticket": tk}) + writeJSON(w, map[string]any{"ticket": tk}) +} diff --git a/handle_settings.go b/handle_settings.go index 9021368..f812134 100644 --- a/handle_settings.go +++ b/handle_settings.go @@ -90,6 +90,17 @@ func (app *App) handleResetAttendees(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"deleted": n}) } +func (app *App) handleResetTickets(w http.ResponseWriter, r *http.Request) { + ts := now() + result, err := app.db.Exec(`UPDATE tickets SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL`, ts, ts) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + n, _ := result.RowsAffected() + writeJSON(w, map[string]any{"deleted": n}) +} + func (app *App) handleResetVolunteers(w http.ResponseWriter, r *http.Request) { ts := now() result, err := app.db.Exec(`UPDATE volunteers SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL`, ts, ts) diff --git a/handle_signup.go b/handle_signup.go index 2f8eb46..31c796b 100644 --- a/handle_signup.go +++ b/handle_signup.go @@ -68,32 +68,23 @@ func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) { 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 - } + // Find or create participant by email. + name := body.PreferredName + if body.TicketName != "" { + name = body.TicketName } - - 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 + participant, _, err := app.upsertParticipant(body.Email, name) + if err != nil { + writeError(w, "internal error", http.StatusInternalServerError) + return + } + // Update participant's personal details if they signed up with more info. + if body.Phone != "" || body.Pronouns != "" { + app.db.Exec(`UPDATE participants SET + phone = CASE WHEN phone = '' THEN ? ELSE phone END, + pronouns = CASE WHEN pronouns = '' THEN ? ELSE pronouns END, + updated_at = ? + WHERE id = ?`, body.Phone, body.Pronouns, now(), participant.ID) } confirmToken, err := generateConfirmationToken() @@ -103,7 +94,7 @@ func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) { } vol := Volunteer{ - AttendeeID: attendeeID, + ParticipantID: &participant.ID, Name: body.PreferredName, PreferredName: body.PreferredName, TicketName: body.TicketName, @@ -159,17 +150,39 @@ 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.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 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 a != nil && a.VolunteerToken != nil { - kioskLink := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), *a.VolunteerToken) + 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 { + stub, err := app.createTicket(Ticket{ + ParticipantID: vol.ParticipantID, + 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 { @@ -203,36 +216,57 @@ func (app *App) handleToggleShiftSignups(w http.ResponseWriter, r *http.Request) } func (app *App) openShiftSignups() { - // Generate kiosk tokens for confirmed volunteers whose attendees lack one - vols, _ := app.listConfirmedVolunteersWithoutKioskToken() + // Generate codes for tickets belonging to confirmed volunteers that have no code yet. + vols, _ := app.listConfirmedVolunteersNeedingCode() for _, v := range vols { - if v.AttendeeID == nil { + 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 { + stub, err := app.createTicket(Ticket{ + ParticipantID: v.ParticipantID, + Source: "manual", + }) + if err != nil { + continue + } + ticketID = stub.ID + } t, err := app.generateUniqueToken() if err != nil { continue } - app.db.Exec(`UPDATE attendees SET volunteer_token=?, updated_at=? WHERE id=?`, t, now(), *v.AttendeeID) + app.db.Exec(`UPDATE tickets SET code=?, updated_at=? WHERE id=?`, t, now(), ticketID) } - // Email all confirmed volunteers with kiosk links + // Email all confirmed volunteers that now have a ticket with a code. confirmed, _ := queryVolunteers(app.db, ` - SELECT `+volunteerCols+` - FROM volunteers - WHERE email_confirmed = 1 AND deleted_at IS NULL AND attendee_id IS NOT NULL`) + SELECT `+volunteerSelect+` `+volunteerFrom+` + WHERE v.email_confirmed = 1 AND v.deleted_at IS NULL AND v.participant_id IS NOT NULL`) baseURL := app.resolveBaseURL() sent := 0 for _, v := range confirmed { - if v.AttendeeID == nil || v.Email == "" { + if v.ParticipantID == nil || v.Email == "" { continue } - a, _ := app.getAttendee(*v.AttendeeID) - if a == nil || a.VolunteerToken == nil { + 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, *a.VolunteerToken) + kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *code) name := v.PreferredName if name == "" { name = v.Name diff --git a/handle_signup_test.go b/handle_signup_test.go index a59da69..a9bdab0 100644 --- a/handle_signup_test.go +++ b/handle_signup_test.go @@ -71,25 +71,25 @@ func TestPublicSignup(t *testing.T) { t.Error("should not be confirmed yet") } - // Attendee should be auto-created and linked - if vol.AttendeeID == nil { - t.Fatal("expected attendee to be linked") + // Participant should be auto-created and linked + if vol.ParticipantID == nil { + t.Fatal("expected participant to be linked") } - a, _ := app.getAttendee(*vol.AttendeeID) - if a == nil { - t.Fatal("linked attendee not found") + p, _ := app.getParticipant(*vol.ParticipantID) + if p == nil { + t.Fatal("linked participant not found") } - if a.Email != "titania@example.com" { - t.Errorf("attendee email = %q, want titania@example.com", a.Email) + if p.Email != "titania@example.com" { + t.Errorf("participant email = %q, want titania@example.com", p.Email) } } -func TestPublicSignupAutoMatchAttendee(t *testing.T) { +func TestPublicSignupAutoMatchParticipant(t *testing.T) { app := testApp(t) mux := testMux(app) - // Pre-existing attendee - existing, _ := app.createAttendee(Attendee{Name: "Titania Fairweather", Email: "titania@example.com"}) + // Pre-existing participant + existing, _ := app.createParticipant(Participant{PreferredName: "Titania Fairweather", Email: "titania@example.com"}) w := httptest.NewRecorder() mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{ @@ -105,8 +105,8 @@ func TestPublicSignupAutoMatchAttendee(t *testing.T) { if vol == nil { t.Fatal("volunteer not created") } - if vol.AttendeeID == nil || *vol.AttendeeID != existing.ID { - t.Errorf("expected volunteer linked to existing attendee %d, got %v", existing.ID, vol.AttendeeID) + if vol.ParticipantID == nil || *vol.ParticipantID != existing.ID { + t.Errorf("expected volunteer linked to existing participant %d, got %v", existing.ID, vol.ParticipantID) } } @@ -277,13 +277,13 @@ func TestConfirmEmailWithSignupsOpen(t *testing.T) { app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`) app.baseURL = "https://example.com" - attendee, _ := app.createAttendee(Attendee{Name: "Titania", Email: "titania@example.com"}) + participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) token := "abc123def456" app.createVolunteer(Volunteer{ Name: "Titania", PreferredName: "Titania", Email: "titania@example.com", - AttendeeID: &attendee.ID, + ParticipantID: &participant.ID, ConfirmationToken: &token, }) @@ -301,10 +301,17 @@ func TestConfirmEmailWithSignupsOpen(t *testing.T) { t.Error("expected kiosk_link when signups are open") } - // Attendee should now have a kiosk token - a, _ := app.getAttendee(attendee.ID) - if a.VolunteerToken == nil { - t.Error("attendee should have kiosk token after confirm with signups 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 + } + } + if !hasCode { + t.Error("participant should have a ticket with code after confirm with signups open") } } diff --git a/handle_sync.go b/handle_sync.go index 78725c5..a11d414 100644 --- a/handle_sync.go +++ b/handle_sync.go @@ -13,6 +13,8 @@ func (app *App) handleSyncPull(w http.ResponseWriter, r *http.Request) { event, _ := app.getEvent() attendees, _ := app.attendeesSince(since) + participants, _ := app.listParticipants("", since) + tickets, _ := app.listTickets(nil, since) departments, _ := app.listDepartments(since) volunteers, _ := app.listVolunteers("", nil, since) shifts, _ := app.listShifts(nil, "", since) @@ -21,6 +23,12 @@ func (app *App) handleSyncPull(w http.ResponseWriter, r *http.Request) { if attendees == nil { attendees = []Attendee{} } + if participants == nil { + participants = []Participant{} + } + if tickets == nil { + tickets = []Ticket{} + } if departments == nil { departments = []Department{} } @@ -38,6 +46,8 @@ func (app *App) handleSyncPull(w http.ResponseWriter, r *http.Request) { "server_time": time.Now().UTC().Format("2006-01-02T15:04:05Z"), "event": event, "attendees": attendees, + "participants": participants, + "tickets": tickets, "departments": departments, "volunteers": volunteers, "shifts": shifts, diff --git a/handle_tokens.go b/handle_tokens.go index 480d03d..63e19a6 100644 --- a/handle_tokens.go +++ b/handle_tokens.go @@ -8,9 +8,9 @@ import ( "strings" ) -// handleGenerateTokens creates volunteer_token values for all attendees that don't have one. +// handleGenerateTokens creates codes for all tickets that don't have one. func (app *App) handleGenerateTokens(w http.ResponseWriter, r *http.Request) { - count, err := app.generateTokensForAll() + count, err := app.generateCodesForAll() if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -21,7 +21,7 @@ func (app *App) handleGenerateTokens(w http.ResponseWriter, r *http.Request) { // handleExportTokenLinks streams a CSV download with token signup links, // compatible with MailChimp / Zeffy bulk-send workflows. func (app *App) handleExportTokenLinks(w http.ResponseWriter, r *http.Request) { - attendees, err := app.listAttendees("", "", "") + tickets, err := app.listTickets(nil, "") if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -37,55 +37,62 @@ func (app *App) handleExportTokenLinks(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Disposition", `attachment; filename="volunteer-tokens.csv"`) wr := csv.NewWriter(w) wr.Write([]string{"Email Address", "First Name", "Token", "Signup Link"}) - for _, a := range attendees { - if a.VolunteerToken == nil { + for _, tk := range tickets { + if tk.Code == nil || tk.ParticipantID == nil { continue } - firstName := a.Name - if parts := strings.Fields(a.Name); len(parts) > 0 { + p, _ := app.getParticipant(*tk.ParticipantID) + if p == nil || p.Email == "" { + continue + } + firstName := p.PreferredName + if firstName == "" { + firstName = tk.Name + } + if parts := strings.Fields(firstName); len(parts) > 0 { firstName = parts[0] } - link := fmt.Sprintf("%s/v/%s", baseURL, *a.VolunteerToken) - wr.Write([]string{a.Email, firstName, *a.VolunteerToken, link}) + link := fmt.Sprintf("%s/v/%s", baseURL, *tk.Code) + wr.Write([]string{p.Email, firstName, *tk.Code, link}) } wr.Flush() } -// handleEmailToken sends a token email to a single attendee. +// handleEmailToken sends a token email to a single ticket's participant. func (app *App) handleEmailToken(w http.ResponseWriter, r *http.Request) { id, err := strconv.Atoi(r.PathValue("id")) if err != nil { writeError(w, "invalid id", http.StatusBadRequest) return } - a, err := app.getAttendee(id) - if err != nil || a == nil { + tk, err := app.getTicket(id) + if err != nil || tk == nil { writeError(w, "not found", http.StatusNotFound) return } - if err := app.sendTokenEmail(*a); err != nil { + if err := app.sendTicketTokenEmail(*tk); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, map[string]any{"ok": true}) } -// handleEmailAllTokens bulk-sends token emails to all attendees that have both a token and email. +// handleEmailAllTokens bulk-sends token emails to all tickets that have a code and participant email. func (app *App) handleEmailAllTokens(w http.ResponseWriter, r *http.Request) { - attendees, err := app.listAttendees("", "", "") + tickets, err := app.listTickets(nil, "") if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } var sent, skipped int var errors []string - for _, a := range attendees { - if a.Email == "" || a.VolunteerToken == nil { + for _, tk := range tickets { + if tk.Code == nil || tk.ParticipantID == nil { skipped++ continue } - if err := app.sendTokenEmail(a); err != nil { - errors = append(errors, fmt.Sprintf("%s: %v", a.Name, err)) + if err := app.sendTicketTokenEmail(tk); err != nil { + errors = append(errors, fmt.Sprintf("ticket %d: %v", tk.ID, err)) skipped++ } else { sent++ diff --git a/main.go b/main.go index 77e09c4..22551b6 100644 --- a/main.go +++ b/main.go @@ -111,6 +111,21 @@ func (app *App) registerRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /api/attendees/{id}/checkin", auth(app.handleCheckInAttendee, "admin", "ticketing", "gate")) mux.HandleFunc("POST /api/attendees/{id}/email-token", auth(app.handleEmailToken, "admin", "ticketing")) + mux.HandleFunc("GET /api/participants", auth(app.handleListParticipants, "admin", "ticketing", "gate")) + mux.HandleFunc("POST /api/participants", auth(app.handleCreateParticipant, "admin", "ticketing")) + mux.HandleFunc("GET /api/participants/export", auth(app.handleExportParticipants, "admin", "ticketing")) + mux.HandleFunc("GET /api/participants/{id}", auth(app.handleGetParticipant, "admin", "ticketing", "gate")) + mux.HandleFunc("PUT /api/participants/{id}", auth(app.handleUpdateParticipant, "admin", "ticketing")) + mux.HandleFunc("DELETE /api/participants/{id}", auth(app.handleDeleteParticipant, "admin", "ticketing")) + mux.HandleFunc("POST /api/participants/{id}/merge/{other_id}", auth(app.handleMergeParticipants, "admin", "ticketing")) + + mux.HandleFunc("GET /api/tickets", auth(app.handleListTickets, "admin", "ticketing", "gate")) + mux.HandleFunc("POST /api/tickets/{id}/checkin", auth(app.handleCheckInTicket, "admin", "ticketing", "gate")) + mux.HandleFunc("POST /api/tickets/generate-codes", auth(app.handleGenerateTokens, "admin", "ticketing")) + mux.HandleFunc("GET /api/tickets/export-links", auth(app.handleExportTokenLinks, "admin", "ticketing")) + mux.HandleFunc("POST /api/tickets/email-codes", auth(app.handleEmailAllTokens, "admin", "ticketing")) + mux.HandleFunc("POST /api/tickets/{id}/email-code", auth(app.handleEmailToken, "admin", "ticketing")) + mux.HandleFunc("GET /api/departments", auth(app.handleListDepartments)) mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "coordinator")) mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "coordinator")) @@ -142,6 +157,7 @@ func (app *App) registerRoutes(mux *http.ServeMux) { mux.HandleFunc("PUT /api/settings", auth(app.handleUpdateSettings, "admin")) mux.HandleFunc("POST /api/settings/test-email", auth(app.handleTestEmail, "admin")) mux.HandleFunc("POST /api/settings/reset-attendees", auth(app.handleResetAttendees, "admin")) + mux.HandleFunc("POST /api/settings/reset-tickets", auth(app.handleResetTickets, "admin")) mux.HandleFunc("POST /api/settings/reset-volunteers", auth(app.handleResetVolunteers, "admin")) mux.HandleFunc("POST /api/settings/reset-shifts", auth(app.handleResetShifts, "admin")) mux.HandleFunc("POST /api/settings/reset-departments", auth(app.handleResetDepartments, "admin"))