diff --git a/auth_test.go b/auth_test.go index f611bc1..1a16571 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", "gatekeeper", []int{}) + gate := testUserWithRole(t, app, "gateuser", "gate", []int{}) token := testToken(t, app, gate) req := testAuthRequest("GET", "/api/users", nil, token) diff --git a/db.go b/db.go index d93807d..bc97c91 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','ticketing','staffing','colead','gatekeeper')), + role TEXT NOT NULL CHECK(role IN ('admin','coordinator','gate','ticketing','volunteer_lead')), created_at TEXT NOT NULL DEFAULT (datetime('now')) ); @@ -121,40 +121,6 @@ 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 @@ -177,129 +143,6 @@ 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 } @@ -380,8 +223,7 @@ type Department struct { type Volunteer struct { ID int `json:"id"` - ParticipantID *int `json:"participant_id,omitempty"` - AttendeeID *int `json:"attendee_id,omitempty"` // deprecated; kept for migration compat + AttendeeID *int `json:"attendee_id,omitempty"` Name string `json:"name"` PreferredName string `json:"preferred_name"` TicketName string `json:"ticket_name"` @@ -400,34 +242,6 @@ 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"` @@ -630,7 +444,7 @@ func (app *App) generateUniqueToken() (string, error) { return "", err } var count int - if err := app.db.QueryRow(`SELECT COUNT(*) FROM tickets WHERE code = ?`, t).Scan(&count); err != nil { + if err := app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE volunteer_token = ?`, t).Scan(&count); err != nil { return "", fmt.Errorf("check token uniqueness: %w", err) } if count == 0 { @@ -640,10 +454,19 @@ func (app *App) generateUniqueToken() (string, error) { return "", fmt.Errorf("failed to generate unique token") } -// generateCodesForAll generates codes for every ticket that doesn't have one yet. -func (app *App) generateCodesForAll() (int, error) { +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) { rows, err := app.db.Query( - `SELECT id FROM tickets WHERE code IS NULL AND deleted_at IS NULL`, + `SELECT id FROM attendees WHERE volunteer_token IS NULL AND deleted_at IS NULL`, ) if err != nil { return 0, err @@ -653,7 +476,7 @@ func (app *App) generateCodesForAll() (int, error) { for rows.Next() { var id int if err := rows.Scan(&id); err != nil { - return 0, fmt.Errorf("scan ticket id: %w", err) + return 0, fmt.Errorf("scan attendee id: %w", err) } ids = append(ids, id) } @@ -664,13 +487,14 @@ func (app *App) generateCodesForAll() (int, error) { if err != nil { continue } - app.db.Exec(`UPDATE tickets SET code=?, updated_at=? WHERE id=?`, t, now(), id) + app.db.Exec(`UPDATE attendees SET volunteer_token=?, updated_at=? WHERE id=?`, t, now(), id) count++ } return count, nil } -// incrementPartySize is kept for backward compatibility with existing tests. +// 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. func (app *App) incrementPartySize(name, ticketID string) (bool, error) { res, err := app.db.Exec( `UPDATE attendees SET party_size = party_size + 1, updated_at = ? @@ -838,274 +662,6 @@ 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) { @@ -1173,49 +729,33 @@ 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 ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1` + q := `SELECT ` + volunteerCols + ` FROM volunteers WHERE 1=1` var args []any if since != "" { - q += ` AND v.updated_at > ?` + q += ` AND updated_at > ?` args = append(args, since) } else { - q += ` AND v.deleted_at IS NULL` + q += ` AND deleted_at IS NULL` } if search != "" { - q += ` AND (v.name LIKE ? OR v.email LIKE ? OR p.preferred_name LIKE ? OR p.email LIKE ?)` + q += ` AND (name LIKE ? OR email LIKE ?)` s := "%" + search + "%" - args = append(args, s, s, s, s) + args = append(args, s, s) } if deptID != nil { - q += ` AND v.department_id = ?` + q += ` AND department_id = ?` args = append(args, *deptID) } - q += ` ORDER BY COALESCE(NULLIF(p.preferred_name,''), v.name)` + q += ` ORDER BY name` return queryVolunteers(app.db, q, args...) } func (app *App) getVolunteer(id int) (*Volunteer, error) { rows, err := queryVolunteers(app.db, - `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.id = ?`, id) + `SELECT `+volunteerCols+` FROM volunteers WHERE id = ?`, id) if err != nil || len(rows) == 0 { return nil, err } @@ -1224,16 +764,7 @@ func (app *App) getVolunteer(id int) (*Volunteer, error) { func (app *App) getVolunteerByAttendeeID(attendeeID int) (*Volunteer, error) { rows, err := queryVolunteers(app.db, - `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) + `SELECT `+volunteerCols+` FROM volunteers WHERE attendee_id = ? AND deleted_at IS NULL LIMIT 1`, attendeeID) if err != nil || len(rows) == 0 { return nil, err } @@ -1242,9 +773,9 @@ func (app *App) getVolunteerByParticipantID(participantID int) (*Volunteer, erro func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { res, err := app.db.Exec( - `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, + `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, v.DepartmentID, boolInt(v.IsLead), boolInt(v.EmailConfirmed), v.ConfirmationToken, v.Note, now(), ) if err != nil { @@ -1256,9 +787,9 @@ func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { func (app *App) updateVolunteer(v Volunteer) error { _, err := app.db.Exec( - `UPDATE volunteers SET participant_id=?, attendee_id=?, name=?, preferred_name=?, ticket_name=?, email=?, phone=?, pronouns=?, department_id=?, is_lead=?, note=?, updated_at=? + `UPDATE volunteers SET 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.ParticipantID, v.AttendeeID, v.Name, v.PreferredName, v.TicketName, v.Email, v.Phone, v.Pronouns, + 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 @@ -1302,11 +833,11 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { var result []Volunteer for rows.Next() { var v Volunteer - var participantID, attendeeID, deptID sql.NullInt64 + var attendeeID, deptID sql.NullInt64 var isLead, checkedIn, emailConfirmed int var confirmationToken sql.NullString if err := rows.Scan( - &v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName, + &v.ID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName, &v.Email, &v.Phone, &v.Pronouns, &deptID, &isLead, &checkedIn, &v.CheckedInAt, &emailConfirmed, &confirmationToken, &v.Note, @@ -1314,10 +845,6 @@ 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 @@ -1339,7 +866,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 `+volunteerSelect+` `+volunteerFrom+` WHERE LOWER(v.email) = LOWER(?) AND v.deleted_at IS NULL LIMIT 1`, email) + `SELECT `+volunteerCols+` FROM volunteers WHERE LOWER(email) = LOWER(?) AND deleted_at IS NULL LIMIT 1`, email) if err != nil || len(rows) == 0 { return nil, err } @@ -1348,7 +875,7 @@ func (app *App) getVolunteerByEmail(email string) (*Volunteer, error) { func (app *App) getVolunteerByConfirmationToken(token string) (*Volunteer, error) { rows, err := queryVolunteers(app.db, - `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.confirmation_token = ? AND v.deleted_at IS NULL LIMIT 1`, token) + `SELECT `+volunteerCols+` FROM volunteers WHERE confirmation_token = ? AND deleted_at IS NULL LIMIT 1`, token) if err != nil || len(rows) == 0 { return nil, err } @@ -1362,19 +889,13 @@ func (app *App) confirmVolunteerEmail(id int) error { return err } -// listConfirmedVolunteersNeedingCode returns confirmed volunteers whose participant -// has no ticket with a code yet. -func (app *App) listConfirmedVolunteersNeedingCode() ([]Volunteer, error) { +func (app *App) listConfirmedVolunteersWithoutKioskToken() ([]Volunteer, error) { return queryVolunteers(app.db, ` - SELECT `+volunteerSelect+` `+volunteerFrom+` - WHERE v.email_confirmed = 1 AND v.deleted_at IS NULL - AND v.participant_id IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM tickets t - WHERE t.participant_id = v.participant_id - AND t.code IS NOT NULL - AND t.deleted_at IS NULL - )`) + 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`) } func generateConfirmationToken() (string, error) { diff --git a/email.go b/email.go index 41a7a55..0a1c65d 100644 --- a/email.go +++ b/email.go @@ -122,36 +122,25 @@ func (app *App) eventName() string { return "the event" } -// 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") +// 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") } - 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") + if a.VolunteerToken == nil || *a.VolunteerToken == "" { + return fmt.Errorf("attendee has no volunteer token") } cfg := app.loadSMTPConfig() eventName := app.eventName() - link := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), *tk.Code) - name := p.PreferredName - if name == "" { - name = tk.Name - } + link := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), *a.VolunteerToken) 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", - name, eventName, *tk.Code, link, + a.Name, eventName, *a.VolunteerToken, link, ) - return sendEmail(cfg, p.Email, subject, body) + return sendEmail(cfg, a.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 7f28a8a..0fed314 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 Participants from './pages/Participants.svelte' + import Attendees from './pages/Attendees.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 === 'gatekeeper'} +{:else if role === 'gate'} {:else} @@ -121,13 +121,13 @@ Turnpike {#if path === '/' || path === ''} - {#if role === 'colead'} + {#if role === 'volunteer_lead'} {:else} {/if} - {:else if path.startsWith('/participants')} - + {:else if path.startsWith('/attendees')} + {:else if path.startsWith('/volunteers')} {:else if path.startsWith('/departments')} diff --git a/frontend/src/api.js b/frontend/src/api.js index b0767e6..e288308 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -56,21 +56,20 @@ 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'), - 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' }), + 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' }), }, volunteers: { list: (params = {}) => apiJSON('/api/volunteers?' + new URLSearchParams(params)), @@ -111,7 +110,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 }) }), - resetTickets: () => apiJSON('/api/settings/reset-tickets', { method: 'POST' }), + resetAttendees: () => apiJSON('/api/settings/reset-attendees', { 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 974dd32..f6527f5 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('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.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.delete uses DELETE method', async () => { + it('attendees.delete uses DELETE method', async () => { const f = mockFetch({}, 204) - await api.participants.delete(5) - expect(f.mock.calls[0][0]).toBe('/api/participants/5') + await api.attendees.delete(5) + expect(f.mock.calls[0][0]).toBe('/api/attendees/5') expect(f.mock.calls[0][1].method).toBe('DELETE') }) diff --git a/frontend/src/app.css b/frontend/src/app.css index 5727e4d..f10e75b 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -131,7 +131,6 @@ 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 545e171..cef8d7c 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 b50fde4..81408d8 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', 'ticketing', 'staffing'].includes(role)) - const canDelete = $derived(['admin', 'ticketing'].includes(role)) + const canCreate = $derived(['admin', 'coordinator'].includes(role)) + const canDelete = $derived(role === 'admin') 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 6c7dc71..f3ea8a9 100644 --- a/frontend/src/pages/GateUI.svelte +++ b/frontend/src/pages/GateUI.svelte @@ -16,67 +16,36 @@ let detector = $state(null) let scanInterval = $state(null) - const tickets = liveQuery(() => - db.tickets.filter(t => !t.deleted_at).toArray() - ) - - const participants = liveQuery(() => - db.participants.filter(p => !p.deleted_at).toArray() + const attendees = liveQuery(() => + db.attendees.filter(a => !a.deleted_at).toArray() ) const recentCheckIns = liveQuery(() => - db.tickets - .filter(t => !!t.checked_in_at && !t.deleted_at) + db.attendees + .filter(a => a.checked_in && !a.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) ) ) - // 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 filtered = $derived.by(() => { const s = search.trim().toLowerCase() if (!s || s.length < 2) return [] - 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 || '')) + 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)) .slice(0, 8) }) - // Auto-select when exactly one participant matches - const selectedParticipant = $derived.by(() => { - if (filteredParticipants.length === 1) return filteredParticipants[0] - return null + 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 }) - 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 }) @@ -127,19 +96,40 @@ } catch {} } - async function checkInTicket(ticket) { + async function checkIn(attendee, count = 1) { error = '' try { - const result = await api.tickets.checkIn(ticket.id) - if (result.ticket) { - await db.tickets.put(result.ticket) - search = '' + const result = await api.attendees.checkIn(attendee.id, { count }) + if (result.attendee) { + await db.attendees.put(result.attendee) } } 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' }) @@ -182,95 +172,74 @@
{error}
{/if} - - {#if matchedTicket} - {@const p = participantFor(matchedTicket)} + + {#if selected} + {@const rem = remaining(selected)} + {@const prog = progressLabel(selected)}
-
{nameFor(matchedTicket)}
- {#if matchedTicket.ticket_type} -
{matchedTicket.ticket_type}
+
{selected.name}
+ {#if selected.ticket_type} +
{selected.ticket_type}
{/if} - {#if matchedTicket.external_id} -
#{matchedTicket.external_id}
+ {#if selected.ticket_id} +
#{selected.ticket_id}
{/if} - {#if p?.email} -
{p.email}
+ {#if prog} +
+ {prog} +
{/if} +
- {#if !matchedTicket.checked_in_at} - + {#if rem > 1} + + {/if} {:else} - ✓ Checked in {fmt(matchedTicket.checked_in_at)} + All checked in + {/if} + + {#if selected.volunteer_token && !selected.checked_in} + {/if}
- - - {: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} + {:else if search.trim().length >= 2 && filtered.length > 1} +
- {#each filteredParticipants as p} - {@const pts = ticketsFor(p.id)} - {@const ci = pts.filter(t => t.checked_in_at).length} - {/each}
- - {:else if search.trim().length >= 2} -
No matching participants or tickets found.
+ {:else if search.trim().length >= 2 && filtered.length === 0} +
No matching attendees found.
{/if}
Recent Check-ins
{#if ($recentCheckIns ?? []).length === 0} -
No check-ins yet.
+
No check-ins yet today.
{:else} - {#each $recentCheckIns ?? [] as tk} + {#each $recentCheckIns ?? [] as a}
- {nameFor(tk)} - {fmt(tk.checked_in_at)} + {a.name} + {fmt(a.checked_in_at)}
{/each} {/if} @@ -415,16 +384,6 @@ 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 17bff4d..a3f9ccc 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 tickets (same source + external ID) are skipped. Participants are matched or created by email. + Duplicate names are skipped.
- 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 5d9d265..8e2efd3 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', 'ticketing', 'staffing', 'colead'].includes(role)) + const canManage = $derived(['admin', 'coordinator', 'volunteer_lead'].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 === 'colead') return depts.filter(d => myDeptIDs.includes(d.id)) + if (role === 'volunteer_lead') 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 5caf2ee..72d6b5b 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.

-