From 7d56ef2f339f2a8d0c43dc474a8232dc9645fac7 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Fri, 6 Mar 2026 07:11:19 -0600 Subject: [PATCH 1/2] Moved properties from Volunteer to Participant. --- db.go | 422 +++++++++----------------------------- db_test.go | 9 +- frontend/src/db.js | 5 + handle_kiosk_test.go | 5 +- handle_shifts_test.go | 6 +- handle_signup.go | 25 +-- handle_signup_test.go | 65 +++--- handle_volunteers.go | 66 +++--- handle_volunteers_test.go | 25 ++- 9 files changed, 200 insertions(+), 428 deletions(-) diff --git a/db.go b/db.go index 514460e..bda44ed 100644 --- a/db.go +++ b/db.go @@ -86,21 +86,24 @@ 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, - 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 + 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 ); + 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, @@ -119,19 +122,23 @@ 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 '', - 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 + 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 ); CREATE UNIQUE INDEX IF NOT EXISTS idx_participants_email @@ -159,210 +166,9 @@ 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, @@ -420,40 +226,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"` - Email string `json:"email"` - PreferredName string `json:"preferred_name"` - TicketName string `json:"ticket_name"` - Phone string `json:"phone"` - Pronouns string `json:"pronouns"` + 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"` 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 { @@ -884,7 +690,7 @@ func (app *App) attendeeCounts() (total, checkedIn int, err error) { // --- Participants --- -const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, created_at, updated_at, deleted_at` +const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, email_confirmed, confirmation_token, created_at, updated_at, deleted_at` func (app *App) listParticipants(search, since string) ([]Participant, error) { var q string @@ -924,8 +730,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, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, - strings.ToLower(p.Email), p.PreferredName, p.TicketName, p.Phone, p.Pronouns, p.Note, now(), + `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(), ) if err != nil { return nil, err @@ -980,12 +786,19 @@ 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() @@ -1217,23 +1030,13 @@ 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), - COALESCE(NULLIF(p.email,''), v.email), - COALESCE(NULLIF(p.phone,''), v.phone), - COALESCE(NULLIF(p.pronouns,''), v.pronouns), +const volunteerSelect = `v.id, v.participant_id, + p.preferred_name, p.email, p.phone, p.pronouns, v.department_id, v.is_lead, v.ready, v.ready_at, v.confirmed, v.confirmed_at, - v.email_confirmed, v.confirmation_token, v.kiosk_code, v.note, + p.email_confirmed, v.kiosk_code, 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, email, phone, pronouns, department_id, is_lead, ready, ready_at, email_confirmed, confirmation_token, note, created_at, updated_at, deleted_at` +const volunteerFrom = `FROM volunteers v INNER JOIN participants p ON p.id = v.participant_id` func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) { q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1` @@ -1245,15 +1048,15 @@ func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volu q += ` AND v.deleted_at IS NULL` } if search != "" { - q += ` AND (v.name LIKE ? OR v.email LIKE ? OR p.preferred_name LIKE ? OR p.email LIKE ?)` + q += ` AND (p.preferred_name LIKE ? OR p.email LIKE ?)` s := "%" + search + "%" - args = append(args, s, s, s, s) + args = append(args, s, s) } if deptID != nil { q += ` AND v.department_id = ?` args = append(args, *deptID) } - q += ` ORDER BY COALESCE(NULLIF(p.preferred_name,''), v.name)` + q += ` ORDER BY p.preferred_name` return queryVolunteers(app.db, q, args...) } @@ -1266,15 +1069,6 @@ 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) @@ -1286,10 +1080,9 @@ func (app *App) getVolunteerByParticipantID(participantID int) (*Volunteer, erro func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { res, err := app.db.Exec( - `INSERT INTO volunteers (participant_id, attendee_id, name, preferred_name, 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(), + `INSERT INTO volunteers (participant_id, department_id, is_lead, note, updated_at) + VALUES (?, ?, ?, ?, ?)`, + v.ParticipantID, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), ) if err != nil { return nil, err @@ -1300,9 +1093,8 @@ func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { func (app *App) updateVolunteer(v Volunteer) error { _, err := app.db.Exec( - `UPDATE volunteers SET participant_id=?, attendee_id=?, name=?, preferred_name=?, email=?, phone=?, pronouns=?, department_id=?, is_lead=?, note=?, updated_at=? + `UPDATE volunteers SET 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 @@ -1315,8 +1107,6 @@ 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( @@ -1327,14 +1117,7 @@ func (app *App) markVolunteerReady(id, userID int) (*Volunteer, error) { if err != nil { return nil, err } - 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 + return app.getVolunteer(id) } func (app *App) confirmVolunteer(id int) (*Volunteer, error) { @@ -1359,34 +1142,23 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { var result []Volunteer for rows.Next() { var v Volunteer - var participantID, attendeeID, deptID sql.NullInt64 + var deptID sql.NullInt64 var isLead, ready, confirmed, emailConfirmed int - var confirmationToken, confirmedAt, kioskCode sql.NullString + var confirmedAt, kioskCode sql.NullString if err := rows.Scan( - &v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, - &v.Email, &v.Phone, &v.Pronouns, &deptID, - &isLead, &ready, &v.ReadyAt, + &v.ID, &v.ParticipantID, + &v.Name, &v.Email, &v.Phone, &v.Pronouns, + &deptID, &isLead, &ready, &v.ReadyAt, &confirmed, &confirmedAt, - &emailConfirmed, &confirmationToken, &kioskCode, &v.Note, + &emailConfirmed, &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 } @@ -1404,7 +1176,7 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { func (app *App) getVolunteerByEmail(email string) (*Volunteer, error) { rows, err := queryVolunteers(app.db, - `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE LOWER(v.email) = LOWER(?) AND v.deleted_at IS NULL LIMIT 1`, email) + `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE LOWER(p.email) = LOWER(?) AND v.deleted_at IS NULL LIMIT 1`, email) if err != nil || len(rows) == 0 { return nil, err } @@ -1413,17 +1185,24 @@ func (app *App) getVolunteerByEmail(email string) (*Volunteer, error) { func (app *App) getVolunteerByConfirmationToken(token string) (*Volunteer, error) { rows, err := queryVolunteers(app.db, - `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.confirmation_token = ? AND v.deleted_at IS NULL LIMIT 1`, token) + `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE p.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) confirmVolunteerEmail(id int) error { +func (app *App) confirmParticipantEmail(participantID int) error { _, err := app.db.Exec( - `UPDATE volunteers SET email_confirmed = 1, confirmation_token = NULL, updated_at = ? WHERE id = ?`, - now(), id) + `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) return err } @@ -1442,11 +1221,10 @@ 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 v.email_confirmed = 1 AND v.kiosk_code IS NULL AND v.deleted_at IS NULL`) + WHERE p.email_confirmed = 1 AND v.kiosk_code IS NULL AND v.deleted_at IS NULL`) } func (app *App) generateVolunteerKioskCode() (string, error) { @@ -1658,7 +1436,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 0d453bb..c9e8b68 100644 --- a/db_test.go +++ b/db_test.go @@ -197,7 +197,8 @@ 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}) - v, _ := app.createVolunteer(Volunteer{Name: "Helena", DepartmentID: &deptID}) + p, _ := app.createParticipant(Participant{PreferredName: "Helena", Email: "helena@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) if err := app.assignShift(v.ID, s.ID); err != nil { t.Fatal(err) @@ -221,7 +222,8 @@ func TestCheckShiftConflict(t *testing.T) { dept, _ := app.createDepartment(Department{Name: "Gate"}) deptID := dept.ID - v, _ := app.createVolunteer(Volunteer{Name: "Hermia", DepartmentID: &deptID}) + p, _ := app.createParticipant(Participant{PreferredName: "Hermia", Email: "hermia@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, 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"}) @@ -250,7 +252,8 @@ func TestCheckShiftConflictMidnight(t *testing.T) { dept, _ := app.createDepartment(Department{Name: "Sound"}) deptID := dept.ID - v, _ := app.createVolunteer(Volunteer{Name: "Lysander", DepartmentID: &deptID}) + p, _ := app.createParticipant(Participant{PreferredName: "Lysander", Email: "lysander@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, 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 a09f332..7e96bbc 100644 --- a/frontend/src/db.js +++ b/frontend/src/db.js @@ -46,6 +46,11 @@ 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 e385ad3..2bac7cf 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{Name: "Titania", ParticipantID: &p.ID, DepartmentID: &deptID}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) token, _ := app.generateVolunteerKioskCode() app.assignKioskCode(v.ID, token) @@ -132,7 +132,8 @@ func TestKioskClaimFull(t *testing.T) { // Shift 2 has capacity 1. Fill it with another volunteer. dept, _ := app.createDepartment(Department{Name: "Build"}) deptID := dept.ID - other, _ := app.createVolunteer(Volunteer{Name: "Other", DepartmentID: &deptID}) + otherP, _ := app.createParticipant(Participant{PreferredName: "Other", Email: "other@test.com"}) + other, _ := app.createVolunteer(Volunteer{ParticipantID: otherP.ID, 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 8c629b1..940164e 100644 --- a/handle_shifts_test.go +++ b/handle_shifts_test.go @@ -55,7 +55,8 @@ 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"}) - app.createVolunteer(Volunteer{Name: "Titania", DepartmentID: &deptID}) + p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com"}) + app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) // Assign req := testAuthRequest("POST", "/api/shifts/1/volunteers", map[string]any{ @@ -86,7 +87,8 @@ 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"}) - app.createVolunteer(Volunteer{Name: "Titania", DepartmentID: &deptID}) + p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com"}) + app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) // Assign to first shift app.assignShift(1, 1) diff --git a/handle_signup.go b/handle_signup.go index 77a7c63..1f0fe2e 100644 --- a/handle_signup.go +++ b/handle_signup.go @@ -89,17 +89,12 @@ 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, - Name: body.PreferredName, - PreferredName: body.PreferredName, - Email: body.Email, - Phone: body.Phone, - Pronouns: body.Pronouns, - DepartmentID: body.DepartmentID, - Note: body.Note, - ConfirmationToken: &confirmToken, + ParticipantID: participant.ID, + DepartmentID: body.DepartmentID, + Note: body.Note, } if _, err := app.createVolunteer(vol); err != nil { @@ -136,7 +131,7 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) { return } - if err := app.confirmVolunteerEmail(vol.ID); err != nil { + if err := app.confirmParticipantEmail(vol.ParticipantID); err != nil { writeError(w, "internal error", http.StatusInternalServerError) return } @@ -153,7 +148,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.PreferredName, kioskLink); err != nil { + if err := app.sendShiftSignupEmail(vol.Email, vol.Name, kioskLink); err != nil { log.Printf("shift signup email to %s failed: %v", vol.Email, err) } }() @@ -198,7 +193,7 @@ func (app *App) openShiftSignups() { // Email all email-confirmed volunteers that now have a kiosk code. confirmed, _ := queryVolunteers(app.db, ` SELECT `+volunteerSelect+` `+volunteerFrom+` - WHERE v.email_confirmed = 1 AND v.kiosk_code IS NOT NULL AND v.deleted_at IS NULL`) + WHERE p.email_confirmed = 1 AND v.kiosk_code IS NOT NULL AND v.deleted_at IS NULL`) baseURL := app.resolveBaseURL() sent := 0 @@ -207,11 +202,7 @@ func (app *App) openShiftSignups() { continue } kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *v.KioskCode) - name := v.PreferredName - if name == "" { - name = v.Name - } - if err := app.sendShiftSignupEmail(v.Email, name, kioskLink); err == nil { + if err := app.sendShiftSignupEmail(v.Email, v.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 2b63c16..62f59d1 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.PreferredName != "Titania" { - t.Errorf("preferred_name = %q, want Titania", vol.PreferredName) + if vol.Name != "Titania" { + t.Errorf("name = %q, want Titania", vol.Name) } 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 == nil { + if vol.ParticipantID == 0 { 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 == nil || *vol.ParticipantID != existing.ID { - t.Errorf("expected volunteer linked to existing participant %d, got %v", existing.ID, vol.ParticipantID) + if vol.ParticipantID == 0 || vol.ParticipantID != existing.ID { + t.Errorf("expected volunteer linked to existing participant %d, got %d", existing.ID, vol.ParticipantID) } } @@ -200,12 +200,8 @@ func TestConfirmEmail(t *testing.T) { mux := testMux(app) token := "abc123def456" - app.createVolunteer(Volunteer{ - Name: "Titania", - PreferredName: "Titania", - Email: "titania@example.com", - ConfirmationToken: &token, - }) + p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token}) + app.createVolunteer(Volunteer{ParticipantID: p.ID}) w := httptest.NewRecorder() mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) @@ -217,12 +213,13 @@ func TestConfirmEmail(t *testing.T) { t.Errorf("expected confirmed, got %v", result["status"]) } - // Verify volunteer is confirmed + // Verify participant is email confirmed vol, _ := app.getVolunteerByEmail("titania@example.com") if vol == nil || !vol.EmailConfirmed { - t.Error("volunteer should be email confirmed") + t.Error("volunteer should show email confirmed via participant") } - if vol.ConfirmationToken != nil { + updatedP, _ := app.getParticipant(p.ID) + if updatedP.ConfirmationToken != nil { t.Error("confirmation token should be cleared after confirmation") } } @@ -247,12 +244,8 @@ func TestConfirmEmailAlreadyConfirmed(t *testing.T) { mux := testMux(app) token := "abc123def456" - app.createVolunteer(Volunteer{ - Name: "Titania", - PreferredName: "Titania", - Email: "titania@example.com", - ConfirmationToken: &token, - }) + p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token}) + app.createVolunteer(Volunteer{ParticipantID: p.ID}) // Confirm first time w := httptest.NewRecorder() @@ -277,15 +270,9 @@ 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" - app.createVolunteer(Volunteer{ - Name: "Titania", - PreferredName: "Titania", - Email: "titania@example.com", - ParticipantID: &participant.ID, - ConfirmationToken: &token, - }) + participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token}) + app.createVolunteer(Volunteer{ParticipantID: participant.ID}) w := httptest.NewRecorder() mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) @@ -327,10 +314,10 @@ func TestPublicSignupTicketNameDoesNotOverwritePreferredName(t *testing.T) { } vol, _ := app.getVolunteerByEmail("titania@example.com") - if vol == nil || vol.ParticipantID == nil { + if vol == nil || vol.ParticipantID == 0 { t.Fatal("volunteer/participant not created") } - p, _ := app.getParticipant(*vol.ParticipantID) + p, _ := app.getParticipant(vol.ParticipantID) if p == nil { t.Fatal("participant not found") } @@ -349,15 +336,9 @@ 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" - app.createVolunteer(Volunteer{ - Name: "Titania", - PreferredName: "Titania", - Email: "titania@example.com", - ParticipantID: &participant.ID, - ConfirmationToken: &token, - }) + participant, _ := app.createParticipant(Participant{PreferredName: "Titania", TicketName: "Titania Fairweather", Email: "titania@example.com", ConfirmationToken: &token}) + app.createVolunteer(Volunteer{ParticipantID: participant.ID}) 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 0a9fec0..5d086ad 100644 --- a/handle_volunteers.go +++ b/handle_volunteers.go @@ -35,54 +35,62 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) { func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) { var body struct { - Volunteer - TicketName string `json:"ticket_name"` + 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"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, "invalid request", http.StatusBadRequest) return } - v := body.Volunteer - if v.Name == "" { + if body.Name == "" { writeError(w, "name is required", http.StatusBadRequest) return } - if v.Email == "" { + if body.Email == "" { writeError(w, "email is required", http.StatusBadRequest) return } claims := claimsFromContext(r) if claims.Role == "colead" { - if v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { + if body.DepartmentID == nil || !inSlice(*body.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, 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 - } + 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 } confirmToken, err := generateConfirmationToken() if err != nil { writeError(w, "internal error", http.StatusInternalServerError) return } - v.ConfirmationToken = &confirmToken + app.setParticipantConfirmationToken(p.ID, confirmToken) + v := Volunteer{ + ParticipantID: p.ID, + DepartmentID: body.DepartmentID, + IsLead: body.IsLead, + Note: body.Note, + } created, err := app.createVolunteer(v) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } go func() { - if err := app.sendConfirmationEmail(v.Email, v.Name, confirmToken); err != nil { - log.Printf("confirmation email to %s failed: %v", v.Email, err) + if err := app.sendConfirmationEmail(body.Email, body.Name, confirmToken); err != nil { + log.Printf("confirmation email to %s failed: %v", body.Email, err) } }() w.WriteHeader(http.StatusCreated) @@ -109,13 +117,13 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) { writeError(w, "invalid id", http.StatusBadRequest) return } - var v Volunteer - if err := json.NewDecoder(r.Body).Decode(&v); err != nil { - writeError(w, "invalid request", http.StatusBadRequest) - return + var body struct { + DepartmentID *int `json:"department_id"` + IsLead bool `json:"is_lead"` + Note string `json:"note"` } - if v.Name == "" { - writeError(w, "name is required", http.StatusBadRequest) + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, "invalid request", http.StatusBadRequest) return } claims := claimsFromContext(r) @@ -126,12 +134,16 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) { return } } - v.ID = id + v := Volunteer{ + ID: id, + DepartmentID: body.DepartmentID, + IsLead: body.IsLead, + Note: body.Note, + } 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 e10815c..ab51b9b 100644 --- a/handle_volunteers_test.go +++ b/handle_volunteers_test.go @@ -14,10 +14,8 @@ func TestConfirmVolunteer(t *testing.T) { dept, _ := app.createDepartment(Department{Name: "Gate"}) deptID := dept.ID - v, _ := app.createVolunteer(Volunteer{ - Name: "Titania", Email: "titania@test.com", - DepartmentID: &deptID, EmailConfirmed: true, - }) + p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com", EmailConfirmed: true}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) w := httptest.NewRecorder() mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) @@ -46,7 +44,8 @@ func TestConfirmVolunteerIdempotent(t *testing.T) { admin := testAdminUser(t, app) tok := testToken(t, app, admin) - v, _ := app.createVolunteer(Volunteer{Name: "Puck", Email: "puck@test.com", EmailConfirmed: true}) + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com", EmailConfirmed: true}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID}) // Confirm twice — second should be a no-op, not an error. w := httptest.NewRecorder() @@ -70,7 +69,8 @@ func TestConfirmVolunteerRequiresRole(t *testing.T) { ticketing := testUserWithRole(t, app, "ticket_lead", "ticketing", nil) tok := testToken(t, app, ticketing) - v, _ := app.createVolunteer(Volunteer{Name: "Helena", EmailConfirmed: true}) + p, _ := app.createParticipant(Participant{PreferredName: "Helena", EmailConfirmed: true}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID}) w := httptest.NewRecorder() mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) @@ -86,12 +86,13 @@ func TestUpdateVolunteerDepartment(t *testing.T) { tok := testToken(t, app, admin) dept, _ := app.createDepartment(Department{Name: "Gate"}) - v, _ := app.createVolunteer(Volunteer{Name: "Hermia"}) + p, _ := app.createParticipant(Participant{PreferredName: "Hermia"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID}) // Assign department via update. w := httptest.NewRecorder() mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{ - "name": "Hermia", "department_id": dept.ID, + "department_id": dept.ID, }, tok)) if w.Code != 200 { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) @@ -111,10 +112,8 @@ func TestUpdateVolunteerCoLeadAutoConfirms(t *testing.T) { dept, _ := app.createDepartment(Department{Name: "Build"}) deptID := dept.ID - v, _ := app.createVolunteer(Volunteer{ - Name: "Lysander", Email: "lys@test.com", - DepartmentID: &deptID, EmailConfirmed: true, - }) + p, _ := app.createParticipant(Participant{PreferredName: "Lysander", Email: "lys@test.com", EmailConfirmed: true}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) // Verify not confirmed before update. got, _ := app.getVolunteer(v.ID) @@ -125,7 +124,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{ - "name": "Lysander", "department_id": deptID, "is_lead": true, + "department_id": deptID, "is_lead": true, }, tok)) if w.Code != 200 { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) From e640bf8bedbddd09341c79f381a4d4a268d00e15 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 10 Mar 2026 13:01:35 -0500 Subject: [PATCH 2/2] Removed `v` prefix from git tag recipes --- Makefile | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index 30bca30..a8de0d3 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) | 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) +MAJOR := $(shell echo $(LAST_TAG) | cut -d. -f1) +MINOR := $(shell echo $(LAST_TAG) | cut -d. -f2) +PATCH := $(shell echo $(LAST_TAG) | 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 v$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1))) - @echo "Tagged v$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))" + git tag $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1))) + @echo "Tagged $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))" minor: - git tag v$(MAJOR).$(shell echo $$(($(MINOR)+1))).0 - @echo "Tagged v$(MAJOR).$(shell echo $$(($(MINOR)+1))).0" + git tag $(MAJOR).$(shell echo $$(($(MINOR)+1))).0 + @echo "Tagged $(MAJOR).$(shell echo $$(($(MINOR)+1))).0" major: - git tag v$(shell echo $$(($(MAJOR)+1))).0.0 - @echo "Tagged v$(shell echo $$(($(MAJOR)+1))).0.0" + git tag $(shell echo $$(($(MAJOR)+1))).0.0 + @echo "Tagged $(shell echo $$(($(MAJOR)+1))).0.0"