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})
+
+
+ Confirm merge
+ Cancel
+
+ {:else}
+
Click a participant row below to select as merge target.
+
+ Cancel
+
+ {/if}
+
+ {/if}
+
+ {#if error}
+
{error}
+ {/if}
+ {#if success}
+
{success}
+ {/if}
+
+
+
+
+ {filtered.length} shown
+
+
+
+ {#if ($allParticipants ?? []).length === 0}
+
+
No participants yet
+
Import a CSV or wait for volunteer signups.
+
+ {:else}
+
+
+
+
+ Name
+ Email
+ Tickets
+ Status
+ {#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' : ''}
+ >
+
+ {p.preferred_name || '—'}
+ {#if p.pronouns}
+ · {p.pronouns}
+ {/if}
+ {#if p.note}
+ {p.note}
+ {/if}
+
+ {p.email || '—'}
+
+ {#if pts.length > 0}
+ { e.stopPropagation(); toggleExpand(p.id) }}>
+ {pts.length} ticket{pts.length !== 1 ? 's' : ''}
+ {isExpanded ? '▲' : '▼'}
+
+ {:else}
+ —
+ {/if}
+
+
+ {#if pts.length > 0}
+
+ {ci}/{pts.length} in
+
+ {:else}
+ No ticket
+ {/if}
+
+ {#if canManage}
+
+ {#if !mergeMode}
+ { e.stopPropagation(); startMerge(p) }}
+ title="Merge this participant into another">⇄
+ {/if}
+
+ {/if}
+
+ {#if isExpanded && pts.length > 0}
+
+
+
+ {#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}
+ api.tickets.emailCode(tk.id).then(() => success = 'Email sent.').catch(e => error = e.message)}>✉
+ {/if}
+
+ {/if}
+
+
+ {#if tk.checked_in_at}
+
In {fmtTime(tk.checked_in_at)}
+ {:else}
+
Pending
+ {/if}
+
{tk.source}
+
+
+ {/each}
+
+
+
+ {/if}
+ {/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"))