diff --git a/Makefile b/Makefile index a8de0d3..30bca30 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ .PHONY: build frontend-build dev clean test patch minor major LAST_TAG := $(shell git tag --sort=-v:refname | head -1) -MAJOR := $(shell echo $(LAST_TAG) | cut -d. -f1) -MINOR := $(shell echo $(LAST_TAG) | cut -d. -f2) -PATCH := $(shell echo $(LAST_TAG) | cut -d. -f3) +MAJOR := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f1) +MINOR := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f2) +PATCH := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f3) build: frontend-build CGO_ENABLED=0 go build -ldflags "-X main.buildID=$$(git rev-parse --short HEAD)" -o turnpike . @@ -25,13 +25,13 @@ clean: rm -rf frontend/dist patch: - git tag $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1))) - @echo "Tagged $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))" + git tag v$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1))) + @echo "Tagged v$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))" minor: - git tag $(MAJOR).$(shell echo $$(($(MINOR)+1))).0 - @echo "Tagged $(MAJOR).$(shell echo $$(($(MINOR)+1))).0" + git tag v$(MAJOR).$(shell echo $$(($(MINOR)+1))).0 + @echo "Tagged v$(MAJOR).$(shell echo $$(($(MINOR)+1))).0" major: - git tag $(shell echo $$(($(MAJOR)+1))).0.0 - @echo "Tagged $(shell echo $$(($(MAJOR)+1))).0.0" + git tag v$(shell echo $$(($(MAJOR)+1))).0.0 + @echo "Tagged v$(shell echo $$(($(MAJOR)+1))).0.0" diff --git a/db.go b/db.go index bda44ed..514460e 100644 --- a/db.go +++ b/db.go @@ -86,24 +86,21 @@ func migrate(db *sql.DB) error { ON attendees(name, ticket_id) WHERE deleted_at IS NULL; CREATE TABLE IF NOT EXISTS volunteers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE, - department_id INTEGER REFERENCES departments(id) ON DELETE SET NULL, - is_lead INTEGER NOT NULL DEFAULT 0, - ready INTEGER NOT NULL DEFAULT 0, - ready_at TEXT, - confirmed INTEGER NOT NULL DEFAULT 0, - confirmed_at TEXT, - kiosk_code TEXT, - note TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - deleted_at TEXT + id INTEGER PRIMARY KEY AUTOINCREMENT, + attendee_id INTEGER REFERENCES attendees(id) ON DELETE SET NULL, + name TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '', + department_id INTEGER REFERENCES departments(id) ON DELETE SET NULL, + is_lead INTEGER NOT NULL DEFAULT 0, + ready INTEGER NOT NULL DEFAULT 0, + ready_at TEXT, + 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_volunteers_kiosk_code - ON volunteers(kiosk_code) WHERE kiosk_code IS NOT NULL; - CREATE TABLE IF NOT EXISTS shifts ( id INTEGER PRIMARY KEY AUTOINCREMENT, department_id INTEGER NOT NULL REFERENCES departments(id) ON DELETE CASCADE, @@ -122,23 +119,19 @@ func migrate(db *sql.DB) error { shift_id INTEGER NOT NULL REFERENCES shifts(id) ON DELETE CASCADE, confirmed INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL DEFAULT (datetime('now')), - deleted_at TEXT, 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 '', - ticket_name TEXT NOT NULL DEFAULT '', - phone TEXT NOT NULL DEFAULT '', - pronouns TEXT NOT NULL DEFAULT '', - note TEXT NOT NULL DEFAULT '', - email_confirmed INTEGER NOT NULL DEFAULT 0, - confirmation_token TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - deleted_at TEXT + 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 @@ -166,9 +159,210 @@ func migrate(db *sql.DB) error { if err != nil { return err } + return migrateV2(db) +} + +// migrateV2 adds new columns to existing databases without data loss. +func migrateV2(db *sql.DB) error { + addColumnIfMissing(db, "attendees", "volunteer_token TEXT UNIQUE") + addColumnIfMissing(db, "attendees", "party_size INTEGER NOT NULL DEFAULT 1") + addColumnIfMissing(db, "attendees", "checked_in_count INTEGER NOT NULL DEFAULT 0") + addColumnIfMissing(db, "shifts", "position INTEGER NOT NULL DEFAULT 0") + addColumnIfMissing(db, "volunteer_shifts", "deleted_at TEXT") + addColumnIfMissing(db, "volunteers", "preferred_name TEXT NOT NULL DEFAULT ''") + addColumnIfMissing(db, "volunteers", "ticket_name TEXT NOT NULL DEFAULT ''") + addColumnIfMissing(db, "volunteers", "pronouns TEXT NOT NULL DEFAULT ''") + addColumnIfMissing(db, "volunteers", "email_confirmed INTEGER NOT NULL DEFAULT 0") + addColumnIfMissing(db, "volunteers", "confirmation_token TEXT") + addColumnIfMissing(db, "volunteers", "confirmed INTEGER NOT NULL DEFAULT 0") + addColumnIfMissing(db, "volunteers", "confirmed_at TEXT") + addColumnIfMissing(db, "volunteers", "kiosk_code TEXT") + renameColumnIfExists(db, "volunteers", "checked_in", "ready") + renameColumnIfExists(db, "volunteers", "checked_in_at", "ready_at") + db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_volunteers_kiosk_code ON volunteers(kiosk_code) WHERE kiosk_code IS NOT NULL`) + // Migrate kiosk codes from tickets to volunteers (idempotent). + db.Exec(` + UPDATE volunteers SET kiosk_code = ( + SELECT t.code FROM tickets t + WHERE t.participant_id = volunteers.participant_id + AND t.code IS NOT NULL AND t.deleted_at IS NULL + LIMIT 1 + ) WHERE kiosk_code IS NULL AND participant_id IS NOT NULL`) + // Delete stub tickets whose code has been migrated to the volunteer. + db.Exec(` + DELETE FROM tickets + WHERE source = 'manual' AND external_id = '' AND code IS NOT NULL + AND participant_id IN (SELECT id FROM volunteers WHERE kiosk_code IS NOT NULL)`) + // 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)") + addColumnIfMissing(db, "participants", "ticket_name TEXT NOT NULL DEFAULT ''") + + // 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 } +func addColumnIfMissing(db *sql.DB, table, colDef string) { + colName := strings.Fields(colDef)[0] + rows, err := db.Query(`PRAGMA table_info("` + table + `")`) + if err != nil { + return + } + defer rows.Close() + for rows.Next() { + var cid, notNull, pk int + var name, typ string + var dflt sql.NullString + rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk) + if name == colName { + return + } + } + db.Exec(`ALTER TABLE "` + table + `" ADD COLUMN ` + colDef) +} + +func renameColumnIfExists(db *sql.DB, table, oldName, newName string) { + found := false + rows, err := db.Query(`PRAGMA table_info("` + table + `")`) + if err != nil { + return + } + for rows.Next() { + var cid, notNull, pk int + var name, typ string + var dflt sql.NullString + rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk) + if name == oldName { + found = true + } + } + rows.Close() + if found { + db.Exec(`ALTER TABLE "` + table + `" RENAME COLUMN "` + oldName + `" TO "` + newName + `"`) + } +} + // --- Types --- const attendeeCols = `id, name, email, phone, ticket_id, ticket_type, volunteer_token, @@ -226,40 +420,40 @@ type Department struct { } type Volunteer struct { + ID int `json:"id"` + ParticipantID *int `json:"participant_id,omitempty"` + AttendeeID *int `json:"attendee_id,omitempty"` // deprecated; kept for migration compat + Name string `json:"name"` + PreferredName string `json:"preferred_name"` + Email string `json:"email"` + Phone string `json:"phone"` + Pronouns string `json:"pronouns"` + DepartmentID *int `json:"department_id,omitempty"` + IsLead bool `json:"is_lead"` + Ready bool `json:"ready"` + ReadyAt *string `json:"ready_at,omitempty"` + Confirmed bool `json:"confirmed"` + ConfirmedAt *string `json:"confirmed_at,omitempty"` + EmailConfirmed bool `json:"email_confirmed"` + ConfirmationToken *string `json:"-"` + KioskCode *string `json:"kiosk_code,omitempty"` + Note string `json:"note"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at,omitempty"` +} + +type Participant struct { ID int `json:"id"` - ParticipantID int `json:"participant_id"` - DepartmentID *int `json:"department_id,omitempty"` - IsLead bool `json:"is_lead"` - Ready bool `json:"ready"` - ReadyAt *string `json:"ready_at,omitempty"` - Confirmed bool `json:"confirmed"` - ConfirmedAt *string `json:"confirmed_at,omitempty"` - KioskCode *string `json:"kiosk_code,omitempty"` + Email string `json:"email"` + PreferredName string `json:"preferred_name"` + TicketName string `json:"ticket_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"` - // Populated via JOIN from participant, not stored on volunteers table: - Name string `json:"name"` - Email string `json:"email"` - Phone string `json:"phone"` - Pronouns string `json:"pronouns"` - EmailConfirmed bool `json:"email_confirmed"` -} - -type Participant struct { - ID int `json:"id"` - Email string `json:"email"` - PreferredName string `json:"preferred_name"` - TicketName string `json:"ticket_name"` - Phone string `json:"phone"` - Pronouns string `json:"pronouns"` - Note string `json:"note"` - EmailConfirmed bool `json:"email_confirmed"` - ConfirmationToken *string `json:"-"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - DeletedAt *string `json:"deleted_at,omitempty"` } type Ticket struct { @@ -690,7 +884,7 @@ func (app *App) attendeeCounts() (total, checkedIn int, err error) { // --- Participants --- -const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, email_confirmed, confirmation_token, created_at, updated_at, deleted_at` +const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, created_at, updated_at, deleted_at` func (app *App) listParticipants(search, since string) ([]Participant, error) { var q string @@ -730,8 +924,8 @@ func (app *App) getParticipantByEmail(email string) (*Participant, error) { func (app *App) createParticipant(p Participant) (*Participant, error) { res, err := app.db.Exec( - `INSERT INTO participants (email, preferred_name, ticket_name, phone, pronouns, note, email_confirmed, confirmation_token, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - strings.ToLower(p.Email), p.PreferredName, p.TicketName, p.Phone, p.Pronouns, p.Note, boolInt(p.EmailConfirmed), p.ConfirmationToken, now(), + `INSERT INTO participants (email, preferred_name, ticket_name, phone, pronouns, note, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + strings.ToLower(p.Email), p.PreferredName, p.TicketName, p.Phone, p.Pronouns, p.Note, now(), ) if err != nil { return nil, err @@ -786,19 +980,12 @@ func queryParticipants(db *sql.DB, q string, args ...any) ([]Participant, error) var result []Participant for rows.Next() { var p Participant - var emailConfirmed int - var confirmationToken sql.NullString if err := rows.Scan( &p.ID, &p.Email, &p.PreferredName, &p.TicketName, &p.Phone, &p.Pronouns, &p.Note, - &emailConfirmed, &confirmationToken, &p.CreatedAt, &p.UpdatedAt, &p.DeletedAt, ); err != nil { return nil, err } - p.EmailConfirmed = emailConfirmed == 1 - if confirmationToken.Valid { - p.ConfirmationToken = &confirmationToken.String - } result = append(result, p) } return result, rows.Err() @@ -1030,13 +1217,23 @@ func queryDepartments(db *sql.DB, q string, args ...any) ([]Department, error) { // --- Volunteers --- -const volunteerSelect = `v.id, v.participant_id, - p.preferred_name, p.email, p.phone, p.pronouns, +// 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), + 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.ready, v.ready_at, v.confirmed, v.confirmed_at, - p.email_confirmed, v.kiosk_code, v.note, + v.email_confirmed, v.confirmation_token, v.kiosk_code, v.note, v.created_at, v.updated_at, v.deleted_at` -const volunteerFrom = `FROM volunteers v INNER JOIN participants p ON p.id = v.participant_id` +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, email, phone, pronouns, department_id, is_lead, ready, ready_at, email_confirmed, confirmation_token, note, created_at, updated_at, deleted_at` func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) { q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1` @@ -1048,15 +1245,15 @@ func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volu q += ` AND v.deleted_at IS NULL` } if search != "" { - q += ` AND (p.preferred_name LIKE ? OR p.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 v.department_id = ?` args = append(args, *deptID) } - q += ` ORDER BY p.preferred_name` + q += ` ORDER BY COALESCE(NULLIF(p.preferred_name,''), v.name)` return queryVolunteers(app.db, q, args...) } @@ -1069,6 +1266,15 @@ func (app *App) getVolunteer(id int) (*Volunteer, error) { return &rows[0], nil } +func (app *App) getVolunteerByAttendeeID(attendeeID int) (*Volunteer, error) { + rows, err := queryVolunteers(app.db, + `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.attendee_id = ? AND v.deleted_at IS NULL LIMIT 1`, attendeeID) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + func (app *App) getVolunteerByParticipantID(participantID int) (*Volunteer, error) { rows, err := queryVolunteers(app.db, `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.participant_id = ? AND v.deleted_at IS NULL LIMIT 1`, participantID) @@ -1080,9 +1286,10 @@ func (app *App) getVolunteerByParticipantID(participantID int) (*Volunteer, erro func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { res, err := app.db.Exec( - `INSERT INTO volunteers (participant_id, department_id, is_lead, note, updated_at) - VALUES (?, ?, ?, ?, ?)`, - v.ParticipantID, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), + `INSERT INTO volunteers (participant_id, attendee_id, name, preferred_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.Email, v.Phone, v.Pronouns, + v.DepartmentID, boolInt(v.IsLead), boolInt(v.EmailConfirmed), v.ConfirmationToken, v.Note, now(), ) if err != nil { return nil, err @@ -1093,8 +1300,9 @@ func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { func (app *App) updateVolunteer(v Volunteer) error { _, err := app.db.Exec( - `UPDATE volunteers SET department_id=?, is_lead=?, note=?, updated_at=? + `UPDATE volunteers SET participant_id=?, attendee_id=?, name=?, preferred_name=?, email=?, phone=?, pronouns=?, department_id=?, is_lead=?, note=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, + v.ParticipantID, v.AttendeeID, v.Name, v.PreferredName, v.Email, v.Phone, v.Pronouns, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID, ) return err @@ -1107,6 +1315,8 @@ func (app *App) deleteVolunteer(id int) error { return err } +// markVolunteerReady marks the volunteer as ready and, if linked to an attendee, +// also increments the attendee's checked_in_count. func (app *App) markVolunteerReady(id, userID int) (*Volunteer, error) { t := now() _, err := app.db.Exec( @@ -1117,7 +1327,14 @@ func (app *App) markVolunteerReady(id, userID int) (*Volunteer, error) { if err != nil { return nil, err } - return app.getVolunteer(id) + v, err := app.getVolunteer(id) + if err != nil || v == nil { + return v, err + } + if v.AttendeeID != nil { + app.checkInAttendee(*v.AttendeeID, userID, 1) + } + return v, nil } func (app *App) confirmVolunteer(id int) (*Volunteer, error) { @@ -1142,23 +1359,34 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { var result []Volunteer for rows.Next() { var v Volunteer - var deptID sql.NullInt64 + var participantID, attendeeID, deptID sql.NullInt64 var isLead, ready, confirmed, emailConfirmed int - var confirmedAt, kioskCode sql.NullString + var confirmationToken, confirmedAt, kioskCode sql.NullString if err := rows.Scan( - &v.ID, &v.ParticipantID, - &v.Name, &v.Email, &v.Phone, &v.Pronouns, - &deptID, &isLead, &ready, &v.ReadyAt, + &v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, + &v.Email, &v.Phone, &v.Pronouns, &deptID, + &isLead, &ready, &v.ReadyAt, &confirmed, &confirmedAt, - &emailConfirmed, &kioskCode, &v.Note, + &emailConfirmed, &confirmationToken, &kioskCode, &v.Note, &v.CreatedAt, &v.UpdatedAt, &v.DeletedAt, ); 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 + } if deptID.Valid { id := int(deptID.Int64) v.DepartmentID = &id } + if confirmationToken.Valid { + v.ConfirmationToken = &confirmationToken.String + } if confirmedAt.Valid { v.ConfirmedAt = &confirmedAt.String } @@ -1176,7 +1404,7 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { func (app *App) getVolunteerByEmail(email string) (*Volunteer, error) { rows, err := queryVolunteers(app.db, - `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE LOWER(p.email) = LOWER(?) AND v.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 } @@ -1185,24 +1413,17 @@ func (app *App) getVolunteerByEmail(email string) (*Volunteer, error) { func (app *App) getVolunteerByConfirmationToken(token string) (*Volunteer, error) { rows, err := queryVolunteers(app.db, - `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE p.confirmation_token = ? AND v.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 } return &rows[0], nil } -func (app *App) confirmParticipantEmail(participantID int) error { +func (app *App) confirmVolunteerEmail(id int) error { _, err := app.db.Exec( - `UPDATE participants SET email_confirmed = 1, confirmation_token = NULL, updated_at = ? WHERE id = ?`, - now(), participantID) - return err -} - -func (app *App) setParticipantConfirmationToken(participantID int, token string) error { - _, err := app.db.Exec( - `UPDATE participants SET confirmation_token = ?, updated_at = ? WHERE id = ?`, - token, now(), participantID) + `UPDATE volunteers SET email_confirmed = 1, confirmation_token = NULL, updated_at = ? WHERE id = ?`, + now(), id) return err } @@ -1221,10 +1442,11 @@ func (app *App) assignKioskCode(id int, code string) error { return err } +// listVolunteersNeedingKioskCode returns email-confirmed volunteers without a kiosk code. func (app *App) listVolunteersNeedingKioskCode() ([]Volunteer, error) { return queryVolunteers(app.db, ` SELECT `+volunteerSelect+` `+volunteerFrom+` - WHERE p.email_confirmed = 1 AND v.kiosk_code IS NULL AND v.deleted_at IS NULL`) + WHERE v.email_confirmed = 1 AND v.kiosk_code IS NULL AND v.deleted_at IS NULL`) } func (app *App) generateVolunteerKioskCode() (string, error) { @@ -1436,7 +1658,7 @@ var errShiftFull = fmt.Errorf("shift is full") func (app *App) unassignShift(volunteerID, shiftID int) error { _, err := app.db.Exec( - `UPDATE volunteer_shifts SET deleted_at=?, updated_at=? WHERE volunteer_id=? AND shift_id=?`, + `UPDATE volunteer_shifts SET deleted_at = ?, updated_at = ? WHERE volunteer_id=? AND shift_id=?`, now(), now(), volunteerID, shiftID, ) return err diff --git a/db_test.go b/db_test.go index c9e8b68..0d453bb 100644 --- a/db_test.go +++ b/db_test.go @@ -197,8 +197,7 @@ func TestAssignAndUnassignShift(t *testing.T) { dept, _ := app.createDepartment(Department{Name: "Gate"}) deptID := dept.ID s, _ := app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2}) - p, _ := app.createParticipant(Participant{PreferredName: "Helena", Email: "helena@test.com"}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) + v, _ := app.createVolunteer(Volunteer{Name: "Helena", DepartmentID: &deptID}) if err := app.assignShift(v.ID, s.ID); err != nil { t.Fatal(err) @@ -222,8 +221,7 @@ func TestCheckShiftConflict(t *testing.T) { dept, _ := app.createDepartment(Department{Name: "Gate"}) deptID := dept.ID - p, _ := app.createParticipant(Participant{PreferredName: "Hermia", Email: "hermia@test.com"}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) + v, _ := app.createVolunteer(Volunteer{Name: "Hermia", DepartmentID: &deptID}) s1, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) s2, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"}) @@ -252,8 +250,7 @@ func TestCheckShiftConflictMidnight(t *testing.T) { dept, _ := app.createDepartment(Department{Name: "Sound"}) deptID := dept.ID - p, _ := app.createParticipant(Participant{PreferredName: "Lysander", Email: "lysander@test.com"}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) + v, _ := app.createVolunteer(Volunteer{Name: "Lysander", DepartmentID: &deptID}) // Night shift: 22:00-02:00 (spans midnight) night, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Night", Day: "2026-03-15", StartTime: "22:00", EndTime: "02:00"}) diff --git a/frontend/src/db.js b/frontend/src/db.js index 7e96bbc..a09f332 100644 --- a/frontend/src/db.js +++ b/frontend/src/db.js @@ -46,11 +46,6 @@ db.version(4).stores({ volunteers: 'id, name, department_id, checked_in, participant_id, deleted_at', }) -db.version(5).stores({ - volunteers: 'id, participant_id, department_id, deleted_at', - participants: 'id, email, preferred_name, email_confirmed, updated_at, deleted_at', -}) - export async function getLastSync() { const m = await db.meta.get('last_sync') return m?.value ?? '' diff --git a/handle_kiosk_test.go b/handle_kiosk_test.go index 2bac7cf..e385ad3 100644 --- a/handle_kiosk_test.go +++ b/handle_kiosk_test.go @@ -16,7 +16,7 @@ func setupKiosk(t *testing.T) (*App, *http.ServeMux, string) { // Create volunteer with a kiosk_code directly on the volunteer record p, _ := app.createParticipant(Participant{Email: "titania@test.com", PreferredName: "Titania"}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) + v, _ := app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: &p.ID, DepartmentID: &deptID}) token, _ := app.generateVolunteerKioskCode() app.assignKioskCode(v.ID, token) @@ -132,8 +132,7 @@ func TestKioskClaimFull(t *testing.T) { // Shift 2 has capacity 1. Fill it with another volunteer. dept, _ := app.createDepartment(Department{Name: "Build"}) deptID := dept.ID - otherP, _ := app.createParticipant(Participant{PreferredName: "Other", Email: "other@test.com"}) - other, _ := app.createVolunteer(Volunteer{ParticipantID: otherP.ID, DepartmentID: &deptID}) + other, _ := app.createVolunteer(Volunteer{Name: "Other", DepartmentID: &deptID}) app.assignShift(other.ID, 2) // fills the capacity-1 shift req := httptest.NewRequest("POST", "/api/v/"+token+"/shifts/2", nil) diff --git a/handle_shifts_test.go b/handle_shifts_test.go index 940164e..8c629b1 100644 --- a/handle_shifts_test.go +++ b/handle_shifts_test.go @@ -55,8 +55,7 @@ func TestShiftAssignVolunteer(t *testing.T) { dept, _ := app.createDepartment(Department{Name: "Gate"}) deptID := dept.ID app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) - p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com"}) - app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) + app.createVolunteer(Volunteer{Name: "Titania", DepartmentID: &deptID}) // Assign req := testAuthRequest("POST", "/api/shifts/1/volunteers", map[string]any{ @@ -87,8 +86,7 @@ func TestShiftAssignConflict(t *testing.T) { deptID := dept.ID app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"}) - p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com"}) - app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) + app.createVolunteer(Volunteer{Name: "Titania", DepartmentID: &deptID}) // Assign to first shift app.assignShift(1, 1) diff --git a/handle_signup.go b/handle_signup.go index 1f0fe2e..77a7c63 100644 --- a/handle_signup.go +++ b/handle_signup.go @@ -89,12 +89,17 @@ func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) { writeError(w, "internal error", http.StatusInternalServerError) return } - app.setParticipantConfirmationToken(participant.ID, confirmToken) vol := Volunteer{ - ParticipantID: participant.ID, - DepartmentID: body.DepartmentID, - Note: body.Note, + ParticipantID: &participant.ID, + Name: body.PreferredName, + PreferredName: body.PreferredName, + Email: body.Email, + Phone: body.Phone, + Pronouns: body.Pronouns, + DepartmentID: body.DepartmentID, + Note: body.Note, + ConfirmationToken: &confirmToken, } if _, err := app.createVolunteer(vol); err != nil { @@ -131,7 +136,7 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) { return } - if err := app.confirmParticipantEmail(vol.ParticipantID); err != nil { + if err := app.confirmVolunteerEmail(vol.ID); err != nil { writeError(w, "internal error", http.StatusInternalServerError) return } @@ -148,7 +153,7 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) { kioskLink := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), code) response["kiosk_link"] = kioskLink go func() { - if err := app.sendShiftSignupEmail(vol.Email, vol.Name, kioskLink); err != nil { + if err := app.sendShiftSignupEmail(vol.Email, vol.PreferredName, kioskLink); err != nil { log.Printf("shift signup email to %s failed: %v", vol.Email, err) } }() @@ -193,7 +198,7 @@ func (app *App) openShiftSignups() { // Email all email-confirmed volunteers that now have a kiosk code. confirmed, _ := queryVolunteers(app.db, ` SELECT `+volunteerSelect+` `+volunteerFrom+` - WHERE p.email_confirmed = 1 AND v.kiosk_code IS NOT NULL AND v.deleted_at IS NULL`) + WHERE v.email_confirmed = 1 AND v.kiosk_code IS NOT NULL AND v.deleted_at IS NULL`) baseURL := app.resolveBaseURL() sent := 0 @@ -202,7 +207,11 @@ func (app *App) openShiftSignups() { continue } kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *v.KioskCode) - if err := app.sendShiftSignupEmail(v.Email, v.Name, kioskLink); err == nil { + name := v.PreferredName + if name == "" { + name = v.Name + } + if err := app.sendShiftSignupEmail(v.Email, name, kioskLink); err == nil { sent++ } else { log.Printf("shift signup email to %s failed: %v", v.Email, err) diff --git a/handle_signup_test.go b/handle_signup_test.go index 62f59d1..2b63c16 100644 --- a/handle_signup_test.go +++ b/handle_signup_test.go @@ -58,27 +58,27 @@ func TestPublicSignup(t *testing.T) { if err != nil || vol == nil { t.Fatal("volunteer not created") } - if vol.Name != "Titania" { - t.Errorf("name = %q, want Titania", vol.Name) + if vol.PreferredName != "Titania" { + t.Errorf("preferred_name = %q, want Titania", vol.PreferredName) } if vol.Pronouns != "she/they" { t.Errorf("pronouns = %q, want she/they", vol.Pronouns) } + if vol.ConfirmationToken == nil || *vol.ConfirmationToken == "" { + t.Error("expected confirmation token to be set") + } if vol.EmailConfirmed { t.Error("should not be confirmed yet") } // Participant should be auto-created and linked - if vol.ParticipantID == 0 { + if vol.ParticipantID == nil { t.Fatal("expected participant to be linked") } - p, _ := app.getParticipant(vol.ParticipantID) + p, _ := app.getParticipant(*vol.ParticipantID) if p == nil { t.Fatal("linked participant not found") } - if p.ConfirmationToken == nil || *p.ConfirmationToken == "" { - t.Error("expected confirmation token on participant") - } if p.Email != "titania@example.com" { t.Errorf("participant email = %q, want titania@example.com", p.Email) } @@ -105,8 +105,8 @@ func TestPublicSignupAutoMatchParticipant(t *testing.T) { if vol == nil { t.Fatal("volunteer not created") } - if vol.ParticipantID == 0 || vol.ParticipantID != existing.ID { - t.Errorf("expected volunteer linked to existing participant %d, got %d", existing.ID, vol.ParticipantID) + if vol.ParticipantID == nil || *vol.ParticipantID != existing.ID { + t.Errorf("expected volunteer linked to existing participant %d, got %v", existing.ID, vol.ParticipantID) } } @@ -200,8 +200,12 @@ func TestConfirmEmail(t *testing.T) { mux := testMux(app) token := "abc123def456" - p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token}) - app.createVolunteer(Volunteer{ParticipantID: p.ID}) + app.createVolunteer(Volunteer{ + Name: "Titania", + PreferredName: "Titania", + Email: "titania@example.com", + ConfirmationToken: &token, + }) w := httptest.NewRecorder() mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) @@ -213,13 +217,12 @@ func TestConfirmEmail(t *testing.T) { t.Errorf("expected confirmed, got %v", result["status"]) } - // Verify participant is email confirmed + // Verify volunteer is confirmed vol, _ := app.getVolunteerByEmail("titania@example.com") if vol == nil || !vol.EmailConfirmed { - t.Error("volunteer should show email confirmed via participant") + t.Error("volunteer should be email confirmed") } - updatedP, _ := app.getParticipant(p.ID) - if updatedP.ConfirmationToken != nil { + if vol.ConfirmationToken != nil { t.Error("confirmation token should be cleared after confirmation") } } @@ -244,8 +247,12 @@ func TestConfirmEmailAlreadyConfirmed(t *testing.T) { mux := testMux(app) token := "abc123def456" - p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token}) - app.createVolunteer(Volunteer{ParticipantID: p.ID}) + app.createVolunteer(Volunteer{ + Name: "Titania", + PreferredName: "Titania", + Email: "titania@example.com", + ConfirmationToken: &token, + }) // Confirm first time w := httptest.NewRecorder() @@ -270,9 +277,15 @@ 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" + participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) token := "abc123def456" - participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token}) - app.createVolunteer(Volunteer{ParticipantID: participant.ID}) + app.createVolunteer(Volunteer{ + Name: "Titania", + PreferredName: "Titania", + Email: "titania@example.com", + ParticipantID: &participant.ID, + ConfirmationToken: &token, + }) w := httptest.NewRecorder() mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) @@ -314,10 +327,10 @@ func TestPublicSignupTicketNameDoesNotOverwritePreferredName(t *testing.T) { } vol, _ := app.getVolunteerByEmail("titania@example.com") - if vol == nil || vol.ParticipantID == 0 { + if vol == nil || vol.ParticipantID == nil { t.Fatal("volunteer/participant not created") } - p, _ := app.getParticipant(vol.ParticipantID) + p, _ := app.getParticipant(*vol.ParticipantID) if p == nil { t.Fatal("participant not found") } @@ -336,9 +349,15 @@ func TestConfirmEmailAssignsKioskCode(t *testing.T) { app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`) app.baseURL = "https://example.com" + participant, _ := app.createParticipant(Participant{PreferredName: "Titania", TicketName: "Titania Fairweather", Email: "titania@example.com"}) token := "abc123def456" - participant, _ := app.createParticipant(Participant{PreferredName: "Titania", TicketName: "Titania Fairweather", Email: "titania@example.com", ConfirmationToken: &token}) - app.createVolunteer(Volunteer{ParticipantID: participant.ID}) + app.createVolunteer(Volunteer{ + Name: "Titania", + PreferredName: "Titania", + Email: "titania@example.com", + ParticipantID: &participant.ID, + ConfirmationToken: &token, + }) w := httptest.NewRecorder() mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) diff --git a/handle_volunteers.go b/handle_volunteers.go index 5d086ad..0a9fec0 100644 --- a/handle_volunteers.go +++ b/handle_volunteers.go @@ -35,62 +35,54 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) { func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) { var body struct { - Name string `json:"name"` - TicketName string `json:"ticket_name"` - Email string `json:"email"` - DepartmentID *int `json:"department_id"` - IsLead bool `json:"is_lead"` - Note string `json:"note"` + Volunteer + TicketName string `json:"ticket_name"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, "invalid request", http.StatusBadRequest) return } - if body.Name == "" { + v := body.Volunteer + if v.Name == "" { writeError(w, "name is required", http.StatusBadRequest) return } - if body.Email == "" { + if v.Email == "" { writeError(w, "email is required", http.StatusBadRequest) return } claims := claimsFromContext(r) if claims.Role == "colead" { - if body.DepartmentID == nil || !inSlice(*body.DepartmentID, claims.DeptIDs) { + if v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) return } } - p, _ := app.getParticipantByEmail(body.Email) - if p == nil { - p, _ = app.createParticipant(Participant{PreferredName: body.Name, Email: body.Email, TicketName: body.TicketName}) - } else if body.TicketName != "" && p.TicketName == "" { - app.db.Exec(`UPDATE participants SET ticket_name = ?, updated_at = ? WHERE id = ?`, body.TicketName, now(), p.ID) - } - if p == nil { - writeError(w, "failed to create participant", http.StatusInternalServerError) - 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, TicketName: body.TicketName}) + } else if body.TicketName != "" && p.TicketName == "" { + app.db.Exec(`UPDATE participants SET ticket_name = ?, updated_at = ? WHERE id = ?`, body.TicketName, now(), p.ID) + } + if p != nil { + v.ParticipantID = &p.ID + } } confirmToken, err := generateConfirmationToken() if err != nil { writeError(w, "internal error", http.StatusInternalServerError) return } - app.setParticipantConfirmationToken(p.ID, confirmToken) - v := Volunteer{ - ParticipantID: p.ID, - DepartmentID: body.DepartmentID, - IsLead: body.IsLead, - Note: body.Note, - } + v.ConfirmationToken = &confirmToken created, err := app.createVolunteer(v) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } go func() { - if err := app.sendConfirmationEmail(body.Email, body.Name, confirmToken); err != nil { - log.Printf("confirmation email to %s failed: %v", body.Email, err) + if err := app.sendConfirmationEmail(v.Email, v.Name, confirmToken); err != nil { + log.Printf("confirmation email to %s failed: %v", v.Email, err) } }() w.WriteHeader(http.StatusCreated) @@ -117,15 +109,15 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) { writeError(w, "invalid id", http.StatusBadRequest) return } - var body struct { - DepartmentID *int `json:"department_id"` - IsLead bool `json:"is_lead"` - Note string `json:"note"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + var v Volunteer + if err := json.NewDecoder(r.Body).Decode(&v); err != nil { writeError(w, "invalid request", http.StatusBadRequest) return } + if v.Name == "" { + writeError(w, "name is required", http.StatusBadRequest) + return + } claims := claimsFromContext(r) if claims.Role == "colead" { existing, _ := app.getVolunteer(id) @@ -134,16 +126,12 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) { return } } - v := Volunteer{ - ID: id, - DepartmentID: body.DepartmentID, - IsLead: body.IsLead, - Note: body.Note, - } + v.ID = id if err := app.updateVolunteer(v); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } + if v.IsLead { app.confirmVolunteer(id) } diff --git a/handle_volunteers_test.go b/handle_volunteers_test.go index ab51b9b..e10815c 100644 --- a/handle_volunteers_test.go +++ b/handle_volunteers_test.go @@ -14,8 +14,10 @@ func TestConfirmVolunteer(t *testing.T) { dept, _ := app.createDepartment(Department{Name: "Gate"}) deptID := dept.ID - p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com", EmailConfirmed: true}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) + v, _ := app.createVolunteer(Volunteer{ + Name: "Titania", Email: "titania@test.com", + DepartmentID: &deptID, EmailConfirmed: true, + }) w := httptest.NewRecorder() mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) @@ -44,8 +46,7 @@ func TestConfirmVolunteerIdempotent(t *testing.T) { admin := testAdminUser(t, app) tok := testToken(t, app, admin) - p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com", EmailConfirmed: true}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID}) + v, _ := app.createVolunteer(Volunteer{Name: "Puck", Email: "puck@test.com", EmailConfirmed: true}) // Confirm twice — second should be a no-op, not an error. w := httptest.NewRecorder() @@ -69,8 +70,7 @@ func TestConfirmVolunteerRequiresRole(t *testing.T) { ticketing := testUserWithRole(t, app, "ticket_lead", "ticketing", nil) tok := testToken(t, app, ticketing) - p, _ := app.createParticipant(Participant{PreferredName: "Helena", EmailConfirmed: true}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID}) + v, _ := app.createVolunteer(Volunteer{Name: "Helena", EmailConfirmed: true}) w := httptest.NewRecorder() mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) @@ -86,13 +86,12 @@ func TestUpdateVolunteerDepartment(t *testing.T) { tok := testToken(t, app, admin) dept, _ := app.createDepartment(Department{Name: "Gate"}) - p, _ := app.createParticipant(Participant{PreferredName: "Hermia"}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID}) + v, _ := app.createVolunteer(Volunteer{Name: "Hermia"}) // Assign department via update. w := httptest.NewRecorder() mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{ - "department_id": dept.ID, + "name": "Hermia", "department_id": dept.ID, }, tok)) if w.Code != 200 { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) @@ -112,8 +111,10 @@ func TestUpdateVolunteerCoLeadAutoConfirms(t *testing.T) { dept, _ := app.createDepartment(Department{Name: "Build"}) deptID := dept.ID - p, _ := app.createParticipant(Participant{PreferredName: "Lysander", Email: "lys@test.com", EmailConfirmed: true}) - v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) + v, _ := app.createVolunteer(Volunteer{ + Name: "Lysander", Email: "lys@test.com", + DepartmentID: &deptID, EmailConfirmed: true, + }) // Verify not confirmed before update. got, _ := app.getVolunteer(v.ID) @@ -124,7 +125,7 @@ func TestUpdateVolunteerCoLeadAutoConfirms(t *testing.T) { // Update is_lead=true should auto-confirm. w := httptest.NewRecorder() mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{ - "department_id": deptID, "is_lead": true, + "name": "Lysander", "department_id": deptID, "is_lead": true, }, tok)) if w.Code != 200 { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())