Compare commits
6 commits
0df93e1886
...
260e017f79
| Author | SHA1 | Date | |
|---|---|---|---|
| 260e017f79 | |||
| 64ce97c74d | |||
| 219acb62d6 | |||
| 3906b73c61 | |||
| d30ee18e77 | |||
| cd8e1e3b3b |
38 changed files with 1789 additions and 899 deletions
|
|
@ -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)
|
||||
|
|
|
|||
565
db.go
565
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) {
|
||||
|
|
|
|||
29
email.go
29
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 {
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<ConfirmEmail />
|
||||
{:else if !session}
|
||||
<Login onlogin={onLogin} />
|
||||
{:else if role === 'gate'}
|
||||
{:else if role === 'gatekeeper'}
|
||||
<!-- Gate users get the full-screen GateUI instead of the standard layout -->
|
||||
<GateUI {session} {onLogout} />
|
||||
{:else}
|
||||
|
|
@ -121,13 +121,13 @@
|
|||
<span class="mobile-brand">Turn<span class="accent">pike</span></span>
|
||||
</header>
|
||||
{#if path === '/' || path === ''}
|
||||
{#if role === 'volunteer_lead'}
|
||||
{#if role === 'colead'}
|
||||
<ScheduleBoard {session} />
|
||||
{:else}
|
||||
<Dashboard {session} />
|
||||
{/if}
|
||||
{:else if path.startsWith('/attendees')}
|
||||
<Attendees {session} />
|
||||
{:else if path.startsWith('/participants')}
|
||||
<Participants {session} />
|
||||
{:else if path.startsWith('/volunteers')}
|
||||
<Volunteers {session} />
|
||||
{:else if path.startsWith('/departments')}
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import { LayoutDashboard, ClipboardCheck, Heart, Hexagon, CalendarDays, Upload, Users, Settings, LogOut } from 'lucide-svelte'
|
||||
import { LayoutDashboard, Heart, Hexagon, CalendarDays, Upload, Users, Settings, LogOut, Ticket } from 'lucide-svelte'
|
||||
|
||||
let { session, active, onLogout, navigate, open = false } = $props()
|
||||
|
||||
|
|
@ -8,16 +8,12 @@
|
|||
const iconProps = { size: 18, strokeWidth: 1.75 }
|
||||
|
||||
const links = $derived.by(() => {
|
||||
if (role === 'ticketing') return [
|
||||
{ href: '/attendees', label: 'Attendees', icon: ClipboardCheck },
|
||||
{ href: '/import', label: 'Import', icon: Upload },
|
||||
]
|
||||
if (role === 'volunteer_lead') return [
|
||||
if (role === 'colead') return [
|
||||
{ href: '/', label: 'Schedule', icon: CalendarDays },
|
||||
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
||||
{ href: '/departments', label: 'Departments', icon: Hexagon },
|
||||
]
|
||||
if (role === 'coordinator') return [
|
||||
if (role === 'staffing') return [
|
||||
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '/schedule', label: 'Schedule', icon: CalendarDays },
|
||||
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
||||
|
|
@ -25,7 +21,7 @@
|
|||
]
|
||||
return [
|
||||
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '/attendees', label: 'Attendees', icon: ClipboardCheck },
|
||||
{ href: '/participants', label: 'Participants', icon: Ticket },
|
||||
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
||||
{ href: '/departments', label: 'Departments', icon: Hexagon },
|
||||
{ href: '/schedule', label: 'Schedule', icon: CalendarDays },
|
||||
|
|
|
|||
|
|
@ -26,6 +26,26 @@ db.version(2).stores({
|
|||
outbox: '++id, table, op, synced_at',
|
||||
})
|
||||
|
||||
db.version(3).stores({
|
||||
session: 'id, token, user',
|
||||
meta: 'key',
|
||||
event: 'id',
|
||||
attendees: 'id, name, ticket_type, checked_in, volunteer_token, deleted_at',
|
||||
participants: 'id, email, preferred_name, updated_at, deleted_at',
|
||||
tickets: 'id, participant_id, code, source, checked_in_at, updated_at, deleted_at',
|
||||
departments: 'id, name, deleted_at',
|
||||
volunteers: 'id, name, department_id, checked_in, attendee_id, participant_id, deleted_at',
|
||||
shifts: 'id, department_id, day, position, deleted_at',
|
||||
volunteer_shifts: '[volunteer_id+shift_id], volunteer_id, shift_id',
|
||||
outbox: '++id, table, op, synced_at',
|
||||
})
|
||||
|
||||
db.version(4).stores({
|
||||
attendees: null,
|
||||
outbox: null,
|
||||
volunteers: 'id, name, department_id, checked_in, participant_id, deleted_at',
|
||||
})
|
||||
|
||||
export async function getLastSync() {
|
||||
const m = await db.meta.get('last_sync')
|
||||
return m?.value ?? ''
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ describe('db schema', () => {
|
|||
it('has expected tables', () => {
|
||||
const names = db.tables.map(t => t.name).sort()
|
||||
expect(names).toEqual([
|
||||
'attendees', 'departments', 'event', 'meta',
|
||||
'outbox', 'session', 'shifts', 'volunteer_shifts', 'volunteers',
|
||||
'departments', 'event', 'meta',
|
||||
'participants', 'session', 'shifts', 'tickets', 'volunteer_shifts', 'volunteers',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,274 +0,0 @@
|
|||
<script>
|
||||
import { liveQuery } from 'dexie'
|
||||
import { db } from '../db.js'
|
||||
import { api } from '../api.js'
|
||||
import CheckInButton from '../components/CheckInButton.svelte'
|
||||
|
||||
let { session } = $props()
|
||||
|
||||
let search = $state('')
|
||||
let filterType = $state('')
|
||||
let filterChecked = $state('')
|
||||
let error = $state('')
|
||||
let success = $state('')
|
||||
let showAdd = $state(false)
|
||||
let newName = $state('')
|
||||
let newEmail = $state('')
|
||||
let newPhone = $state('')
|
||||
let newTicketID = $state('')
|
||||
let newTicketType = $state('')
|
||||
let newNote = $state('')
|
||||
let adding = $state(false)
|
||||
let generating = $state(false)
|
||||
let emailing = $state(false)
|
||||
|
||||
const role = $derived(session?.user?.role ?? '')
|
||||
const canManage = $derived(['admin', 'ticketing'].includes(role))
|
||||
const canCheckIn = $derived(['admin', 'ticketing', 'gate'].includes(role))
|
||||
|
||||
const allAttendees = liveQuery(() => db.attendees.toArray())
|
||||
const ticketTypes = liveQuery(() =>
|
||||
db.attendees.orderBy('ticket_type').uniqueKeys()
|
||||
)
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const list = $allAttendees ?? []
|
||||
const s = search.toLowerCase()
|
||||
return list
|
||||
.filter(a => {
|
||||
if (filterType && a.ticket_type !== filterType) return false
|
||||
if (filterChecked === 'true' && !a.checked_in) return false
|
||||
if (filterChecked === 'false' && a.checked_in) return false
|
||||
if (s && !a.name.toLowerCase().includes(s) &&
|
||||
!a.email.toLowerCase().includes(s) &&
|
||||
!a.ticket_id.toLowerCase().includes(s)) return false
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
|
||||
async function checkIn(attendee) {
|
||||
try {
|
||||
const result = await api.attendees.checkIn(attendee.id)
|
||||
if (result.attendee) await db.attendees.put(result.attendee)
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
}
|
||||
}
|
||||
|
||||
async function addAttendee(e) {
|
||||
e.preventDefault()
|
||||
adding = true
|
||||
error = ''
|
||||
try {
|
||||
const a = await api.attendees.create({
|
||||
name: newName, email: newEmail, phone: newPhone,
|
||||
ticket_id: newTicketID, ticket_type: newTicketType, note: newNote,
|
||||
})
|
||||
await db.attendees.put(a)
|
||||
showAdd = false
|
||||
newName = newEmail = newPhone = newTicketID = newTicketType = newNote = ''
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
adding = false
|
||||
}
|
||||
}
|
||||
|
||||
async function generateTokens() {
|
||||
generating = true
|
||||
error = ''
|
||||
success = ''
|
||||
try {
|
||||
const result = await api.attendees.generateTokens()
|
||||
success = `Generated ${result.generated} token${result.generated !== 1 ? 's' : ''}.`
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
generating = false
|
||||
}
|
||||
}
|
||||
|
||||
async function emailAll() {
|
||||
if (!confirm('Send token emails to all attendees with a token and email address?')) return
|
||||
emailing = true
|
||||
error = ''
|
||||
success = ''
|
||||
try {
|
||||
const result = await api.attendees.emailAllTokens()
|
||||
success = `Sent ${result.sent} email${result.sent !== 1 ? 's' : ''}${result.skipped ? `, skipped ${result.skipped}` : ''}.`
|
||||
if (result.errors?.length) error = result.errors.join('; ')
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
emailing = false
|
||||
}
|
||||
}
|
||||
|
||||
async function emailToken(attendee) {
|
||||
error = ''
|
||||
success = ''
|
||||
try {
|
||||
await api.attendees.emailToken(attendee.id)
|
||||
success = `Token email sent to ${attendee.name}.`
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Attendees</h1>
|
||||
<div class="actions">
|
||||
{#if canManage}
|
||||
<button class="btn btn-primary btn-sm" onclick={() => showAdd = !showAdd}>+ Add</button>
|
||||
<a href="/api/attendees/export" class="btn btn-ghost btn-sm">Export CSV</a>
|
||||
<button class="btn btn-ghost btn-sm" onclick={generateTokens} disabled={generating}>
|
||||
{generating ? '…' : '⚿ Tokens'}
|
||||
</button>
|
||||
<a href="/api/attendees/export-tokens" class="btn btn-ghost btn-sm">Export Links</a>
|
||||
<button class="btn btn-ghost btn-sm" onclick={emailAll} disabled={emailing}>
|
||||
{emailing ? '…' : '✉ Email All'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error">{error}</div>
|
||||
{/if}
|
||||
{#if success}
|
||||
<div class="alert alert-success">{success}</div>
|
||||
{/if}
|
||||
|
||||
{#if showAdd && canManage}
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<form onsubmit={addAttendee}>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||
<div class="form-group">
|
||||
<label for="new-name">Name *</label>
|
||||
<input id="new-name" bind:value={newName} required placeholder="Full name" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-email">Email</label>
|
||||
<input id="new-email" type="email" bind:value={newEmail} placeholder="email@example.com" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-ticket-id">Ticket ID</label>
|
||||
<input id="new-ticket-id" bind:value={newTicketID} placeholder="From ticketing system" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-ticket-type">Ticket type</label>
|
||||
<input id="new-ticket-type" bind:value={newTicketType} placeholder="e.g. General, VIP" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-note">Note</label>
|
||||
<input id="new-note" bind:value={newNote} placeholder="Optional note" />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary" disabled={adding}>
|
||||
{adding ? 'Adding…' : 'Add attendee'}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" onclick={() => showAdd = false}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="search-bar">
|
||||
<input placeholder="Search name, email, ticket ID…" bind:value={search} />
|
||||
{#if ($ticketTypes ?? []).length > 0}
|
||||
<select bind:value={filterType} style="width:auto">
|
||||
<option value="">All types</option>
|
||||
{#each $ticketTypes ?? [] as t}
|
||||
<option value={t}>{t}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<select bind:value={filterChecked} style="width:auto">
|
||||
<option value="">All</option>
|
||||
<option value="false">Not checked in</option>
|
||||
<option value="true">Checked in</option>
|
||||
</select>
|
||||
<span class="text-muted" style="font-size:0.85rem;white-space:nowrap">
|
||||
{filtered.length} shown
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if ($allAttendees ?? []).length === 0}
|
||||
<div class="empty">
|
||||
<strong>No attendees yet</strong>
|
||||
<p>Import a CSV or add attendees manually.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Ticket type</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
{#if canCheckIn}<th></th>{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filtered as a (a.id)}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{a.name}</strong>
|
||||
{#if a.ticket_id}
|
||||
<span class="text-muted" style="font-size:0.8rem"> · {a.ticket_id}</span>
|
||||
{/if}
|
||||
{#if (a.party_size ?? 1) > 1}
|
||||
<span class="badge badge-lead" style="margin-left:0.3rem">×{a.party_size}</span>
|
||||
{/if}
|
||||
{#if a.note}
|
||||
<div class="text-muted" style="font-size:0.78rem">{a.note}</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="text-muted">{a.ticket_type || '—'}</td>
|
||||
<td>
|
||||
<div>{a.email || '—'}</div>
|
||||
{#if a.volunteer_token && canManage}
|
||||
<div style="font-size:0.75rem;margin-top:0.15rem">
|
||||
<code style="color:var(--c-accent-h)">{a.volunteer_token}</code>
|
||||
{#if a.email}
|
||||
<button class="btn btn-ghost btn-sm" style="padding:0.1rem 0.4rem;margin-left:0.25rem"
|
||||
onclick={() => emailToken(a)}>✉</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if (a.party_size ?? 1) > 1}
|
||||
<span class="badge {a.checked_in ? 'badge-checked' : 'badge-unchecked'}">
|
||||
{a.checked_in_count ?? 0}/{a.party_size} in
|
||||
</span>
|
||||
{:else}
|
||||
<span class="badge {a.checked_in ? 'badge-checked' : 'badge-unchecked'}">
|
||||
{a.checked_in ? 'Checked in' : 'Pending'}
|
||||
</span>
|
||||
{/if}
|
||||
{#if a.checked_in_at}
|
||||
<div class="text-muted" style="font-size:0.75rem">
|
||||
{new Date(a.checked_in_at).toLocaleTimeString()}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
{#if canCheckIn}
|
||||
<td>
|
||||
{#if (a.checked_in_count ?? 0) < (a.party_size ?? 1)}
|
||||
<CheckInButton onclick={() => checkIn(a)} />
|
||||
{/if}
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<div class="gate-msg gate-msg-error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Matched attendee card -->
|
||||
{#if selected}
|
||||
{@const rem = remaining(selected)}
|
||||
{@const prog = progressLabel(selected)}
|
||||
<!-- Exact code/ID match card -->
|
||||
{#if matchedTicket}
|
||||
{@const p = participantFor(matchedTicket)}
|
||||
<div class="gate-match">
|
||||
<div class="gate-match-name">{selected.name}</div>
|
||||
{#if selected.ticket_type}
|
||||
<div class="gate-match-sub">{selected.ticket_type}</div>
|
||||
<div class="gate-match-name">{nameFor(matchedTicket)}</div>
|
||||
{#if matchedTicket.ticket_type}
|
||||
<div class="gate-match-sub">{matchedTicket.ticket_type}</div>
|
||||
{/if}
|
||||
{#if selected.ticket_id}
|
||||
<div class="gate-match-sub text-muted">#{selected.ticket_id}</div>
|
||||
{#if matchedTicket.external_id}
|
||||
<div class="gate-match-sub text-muted">#{matchedTicket.external_id}</div>
|
||||
{/if}
|
||||
{#if prog}
|
||||
<div class="gate-party">
|
||||
<span class="gate-party-label">{prog}</span>
|
||||
</div>
|
||||
{#if p?.email}
|
||||
<div class="gate-match-sub text-muted">{p.email}</div>
|
||||
{/if}
|
||||
|
||||
<div class="gate-match-actions">
|
||||
{#if rem > 0}
|
||||
<button class="gbtn gbtn-success" onclick={() => checkIn(selected, 1)}>
|
||||
✓ Check in 1
|
||||
{#if !matchedTicket.checked_in_at}
|
||||
<button class="gbtn gbtn-success" onclick={() => checkInTicket(matchedTicket)}>
|
||||
✓ Check in
|
||||
</button>
|
||||
{#if rem > 1}
|
||||
<button class="gbtn gbtn-ghost" onclick={() => checkIn(selected, rem)}>
|
||||
Check in all {rem}
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="gate-done">All checked in</span>
|
||||
<span class="gate-done">✓ Checked in {fmt(matchedTicket.checked_in_at)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selected.volunteer_token && !selected.checked_in}
|
||||
<button class="gbtn gbtn-ghost" onclick={() => checkInWithVolunteer(selected)}>
|
||||
+ Volunteer
|
||||
</button>
|
||||
<!-- Single participant match — show their tickets -->
|
||||
{:else if selectedParticipant}
|
||||
{@const pts = ticketsFor(selectedParticipant.id)}
|
||||
<div class="gate-match">
|
||||
<div class="gate-match-name">{selectedParticipant.preferred_name || selectedParticipant.email || '(unknown)'}</div>
|
||||
{#if selectedParticipant.email}
|
||||
<div class="gate-match-sub text-muted">{selectedParticipant.email}</div>
|
||||
{/if}
|
||||
{#if pts.length === 0}
|
||||
<div class="gate-match-sub" style="margin-top:0.5rem;color:var(--c-warn)">No tickets on file</div>
|
||||
{:else}
|
||||
<div style="margin-top:0.75rem;display:flex;flex-direction:column;gap:0.4rem">
|
||||
{#each pts as tk (tk.id)}
|
||||
<div class="gate-ticket-row">
|
||||
<span>
|
||||
<strong>{tk.name || '(unnamed)'}</strong>
|
||||
{#if tk.ticket_type}<span class="text-muted"> · {tk.ticket_type}</span>{/if}
|
||||
</span>
|
||||
{#if tk.checked_in_at}
|
||||
<span class="gate-done" style="font-size:0.8rem">✓ {fmt(tk.checked_in_at)}</span>
|
||||
{:else}
|
||||
<button class="gbtn gbtn-success" style="padding:0.3rem 0.75rem;font-size:0.8rem"
|
||||
onclick={() => checkInTicket(tk)}>✓ Check in</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if search.trim().length >= 2 && filtered.length > 1}
|
||||
<!-- Multiple results list -->
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Multiple participant matches -->
|
||||
{:else if search.trim().length >= 2 && filteredParticipants.length > 1}
|
||||
<div class="gate-results">
|
||||
{#each filtered as a}
|
||||
<button class="gate-result-row" onclick={() => search = a.ticket_id || a.name}>
|
||||
{#each filteredParticipants as p}
|
||||
{@const pts = ticketsFor(p.id)}
|
||||
{@const ci = pts.filter(t => t.checked_in_at).length}
|
||||
<button class="gate-result-row" onclick={() => search = p.preferred_name || p.email || ''}>
|
||||
<span>
|
||||
<strong>{a.name}</strong>
|
||||
{#if a.ticket_type} · {a.ticket_type}{/if}
|
||||
<strong>{p.preferred_name || p.email || '(unknown)'}</strong>
|
||||
{#if p.email && p.preferred_name}
|
||||
<span class="text-muted" style="font-size:0.8rem"> · {p.email}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="badge {a.checked_in ? 'badge-checked' : 'badge-unchecked'}">
|
||||
{a.checked_in ? 'In' : 'Pending'}
|
||||
<span class="badge {ci === pts.length && pts.length > 0 ? 'badge-checked' : ci > 0 ? 'badge-partial' : 'badge-unchecked'}">
|
||||
{pts.length > 0 ? `${ci}/${pts.length}` : 'No ticket'}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if search.trim().length >= 2 && filtered.length === 0}
|
||||
<div class="gate-msg gate-msg-warn">No matching attendees found.</div>
|
||||
|
||||
{:else if search.trim().length >= 2}
|
||||
<div class="gate-msg gate-msg-warn">No matching participants or tickets found.</div>
|
||||
{/if}
|
||||
|
||||
<!-- Recent check-ins -->
|
||||
<div class="gate-recent">
|
||||
<div class="gate-recent-title">Recent Check-ins</div>
|
||||
{#if ($recentCheckIns ?? []).length === 0}
|
||||
<div class="gate-recent-empty">No check-ins yet today.</div>
|
||||
<div class="gate-recent-empty">No check-ins yet.</div>
|
||||
{:else}
|
||||
{#each $recentCheckIns ?? [] as a}
|
||||
{#each $recentCheckIns ?? [] as tk}
|
||||
<div class="gate-recent-row">
|
||||
<span>{a.name}</span>
|
||||
<span class="text-muted">{fmt(a.checked_in_at)}</span>
|
||||
<span>{nameFor(tk)}</span>
|
||||
<span class="text-muted">{fmt(tk.checked_in_at)}</span>
|
||||
</div>
|
||||
{/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);
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@
|
|||
<strong style="color:var(--c-text)">Supported formats:</strong><br>
|
||||
<strong>CrowdWork / ticketing platform:</strong> columns <code>Patron Name</code>, <code>Patron Email</code>, <code>Tier Name</code>, <code>Order Number</code><br>
|
||||
<strong>Generic:</strong> columns <code>name</code>, <code>email</code>, <code>ticket_id</code>, <code>ticket_type</code>, <code>note</code><br>
|
||||
Duplicate names are skipped.
|
||||
Duplicate tickets (same source + external ID) are skipped. Participants are matched or created by email.
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" disabled={!file || importing}>
|
||||
|
|
|
|||
497
frontend/src/pages/Participants.svelte
Normal file
497
frontend/src/pages/Participants.svelte
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
<script>
|
||||
import { liveQuery } from 'dexie'
|
||||
import { db } from '../db.js'
|
||||
import { api } from '../api.js'
|
||||
|
||||
let { session } = $props()
|
||||
|
||||
let search = $state('')
|
||||
let error = $state('')
|
||||
let success = $state('')
|
||||
let generating = $state(false)
|
||||
let emailing = $state(false)
|
||||
let mergeMode = $state(false)
|
||||
let mergeSource = $state(null)
|
||||
let mergeTarget = $state(null)
|
||||
let expandedId = $state(null)
|
||||
|
||||
// Add participant form
|
||||
let showAdd = $state(false)
|
||||
let adding = $state(false)
|
||||
let newName = $state('')
|
||||
let newEmail = $state('')
|
||||
let newPhone = $state('')
|
||||
let newPronouns = $state('')
|
||||
let newNote = $state('')
|
||||
|
||||
// Edit participant
|
||||
let editId = $state(null)
|
||||
let editName = $state('')
|
||||
let editEmail = $state('')
|
||||
let editPhone = $state('')
|
||||
let editPronouns = $state('')
|
||||
let editNote = $state('')
|
||||
let saving = $state(false)
|
||||
|
||||
// Add ticket form (per participant)
|
||||
let addTicketFor = $state(null) // participant id
|
||||
let addingTicket = $state(false)
|
||||
let newTicketName = $state('')
|
||||
let newTicketType = $state('')
|
||||
let newTicketExtId = $state('')
|
||||
|
||||
const role = $derived(session?.user?.role ?? '')
|
||||
const canManage = $derived(['admin', 'ticketing'].includes(role))
|
||||
|
||||
const allParticipants = liveQuery(() => db.participants.toArray())
|
||||
const allTickets = liveQuery(() => db.tickets.toArray())
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const list = $allParticipants ?? []
|
||||
const s = search.toLowerCase().trim()
|
||||
return list
|
||||
.filter(p => {
|
||||
if (!s) return true
|
||||
return p.preferred_name?.toLowerCase().includes(s) ||
|
||||
p.email?.toLowerCase().includes(s) ||
|
||||
p.phone?.toLowerCase().includes(s)
|
||||
})
|
||||
.sort((a, b) => (a.preferred_name || a.email).localeCompare(b.preferred_name || b.email))
|
||||
})
|
||||
|
||||
function ticketsFor(participantId) {
|
||||
return ($allTickets ?? []).filter(t => t.participant_id === participantId)
|
||||
}
|
||||
|
||||
function checkedInCount(participantId) {
|
||||
return ticketsFor(participantId).filter(t => t.checked_in_at).length
|
||||
}
|
||||
|
||||
function toggleExpand(id) {
|
||||
expandedId = expandedId === id ? null : id
|
||||
}
|
||||
|
||||
async function generateCodes() {
|
||||
generating = true
|
||||
error = ''
|
||||
success = ''
|
||||
try {
|
||||
const result = await api.tickets.generateCodes()
|
||||
success = `Generated ${result.generated} code${result.generated !== 1 ? 's' : ''}.`
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
generating = false
|
||||
}
|
||||
}
|
||||
|
||||
async function emailAll() {
|
||||
if (!confirm('Send code emails to all participants with a ticket code and email?')) return
|
||||
emailing = true
|
||||
error = ''
|
||||
success = ''
|
||||
try {
|
||||
const result = await api.tickets.emailAllCodes()
|
||||
success = `Sent ${result.sent} email${result.sent !== 1 ? 's' : ''}${result.skipped ? `, skipped ${result.skipped}` : ''}.`
|
||||
if (result.errors?.length) error = result.errors.join('; ')
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
emailing = false
|
||||
}
|
||||
}
|
||||
|
||||
function startMerge(p) {
|
||||
mergeMode = true
|
||||
mergeSource = p
|
||||
mergeTarget = null
|
||||
error = ''
|
||||
}
|
||||
|
||||
function cancelMerge() {
|
||||
mergeMode = false
|
||||
mergeSource = null
|
||||
mergeTarget = null
|
||||
}
|
||||
|
||||
async function confirmMerge() {
|
||||
if (!mergeSource || !mergeTarget) return
|
||||
error = ''
|
||||
try {
|
||||
const result = await api.participants.merge(mergeTarget.id, mergeSource.id)
|
||||
success = `Merged "${mergeSource.preferred_name || mergeSource.email}" into "${mergeTarget.preferred_name || mergeTarget.email}".`
|
||||
if (result.participant) await db.participants.put(result.participant)
|
||||
if (result.tickets?.length) await db.tickets.bulkPut(result.tickets)
|
||||
await db.participants.delete(mergeSource.id)
|
||||
cancelMerge()
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(ts) {
|
||||
if (!ts) return ''
|
||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
async function addParticipant(e) {
|
||||
e.preventDefault()
|
||||
adding = true; error = ''
|
||||
try {
|
||||
const p = await api.participants.create({
|
||||
preferred_name: newName, email: newEmail, phone: newPhone,
|
||||
pronouns: newPronouns, note: newNote,
|
||||
})
|
||||
await db.participants.put(p)
|
||||
showAdd = false
|
||||
newName = newEmail = newPhone = newPronouns = newNote = ''
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
adding = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(p) {
|
||||
editId = p.id
|
||||
editName = p.preferred_name
|
||||
editEmail = p.email
|
||||
editPhone = p.phone
|
||||
editPronouns = p.pronouns
|
||||
editNote = p.note
|
||||
}
|
||||
|
||||
async function saveEdit(e) {
|
||||
e.preventDefault()
|
||||
saving = true; error = ''
|
||||
try {
|
||||
const p = await api.participants.update(editId, {
|
||||
preferred_name: editName, email: editEmail, phone: editPhone,
|
||||
pronouns: editPronouns, note: editNote,
|
||||
})
|
||||
await db.participants.put(p)
|
||||
editId = null
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteParticipant(id) {
|
||||
if (!confirm('Permanently delete this participant and all their records?')) return
|
||||
error = ''
|
||||
try {
|
||||
await api.participants.delete(id)
|
||||
await db.participants.delete(id)
|
||||
editId = null
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
}
|
||||
}
|
||||
|
||||
async function addTicket(e, participantId) {
|
||||
e.preventDefault()
|
||||
addingTicket = true; error = ''
|
||||
try {
|
||||
const tk = await api.tickets.create({
|
||||
participant_id: participantId,
|
||||
name: newTicketName,
|
||||
ticket_type: newTicketType,
|
||||
external_id: newTicketExtId,
|
||||
source: 'manual',
|
||||
})
|
||||
await db.tickets.put(tk)
|
||||
addTicketFor = null
|
||||
newTicketName = newTicketType = newTicketExtId = ''
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
addingTicket = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Participants</h1>
|
||||
<div class="actions">
|
||||
{#if canManage}
|
||||
<button class="btn btn-primary btn-sm" onclick={() => showAdd = !showAdd}>+ Add</button>
|
||||
<a href="/api/participants/export" class="btn btn-ghost btn-sm">Export CSV</a>
|
||||
<button class="btn btn-ghost btn-sm" onclick={generateCodes} disabled={generating}>
|
||||
{generating ? '…' : '⚿ Generate Codes'}
|
||||
</button>
|
||||
<a href="/api/tickets/export-links" class="btn btn-ghost btn-sm">Export Links</a>
|
||||
<button class="btn btn-ghost btn-sm" onclick={emailAll} disabled={emailing}>
|
||||
{emailing ? '…' : '✉ Email All'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showAdd && canManage}
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<form onsubmit={addParticipant}>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||
<div class="form-group">
|
||||
<label for="p-name">Name</label>
|
||||
<input id="p-name" bind:value={newName} placeholder="Preferred name" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="p-email">Email</label>
|
||||
<input id="p-email" type="email" bind:value={newEmail} placeholder="email@example.com" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="p-phone">Phone</label>
|
||||
<input id="p-phone" bind:value={newPhone} placeholder="Optional" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="p-pronouns">Pronouns</label>
|
||||
<input id="p-pronouns" bind:value={newPronouns} placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="p-note">Note</label>
|
||||
<input id="p-note" bind:value={newNote} placeholder="Optional note" />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary" disabled={adding || (!newName && !newEmail)}>
|
||||
{adding ? 'Adding…' : 'Add participant'}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" onclick={() => showAdd = false}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mergeMode && mergeSource}
|
||||
<div class="card" style="margin-bottom:1.5rem;border-color:var(--c-accent)">
|
||||
<div style="margin-bottom:0.75rem">
|
||||
<strong>Merge:</strong> "{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.
|
||||
</div>
|
||||
{#if mergeTarget}
|
||||
<div style="margin-bottom:0.75rem">
|
||||
<strong>Target:</strong> {mergeTarget.preferred_name || mergeTarget.email} ({mergeTarget.email})
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" onclick={confirmMerge}>Confirm merge</button>
|
||||
<button class="btn btn-ghost" onclick={cancelMerge}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-muted" style="font-size:0.875rem">Click a participant row below to select as merge target.</div>
|
||||
<div class="actions" style="margin-top:0.5rem">
|
||||
<button class="btn btn-ghost" onclick={cancelMerge}>Cancel</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error">{error}</div>
|
||||
{/if}
|
||||
{#if success}
|
||||
<div class="alert alert-success">{success}</div>
|
||||
{/if}
|
||||
|
||||
<div class="search-bar">
|
||||
<input placeholder="Search name or email…" bind:value={search} />
|
||||
<span class="text-muted" style="font-size:0.85rem;white-space:nowrap">
|
||||
{filtered.length} shown
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if ($allParticipants ?? []).length === 0}
|
||||
<div class="empty">
|
||||
<strong>No participants yet</strong>
|
||||
<p>Import a CSV or wait for volunteer signups.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Tickets</th>
|
||||
<th>Status</th>
|
||||
{#if canManage}<th></th>{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#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}
|
||||
<tr class="edit-row">
|
||||
<td colspan={canManage ? 5 : 4}>
|
||||
<form class="participant-edit-form" onsubmit={saveEdit}>
|
||||
<div class="edit-fields">
|
||||
<input bind:value={editName} placeholder="Preferred name" />
|
||||
<input type="email" bind:value={editEmail} placeholder="Email" />
|
||||
<input bind:value={editPhone} placeholder="Phone" />
|
||||
<input bind:value={editPronouns} placeholder="Pronouns" />
|
||||
<input bind:value={editNote} placeholder="Note" style="flex:2" />
|
||||
</div>
|
||||
<div class="actions" style="margin-top:0.5rem">
|
||||
<button type="submit" class="btn btn-primary btn-sm" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" onclick={() => editId = null}>Cancel</button>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick={() => deleteParticipant(editId)}>Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr
|
||||
class:merge-target={isMergeTarget}
|
||||
onclick={mergeMode && mergeSource?.id !== p.id ? () => { mergeTarget = p } : null}
|
||||
style={mergeMode && mergeSource?.id !== p.id ? 'cursor:pointer' : ''}
|
||||
>
|
||||
<td>
|
||||
<strong>{p.preferred_name || '—'}</strong>
|
||||
{#if p.pronouns}
|
||||
<span class="text-muted" style="font-size:0.78rem"> · {p.pronouns}</span>
|
||||
{/if}
|
||||
{#if p.note}
|
||||
<div class="text-muted" style="font-size:0.78rem">{p.note}</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="text-muted">
|
||||
{p.email || '—'}
|
||||
{#if p.phone}
|
||||
<div style="font-size:0.78rem">{p.phone}</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if pts.length > 0}
|
||||
<button class="btn btn-ghost btn-sm" onclick={(e) => { e.stopPropagation(); toggleExpand(p.id) }}>
|
||||
{pts.length} ticket{pts.length !== 1 ? 's' : ''}
|
||||
{isExpanded ? '▲' : '▼'}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="text-muted">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if pts.length > 0}
|
||||
<span class="badge {ci === pts.length ? 'badge-checked' : ci > 0 ? 'badge-partial' : 'badge-unchecked'}">
|
||||
{ci}/{pts.length} in
|
||||
</span>
|
||||
{:else}
|
||||
<span class="badge badge-unchecked">No ticket</span>
|
||||
{/if}
|
||||
</td>
|
||||
{#if canManage}
|
||||
<td>
|
||||
{#if !mergeMode}
|
||||
<button class="btn btn-ghost btn-sm" onclick={(e) => { e.stopPropagation(); startEdit(p) }}
|
||||
title="Edit participant">✎</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick={(e) => { e.stopPropagation(); startMerge(p) }}
|
||||
title="Merge this participant into another">⇄</button>
|
||||
{/if}
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/if}
|
||||
{#if isExpanded && !isEditing}
|
||||
<tr class="ticket-rows">
|
||||
<td colspan="5">
|
||||
<div class="ticket-list">
|
||||
{#each pts as tk (tk.id)}
|
||||
<div class="ticket-row">
|
||||
<div>
|
||||
<strong>{tk.name || '(unnamed)'}</strong>
|
||||
{#if tk.ticket_type}
|
||||
<span class="text-muted"> · {tk.ticket_type}</span>
|
||||
{/if}
|
||||
{#if tk.external_id}
|
||||
<span class="text-muted" style="font-size:0.78rem"> · #{tk.external_id}</span>
|
||||
{/if}
|
||||
{#if tk.code}
|
||||
<div style="font-size:0.75rem;margin-top:0.15rem">
|
||||
<code style="color:var(--c-accent-h)">{tk.code}</code>
|
||||
{#if p.email && canManage}
|
||||
<button class="btn btn-ghost btn-sm" style="padding:0.1rem 0.4rem;margin-left:0.25rem"
|
||||
onclick={() => api.tickets.emailCode(tk.id).then(() => success = 'Email sent.').catch(e => error = e.message)}>✉</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
{#if tk.checked_in_at}
|
||||
<span class="badge badge-checked">In {fmtTime(tk.checked_in_at)}</span>
|
||||
{:else}
|
||||
<span class="badge badge-unchecked">Pending</span>
|
||||
{/if}
|
||||
<div class="text-muted" style="font-size:0.75rem">{tk.source}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if canManage}
|
||||
{#if addTicketFor === p.id}
|
||||
<form class="ticket-add-form" onsubmit={(e) => addTicket(e, p.id)}>
|
||||
<input bind:value={newTicketName} placeholder="Name on ticket (optional)" style="flex:2" />
|
||||
<input bind:value={newTicketType} placeholder="Type (optional)" style="flex:1" />
|
||||
<input bind:value={newTicketExtId} placeholder="External ID (optional)" style="flex:1" />
|
||||
<button type="submit" class="btn btn-primary btn-sm" disabled={addingTicket}>
|
||||
{addingTicket ? '…' : 'Add'}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" onclick={() => addTicketFor = null}>Cancel</button>
|
||||
</form>
|
||||
{:else}
|
||||
<button class="btn btn-ghost btn-sm" style="align-self:flex-start;margin-top:0.25rem"
|
||||
onclick={() => { addTicketFor = p.id; newTicketName = newTicketType = newTicketExtId = '' }}>
|
||||
+ Add ticket
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.merge-target:hover { background: rgba(var(--c-accent-rgb, 99,102,241), 0.08); }
|
||||
.ticket-rows td { padding: 0; background: var(--c-bg); }
|
||||
.ticket-list { padding: 0.5rem 1rem 0.75rem; display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.ticket-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
background: var(--c-surface);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.ticket-add-form {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--c-bg);
|
||||
border-radius: 6px;
|
||||
border: 1px dashed var(--c-border);
|
||||
}
|
||||
.ticket-add-form input {
|
||||
min-width: 0;
|
||||
font-size: 0.825rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
}
|
||||
.edit-row td { padding: 0.5rem 1rem; background: var(--c-bg); }
|
||||
.participant-edit-form { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.edit-fields { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||||
.edit-fields input { flex: 1; min-width: 120px; font-size: 0.825rem; padding: 0.3rem 0.5rem; width: auto; }
|
||||
|
||||
</style>
|
||||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -232,8 +232,8 @@
|
|||
Permanently delete all records of a given type. This cannot be undone.
|
||||
</p>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.5rem">
|
||||
<button class="btn btn-danger" onclick={() => resetModel('attendees', api.settings.resetAttendees)}>
|
||||
Delete All Attendees
|
||||
<button class="btn btn-danger" onclick={() => resetModel('tickets', api.settings.resetTickets)}>
|
||||
Delete All Tickets
|
||||
</button>
|
||||
<button class="btn btn-danger" onclick={() => resetModel('volunteers', api.settings.resetVolunteers)}>
|
||||
Delete All Volunteers
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
|
||||
)
|
||||
|
||||
const roles = ['admin', 'coordinator', 'ticketing', 'gate', 'volunteer_lead']
|
||||
const roles = ['admin', 'ticketing', 'staffing', 'colead', 'gatekeeper']
|
||||
|
||||
const me = $derived(session?.user?.id)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,17 +14,17 @@
|
|||
let adding = $state(false)
|
||||
let newName = $state('')
|
||||
let newEmail = $state('')
|
||||
let newPhone = $state('')
|
||||
let newDeptID = $state('')
|
||||
let newIsLead = $state(false)
|
||||
let newNote = $state('')
|
||||
|
||||
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 allVolunteers = liveQuery(() =>
|
||||
db.volunteers.filter(v => !v.deleted_at).toArray()
|
||||
)
|
||||
const allParticipants = liveQuery(() => db.participants.toArray())
|
||||
const allDepts = liveQuery(() =>
|
||||
db.departments.filter(d => !d.deleted_at).toArray()
|
||||
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
|
||||
|
|
@ -62,7 +62,6 @@
|
|||
const data = {
|
||||
name: newName,
|
||||
email: newEmail,
|
||||
phone: newPhone,
|
||||
is_lead: newIsLead,
|
||||
note: newNote,
|
||||
}
|
||||
|
|
@ -70,7 +69,7 @@
|
|||
const v = await api.volunteers.create(data)
|
||||
await db.volunteers.put(v)
|
||||
showAdd = false
|
||||
newName = newEmail = newPhone = newNote = ''
|
||||
newName = newEmail = newNote = ''
|
||||
newDeptID = ''
|
||||
newIsLead = false
|
||||
} catch (err) {
|
||||
|
|
@ -93,6 +92,10 @@
|
|||
function deptFor(id) {
|
||||
return ($allDepts ?? []).find(d => d.id === id)
|
||||
}
|
||||
|
||||
function participantFor(id) {
|
||||
return ($allParticipants ?? []).find(p => p.id === id) ?? null
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
|
|
@ -121,10 +124,6 @@
|
|||
<label for="v-email">Email</label>
|
||||
<input id="v-email" type="email" bind:value={newEmail} placeholder="email@example.com" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="v-phone">Phone</label>
|
||||
<input id="v-phone" bind:value={newPhone} placeholder="Optional" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="v-dept">Department</label>
|
||||
<select id="v-dept" bind:value={newDeptID}>
|
||||
|
|
@ -195,12 +194,19 @@
|
|||
<tbody>
|
||||
{#each filtered as v (v.id)}
|
||||
{@const dept = deptFor(v.department_id)}
|
||||
{@const participant = participantFor(v.participant_id)}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{v.name}</strong>
|
||||
{#if v.is_lead}
|
||||
<span class="badge badge-lead" style="margin-left:0.4rem">Lead</span>
|
||||
{/if}
|
||||
{#if !v.participant_id}
|
||||
<span class="badge badge-unchecked" style="margin-left:0.4rem" title="Not linked to a participant — no ticket record">No ticket</span>
|
||||
{/if}
|
||||
{#if v.email}
|
||||
<div class="text-muted" style="font-size:0.78rem">{v.email}</div>
|
||||
{/if}
|
||||
{#if v.note}
|
||||
<div class="text-muted" style="font-size:0.78rem">{v.note}</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -12,16 +12,20 @@ export async function syncPull() {
|
|||
const data = await api.sync.pull(since)
|
||||
|
||||
await db.transaction('rw',
|
||||
[db.event, db.attendees, db.departments, db.volunteers, db.shifts, db.volunteer_shifts],
|
||||
[db.event, db.participants, db.tickets, db.departments, db.volunteers, db.shifts, db.volunteer_shifts],
|
||||
async () => {
|
||||
if (data.event) {
|
||||
await db.event.put(data.event)
|
||||
}
|
||||
if (data.attendees?.length) {
|
||||
await db.attendees.bulkPut(data.attendees)
|
||||
// Purge hard-deleted records from Dexie
|
||||
const deleted = data.attendees.filter(a => a.deleted_at).map(a => a.id)
|
||||
if (deleted.length) await db.attendees.bulkDelete(deleted)
|
||||
if (data.participants?.length) {
|
||||
await db.participants.bulkPut(data.participants)
|
||||
const deleted = data.participants.filter(p => p.deleted_at).map(p => p.id)
|
||||
if (deleted.length) await db.participants.bulkDelete(deleted)
|
||||
}
|
||||
if (data.tickets?.length) {
|
||||
await db.tickets.bulkPut(data.tickets)
|
||||
const deleted = data.tickets.filter(t => t.deleted_at).map(t => t.id)
|
||||
if (deleted.length) await db.tickets.bulkDelete(deleted)
|
||||
}
|
||||
if (data.departments?.length) {
|
||||
await db.departments.bulkPut(data.departments)
|
||||
|
|
@ -72,8 +76,8 @@ export function startSSE(onEvent) {
|
|||
try {
|
||||
const payload = JSON.parse(e.data)
|
||||
if (payload.event === 'checkin') {
|
||||
if (payload.data?.type === 'attendee' && payload.data?.attendee) {
|
||||
await db.attendees.put(payload.data.attendee)
|
||||
if (payload.data?.type === 'ticket' && payload.data?.ticket) {
|
||||
await db.tickets.put(payload.data.ticket)
|
||||
}
|
||||
if (payload.data?.type === 'volunteer' && payload.data?.volunteer) {
|
||||
await db.volunteers.put(payload.data.volunteer)
|
||||
|
|
|
|||
|
|
@ -18,30 +18,31 @@ function mockFetch(body = {}, status = 200) {
|
|||
}
|
||||
|
||||
describe('syncPull', () => {
|
||||
it('writes attendees to Dexie', async () => {
|
||||
it('writes participants to Dexie', async () => {
|
||||
mockFetch({
|
||||
server_time: '2026-03-01T12:00:00Z',
|
||||
attendees: [{ id: 1, name: 'Titania' }],
|
||||
participants: [{ id: 1, preferred_name: 'Titania', email: 'titania@example.com' }],
|
||||
tickets: [],
|
||||
departments: [],
|
||||
volunteers: [],
|
||||
shifts: [],
|
||||
volunteer_shifts: [],
|
||||
})
|
||||
// Import fresh to reset syncing guard
|
||||
const { syncPull } = await import('./sync.js')
|
||||
await syncPull()
|
||||
|
||||
const a = await db.attendees.get(1)
|
||||
expect(a.name).toBe('Titania')
|
||||
const p = await db.participants.get(1)
|
||||
expect(p.preferred_name).toBe('Titania')
|
||||
expect(await getLastSync()).toBe('2026-03-01T12:00:00Z')
|
||||
})
|
||||
|
||||
it('deletes soft-deleted attendees from Dexie', async () => {
|
||||
await db.attendees.put({ id: 1, name: 'Titania' })
|
||||
it('deletes soft-deleted participants from Dexie', async () => {
|
||||
await db.participants.put({ id: 1, preferred_name: 'Titania', email: 'titania@example.com' })
|
||||
|
||||
mockFetch({
|
||||
server_time: '2026-03-01T13:00:00Z',
|
||||
attendees: [{ id: 1, name: 'Titania', deleted_at: '2026-03-01T12:30:00Z' }],
|
||||
participants: [{ id: 1, preferred_name: 'Titania', deleted_at: '2026-03-01T12:30:00Z' }],
|
||||
tickets: [],
|
||||
departments: [],
|
||||
volunteers: [],
|
||||
shifts: [],
|
||||
|
|
@ -50,8 +51,8 @@ describe('syncPull', () => {
|
|||
const { syncPull } = await import('./sync.js')
|
||||
await syncPull()
|
||||
|
||||
const a = await db.attendees.get(1)
|
||||
expect(a).toBeUndefined()
|
||||
const p = await db.participants.get(1)
|
||||
expect(p).toBeUndefined()
|
||||
})
|
||||
|
||||
it('deletes soft-deleted volunteer_shifts from Dexie', async () => {
|
||||
|
|
@ -59,7 +60,8 @@ describe('syncPull', () => {
|
|||
|
||||
mockFetch({
|
||||
server_time: '2026-03-01T13:00:00Z',
|
||||
attendees: [],
|
||||
participants: [],
|
||||
tickets: [],
|
||||
departments: [],
|
||||
volunteers: [],
|
||||
shifts: [],
|
||||
|
|
@ -75,7 +77,8 @@ describe('syncPull', () => {
|
|||
it('sets lastSync timestamp', async () => {
|
||||
mockFetch({
|
||||
server_time: '2026-03-02T00:00:00Z',
|
||||
attendees: [],
|
||||
participants: [],
|
||||
tickets: [],
|
||||
departments: [],
|
||||
volunteers: [],
|
||||
shifts: [],
|
||||
|
|
|
|||
|
|
@ -1,167 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func (app *App) handleListAttendees(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
attendees, err := app.listAttendees(q.Get("search"), q.Get("ticket_type"), q.Get("checked_in"))
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
types, _ := app.attendeeTicketTypes()
|
||||
total, checkedIn, _ := app.attendeeCounts()
|
||||
writeJSON(w, map[string]any{
|
||||
"attendees": attendees,
|
||||
"ticket_types": types,
|
||||
"total": total,
|
||||
"checked_in": checkedIn,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleCreateAttendee(w http.ResponseWriter, r *http.Request) {
|
||||
var a Attendee
|
||||
if err := json.NewDecoder(r.Body).Decode(&a); err != nil {
|
||||
writeError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if a.Name == "" {
|
||||
writeError(w, "name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
created, err := app.createAttendee(a)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
writeJSON(w, created)
|
||||
}
|
||||
|
||||
func (app *App) handleGetAttendee(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
a, err := app.getAttendee(id)
|
||||
if err != nil || a == nil {
|
||||
writeError(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, a)
|
||||
}
|
||||
|
||||
func (app *App) handleUpdateAttendee(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var a Attendee
|
||||
if err := json.NewDecoder(r.Body).Decode(&a); err != nil {
|
||||
writeError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if a.Name == "" {
|
||||
writeError(w, "name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
a.ID = id
|
||||
if err := app.updateAttendee(a); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
updated, _ := app.getAttendee(id)
|
||||
writeJSON(w, updated)
|
||||
}
|
||||
|
||||
func (app *App) handleDeleteAttendee(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := app.deleteAttendee(id); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleCheckInAttendee handles POST /api/attendees/:id/checkin.
|
||||
// Optional body: {"count": N, "also_volunteer": true}
|
||||
// Returns {"attendee": ..., "volunteer": ...} — volunteer is included if also_volunteer=true
|
||||
// and the attendee has a linked volunteer record.
|
||||
func (app *App) handleCheckInAttendee(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Count int `json:"count"`
|
||||
AlsoVolunteer bool `json:"also_volunteer"`
|
||||
}
|
||||
body.Count = 1
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body.Count < 1 {
|
||||
body.Count = 1
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r)
|
||||
a, err := app.checkInAttendee(id, claims.UserID, body.Count)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
result := map[string]any{"attendee": a}
|
||||
|
||||
if body.AlsoVolunteer {
|
||||
v, _ := app.getVolunteerByAttendeeID(id)
|
||||
if v != nil {
|
||||
if !v.CheckedIn {
|
||||
if v2, err := app.checkInVolunteer(v.ID, claims.UserID); err == nil {
|
||||
result["volunteer"] = v2
|
||||
app.broker.publish("checkin", map[string]any{"type": "volunteer", "volunteer": v2})
|
||||
}
|
||||
} else {
|
||||
result["volunteer"] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.broker.publish("checkin", map[string]any{"type": "attendee", "attendee": a})
|
||||
writeJSON(w, result)
|
||||
}
|
||||
|
||||
func (app *App) handleExportAttendees(w http.ResponseWriter, r *http.Request) {
|
||||
attendees, err := app.listAttendees("", "", "")
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="attendees.csv"`)
|
||||
wr := csv.NewWriter(w)
|
||||
wr.Write([]string{"name", "email", "phone", "ticket_id", "ticket_type", "party_size", "checked_in_count", "note", "checked_in"})
|
||||
for _, a := range attendees {
|
||||
ci := "no"
|
||||
if a.CheckedIn {
|
||||
ci = "yes"
|
||||
}
|
||||
wr.Write([]string{
|
||||
a.Name, a.Email, a.Phone, a.TicketID, a.TicketType,
|
||||
strconv.Itoa(a.PartySize), strconv.Itoa(a.CheckedInCount),
|
||||
a.Note, ci,
|
||||
})
|
||||
}
|
||||
wr.Flush()
|
||||
}
|
||||
|
|
@ -6,14 +6,14 @@ import (
|
|||
"testing"
|
||||
)
|
||||
|
||||
func TestAttendeesListCreateDelete(t *testing.T) {
|
||||
func TestParticipantsListCreateDelete(t *testing.T) {
|
||||
app := testApp(t)
|
||||
admin := testAdminUser(t, app)
|
||||
token := testToken(t, app, admin)
|
||||
mux := testMux(app)
|
||||
|
||||
// Create
|
||||
req := testAuthRequest("POST", "/api/attendees", map[string]string{"name": "Titania"}, token)
|
||||
req := testAuthRequest("POST", "/api/participants", map[string]string{"preferred_name": "Titania", "email": "titania@example.com"}, token)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
|
|
@ -23,20 +23,20 @@ func TestAttendeesListCreateDelete(t *testing.T) {
|
|||
id := created["id"].(float64)
|
||||
|
||||
// List
|
||||
req = testAuthRequest("GET", "/api/attendees", nil, token)
|
||||
req = testAuthRequest("GET", "/api/participants", nil, token)
|
||||
w = httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list: status = %d", w.Code)
|
||||
}
|
||||
list := parseJSON(t, w)
|
||||
attendees := list["attendees"].([]any)
|
||||
if len(attendees) != 1 {
|
||||
t.Errorf("list: got %d, want 1", len(attendees))
|
||||
participants := list["participants"].([]any)
|
||||
if len(participants) != 1 {
|
||||
t.Errorf("list: got %d, want 1", len(participants))
|
||||
}
|
||||
|
||||
// Delete
|
||||
req = testAuthRequest("DELETE", "/api/attendees/"+itoa(int(id)), nil, token)
|
||||
req = testAuthRequest("DELETE", "/api/participants/"+itoa(int(id)), nil, token)
|
||||
w = httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNoContent {
|
||||
|
|
@ -44,66 +44,66 @@ func TestAttendeesListCreateDelete(t *testing.T) {
|
|||
}
|
||||
|
||||
// List again — should be empty
|
||||
req = testAuthRequest("GET", "/api/attendees", nil, token)
|
||||
req = testAuthRequest("GET", "/api/participants", nil, token)
|
||||
w = httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
list = parseJSON(t, w)
|
||||
if a2, ok := list["attendees"].([]any); ok && len(a2) != 0 {
|
||||
t.Errorf("after delete: got %d, want 0", len(a2))
|
||||
if ps, ok := list["participants"].([]any); ok && len(ps) != 0 {
|
||||
t.Errorf("after delete: got %d, want 0", len(ps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckInAttendeeHandler(t *testing.T) {
|
||||
func TestCheckInTicketHandler(t *testing.T) {
|
||||
app := testApp(t)
|
||||
admin := testAdminUser(t, app)
|
||||
token := testToken(t, app, admin)
|
||||
mux := testMux(app)
|
||||
|
||||
app.createAttendee(Attendee{Name: "Oberon"})
|
||||
app.db.Exec(`UPDATE attendees SET party_size = 3 WHERE id = 1`)
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Oberon", Email: "oberon@example.com"})
|
||||
tk, _ := app.createTicket(Ticket{ParticipantID: &p.ID, Name: "Oberon", Source: "manual"})
|
||||
|
||||
// Check in 1
|
||||
req := testAuthRequest("POST", "/api/attendees/1/checkin", map[string]int{"count": 1}, token)
|
||||
req := testAuthRequest("POST", "/api/tickets/"+itoa(tk.ID)+"/checkin", nil, token)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("checkin: status = %d\nbody: %s", w.Code, w.Body.String())
|
||||
}
|
||||
result := parseJSON(t, w)
|
||||
attendee := result["attendee"].(map[string]any)
|
||||
if attendee["checked_in_count"] != float64(1) {
|
||||
t.Errorf("checked_in_count = %v, want 1", attendee["checked_in_count"])
|
||||
ticket := result["ticket"].(map[string]any)
|
||||
if ticket["checked_in_at"] == nil {
|
||||
t.Error("checked_in_at should be set after check-in")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateRoleCanCheckIn(t *testing.T) {
|
||||
func TestGatekeeperRoleCanCheckIn(t *testing.T) {
|
||||
app := testApp(t)
|
||||
gate := testUserWithRole(t, app, "gateuser", "gate", []int{})
|
||||
gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{})
|
||||
token := testToken(t, app, gate)
|
||||
mux := testMux(app)
|
||||
|
||||
app.createAttendee(Attendee{Name: "Puck"})
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@example.com"})
|
||||
tk, _ := app.createTicket(Ticket{ParticipantID: &p.ID, Name: "Puck", Source: "manual"})
|
||||
|
||||
req := testAuthRequest("POST", "/api/attendees/1/checkin", nil, token)
|
||||
req := testAuthRequest("POST", "/api/tickets/"+itoa(tk.ID)+"/checkin", nil, token)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("gate checkin: status = %d", w.Code)
|
||||
t.Errorf("gatekeeper checkin: status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateRoleCannotDelete(t *testing.T) {
|
||||
func TestGatekeeperRoleCannotDelete(t *testing.T) {
|
||||
app := testApp(t)
|
||||
gate := testUserWithRole(t, app, "gateuser", "gate", []int{})
|
||||
gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{})
|
||||
token := testToken(t, app, gate)
|
||||
mux := testMux(app)
|
||||
|
||||
app.createAttendee(Attendee{Name: "Puck"})
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@example.com"})
|
||||
|
||||
req := testAuthRequest("DELETE", "/api/attendees/1", nil, token)
|
||||
req := testAuthRequest("DELETE", "/api/participants/"+itoa(p.ID), nil, token)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("gate delete: status = %d, want 403", w.Code)
|
||||
t.Errorf("gatekeeper delete: status = %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import (
|
|||
|
||||
type ImportResult struct {
|
||||
Inserted int `json:"inserted"`
|
||||
Grouped int `json:"grouped"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
|
@ -57,12 +56,14 @@ func (app *App) importCSV(r io.Reader) (ImportResult, error) {
|
|||
}
|
||||
|
||||
var (
|
||||
nameIdx, emailIdx, ticketIDIdx, ticketTypeIdx, noteIdx int
|
||||
hasEmail, hasTicketID, hasTicketType, hasNote bool
|
||||
nameIdx, emailIdx, ticketIDIdx, ticketTypeIdx int
|
||||
hasEmail, hasTicketID, hasTicketType bool
|
||||
isCrowdWork bool
|
||||
)
|
||||
|
||||
if idx, ok := colIndex["patron name"]; ok {
|
||||
// CrowdWork / ticketing platform format
|
||||
isCrowdWork = true
|
||||
nameIdx = idx
|
||||
if idx, ok := colIndex["patron email"]; ok {
|
||||
emailIdx, hasEmail = idx, true
|
||||
|
|
@ -85,9 +86,6 @@ func (app *App) importCSV(r io.Reader) (ImportResult, error) {
|
|||
if idx, ok := colIndex["ticket_type"]; ok {
|
||||
ticketTypeIdx, hasTicketType = idx, true
|
||||
}
|
||||
if idx, ok := colIndex["note"]; ok {
|
||||
noteIdx, hasNote = idx, true
|
||||
}
|
||||
} else {
|
||||
return ImportResult{}, fmt.Errorf("CSV must have a 'name' or 'patron name' column")
|
||||
}
|
||||
|
|
@ -111,33 +109,49 @@ func (app *App) importCSV(r io.Reader) (ImportResult, error) {
|
|||
continue
|
||||
}
|
||||
|
||||
a := Attendee{Name: name}
|
||||
email := ""
|
||||
if hasEmail {
|
||||
a.Email = strings.TrimSpace(csvGet(record, emailIdx))
|
||||
email = strings.TrimSpace(csvGet(record, emailIdx))
|
||||
}
|
||||
externalID := ""
|
||||
if hasTicketID {
|
||||
a.TicketID = strings.TrimSpace(csvGet(record, ticketIDIdx))
|
||||
externalID = strings.TrimSpace(csvGet(record, ticketIDIdx))
|
||||
}
|
||||
ticketType := ""
|
||||
if hasTicketType {
|
||||
a.TicketType = strings.TrimSpace(csvGet(record, ticketTypeIdx))
|
||||
}
|
||||
if hasNote {
|
||||
a.Note = strings.TrimSpace(csvGet(record, noteIdx))
|
||||
ticketType = strings.TrimSpace(csvGet(record, ticketTypeIdx))
|
||||
}
|
||||
|
||||
_, err = app.createAttendee(a)
|
||||
source := "manual"
|
||||
orderID := ""
|
||||
if isCrowdWork {
|
||||
source = "crowdwork"
|
||||
orderID = externalID
|
||||
}
|
||||
|
||||
// Find or create participant when email is present.
|
||||
var participantID *int
|
||||
if email != "" {
|
||||
p, _, err := app.upsertParticipant(email, name)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
// CrowdWork exports one row per ticket under the purchaser's name.
|
||||
// If we have a ticket_id and the same (name, ticket_id) already exists,
|
||||
// increment party_size instead of skipping.
|
||||
if hasTicketID && a.TicketID != "" {
|
||||
merged, mergeErr := app.incrementPartySize(a.Name, a.TicketID)
|
||||
if mergeErr == nil && merged {
|
||||
result.Grouped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("line %d (%s): participant: %v", lineNum, name, err))
|
||||
continue
|
||||
}
|
||||
if p != nil {
|
||||
participantID = &p.ID
|
||||
}
|
||||
}
|
||||
|
||||
_, err = app.createTicket(Ticket{
|
||||
ParticipantID: participantID,
|
||||
Name: name,
|
||||
TicketType: ticketType,
|
||||
Source: source,
|
||||
ExternalID: externalID,
|
||||
OrderID: orderID,
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
result.Skipped++
|
||||
} else {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("line %d (%s): %v", lineNum, name, err))
|
||||
|
|
|
|||
|
|
@ -61,13 +61,13 @@ func TestImportGenericFormat(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestImportPartySizeDedup(t *testing.T) {
|
||||
func TestImportDedup(t *testing.T) {
|
||||
app := testApp(t)
|
||||
admin := testAdminUser(t, app)
|
||||
token := testToken(t, app, admin)
|
||||
mux := testMux(app)
|
||||
|
||||
// 3 rows same name+order = 1 record, party_size=3
|
||||
// 3 rows with same order number: first inserts, remaining 2 skip (same external_id)
|
||||
csv := "Patron Name,Patron Email,Order Number,Tier Name\nTitania,titania@test.com,ORD-1,GA\nTitania,titania@test.com,ORD-1,GA\nTitania,titania@test.com,ORD-1,GA\n"
|
||||
w := postCSV(t, mux, token, csv)
|
||||
|
||||
|
|
@ -75,16 +75,16 @@ func TestImportPartySizeDedup(t *testing.T) {
|
|||
if result["inserted"] != float64(1) {
|
||||
t.Errorf("inserted = %v, want 1", result["inserted"])
|
||||
}
|
||||
if result["grouped"] != float64(2) {
|
||||
t.Errorf("grouped = %v, want 2", result["grouped"])
|
||||
if result["skipped"] != float64(2) {
|
||||
t.Errorf("skipped = %v, want 2", result["skipped"])
|
||||
}
|
||||
|
||||
attendees, _ := app.listAttendees("", "", "")
|
||||
if len(attendees) != 1 {
|
||||
t.Fatalf("attendee count = %d, want 1", len(attendees))
|
||||
tickets, _ := app.listTickets(nil, "")
|
||||
if len(tickets) != 1 {
|
||||
t.Fatalf("ticket count = %d, want 1", len(tickets))
|
||||
}
|
||||
if attendees[0].PartySize != 3 {
|
||||
t.Errorf("party_size = %d, want 3", attendees[0].PartySize)
|
||||
if tickets[0].Source != "crowdwork" {
|
||||
t.Errorf("source = %q, want crowdwork", tickets[0].Source)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -94,7 +94,8 @@ func TestImportReimportSkips(t *testing.T) {
|
|||
token := testToken(t, app, admin)
|
||||
mux := testMux(app)
|
||||
|
||||
csv := "name\nTitania\nOberon\n"
|
||||
// Use ticket_ids so re-import dedup works via UNIQUE(source, external_id)
|
||||
csv := "name,email,ticket_id\nTitania,titania@test.com,T001\nOberon,oberon@test.com,T002\n"
|
||||
postCSV(t, mux, token, csv)
|
||||
|
||||
// Re-import same data
|
||||
|
|
|
|||
|
|
@ -7,17 +7,20 @@ import (
|
|||
)
|
||||
|
||||
// handleKioskGet returns the volunteer's profile, current shift assignments, and
|
||||
// available open shifts in their department. Authenticated by volunteer token only —
|
||||
// available open shifts in their department. Authenticated by ticket code only —
|
||||
// no JWT required.
|
||||
func (app *App) handleKioskGet(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.PathValue("token")
|
||||
a, err := app.getAttendeeByToken(token)
|
||||
if err != nil || a == nil {
|
||||
t, err := app.getTicketByCode(token)
|
||||
if err != nil || t == nil {
|
||||
writeError(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
v, _ := app.getVolunteerByAttendeeID(a.ID)
|
||||
var v *Volunteer
|
||||
if t.ParticipantID != nil {
|
||||
v, _ = app.getVolunteerByParticipantID(*t.ParticipantID)
|
||||
}
|
||||
if v == nil {
|
||||
writeError(w, "no volunteer record linked to this token", http.StatusNotFound)
|
||||
return
|
||||
|
|
@ -53,12 +56,15 @@ func (app *App) handleKioskClaim(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
a, err := app.getAttendeeByToken(token)
|
||||
if err != nil || a == nil {
|
||||
t, err := app.getTicketByCode(token)
|
||||
if err != nil || t == nil {
|
||||
writeError(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
v, _ := app.getVolunteerByAttendeeID(a.ID)
|
||||
var v *Volunteer
|
||||
if t.ParticipantID != nil {
|
||||
v, _ = app.getVolunteerByParticipantID(*t.ParticipantID)
|
||||
}
|
||||
if v == nil {
|
||||
writeError(w, "no volunteer linked to this token", http.StatusNotFound)
|
||||
return
|
||||
|
|
@ -110,12 +116,15 @@ func (app *App) handleKioskUnclaim(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
a, err := app.getAttendeeByToken(token)
|
||||
if err != nil || a == nil {
|
||||
t, err := app.getTicketByCode(token)
|
||||
if err != nil || t == nil {
|
||||
writeError(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
v, _ := app.getVolunteerByAttendeeID(a.ID)
|
||||
var v *Volunteer
|
||||
if t.ParticipantID != nil {
|
||||
v, _ = app.getVolunteerByParticipantID(*t.ParticipantID)
|
||||
}
|
||||
if v == nil {
|
||||
writeError(w, "no volunteer linked to this token", http.StatusNotFound)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -14,13 +14,14 @@ func setupKiosk(t *testing.T) (*App, *http.ServeMux, string) {
|
|||
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
||||
deptID := dept.ID
|
||||
|
||||
// Create attendee with token
|
||||
a, _ := app.createAttendee(Attendee{Name: "Titania", Email: "titania@test.com"})
|
||||
// Create participant + ticket with code
|
||||
p, _ := app.createParticipant(Participant{Email: "titania@test.com", PreferredName: "Titania"})
|
||||
token, _ := app.generateUniqueToken()
|
||||
app.db.Exec(`UPDATE attendees SET volunteer_token = ? WHERE id = ?`, token, a.ID)
|
||||
tk, _ := app.createTicket(Ticket{ParticipantID: &p.ID, Name: "Titania", Source: "manual", Code: &token})
|
||||
_ = tk
|
||||
|
||||
// Create linked volunteer
|
||||
app.createVolunteer(Volunteer{Name: "Titania", AttendeeID: &a.ID, DepartmentID: &deptID})
|
||||
// Create linked volunteer via participant_id
|
||||
app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: &p.ID, DepartmentID: &deptID})
|
||||
|
||||
// Create shifts
|
||||
app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2})
|
||||
|
|
|
|||
179
handle_participants.go
Normal file
179
handle_participants.go
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func (app *App) handleListParticipants(w http.ResponseWriter, r *http.Request) {
|
||||
search := r.URL.Query().Get("search")
|
||||
participants, err := app.listParticipants(search, "")
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
total, checkedIn, _ := app.ticketCounts()
|
||||
types, _ := app.ticketTypes()
|
||||
writeJSON(w, map[string]any{
|
||||
"participants": participants,
|
||||
"total": total,
|
||||
"checked_in": checkedIn,
|
||||
"ticket_types": types,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleGetParticipant(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p, err := app.getParticipant(id)
|
||||
if err != nil || p == nil {
|
||||
writeError(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
tickets, _ := app.listTickets(&id, "")
|
||||
writeJSON(w, map[string]any{"participant": p, "tickets": tickets})
|
||||
}
|
||||
|
||||
func (app *App) handleCreateParticipant(w http.ResponseWriter, r *http.Request) {
|
||||
var p Participant
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
writeError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.PreferredName == "" && p.Email == "" {
|
||||
writeError(w, "name or email is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
created, err := app.createParticipant(p)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
writeJSON(w, created)
|
||||
}
|
||||
|
||||
func (app *App) handleUpdateParticipant(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var p Participant
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
writeError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p.ID = id
|
||||
if err := app.updateParticipant(p); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
updated, _ := app.getParticipant(id)
|
||||
writeJSON(w, updated)
|
||||
}
|
||||
|
||||
func (app *App) handleDeleteParticipant(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := app.deleteParticipant(id); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleMergeParticipants reassigns all tickets and volunteers from otherID to
|
||||
// canonicalID, then soft-deletes the other participant.
|
||||
func (app *App) handleMergeParticipants(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
otherID, err := strconv.Atoi(r.PathValue("other_id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid other_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := app.mergeParticipants(id, otherID); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
p, _ := app.getParticipant(id)
|
||||
tickets, _ := app.listTickets(&id, "")
|
||||
writeJSON(w, map[string]any{"participant": p, "tickets": tickets})
|
||||
}
|
||||
|
||||
func (app *App) handleExportParticipants(w http.ResponseWriter, r *http.Request) {
|
||||
participants, err := app.listParticipants("", "")
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="participants.csv"`)
|
||||
wr := csv.NewWriter(w)
|
||||
wr.Write([]string{"id", "email", "preferred_name", "phone", "pronouns", "note"})
|
||||
for _, p := range participants {
|
||||
wr.Write([]string{
|
||||
strconv.Itoa(p.ID), p.Email, p.PreferredName, p.Phone, p.Pronouns, p.Note,
|
||||
})
|
||||
}
|
||||
wr.Flush()
|
||||
}
|
||||
|
||||
func (app *App) handleCreateTicket(w http.ResponseWriter, r *http.Request) {
|
||||
var t Ticket
|
||||
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
|
||||
writeError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if t.ParticipantID == nil {
|
||||
writeError(w, "participant_id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if t.Source == "" {
|
||||
t.Source = "manual"
|
||||
}
|
||||
created, err := app.createTicket(t)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
writeJSON(w, created)
|
||||
}
|
||||
|
||||
func (app *App) handleListTickets(w http.ResponseWriter, r *http.Request) {
|
||||
tickets, err := app.listTickets(nil, "")
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"tickets": tickets})
|
||||
}
|
||||
|
||||
func (app *App) handleCheckInTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
tk, err := app.checkInTicket(id, claims.UserID)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
app.broker.publish("checkin", map[string]any{"type": "ticket", "ticket": tk})
|
||||
writeJSON(w, map[string]any{"ticket": tk})
|
||||
}
|
||||
|
|
@ -79,9 +79,9 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
|||
app.handleGetSettings(w, r)
|
||||
}
|
||||
|
||||
func (app *App) handleResetAttendees(w http.ResponseWriter, r *http.Request) {
|
||||
func (app *App) handleResetTickets(w http.ResponseWriter, r *http.Request) {
|
||||
ts := now()
|
||||
result, err := app.db.Exec(`UPDATE attendees SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL`, ts, ts)
|
||||
result, err := app.db.Exec(`UPDATE tickets SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL`, ts, ts)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -55,17 +55,19 @@ func TestUpdateSettings(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestResetAttendees(t *testing.T) {
|
||||
func TestResetTickets(t *testing.T) {
|
||||
app := testApp(t)
|
||||
admin := testAdminUser(t, app)
|
||||
token := testToken(t, app, admin)
|
||||
mux := testMux(app)
|
||||
|
||||
app.createAttendee(Attendee{Name: "Titania", Email: "titania@example.com"})
|
||||
app.createAttendee(Attendee{Name: "Oberon", Email: "oberon@example.com"})
|
||||
p1, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"})
|
||||
p2, _ := app.createParticipant(Participant{PreferredName: "Oberon", Email: "oberon@example.com"})
|
||||
app.createTicket(Ticket{ParticipantID: &p1.ID, Name: "Titania", Source: "manual"})
|
||||
app.createTicket(Ticket{ParticipantID: &p2.ID, Name: "Oberon", Source: "manual"})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/reset-attendees", nil, token))
|
||||
mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/reset-tickets", nil, token))
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
|
||||
|
|
@ -75,20 +77,20 @@ func TestResetAttendees(t *testing.T) {
|
|||
t.Fatalf("deleted = %v, want 2", result["deleted"])
|
||||
}
|
||||
|
||||
attendees, _ := app.listAttendees("", "", "")
|
||||
if len(attendees) != 0 {
|
||||
t.Fatalf("attendees remaining = %d, want 0", len(attendees))
|
||||
tickets, _ := app.listTickets(nil, "")
|
||||
if len(tickets) != 0 {
|
||||
t.Fatalf("tickets remaining = %d, want 0", len(tickets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetAttendeesRequiresAdmin(t *testing.T) {
|
||||
func TestResetTicketsRequiresAdmin(t *testing.T) {
|
||||
app := testApp(t)
|
||||
gate := testUserWithRole(t, app, "gate1", "gate", []int{})
|
||||
gate := testUserWithRole(t, app, "gate1", "gatekeeper", []int{})
|
||||
token := testToken(t, app, gate)
|
||||
mux := testMux(app)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/reset-attendees", nil, token))
|
||||
mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/reset-tickets", nil, token))
|
||||
|
||||
if w.Code != 403 {
|
||||
t.Fatalf("status = %d, want 403", w.Code)
|
||||
|
|
@ -129,7 +131,7 @@ func TestResetDepartmentsCascadesShifts(t *testing.T) {
|
|||
|
||||
func TestSettingsNonAdminRejected(t *testing.T) {
|
||||
app := testApp(t)
|
||||
gate := testUserWithRole(t, app, "gateuser", "gate", []int{})
|
||||
gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{})
|
||||
token := testToken(t, app, gate)
|
||||
mux := testMux(app)
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ func (app *App) handleListShifts(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
claims := claimsFromContext(r)
|
||||
if claims.Role == "volunteer_lead" && deptID == nil && len(claims.DeptIDs) > 0 {
|
||||
if claims.Role == "colead" && deptID == nil && len(claims.DeptIDs) > 0 {
|
||||
deptID = &claims.DeptIDs[0]
|
||||
}
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ func (app *App) handleCreateShift(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if claims.Role == "volunteer_lead" && !inSlice(s.DepartmentID, claims.DeptIDs) {
|
||||
if claims.Role == "colead" && !inSlice(s.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
|
@ -65,7 +65,7 @@ func (app *App) handleUpdateShift(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if claims.Role == "volunteer_lead" {
|
||||
if claims.Role == "colead" {
|
||||
existing, _ := app.getShift(id)
|
||||
if existing == nil || !inSlice(existing.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
|
|
|
|||
112
handle_signup.go
112
handle_signup.go
|
|
@ -68,32 +68,23 @@ func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Auto-match attendee by email or create new
|
||||
var attendeeID *int
|
||||
attendees, _ := app.listAttendees("", "", "")
|
||||
for _, a := range attendees {
|
||||
if strings.EqualFold(a.Email, body.Email) {
|
||||
id := a.ID
|
||||
attendeeID = &id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if attendeeID == nil {
|
||||
// Find or create participant by email.
|
||||
name := body.PreferredName
|
||||
if body.TicketName != "" {
|
||||
name = body.TicketName
|
||||
}
|
||||
newAttendee, err := app.createAttendee(Attendee{
|
||||
Name: name,
|
||||
Email: body.Email,
|
||||
Phone: body.Phone,
|
||||
})
|
||||
participant, _, err := app.upsertParticipant(body.Email, name)
|
||||
if err != nil {
|
||||
writeError(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
attendeeID = &newAttendee.ID
|
||||
// Update participant's personal details if they signed up with more info.
|
||||
if body.Phone != "" || body.Pronouns != "" {
|
||||
app.db.Exec(`UPDATE participants SET
|
||||
phone = CASE WHEN phone = '' THEN ? ELSE phone END,
|
||||
pronouns = CASE WHEN pronouns = '' THEN ? ELSE pronouns END,
|
||||
updated_at = ?
|
||||
WHERE id = ?`, body.Phone, body.Pronouns, now(), participant.ID)
|
||||
}
|
||||
|
||||
confirmToken, err := generateConfirmationToken()
|
||||
|
|
@ -103,7 +94,7 @@ func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
vol := Volunteer{
|
||||
AttendeeID: attendeeID,
|
||||
ParticipantID: &participant.ID,
|
||||
Name: body.PreferredName,
|
||||
PreferredName: body.PreferredName,
|
||||
TicketName: body.TicketName,
|
||||
|
|
@ -159,17 +150,39 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) {
|
|||
var signupsOpen string
|
||||
app.db.QueryRow(`SELECT value FROM config WHERE key = 'shift_signups_open'`).Scan(&signupsOpen)
|
||||
|
||||
if signupsOpen == "true" && vol.AttendeeID != nil {
|
||||
a, _ := app.getAttendee(*vol.AttendeeID)
|
||||
if a != nil && a.VolunteerToken == nil {
|
||||
t, err := app.generateUniqueToken()
|
||||
if signupsOpen == "true" && vol.ParticipantID != nil {
|
||||
// Find a ticket with a code, or create/assign one.
|
||||
tickets, _ := app.listTickets(vol.ParticipantID, "")
|
||||
var code *string
|
||||
for _, tk := range tickets {
|
||||
if tk.Code != nil {
|
||||
code = tk.Code
|
||||
break
|
||||
}
|
||||
}
|
||||
if code == nil {
|
||||
// No coded ticket — find any ticket or create a stub, then generate code.
|
||||
var ticketID int
|
||||
if len(tickets) > 0 {
|
||||
ticketID = tickets[0].ID
|
||||
} else {
|
||||
stub, err := app.createTicket(Ticket{
|
||||
ParticipantID: vol.ParticipantID,
|
||||
Source: "manual",
|
||||
})
|
||||
if err == nil {
|
||||
app.db.Exec(`UPDATE attendees SET volunteer_token=?, updated_at=? WHERE id=?`, t, now(), a.ID)
|
||||
a.VolunteerToken = &t
|
||||
ticketID = stub.ID
|
||||
}
|
||||
}
|
||||
if a != nil && a.VolunteerToken != nil {
|
||||
kioskLink := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), *a.VolunteerToken)
|
||||
if ticketID > 0 {
|
||||
if t, err := app.generateUniqueToken(); err == nil {
|
||||
app.db.Exec(`UPDATE tickets SET code=?, updated_at=? WHERE id=?`, t, now(), ticketID)
|
||||
code = &t
|
||||
}
|
||||
}
|
||||
}
|
||||
if code != nil {
|
||||
kioskLink := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), *code)
|
||||
response["kiosk_link"] = kioskLink
|
||||
go func() {
|
||||
if err := app.sendShiftSignupEmail(vol.Email, vol.PreferredName, kioskLink); err != nil {
|
||||
|
|
@ -203,36 +216,57 @@ func (app *App) handleToggleShiftSignups(w http.ResponseWriter, r *http.Request)
|
|||
}
|
||||
|
||||
func (app *App) openShiftSignups() {
|
||||
// Generate kiosk tokens for confirmed volunteers whose attendees lack one
|
||||
vols, _ := app.listConfirmedVolunteersWithoutKioskToken()
|
||||
// Generate codes for tickets belonging to confirmed volunteers that have no code yet.
|
||||
vols, _ := app.listConfirmedVolunteersNeedingCode()
|
||||
for _, v := range vols {
|
||||
if v.AttendeeID == nil {
|
||||
if v.ParticipantID == nil {
|
||||
continue
|
||||
}
|
||||
// Find any ticket for this participant, or create a stub one.
|
||||
tickets, _ := app.listTickets(v.ParticipantID, "")
|
||||
var ticketID int
|
||||
if len(tickets) > 0 {
|
||||
ticketID = tickets[0].ID
|
||||
} else {
|
||||
stub, err := app.createTicket(Ticket{
|
||||
ParticipantID: v.ParticipantID,
|
||||
Source: "manual",
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ticketID = stub.ID
|
||||
}
|
||||
t, err := app.generateUniqueToken()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
app.db.Exec(`UPDATE attendees SET volunteer_token=?, updated_at=? WHERE id=?`, t, now(), *v.AttendeeID)
|
||||
app.db.Exec(`UPDATE tickets SET code=?, updated_at=? WHERE id=?`, t, now(), ticketID)
|
||||
}
|
||||
|
||||
// Email all confirmed volunteers with kiosk links
|
||||
// Email all confirmed volunteers that now have a ticket with a code.
|
||||
confirmed, _ := queryVolunteers(app.db, `
|
||||
SELECT `+volunteerCols+`
|
||||
FROM volunteers
|
||||
WHERE email_confirmed = 1 AND deleted_at IS NULL AND attendee_id IS NOT NULL`)
|
||||
SELECT `+volunteerSelect+` `+volunteerFrom+`
|
||||
WHERE v.email_confirmed = 1 AND v.deleted_at IS NULL AND v.participant_id IS NOT NULL`)
|
||||
baseURL := app.resolveBaseURL()
|
||||
sent := 0
|
||||
|
||||
for _, v := range confirmed {
|
||||
if v.AttendeeID == nil || v.Email == "" {
|
||||
if v.ParticipantID == nil || v.Email == "" {
|
||||
continue
|
||||
}
|
||||
a, _ := app.getAttendee(*v.AttendeeID)
|
||||
if a == nil || a.VolunteerToken == nil {
|
||||
tickets, _ := app.listTickets(v.ParticipantID, "")
|
||||
var code *string
|
||||
for _, tk := range tickets {
|
||||
if tk.Code != nil {
|
||||
code = tk.Code
|
||||
break
|
||||
}
|
||||
}
|
||||
if code == nil {
|
||||
continue
|
||||
}
|
||||
kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *a.VolunteerToken)
|
||||
kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *code)
|
||||
name := v.PreferredName
|
||||
if name == "" {
|
||||
name = v.Name
|
||||
|
|
|
|||
|
|
@ -71,25 +71,25 @@ func TestPublicSignup(t *testing.T) {
|
|||
t.Error("should not be confirmed yet")
|
||||
}
|
||||
|
||||
// Attendee should be auto-created and linked
|
||||
if vol.AttendeeID == nil {
|
||||
t.Fatal("expected attendee to be linked")
|
||||
// Participant should be auto-created and linked
|
||||
if vol.ParticipantID == nil {
|
||||
t.Fatal("expected participant to be linked")
|
||||
}
|
||||
a, _ := app.getAttendee(*vol.AttendeeID)
|
||||
if a == nil {
|
||||
t.Fatal("linked attendee not found")
|
||||
p, _ := app.getParticipant(*vol.ParticipantID)
|
||||
if p == nil {
|
||||
t.Fatal("linked participant not found")
|
||||
}
|
||||
if a.Email != "titania@example.com" {
|
||||
t.Errorf("attendee email = %q, want titania@example.com", a.Email)
|
||||
if p.Email != "titania@example.com" {
|
||||
t.Errorf("participant email = %q, want titania@example.com", p.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicSignupAutoMatchAttendee(t *testing.T) {
|
||||
func TestPublicSignupAutoMatchParticipant(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
// Pre-existing attendee
|
||||
existing, _ := app.createAttendee(Attendee{Name: "Titania Fairweather", Email: "titania@example.com"})
|
||||
// Pre-existing participant
|
||||
existing, _ := app.createParticipant(Participant{PreferredName: "Titania Fairweather", Email: "titania@example.com"})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{
|
||||
|
|
@ -105,8 +105,8 @@ func TestPublicSignupAutoMatchAttendee(t *testing.T) {
|
|||
if vol == nil {
|
||||
t.Fatal("volunteer not created")
|
||||
}
|
||||
if vol.AttendeeID == nil || *vol.AttendeeID != existing.ID {
|
||||
t.Errorf("expected volunteer linked to existing attendee %d, got %v", existing.ID, vol.AttendeeID)
|
||||
if vol.ParticipantID == nil || *vol.ParticipantID != existing.ID {
|
||||
t.Errorf("expected volunteer linked to existing participant %d, got %v", existing.ID, vol.ParticipantID)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -277,13 +277,13 @@ func TestConfirmEmailWithSignupsOpen(t *testing.T) {
|
|||
app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`)
|
||||
app.baseURL = "https://example.com"
|
||||
|
||||
attendee, _ := app.createAttendee(Attendee{Name: "Titania", Email: "titania@example.com"})
|
||||
participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"})
|
||||
token := "abc123def456"
|
||||
app.createVolunteer(Volunteer{
|
||||
Name: "Titania",
|
||||
PreferredName: "Titania",
|
||||
Email: "titania@example.com",
|
||||
AttendeeID: &attendee.ID,
|
||||
ParticipantID: &participant.ID,
|
||||
ConfirmationToken: &token,
|
||||
})
|
||||
|
||||
|
|
@ -301,10 +301,17 @@ func TestConfirmEmailWithSignupsOpen(t *testing.T) {
|
|||
t.Error("expected kiosk_link when signups are open")
|
||||
}
|
||||
|
||||
// Attendee should now have a kiosk token
|
||||
a, _ := app.getAttendee(attendee.ID)
|
||||
if a.VolunteerToken == nil {
|
||||
t.Error("attendee should have kiosk token after confirm with signups open")
|
||||
// Ticket for participant should now have a code
|
||||
tickets, _ := app.listTickets(&participant.ID, "")
|
||||
hasCode := false
|
||||
for _, tk := range tickets {
|
||||
if tk.Code != nil && *tk.Code != "" {
|
||||
hasCode = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasCode {
|
||||
t.Error("participant should have a ticket with code after confirm with signups open")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,14 +12,18 @@ func (app *App) handleSyncPull(w http.ResponseWriter, r *http.Request) {
|
|||
since := r.URL.Query().Get("since")
|
||||
|
||||
event, _ := app.getEvent()
|
||||
attendees, _ := app.attendeesSince(since)
|
||||
participants, _ := app.listParticipants("", since)
|
||||
tickets, _ := app.listTickets(nil, since)
|
||||
departments, _ := app.listDepartments(since)
|
||||
volunteers, _ := app.listVolunteers("", nil, since)
|
||||
shifts, _ := app.listShifts(nil, "", since)
|
||||
volunteerShifts, _ := app.listVolunteerShifts(since)
|
||||
|
||||
if attendees == nil {
|
||||
attendees = []Attendee{}
|
||||
if participants == nil {
|
||||
participants = []Participant{}
|
||||
}
|
||||
if tickets == nil {
|
||||
tickets = []Ticket{}
|
||||
}
|
||||
if departments == nil {
|
||||
departments = []Department{}
|
||||
|
|
@ -37,7 +41,8 @@ func (app *App) handleSyncPull(w http.ResponseWriter, r *http.Request) {
|
|||
writeJSON(w, map[string]any{
|
||||
"server_time": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
"event": event,
|
||||
"attendees": attendees,
|
||||
"participants": participants,
|
||||
"tickets": tickets,
|
||||
"departments": departments,
|
||||
"volunteers": volunteers,
|
||||
"shifts": shifts,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ func TestSyncPullFull(t *testing.T) {
|
|||
token := testToken(t, app, admin)
|
||||
mux := testMux(app)
|
||||
|
||||
app.createAttendee(Attendee{Name: "Titania"})
|
||||
app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"})
|
||||
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
||||
deptID := dept.ID
|
||||
app.createVolunteer(Volunteer{Name: "Titania", DepartmentID: &deptID})
|
||||
|
|
@ -31,9 +31,9 @@ func TestSyncPullFull(t *testing.T) {
|
|||
if result["server_time"] == nil {
|
||||
t.Error("missing server_time")
|
||||
}
|
||||
attendees := result["attendees"].([]any)
|
||||
if len(attendees) != 1 {
|
||||
t.Errorf("attendees = %d, want 1", len(attendees))
|
||||
participants := result["participants"].([]any)
|
||||
if len(participants) != 1 {
|
||||
t.Errorf("participants = %d, want 1", len(participants))
|
||||
}
|
||||
depts := result["departments"].([]any)
|
||||
if len(depts) != 1 {
|
||||
|
|
@ -47,29 +47,29 @@ func TestSyncPullIncremental(t *testing.T) {
|
|||
token := testToken(t, app, admin)
|
||||
mux := testMux(app)
|
||||
|
||||
app.createAttendee(Attendee{Name: "Titania"})
|
||||
p1, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"})
|
||||
// Backdate Titania so she falls before the "since" cutoff
|
||||
app.db.Exec(`UPDATE attendees SET updated_at = '2026-01-01T00:00:00Z' WHERE name = 'Titania'`)
|
||||
app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, p1.ID)
|
||||
|
||||
since := "2026-01-01T12:00:00Z"
|
||||
|
||||
// Oberon created with default updated_at (now), which is after our since
|
||||
app.createAttendee(Attendee{Name: "Oberon"})
|
||||
app.createParticipant(Participant{PreferredName: "Oberon", Email: "oberon@example.com"})
|
||||
|
||||
req := testAuthRequest("GET", "/api/sync/pull?since="+since, nil, token)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
result := parseJSON(t, w)
|
||||
attendees := result["attendees"].([]any)
|
||||
participants := result["participants"].([]any)
|
||||
// Should only include Oberon (created after `since`)
|
||||
if len(attendees) != 1 {
|
||||
t.Errorf("incremental: got %d attendees, want 1", len(attendees))
|
||||
if len(participants) != 1 {
|
||||
t.Errorf("incremental: got %d participants, want 1", len(participants))
|
||||
}
|
||||
if len(attendees) == 1 {
|
||||
a := attendees[0].(map[string]any)
|
||||
if a["name"] != "Oberon" {
|
||||
t.Errorf("name = %v, want Oberon", a["name"])
|
||||
if len(participants) == 1 {
|
||||
p := participants[0].(map[string]any)
|
||||
if p["preferred_name"] != "Oberon" {
|
||||
t.Errorf("preferred_name = %v, want Oberon", p["preferred_name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -80,31 +80,31 @@ func TestSyncPullIncludesSoftDeleted(t *testing.T) {
|
|||
token := testToken(t, app, admin)
|
||||
mux := testMux(app)
|
||||
|
||||
a, _ := app.createAttendee(Attendee{Name: "Titania"})
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"})
|
||||
// Backdate Titania's creation so the since cutoff is between creation and deletion
|
||||
app.db.Exec(`UPDATE attendees SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, a.ID)
|
||||
app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, p.ID)
|
||||
|
||||
since := "2026-01-01T12:00:00Z"
|
||||
|
||||
// Delete updates updated_at to now(), which is after our since
|
||||
app.deleteAttendee(a.ID)
|
||||
app.deleteParticipant(p.ID)
|
||||
|
||||
req := testAuthRequest("GET", "/api/sync/pull?since="+since, nil, token)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
var result struct {
|
||||
Attendees []struct {
|
||||
Participants []struct {
|
||||
ID int `json:"id"`
|
||||
DeletedAt *string `json:"deleted_at"`
|
||||
} `json:"attendees"`
|
||||
} `json:"participants"`
|
||||
}
|
||||
json.Unmarshal(w.Body.Bytes(), &result)
|
||||
|
||||
if len(result.Attendees) != 1 {
|
||||
t.Fatalf("got %d attendees, want 1", len(result.Attendees))
|
||||
if len(result.Participants) != 1 {
|
||||
t.Fatalf("got %d participants, want 1", len(result.Participants))
|
||||
}
|
||||
if result.Attendees[0].DeletedAt == nil {
|
||||
if result.Participants[0].DeletedAt == nil {
|
||||
t.Error("deleted_at should be set for soft-deleted record")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
// handleGenerateTokens creates volunteer_token values for all attendees that don't have one.
|
||||
// handleGenerateTokens creates codes for all tickets that don't have one.
|
||||
func (app *App) handleGenerateTokens(w http.ResponseWriter, r *http.Request) {
|
||||
count, err := app.generateTokensForAll()
|
||||
count, err := app.generateCodesForAll()
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -21,7 +21,7 @@ func (app *App) handleGenerateTokens(w http.ResponseWriter, r *http.Request) {
|
|||
// handleExportTokenLinks streams a CSV download with token signup links,
|
||||
// compatible with MailChimp / Zeffy bulk-send workflows.
|
||||
func (app *App) handleExportTokenLinks(w http.ResponseWriter, r *http.Request) {
|
||||
attendees, err := app.listAttendees("", "", "")
|
||||
tickets, err := app.listTickets(nil, "")
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -37,55 +37,62 @@ func (app *App) handleExportTokenLinks(w http.ResponseWriter, r *http.Request) {
|
|||
w.Header().Set("Content-Disposition", `attachment; filename="volunteer-tokens.csv"`)
|
||||
wr := csv.NewWriter(w)
|
||||
wr.Write([]string{"Email Address", "First Name", "Token", "Signup Link"})
|
||||
for _, a := range attendees {
|
||||
if a.VolunteerToken == nil {
|
||||
for _, tk := range tickets {
|
||||
if tk.Code == nil || tk.ParticipantID == nil {
|
||||
continue
|
||||
}
|
||||
firstName := a.Name
|
||||
if parts := strings.Fields(a.Name); len(parts) > 0 {
|
||||
p, _ := app.getParticipant(*tk.ParticipantID)
|
||||
if p == nil || p.Email == "" {
|
||||
continue
|
||||
}
|
||||
firstName := p.PreferredName
|
||||
if firstName == "" {
|
||||
firstName = tk.Name
|
||||
}
|
||||
if parts := strings.Fields(firstName); len(parts) > 0 {
|
||||
firstName = parts[0]
|
||||
}
|
||||
link := fmt.Sprintf("%s/v/%s", baseURL, *a.VolunteerToken)
|
||||
wr.Write([]string{a.Email, firstName, *a.VolunteerToken, link})
|
||||
link := fmt.Sprintf("%s/v/%s", baseURL, *tk.Code)
|
||||
wr.Write([]string{p.Email, firstName, *tk.Code, link})
|
||||
}
|
||||
wr.Flush()
|
||||
}
|
||||
|
||||
// handleEmailToken sends a token email to a single attendee.
|
||||
// handleEmailToken sends a token email to a single ticket's participant.
|
||||
func (app *App) handleEmailToken(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
a, err := app.getAttendee(id)
|
||||
if err != nil || a == nil {
|
||||
tk, err := app.getTicket(id)
|
||||
if err != nil || tk == nil {
|
||||
writeError(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := app.sendTokenEmail(*a); err != nil {
|
||||
if err := app.sendTicketTokenEmail(*tk); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleEmailAllTokens bulk-sends token emails to all attendees that have both a token and email.
|
||||
// handleEmailAllTokens bulk-sends token emails to all tickets that have a code and participant email.
|
||||
func (app *App) handleEmailAllTokens(w http.ResponseWriter, r *http.Request) {
|
||||
attendees, err := app.listAttendees("", "", "")
|
||||
tickets, err := app.listTickets(nil, "")
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var sent, skipped int
|
||||
var errors []string
|
||||
for _, a := range attendees {
|
||||
if a.Email == "" || a.VolunteerToken == nil {
|
||||
for _, tk := range tickets {
|
||||
if tk.Code == nil || tk.ParticipantID == nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if err := app.sendTokenEmail(a); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", a.Name, err))
|
||||
if err := app.sendTicketTokenEmail(tk); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("ticket %d: %v", tk.ID, err))
|
||||
skipped++
|
||||
} else {
|
||||
sent++
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
claims := claimsFromContext(r)
|
||||
if claims.Role == "volunteer_lead" && deptID == nil && len(claims.DeptIDs) > 0 {
|
||||
if claims.Role == "colead" && deptID == nil && len(claims.DeptIDs) > 0 {
|
||||
deptID = &claims.DeptIDs[0]
|
||||
}
|
||||
|
||||
|
|
@ -43,12 +43,21 @@ func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if claims.Role == "volunteer_lead" {
|
||||
if claims.Role == "colead" {
|
||||
if v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
if v.Email != "" && v.ParticipantID == nil {
|
||||
p, _ := app.getParticipantByEmail(v.Email)
|
||||
if p == nil {
|
||||
p, _ = app.createParticipant(Participant{PreferredName: v.Name, Email: v.Email})
|
||||
}
|
||||
if p != nil {
|
||||
v.ParticipantID = &p.ID
|
||||
}
|
||||
}
|
||||
created, err := app.createVolunteer(v)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
|
|
@ -88,7 +97,7 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if claims.Role == "volunteer_lead" {
|
||||
if claims.Role == "colead" {
|
||||
existing, _ := app.getVolunteer(id)
|
||||
if existing == nil || existing.DepartmentID == nil || !inSlice(*existing.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
|
|
|
|||
90
main.go
90
main.go
|
|
@ -97,55 +97,59 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
|
|||
mux.HandleFunc("GET /api/me", auth(app.handleMe))
|
||||
|
||||
mux.HandleFunc("GET /api/event", auth(app.handleGetEvent))
|
||||
mux.HandleFunc("PUT /api/event", auth(app.handleUpdateEvent, "admin"))
|
||||
mux.HandleFunc("PUT /api/event", auth(app.handleUpdateEvent, "admin", "ticketing"))
|
||||
|
||||
mux.HandleFunc("GET /api/attendees", auth(app.handleListAttendees, "admin", "ticketing", "gate"))
|
||||
mux.HandleFunc("POST /api/attendees", auth(app.handleCreateAttendee, "admin", "ticketing"))
|
||||
mux.HandleFunc("GET /api/attendees/export", auth(app.handleExportAttendees, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/attendees/generate-tokens", auth(app.handleGenerateTokens, "admin", "ticketing"))
|
||||
mux.HandleFunc("GET /api/attendees/export-tokens", auth(app.handleExportTokenLinks, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/attendees/email-tokens", auth(app.handleEmailAllTokens, "admin", "ticketing"))
|
||||
mux.HandleFunc("GET /api/attendees/{id}", auth(app.handleGetAttendee, "admin", "ticketing", "gate"))
|
||||
mux.HandleFunc("PUT /api/attendees/{id}", auth(app.handleUpdateAttendee, "admin", "ticketing"))
|
||||
mux.HandleFunc("DELETE /api/attendees/{id}", auth(app.handleDeleteAttendee, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/attendees/{id}/checkin", auth(app.handleCheckInAttendee, "admin", "ticketing", "gate"))
|
||||
mux.HandleFunc("POST /api/attendees/{id}/email-token", auth(app.handleEmailToken, "admin", "ticketing"))
|
||||
mux.HandleFunc("GET /api/participants", auth(app.handleListParticipants, "admin", "ticketing", "gatekeeper"))
|
||||
mux.HandleFunc("POST /api/participants", auth(app.handleCreateParticipant, "admin", "ticketing"))
|
||||
mux.HandleFunc("GET /api/participants/export", auth(app.handleExportParticipants, "admin", "ticketing"))
|
||||
mux.HandleFunc("GET /api/participants/{id}", auth(app.handleGetParticipant, "admin", "ticketing", "gatekeeper"))
|
||||
mux.HandleFunc("PUT /api/participants/{id}", auth(app.handleUpdateParticipant, "admin", "ticketing"))
|
||||
mux.HandleFunc("DELETE /api/participants/{id}", auth(app.handleDeleteParticipant, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/participants/{id}/merge/{other_id}", auth(app.handleMergeParticipants, "admin", "ticketing"))
|
||||
|
||||
mux.HandleFunc("GET /api/tickets", auth(app.handleListTickets, "admin", "ticketing", "gatekeeper"))
|
||||
mux.HandleFunc("POST /api/tickets", auth(app.handleCreateTicket, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/tickets/{id}/checkin", auth(app.handleCheckInTicket, "admin", "ticketing", "gatekeeper"))
|
||||
mux.HandleFunc("POST /api/tickets/generate-codes", auth(app.handleGenerateTokens, "admin", "ticketing"))
|
||||
mux.HandleFunc("GET /api/tickets/export-links", auth(app.handleExportTokenLinks, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/tickets/email-codes", auth(app.handleEmailAllTokens, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/tickets/{id}/email-code", auth(app.handleEmailToken, "admin", "ticketing"))
|
||||
|
||||
mux.HandleFunc("GET /api/departments", auth(app.handleListDepartments))
|
||||
mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "coordinator"))
|
||||
mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "coordinator"))
|
||||
mux.HandleFunc("DELETE /api/departments/{id}", auth(app.handleDeleteDepartment, "admin"))
|
||||
mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "ticketing", "staffing"))
|
||||
mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "ticketing", "staffing"))
|
||||
mux.HandleFunc("DELETE /api/departments/{id}", auth(app.handleDeleteDepartment, "admin", "ticketing"))
|
||||
|
||||
mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("POST /api/volunteers/{id}/checkin", auth(app.handleCheckInVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "ticketing", "staffing", "colead"))
|
||||
mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "ticketing", "staffing", "colead"))
|
||||
mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "ticketing", "staffing", "colead"))
|
||||
mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "ticketing", "staffing", "colead"))
|
||||
mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "ticketing", "staffing", "colead"))
|
||||
mux.HandleFunc("POST /api/volunteers/{id}/checkin", auth(app.handleCheckInVolunteer, "admin", "ticketing", "staffing", "colead"))
|
||||
mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "ticketing", "staffing", "colead"))
|
||||
mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "ticketing", "staffing", "colead"))
|
||||
|
||||
mux.HandleFunc("GET /api/shifts", auth(app.handleListShifts, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("DELETE /api/shifts/{id}", auth(app.handleDeleteShift, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("POST /api/shifts/{id}/volunteers", auth(app.handleAssignShiftVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("DELETE /api/shifts/{id}/volunteers/{volunteer_id}", auth(app.handleUnassignShiftVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("GET /api/shifts", auth(app.handleListShifts, "admin", "ticketing", "staffing", "colead"))
|
||||
mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "ticketing", "staffing", "colead"))
|
||||
mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "ticketing", "staffing", "colead"))
|
||||
mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "ticketing", "staffing", "colead"))
|
||||
mux.HandleFunc("DELETE /api/shifts/{id}", auth(app.handleDeleteShift, "admin", "ticketing", "staffing", "colead"))
|
||||
mux.HandleFunc("POST /api/shifts/{id}/volunteers", auth(app.handleAssignShiftVolunteer, "admin", "ticketing", "staffing", "colead"))
|
||||
mux.HandleFunc("DELETE /api/shifts/{id}/volunteers/{volunteer_id}", auth(app.handleUnassignShiftVolunteer, "admin", "ticketing", "staffing", "colead"))
|
||||
|
||||
mux.HandleFunc("GET /api/users", auth(app.handleListUsers, "admin"))
|
||||
mux.HandleFunc("POST /api/users", auth(app.handleCreateUser, "admin"))
|
||||
mux.HandleFunc("PUT /api/users/{id}", auth(app.handleUpdateUser, "admin"))
|
||||
mux.HandleFunc("DELETE /api/users/{id}", auth(app.handleDeleteUser, "admin"))
|
||||
mux.HandleFunc("GET /api/users", auth(app.handleListUsers, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/users", auth(app.handleCreateUser, "admin", "ticketing"))
|
||||
mux.HandleFunc("PUT /api/users/{id}", auth(app.handleUpdateUser, "admin", "ticketing"))
|
||||
mux.HandleFunc("DELETE /api/users/{id}", auth(app.handleDeleteUser, "admin", "ticketing"))
|
||||
|
||||
mux.HandleFunc("GET /api/settings", auth(app.handleGetSettings, "admin"))
|
||||
mux.HandleFunc("PUT /api/settings", auth(app.handleUpdateSettings, "admin"))
|
||||
mux.HandleFunc("POST /api/settings/test-email", auth(app.handleTestEmail, "admin"))
|
||||
mux.HandleFunc("POST /api/settings/reset-attendees", auth(app.handleResetAttendees, "admin"))
|
||||
mux.HandleFunc("POST /api/settings/reset-volunteers", auth(app.handleResetVolunteers, "admin"))
|
||||
mux.HandleFunc("POST /api/settings/reset-shifts", auth(app.handleResetShifts, "admin"))
|
||||
mux.HandleFunc("POST /api/settings/reset-departments", auth(app.handleResetDepartments, "admin"))
|
||||
mux.HandleFunc("POST /api/settings/reset-volunteer-shifts", auth(app.handleResetVolunteerShifts, "admin"))
|
||||
mux.HandleFunc("GET /api/settings", auth(app.handleGetSettings, "admin", "ticketing"))
|
||||
mux.HandleFunc("PUT /api/settings", auth(app.handleUpdateSettings, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/settings/test-email", auth(app.handleTestEmail, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/settings/reset-tickets", auth(app.handleResetTickets, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/settings/reset-volunteers", auth(app.handleResetVolunteers, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/settings/reset-shifts", auth(app.handleResetShifts, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/settings/reset-departments", auth(app.handleResetDepartments, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/settings/reset-volunteer-shifts", auth(app.handleResetVolunteerShifts, "admin", "ticketing"))
|
||||
|
||||
mux.HandleFunc("POST /api/import", auth(app.handleImport, "admin", "ticketing"))
|
||||
|
||||
|
|
@ -156,7 +160,7 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
|
|||
writeJSON(w, map[string]string{"build": buildID})
|
||||
})
|
||||
|
||||
mux.HandleFunc("POST /api/settings/shift-signups", auth(app.handleToggleShiftSignups, "admin", "volunteer_lead"))
|
||||
mux.HandleFunc("POST /api/settings/shift-signups", auth(app.handleToggleShiftSignups, "admin", "ticketing", "staffing"))
|
||||
|
||||
// Public endpoints — no JWT required.
|
||||
mux.HandleFunc("GET /api/public/signup-config", app.handlePublicSignupConfig)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue