diff --git a/auth_test.go b/auth_test.go index 1a16571..f611bc1 100644 --- a/auth_test.go +++ b/auth_test.go @@ -95,7 +95,7 @@ func TestAuthMiddlewareRoleEnforcement(t *testing.T) { mux := testMux(app) // Create a gate user — should not be able to access /api/users (admin only) - gate := testUserWithRole(t, app, "gateuser", "gate", []int{}) + gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{}) token := testToken(t, app, gate) req := testAuthRequest("GET", "/api/users", nil, token) diff --git a/db.go b/db.go index bc97c91..d93807d 100644 --- a/db.go +++ b/db.go @@ -44,7 +44,7 @@ func migrate(db *sql.DB) error { id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, - role TEXT NOT NULL CHECK(role IN ('admin','coordinator','gate','ticketing','volunteer_lead')), + role TEXT NOT NULL CHECK(role IN ('admin','ticketing','staffing','colead','gatekeeper')), created_at TEXT NOT NULL DEFAULT (datetime('now')) ); @@ -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,129 @@ 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 migrateV4(db) +} + +// migrateV4 renames roles: volunteer_lead→colead, coordinator→staffing, gate→gatekeeper. +func migrateV4(db *sql.DB) error { + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE role IN ('volunteer_lead','coordinator','gate')`).Scan(&count); err != nil || count == 0 { + return nil + } + if _, err := db.Exec(`PRAGMA foreign_keys = OFF`); err != nil { + return err + } + stmts := []string{ + `CREATE TABLE users_v4 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('admin','ticketing','staffing','colead','gatekeeper')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`, + `INSERT INTO users_v4 (id, username, password_hash, role, created_at) + SELECT id, username, password_hash, + CASE role + WHEN 'volunteer_lead' THEN 'colead' + WHEN 'coordinator' THEN 'staffing' + WHEN 'gate' THEN 'gatekeeper' + ELSE role + END, + created_at + FROM users`, + `DROP TABLE users`, + `ALTER TABLE users_v4 RENAME TO users`, + `PRAGMA foreign_keys = ON`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + db.Exec(`PRAGMA foreign_keys = ON`) + return fmt.Errorf("migrateV4: %w", err) + } + } return nil } @@ -223,7 +380,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 +400,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 +630,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 +640,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 +653,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 +664,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 +838,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 +1173,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 +1224,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 +1242,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 +1256,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 +1302,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 +1314,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 +1339,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 +1348,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 +1362,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..7f28a8a 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -4,7 +4,7 @@ import { syncPull, startSSE, startSyncLoop } from './sync.js' 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' @@ -103,7 +103,7 @@ {:else if !session} -{:else if role === 'gate'} +{:else if role === 'gatekeeper'} {:else} @@ -121,13 +121,13 @@ Turnpike {#if path === '/' || path === ''} - {#if role === 'volunteer_lead'} + {#if role === 'colead'} {:else} {/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..b0767e6 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -56,20 +56,21 @@ export const api = { get: () => apiJSON('/api/event'), update: (data) => apiJSON('/api/event', { method: 'PUT', body: JSON.stringify(data) }), }, - attendees: { - list: (params = {}) => apiJSON('/api/attendees?' + new URLSearchParams(params)), - get: (id) => apiJSON(`/api/attendees/${id}`), - create: (data) => apiJSON('/api/attendees', { method: 'POST', body: JSON.stringify(data) }), - update: (id, data) => apiJSON(`/api/attendees/${id}`, { method: 'PUT', body: JSON.stringify(data) }), - delete: (id) => apiFetch(`/api/attendees/${id}`, { method: 'DELETE' }), - checkIn: (id, opts = {}) => - apiJSON(`/api/attendees/${id}/checkin`, { method: 'POST', body: JSON.stringify(opts) }), - generateTokens: () => - apiJSON('/api/attendees/generate-tokens', { method: 'POST' }), - emailToken: (id) => - apiJSON(`/api/attendees/${id}/email-token`, { method: 'POST' }), - emailAllTokens: () => - apiJSON('/api/attendees/email-tokens', { method: 'POST' }), + 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'), + create: (data) => apiJSON('/api/tickets', { method: 'POST', body: JSON.stringify(data) }), + 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' }), }, volunteers: { list: (params = {}) => apiJSON('/api/volunteers?' + new URLSearchParams(params)), @@ -110,7 +111,7 @@ export const api = { update: (data) => apiJSON('/api/settings', { method: 'PUT', body: JSON.stringify(data) }), 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/api.test.js b/frontend/src/api.test.js index f6527f5..974dd32 100644 --- a/frontend/src/api.test.js +++ b/frontend/src/api.test.js @@ -71,16 +71,16 @@ describe('api methods', () => { expect(JSON.parse(opts.body)).toEqual({ username: 'admin', password: 'pass' }) }) - it('attendees.list calls correct endpoint', async () => { - const f = mockFetch({ attendees: [] }) - await api.attendees.list({ search: 'test' }) - expect(f.mock.calls[0][0]).toBe('/api/attendees?search=test') + it('participants.list calls correct endpoint', async () => { + const f = mockFetch({ participants: [] }) + await api.participants.list({ search: 'test' }) + expect(f.mock.calls[0][0]).toBe('/api/participants?search=test') }) - it('attendees.delete uses DELETE method', async () => { + it('participants.delete uses DELETE method', async () => { const f = mockFetch({}, 204) - await api.attendees.delete(5) - expect(f.mock.calls[0][0]).toBe('/api/attendees/5') + await api.participants.delete(5) + expect(f.mock.calls[0][0]).toBe('/api/participants/5') expect(f.mock.calls[0][1].method).toBe('DELETE') }) diff --git a/frontend/src/app.css b/frontend/src/app.css index f10e75b..5727e4d 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -131,6 +131,7 @@ tr:hover td { background: rgba(255,255,255,0.02); } } .badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); } .badge-unchecked { background: rgba(122,127,150,0.15); color: var(--c-muted); } +.badge-partial { background: rgba(245,158,11,0.15); color: var(--c-warn); } .badge-role { background: rgba(99,102,241,0.15); color: var(--c-accent-h); } .badge-lead { background: rgba(245,158,11,0.15); color: var(--c-warn); } diff --git a/frontend/src/components/Nav.svelte b/frontend/src/components/Nav.svelte index cef8d7c..545e171 100644 --- a/frontend/src/components/Nav.svelte +++ b/frontend/src/components/Nav.svelte @@ -1,5 +1,5 @@ - -
- - - {#if error} -
{error}
- {/if} - {#if success} -
{success}
- {/if} - - {#if showAdd && canManage} -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
- - -
-
-
- {/if} - - - - {#if ($allAttendees ?? []).length === 0} -
- No attendees yet -

Import a CSV or add attendees manually.

-
- {:else} -
- - - - - - - - {#if canCheckIn}{/if} - - - - {#each filtered as a (a.id)} - - - - - - {#if canCheckIn} - - {/if} - - {/each} - -
NameTicket typeEmailStatus
- {a.name} - {#if a.ticket_id} - · {a.ticket_id} - {/if} - {#if (a.party_size ?? 1) > 1} - ×{a.party_size} - {/if} - {#if a.note} -
{a.note}
- {/if} -
{a.ticket_type || '—'} -
{a.email || '—'}
- {#if a.volunteer_token && canManage} -
- {a.volunteer_token} - {#if a.email} - - {/if} -
- {/if} -
- {#if (a.party_size ?? 1) > 1} - - {a.checked_in_count ?? 0}/{a.party_size} in - - {:else} - - {a.checked_in ? 'Checked in' : 'Pending'} - - {/if} - {#if a.checked_in_at} -
- {new Date(a.checked_in_at).toLocaleTimeString()} -
- {/if} -
- {#if (a.checked_in_count ?? 0) < (a.party_size ?? 1)} - checkIn(a)} /> - {/if} -
-
- {/if} -
diff --git a/frontend/src/pages/Departments.svelte b/frontend/src/pages/Departments.svelte index 81408d8..b50fde4 100644 --- a/frontend/src/pages/Departments.svelte +++ b/frontend/src/pages/Departments.svelte @@ -19,8 +19,8 @@ let saving = $state(false) const role = $derived(session?.user?.role ?? '') - const canCreate = $derived(['admin', 'coordinator'].includes(role)) - const canDelete = $derived(role === 'admin') + const canCreate = $derived(['admin', 'ticketing', 'staffing'].includes(role)) + const canDelete = $derived(['admin', 'ticketing'].includes(role)) const allDepts = liveQuery(() => db.departments.filter(d => !d.deleted_at).toArray() diff --git a/frontend/src/pages/GateUI.svelte b/frontend/src/pages/GateUI.svelte index f3ea8a9..6c7dc71 100644 --- a/frontend/src/pages/GateUI.svelte +++ b/frontend/src/pages/GateUI.svelte @@ -16,36 +16,67 @@ let detector = $state(null) let scanInterval = $state(null) - const attendees = liveQuery(() => - db.attendees.filter(a => !a.deleted_at).toArray() + const tickets = liveQuery(() => + db.tickets.filter(t => !t.deleted_at).toArray() + ) + + const participants = liveQuery(() => + db.participants.filter(p => !p.deleted_at).toArray() ) const recentCheckIns = liveQuery(() => - db.attendees - .filter(a => a.checked_in && !a.deleted_at) + db.tickets + .filter(t => !!t.checked_in_at && !t.deleted_at) .toArray() .then(arr => arr - .filter(a => a.checked_in_at) .sort((a, b) => b.checked_in_at.localeCompare(a.checked_in_at)) .slice(0, 10) ) ) - const filtered = $derived.by(() => { + // Exact code/external_id match (QR scan or typed code) + const matchedTicket = $derived.by(() => { + const s = search.trim() + if (!s || s.length < 2) return null + const sl = s.toLowerCase() + const byCode = ($tickets ?? []).find(t => t.code?.toLowerCase() === sl) + if (byCode) return byCode + return ($tickets ?? []).find(t => t.external_id?.toLowerCase() === sl) ?? null + }) + + // Name/email search across participants + const filteredParticipants = $derived.by(() => { + if (matchedTicket) return [] const s = search.trim().toLowerCase() if (!s || s.length < 2) return [] - return ($attendees ?? []) - .filter(a => a.name.toLowerCase().includes(s) || a.ticket_id?.toLowerCase().includes(s) || a.email?.toLowerCase().includes(s)) - .sort((a, b) => a.name.localeCompare(b.name)) + return ($participants ?? []) + .filter(p => + p.preferred_name?.toLowerCase().includes(s) || + p.email?.toLowerCase().includes(s) + ) + .sort((a, b) => (a.preferred_name || '').localeCompare(b.preferred_name || '')) .slice(0, 8) }) - const selected = $derived.by(() => { - if (filtered.length === 1) return filtered[0] - const s = search.trim().toLowerCase() - return filtered.find(a => a.ticket_id?.toLowerCase() === s) ?? null + // Auto-select when exactly one participant matches + const selectedParticipant = $derived.by(() => { + if (filteredParticipants.length === 1) return filteredParticipants[0] + return null }) + function ticketsFor(participantId) { + return ($tickets ?? []).filter(t => t.participant_id === participantId && !t.deleted_at) + } + + function participantFor(ticket) { + if (!ticket?.participant_id) return null + return ($participants ?? []).find(p => p.id === ticket.participant_id) ?? null + } + + function nameFor(ticket) { + return ticket.name || participantFor(ticket)?.preferred_name || '(unknown)' + } + onMount(() => { qrSupported = 'BarcodeDetector' in window }) @@ -96,40 +127,19 @@ } catch {} } - async function checkIn(attendee, count = 1) { + async function checkInTicket(ticket) { error = '' try { - const result = await api.attendees.checkIn(attendee.id, { count }) - if (result.attendee) { - await db.attendees.put(result.attendee) + const result = await api.tickets.checkIn(ticket.id) + if (result.ticket) { + await db.tickets.put(result.ticket) + search = '' } } catch (err) { error = err.message } } - async function checkInWithVolunteer(attendee) { - error = '' - try { - const result = await api.attendees.checkIn(attendee.id, { count: 1, also_volunteer: true }) - if (result.attendee) await db.attendees.put(result.attendee) - if (result.volunteer) await db.volunteers.put(result.volunteer) - } catch (err) { - error = err.message - } - } - - function remaining(a) { - return (a.party_size ?? 1) - (a.checked_in_count ?? 0) - } - - function progressLabel(a) { - const ps = a.party_size ?? 1 - const ci = a.checked_in_count ?? 0 - if (ps <= 1) return null - return `${ci}/${ps} checked in` - } - function fmt(ts) { if (!ts) return '' return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) @@ -172,74 +182,95 @@
{error}
{/if} - - {#if selected} - {@const rem = remaining(selected)} - {@const prog = progressLabel(selected)} + + {#if matchedTicket} + {@const p = participantFor(matchedTicket)}
-
{selected.name}
- {#if selected.ticket_type} -
{selected.ticket_type}
+
{nameFor(matchedTicket)}
+ {#if matchedTicket.ticket_type} +
{matchedTicket.ticket_type}
{/if} - {#if selected.ticket_id} -
#{selected.ticket_id}
+ {#if matchedTicket.external_id} +
#{matchedTicket.external_id}
{/if} - {#if prog} -
- {prog} -
+ {#if p?.email} +
{p.email}
{/if} -
- {#if rem > 0} - - {#if rem > 1} - - {/if} {:else} - All checked in - {/if} - - {#if selected.volunteer_token && !selected.checked_in} - + ✓ Checked in {fmt(matchedTicket.checked_in_at)} {/if}
- {:else if search.trim().length >= 2 && filtered.length > 1} - + + + {:else if selectedParticipant} + {@const pts = ticketsFor(selectedParticipant.id)} +
+
{selectedParticipant.preferred_name || selectedParticipant.email || '(unknown)'}
+ {#if selectedParticipant.email} +
{selectedParticipant.email}
+ {/if} + {#if pts.length === 0} +
No tickets on file
+ {:else} +
+ {#each pts as tk (tk.id)} +
+ + {tk.name || '(unnamed)'} + {#if tk.ticket_type} · {tk.ticket_type}{/if} + + {#if tk.checked_in_at} + ✓ {fmt(tk.checked_in_at)} + {:else} + + {/if} +
+ {/each} +
+ {/if} +
+ + + {:else if search.trim().length >= 2 && filteredParticipants.length > 1}
- {#each filtered as a} - {/each}
- {:else if search.trim().length >= 2 && filtered.length === 0} -
No matching attendees found.
+ + {:else if search.trim().length >= 2} +
No matching participants or tickets found.
{/if}
Recent Check-ins
{#if ($recentCheckIns ?? []).length === 0} -
No check-ins yet today.
+
No check-ins yet.
{:else} - {#each $recentCheckIns ?? [] as a} + {#each $recentCheckIns ?? [] as tk}
- {a.name} - {fmt(a.checked_in_at)} + {nameFor(tk)} + {fmt(tk.checked_in_at)}
{/each} {/if} @@ -384,6 +415,16 @@ align-items: center; } + .gate-ticket-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.4rem 0.6rem; + background: var(--c-bg); + border-radius: 6px; + font-size: 0.875rem; + } + .gate-results { background: var(--c-surface); border: 1px solid var(--c-border); diff --git a/frontend/src/pages/Import.svelte b/frontend/src/pages/Import.svelte index a3f9ccc..17bff4d 100644 --- a/frontend/src/pages/Import.svelte +++ b/frontend/src/pages/Import.svelte @@ -53,7 +53,7 @@ Supported formats:
CrowdWork / ticketing platform: columns Patron Name, Patron Email, Tier Name, Order Number
Generic: columns name, email, ticket_id, ticket_type, note
- Duplicate names are skipped. + Duplicate tickets (same source + external ID) are skipped. Participants are matched or created by email.
+ Export CSV + + Export Links + + {/if} + + + + {#if showAdd && canManage} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ {/if} + + {#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} + {@const isEditing = editId === p.id} + {#if isEditing} + + + + {:else} + { mergeTarget = p } : null} + style={mergeMode && mergeSource?.id !== p.id ? 'cursor:pointer' : ''} + > + + + + + {#if canManage} + + {/if} + + {/if} + {#if isExpanded && !isEditing} + + + + {/if} + {/each} + +
NameEmailTicketsStatus
+
+
+ + + + + +
+
+ + + + +
+
+
+ {p.preferred_name || '—'} + {#if p.pronouns} + · {p.pronouns} + {/if} + {#if p.note} +
{p.note}
+ {/if} +
+ {p.email || '—'} + {#if p.phone} +
{p.phone}
+ {/if} +
+ {#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 canManage} + {#if addTicketFor === p.id} +
addTicket(e, p.id)}> + + + + + +
+ {:else} + + {/if} + {/if} +
+
+
+ {/if} + + + diff --git a/frontend/src/pages/ScheduleBoard.svelte b/frontend/src/pages/ScheduleBoard.svelte index 8e2efd3..5d9d265 100644 --- a/frontend/src/pages/ScheduleBoard.svelte +++ b/frontend/src/pages/ScheduleBoard.svelte @@ -26,7 +26,7 @@ let assigning = $state(false) const role = $derived(session?.user?.role ?? '') - const canManage = $derived(['admin', 'coordinator', 'volunteer_lead'].includes(role)) + const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role)) const myDeptIDs = $derived(session?.user?.department_ids ?? []) const allDepts = liveQuery(() => @@ -54,7 +54,7 @@ // Departments visible to this user const visibleDepts = $derived.by(() => { const depts = $allDepts ?? [] - if (role === 'volunteer_lead') return depts.filter(d => myDeptIDs.includes(d.id)) + if (role === 'colead') return depts.filter(d => myDeptIDs.includes(d.id)) return depts }) diff --git a/frontend/src/pages/Settings.svelte b/frontend/src/pages/Settings.svelte index 72d6b5b..5caf2ee 100644 --- a/frontend/src/pages/Settings.svelte +++ b/frontend/src/pages/Settings.svelte @@ -232,8 +232,8 @@ Permanently delete all records of a given type. This cannot be undone.

-