Moved properties from Volunteer to Participant.

This commit is contained in:
Pen Anderson 2026-03-06 07:11:19 -06:00
parent fcf5bf1f34
commit 7d56ef2f33
9 changed files with 200 additions and 428 deletions

344
db.go
View file

@ -87,20 +87,23 @@ func migrate(db *sql.DB) error {
CREATE TABLE IF NOT EXISTS volunteers ( CREATE TABLE IF NOT EXISTS volunteers (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
attendee_id INTEGER REFERENCES attendees(id) ON DELETE SET NULL, participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
email TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
department_id INTEGER REFERENCES departments(id) ON DELETE SET NULL, department_id INTEGER REFERENCES departments(id) ON DELETE SET NULL,
is_lead INTEGER NOT NULL DEFAULT 0, is_lead INTEGER NOT NULL DEFAULT 0,
ready INTEGER NOT NULL DEFAULT 0, ready INTEGER NOT NULL DEFAULT 0,
ready_at TEXT, ready_at TEXT,
confirmed INTEGER NOT NULL DEFAULT 0,
confirmed_at TEXT,
kiosk_code TEXT,
note TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT 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 ( CREATE TABLE IF NOT EXISTS shifts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
department_id INTEGER NOT NULL REFERENCES departments(id) ON DELETE CASCADE, department_id INTEGER NOT NULL REFERENCES departments(id) ON DELETE CASCADE,
@ -119,6 +122,7 @@ func migrate(db *sql.DB) error {
shift_id INTEGER NOT NULL REFERENCES shifts(id) ON DELETE CASCADE, shift_id INTEGER NOT NULL REFERENCES shifts(id) ON DELETE CASCADE,
confirmed INTEGER NOT NULL DEFAULT 1, confirmed INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
PRIMARY KEY (volunteer_id, shift_id) PRIMARY KEY (volunteer_id, shift_id)
); );
@ -126,9 +130,12 @@ func migrate(db *sql.DB) error {
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '',
preferred_name TEXT NOT NULL DEFAULT '', preferred_name TEXT NOT NULL DEFAULT '',
ticket_name TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '', phone TEXT NOT NULL DEFAULT '',
pronouns TEXT NOT NULL DEFAULT '', pronouns TEXT NOT NULL DEFAULT '',
note 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')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT deleted_at TEXT
@ -159,209 +166,8 @@ func migrate(db *sql.DB) error {
if err != nil { if err != nil {
return err 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 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, &notNull, &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, &notNull, &dflt, &pk)
if name == oldName {
found = true
}
}
rows.Close()
if found {
db.Exec(`ALTER TABLE "` + table + `" RENAME COLUMN "` + oldName + `" TO "` + newName + `"`)
}
}
// --- Types --- // --- Types ---
@ -421,26 +227,24 @@ type Department struct {
type Volunteer struct { type Volunteer struct {
ID int `json:"id"` ID int `json:"id"`
ParticipantID *int `json:"participant_id,omitempty"` ParticipantID int `json:"participant_id"`
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"` DepartmentID *int `json:"department_id,omitempty"`
IsLead bool `json:"is_lead"` IsLead bool `json:"is_lead"`
Ready bool `json:"ready"` Ready bool `json:"ready"`
ReadyAt *string `json:"ready_at,omitempty"` ReadyAt *string `json:"ready_at,omitempty"`
Confirmed bool `json:"confirmed"` Confirmed bool `json:"confirmed"`
ConfirmedAt *string `json:"confirmed_at,omitempty"` ConfirmedAt *string `json:"confirmed_at,omitempty"`
EmailConfirmed bool `json:"email_confirmed"`
ConfirmationToken *string `json:"-"`
KioskCode *string `json:"kiosk_code,omitempty"` KioskCode *string `json:"kiosk_code,omitempty"`
Note string `json:"note"` Note string `json:"note"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
DeletedAt *string `json:"deleted_at,omitempty"` 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 { type Participant struct {
@ -451,6 +255,8 @@ type Participant struct {
Phone string `json:"phone"` Phone string `json:"phone"`
Pronouns string `json:"pronouns"` Pronouns string `json:"pronouns"`
Note string `json:"note"` Note string `json:"note"`
EmailConfirmed bool `json:"email_confirmed"`
ConfirmationToken *string `json:"-"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
DeletedAt *string `json:"deleted_at,omitempty"` DeletedAt *string `json:"deleted_at,omitempty"`
@ -884,7 +690,7 @@ func (app *App) attendeeCounts() (total, checkedIn int, err error) {
// --- Participants --- // --- 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) { func (app *App) listParticipants(search, since string) ([]Participant, error) {
var q string var q string
@ -924,8 +730,8 @@ func (app *App) getParticipantByEmail(email string) (*Participant, error) {
func (app *App) createParticipant(p Participant) (*Participant, error) { func (app *App) createParticipant(p Participant) (*Participant, error) {
res, err := app.db.Exec( res, err := app.db.Exec(
`INSERT INTO participants (email, preferred_name, ticket_name, phone, pronouns, note, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, `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, now(), strings.ToLower(p.Email), p.PreferredName, p.TicketName, p.Phone, p.Pronouns, p.Note, boolInt(p.EmailConfirmed), p.ConfirmationToken, now(),
) )
if err != nil { if err != nil {
return nil, err return nil, err
@ -980,12 +786,19 @@ func queryParticipants(db *sql.DB, q string, args ...any) ([]Participant, error)
var result []Participant var result []Participant
for rows.Next() { for rows.Next() {
var p Participant var p Participant
var emailConfirmed int
var confirmationToken sql.NullString
if err := rows.Scan( if err := rows.Scan(
&p.ID, &p.Email, &p.PreferredName, &p.TicketName, &p.Phone, &p.Pronouns, &p.Note, &p.ID, &p.Email, &p.PreferredName, &p.TicketName, &p.Phone, &p.Pronouns, &p.Note,
&emailConfirmed, &confirmationToken,
&p.CreatedAt, &p.UpdatedAt, &p.DeletedAt, &p.CreatedAt, &p.UpdatedAt, &p.DeletedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
p.EmailConfirmed = emailConfirmed == 1
if confirmationToken.Valid {
p.ConfirmationToken = &confirmationToken.String
}
result = append(result, p) result = append(result, p)
} }
return result, rows.Err() return result, rows.Err()
@ -1217,23 +1030,13 @@ func queryDepartments(db *sql.DB, q string, args ...any) ([]Department, error) {
// --- Volunteers --- // --- Volunteers ---
// volunteerSelect / volunteerFrom are used together for all volunteer queries. const volunteerSelect = `v.id, v.participant_id,
// Personal fields (name, email, phone, pronouns) come from the joined participant when available, p.preferred_name, p.email, p.phone, p.pronouns,
// 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.department_id, v.is_lead, v.ready, v.ready_at,
v.confirmed, v.confirmed_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` v.created_at, v.updated_at, v.deleted_at`
const volunteerFrom = `FROM volunteers v LEFT JOIN participants p ON p.id = v.participant_id` const volunteerFrom = `FROM volunteers v INNER 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) { func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) {
q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1` 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` q += ` AND v.deleted_at IS NULL`
} }
if search != "" { 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 + "%" s := "%" + search + "%"
args = append(args, s, s, s, s) args = append(args, s, s)
} }
if deptID != nil { if deptID != nil {
q += ` AND v.department_id = ?` q += ` AND v.department_id = ?`
args = append(args, *deptID) 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...) return queryVolunteers(app.db, q, args...)
} }
@ -1266,15 +1069,6 @@ func (app *App) getVolunteer(id int) (*Volunteer, error) {
return &rows[0], nil 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) { func (app *App) getVolunteerByParticipantID(participantID int) (*Volunteer, error) {
rows, err := queryVolunteers(app.db, rows, err := queryVolunteers(app.db,
`SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.participant_id = ? AND v.deleted_at IS NULL LIMIT 1`, participantID) `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) { func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) {
res, err := app.db.Exec( 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) `INSERT INTO volunteers (participant_id, department_id, is_lead, note, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?)`,
v.ParticipantID, v.AttendeeID, v.Name, v.PreferredName, v.Email, v.Phone, v.Pronouns, v.ParticipantID, v.DepartmentID, boolInt(v.IsLead), v.Note, now(),
v.DepartmentID, boolInt(v.IsLead), boolInt(v.EmailConfirmed), v.ConfirmationToken, v.Note, now(),
) )
if err != nil { if err != nil {
return nil, err return nil, err
@ -1300,9 +1093,8 @@ func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) {
func (app *App) updateVolunteer(v Volunteer) error { func (app *App) updateVolunteer(v Volunteer) error {
_, err := app.db.Exec( _, 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`, 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, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID,
) )
return err return err
@ -1315,8 +1107,6 @@ func (app *App) deleteVolunteer(id int) error {
return err 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) { func (app *App) markVolunteerReady(id, userID int) (*Volunteer, error) {
t := now() t := now()
_, err := app.db.Exec( _, err := app.db.Exec(
@ -1327,14 +1117,7 @@ func (app *App) markVolunteerReady(id, userID int) (*Volunteer, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
v, err := app.getVolunteer(id) return 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) { 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 var result []Volunteer
for rows.Next() { for rows.Next() {
var v Volunteer var v Volunteer
var participantID, attendeeID, deptID sql.NullInt64 var deptID sql.NullInt64
var isLead, ready, confirmed, emailConfirmed int var isLead, ready, confirmed, emailConfirmed int
var confirmationToken, confirmedAt, kioskCode sql.NullString var confirmedAt, kioskCode sql.NullString
if err := rows.Scan( if err := rows.Scan(
&v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.ID, &v.ParticipantID,
&v.Email, &v.Phone, &v.Pronouns, &deptID, &v.Name, &v.Email, &v.Phone, &v.Pronouns,
&isLead, &ready, &v.ReadyAt, &deptID, &isLead, &ready, &v.ReadyAt,
&confirmed, &confirmedAt, &confirmed, &confirmedAt,
&emailConfirmed, &confirmationToken, &kioskCode, &v.Note, &emailConfirmed, &kioskCode, &v.Note,
&v.CreatedAt, &v.UpdatedAt, &v.DeletedAt, &v.CreatedAt, &v.UpdatedAt, &v.DeletedAt,
); err != nil { ); err != nil {
return nil, err 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 { if deptID.Valid {
id := int(deptID.Int64) id := int(deptID.Int64)
v.DepartmentID = &id v.DepartmentID = &id
} }
if confirmationToken.Valid {
v.ConfirmationToken = &confirmationToken.String
}
if confirmedAt.Valid { if confirmedAt.Valid {
v.ConfirmedAt = &confirmedAt.String 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) { func (app *App) getVolunteerByEmail(email string) (*Volunteer, error) {
rows, err := queryVolunteers(app.db, 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 { if err != nil || len(rows) == 0 {
return nil, err return nil, err
} }
@ -1413,17 +1185,24 @@ func (app *App) getVolunteerByEmail(email string) (*Volunteer, error) {
func (app *App) getVolunteerByConfirmationToken(token string) (*Volunteer, error) { func (app *App) getVolunteerByConfirmationToken(token string) (*Volunteer, error) {
rows, err := queryVolunteers(app.db, 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 { if err != nil || len(rows) == 0 {
return nil, err return nil, err
} }
return &rows[0], nil return &rows[0], nil
} }
func (app *App) confirmVolunteerEmail(id int) error { func (app *App) confirmParticipantEmail(participantID int) error {
_, err := app.db.Exec( _, err := app.db.Exec(
`UPDATE volunteers SET email_confirmed = 1, confirmation_token = NULL, updated_at = ? WHERE id = ?`, `UPDATE participants SET email_confirmed = 1, confirmation_token = NULL, updated_at = ? WHERE id = ?`,
now(), 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 return err
} }
@ -1442,11 +1221,10 @@ func (app *App) assignKioskCode(id int, code string) error {
return err return err
} }
// listVolunteersNeedingKioskCode returns email-confirmed volunteers without a kiosk code.
func (app *App) listVolunteersNeedingKioskCode() ([]Volunteer, error) { func (app *App) listVolunteersNeedingKioskCode() ([]Volunteer, error) {
return queryVolunteers(app.db, ` return queryVolunteers(app.db, `
SELECT `+volunteerSelect+` `+volunteerFrom+` 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) { func (app *App) generateVolunteerKioskCode() (string, error) {

View file

@ -197,7 +197,8 @@ func TestAssignAndUnassignShift(t *testing.T) {
dept, _ := app.createDepartment(Department{Name: "Gate"}) dept, _ := app.createDepartment(Department{Name: "Gate"})
deptID := dept.ID deptID := dept.ID
s, _ := app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2}) 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 { if err := app.assignShift(v.ID, s.ID); err != nil {
t.Fatal(err) t.Fatal(err)
@ -221,7 +222,8 @@ func TestCheckShiftConflict(t *testing.T) {
dept, _ := app.createDepartment(Department{Name: "Gate"}) dept, _ := app.createDepartment(Department{Name: "Gate"})
deptID := dept.ID 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"}) 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"}) 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"}) dept, _ := app.createDepartment(Department{Name: "Sound"})
deptID := dept.ID 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 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"}) night, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Night", Day: "2026-03-15", StartTime: "22:00", EndTime: "02:00"})

View file

@ -46,6 +46,11 @@ db.version(4).stores({
volunteers: 'id, name, department_id, checked_in, participant_id, deleted_at', 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() { export async function getLastSync() {
const m = await db.meta.get('last_sync') const m = await db.meta.get('last_sync')
return m?.value ?? '' return m?.value ?? ''

View file

@ -16,7 +16,7 @@ func setupKiosk(t *testing.T) (*App, *http.ServeMux, string) {
// Create volunteer with a kiosk_code directly on the volunteer record // Create volunteer with a kiosk_code directly on the volunteer record
p, _ := app.createParticipant(Participant{Email: "titania@test.com", PreferredName: "Titania"}) 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() token, _ := app.generateVolunteerKioskCode()
app.assignKioskCode(v.ID, token) app.assignKioskCode(v.ID, token)
@ -132,7 +132,8 @@ func TestKioskClaimFull(t *testing.T) {
// Shift 2 has capacity 1. Fill it with another volunteer. // Shift 2 has capacity 1. Fill it with another volunteer.
dept, _ := app.createDepartment(Department{Name: "Build"}) dept, _ := app.createDepartment(Department{Name: "Build"})
deptID := dept.ID 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 app.assignShift(other.ID, 2) // fills the capacity-1 shift
req := httptest.NewRequest("POST", "/api/v/"+token+"/shifts/2", nil) req := httptest.NewRequest("POST", "/api/v/"+token+"/shifts/2", nil)

View file

@ -55,7 +55,8 @@ func TestShiftAssignVolunteer(t *testing.T) {
dept, _ := app.createDepartment(Department{Name: "Gate"}) dept, _ := app.createDepartment(Department{Name: "Gate"})
deptID := dept.ID 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: "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 // Assign
req := testAuthRequest("POST", "/api/shifts/1/volunteers", map[string]any{ req := testAuthRequest("POST", "/api/shifts/1/volunteers", map[string]any{
@ -86,7 +87,8 @@ func TestShiftAssignConflict(t *testing.T) {
deptID := dept.ID 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: "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.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 // Assign to first shift
app.assignShift(1, 1) app.assignShift(1, 1)

View file

@ -89,17 +89,12 @@ func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) {
writeError(w, "internal error", http.StatusInternalServerError) writeError(w, "internal error", http.StatusInternalServerError)
return return
} }
app.setParticipantConfirmationToken(participant.ID, confirmToken)
vol := Volunteer{ vol := Volunteer{
ParticipantID: &participant.ID, ParticipantID: participant.ID,
Name: body.PreferredName,
PreferredName: body.PreferredName,
Email: body.Email,
Phone: body.Phone,
Pronouns: body.Pronouns,
DepartmentID: body.DepartmentID, DepartmentID: body.DepartmentID,
Note: body.Note, Note: body.Note,
ConfirmationToken: &confirmToken,
} }
if _, err := app.createVolunteer(vol); err != nil { if _, err := app.createVolunteer(vol); err != nil {
@ -136,7 +131,7 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := app.confirmVolunteerEmail(vol.ID); err != nil { if err := app.confirmParticipantEmail(vol.ParticipantID); err != nil {
writeError(w, "internal error", http.StatusInternalServerError) writeError(w, "internal error", http.StatusInternalServerError)
return return
} }
@ -153,7 +148,7 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) {
kioskLink := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), code) kioskLink := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), code)
response["kiosk_link"] = kioskLink response["kiosk_link"] = kioskLink
go func() { 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) 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. // Email all email-confirmed volunteers that now have a kiosk code.
confirmed, _ := queryVolunteers(app.db, ` confirmed, _ := queryVolunteers(app.db, `
SELECT `+volunteerSelect+` `+volunteerFrom+` 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() baseURL := app.resolveBaseURL()
sent := 0 sent := 0
@ -207,11 +202,7 @@ func (app *App) openShiftSignups() {
continue continue
} }
kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *v.KioskCode) kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *v.KioskCode)
name := v.PreferredName if err := app.sendShiftSignupEmail(v.Email, v.Name, kioskLink); err == nil {
if name == "" {
name = v.Name
}
if err := app.sendShiftSignupEmail(v.Email, name, kioskLink); err == nil {
sent++ sent++
} else { } else {
log.Printf("shift signup email to %s failed: %v", v.Email, err) log.Printf("shift signup email to %s failed: %v", v.Email, err)

View file

@ -58,27 +58,27 @@ func TestPublicSignup(t *testing.T) {
if err != nil || vol == nil { if err != nil || vol == nil {
t.Fatal("volunteer not created") t.Fatal("volunteer not created")
} }
if vol.PreferredName != "Titania" { if vol.Name != "Titania" {
t.Errorf("preferred_name = %q, want Titania", vol.PreferredName) t.Errorf("name = %q, want Titania", vol.Name)
} }
if vol.Pronouns != "she/they" { if vol.Pronouns != "she/they" {
t.Errorf("pronouns = %q, want she/they", vol.Pronouns) 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 { if vol.EmailConfirmed {
t.Error("should not be confirmed yet") t.Error("should not be confirmed yet")
} }
// Participant should be auto-created and linked // Participant should be auto-created and linked
if vol.ParticipantID == nil { if vol.ParticipantID == 0 {
t.Fatal("expected participant to be linked") t.Fatal("expected participant to be linked")
} }
p, _ := app.getParticipant(*vol.ParticipantID) p, _ := app.getParticipant(vol.ParticipantID)
if p == nil { if p == nil {
t.Fatal("linked participant not found") 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" { if p.Email != "titania@example.com" {
t.Errorf("participant email = %q, want titania@example.com", p.Email) t.Errorf("participant email = %q, want titania@example.com", p.Email)
} }
@ -105,8 +105,8 @@ func TestPublicSignupAutoMatchParticipant(t *testing.T) {
if vol == nil { if vol == nil {
t.Fatal("volunteer not created") t.Fatal("volunteer not created")
} }
if vol.ParticipantID == nil || *vol.ParticipantID != existing.ID { if vol.ParticipantID == 0 || vol.ParticipantID != existing.ID {
t.Errorf("expected volunteer linked to existing participant %d, got %v", existing.ID, vol.ParticipantID) 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) mux := testMux(app)
token := "abc123def456" token := "abc123def456"
app.createVolunteer(Volunteer{ p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token})
Name: "Titania", app.createVolunteer(Volunteer{ParticipantID: p.ID})
PreferredName: "Titania",
Email: "titania@example.com",
ConfirmationToken: &token,
})
w := httptest.NewRecorder() w := httptest.NewRecorder()
mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) 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"]) t.Errorf("expected confirmed, got %v", result["status"])
} }
// Verify volunteer is confirmed // Verify participant is email confirmed
vol, _ := app.getVolunteerByEmail("titania@example.com") vol, _ := app.getVolunteerByEmail("titania@example.com")
if vol == nil || !vol.EmailConfirmed { 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") t.Error("confirmation token should be cleared after confirmation")
} }
} }
@ -247,12 +244,8 @@ func TestConfirmEmailAlreadyConfirmed(t *testing.T) {
mux := testMux(app) mux := testMux(app)
token := "abc123def456" token := "abc123def456"
app.createVolunteer(Volunteer{ p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token})
Name: "Titania", app.createVolunteer(Volunteer{ParticipantID: p.ID})
PreferredName: "Titania",
Email: "titania@example.com",
ConfirmationToken: &token,
})
// Confirm first time // Confirm first time
w := httptest.NewRecorder() 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.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`)
app.baseURL = "https://example.com" app.baseURL = "https://example.com"
participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"})
token := "abc123def456" token := "abc123def456"
app.createVolunteer(Volunteer{ participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token})
Name: "Titania", app.createVolunteer(Volunteer{ParticipantID: participant.ID})
PreferredName: "Titania",
Email: "titania@example.com",
ParticipantID: &participant.ID,
ConfirmationToken: &token,
})
w := httptest.NewRecorder() w := httptest.NewRecorder()
mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) 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") vol, _ := app.getVolunteerByEmail("titania@example.com")
if vol == nil || vol.ParticipantID == nil { if vol == nil || vol.ParticipantID == 0 {
t.Fatal("volunteer/participant not created") t.Fatal("volunteer/participant not created")
} }
p, _ := app.getParticipant(*vol.ParticipantID) p, _ := app.getParticipant(vol.ParticipantID)
if p == nil { if p == nil {
t.Fatal("participant not found") 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.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`)
app.baseURL = "https://example.com" app.baseURL = "https://example.com"
participant, _ := app.createParticipant(Participant{PreferredName: "Titania", TicketName: "Titania Fairweather", Email: "titania@example.com"})
token := "abc123def456" token := "abc123def456"
app.createVolunteer(Volunteer{ participant, _ := app.createParticipant(Participant{PreferredName: "Titania", TicketName: "Titania Fairweather", Email: "titania@example.com", ConfirmationToken: &token})
Name: "Titania", app.createVolunteer(Volunteer{ParticipantID: participant.ID})
PreferredName: "Titania",
Email: "titania@example.com",
ParticipantID: &participant.ID,
ConfirmationToken: &token,
})
w := httptest.NewRecorder() w := httptest.NewRecorder()
mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token}))

View file

@ -35,54 +35,62 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) {
func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) { func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) {
var body struct { var body struct {
Volunteer Name string `json:"name"`
TicketName string `json:"ticket_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 { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request", http.StatusBadRequest) writeError(w, "invalid request", http.StatusBadRequest)
return return
} }
v := body.Volunteer if body.Name == "" {
if v.Name == "" {
writeError(w, "name is required", http.StatusBadRequest) writeError(w, "name is required", http.StatusBadRequest)
return return
} }
if v.Email == "" { if body.Email == "" {
writeError(w, "email is required", http.StatusBadRequest) writeError(w, "email is required", http.StatusBadRequest)
return return
} }
claims := claimsFromContext(r) claims := claimsFromContext(r)
if claims.Role == "colead" { 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) writeError(w, "forbidden: outside your department", http.StatusForbidden)
return return
} }
} }
if v.Email != "" && v.ParticipantID == nil { p, _ := app.getParticipantByEmail(body.Email)
p, _ := app.getParticipantByEmail(v.Email)
if p == nil { if p == nil {
p, _ = app.createParticipant(Participant{PreferredName: v.Name, Email: v.Email, TicketName: body.TicketName}) p, _ = app.createParticipant(Participant{PreferredName: body.Name, Email: body.Email, TicketName: body.TicketName})
} else if body.TicketName != "" && p.TicketName == "" { } else if body.TicketName != "" && p.TicketName == "" {
app.db.Exec(`UPDATE participants SET ticket_name = ?, updated_at = ? WHERE id = ?`, body.TicketName, now(), p.ID) app.db.Exec(`UPDATE participants SET ticket_name = ?, updated_at = ? WHERE id = ?`, body.TicketName, now(), p.ID)
} }
if p != nil { if p == nil {
v.ParticipantID = &p.ID writeError(w, "failed to create participant", http.StatusInternalServerError)
} return
} }
confirmToken, err := generateConfirmationToken() confirmToken, err := generateConfirmationToken()
if err != nil { if err != nil {
writeError(w, "internal error", http.StatusInternalServerError) writeError(w, "internal error", http.StatusInternalServerError)
return 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) created, err := app.createVolunteer(v)
if err != nil { if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
} }
go func() { go func() {
if err := app.sendConfirmationEmail(v.Email, v.Name, confirmToken); err != nil { if err := app.sendConfirmationEmail(body.Email, body.Name, confirmToken); err != nil {
log.Printf("confirmation email to %s failed: %v", v.Email, err) log.Printf("confirmation email to %s failed: %v", body.Email, err)
} }
}() }()
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
@ -109,13 +117,13 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) {
writeError(w, "invalid id", http.StatusBadRequest) writeError(w, "invalid id", http.StatusBadRequest)
return return
} }
var v Volunteer var body struct {
if err := json.NewDecoder(r.Body).Decode(&v); err != nil { DepartmentID *int `json:"department_id"`
writeError(w, "invalid request", http.StatusBadRequest) IsLead bool `json:"is_lead"`
return Note string `json:"note"`
} }
if v.Name == "" { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "name is required", http.StatusBadRequest) writeError(w, "invalid request", http.StatusBadRequest)
return return
} }
claims := claimsFromContext(r) claims := claimsFromContext(r)
@ -126,12 +134,16 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
v.ID = id v := Volunteer{
ID: id,
DepartmentID: body.DepartmentID,
IsLead: body.IsLead,
Note: body.Note,
}
if err := app.updateVolunteer(v); err != nil { if err := app.updateVolunteer(v); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
} }
if v.IsLead { if v.IsLead {
app.confirmVolunteer(id) app.confirmVolunteer(id)
} }

View file

@ -14,10 +14,8 @@ func TestConfirmVolunteer(t *testing.T) {
dept, _ := app.createDepartment(Department{Name: "Gate"}) dept, _ := app.createDepartment(Department{Name: "Gate"})
deptID := dept.ID deptID := dept.ID
v, _ := app.createVolunteer(Volunteer{ p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com", EmailConfirmed: true})
Name: "Titania", Email: "titania@test.com", v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
DepartmentID: &deptID, EmailConfirmed: true,
})
w := httptest.NewRecorder() w := httptest.NewRecorder()
mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) 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) admin := testAdminUser(t, app)
tok := testToken(t, app, admin) 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. // Confirm twice — second should be a no-op, not an error.
w := httptest.NewRecorder() w := httptest.NewRecorder()
@ -70,7 +69,8 @@ func TestConfirmVolunteerRequiresRole(t *testing.T) {
ticketing := testUserWithRole(t, app, "ticket_lead", "ticketing", nil) ticketing := testUserWithRole(t, app, "ticket_lead", "ticketing", nil)
tok := testToken(t, app, ticketing) 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() w := httptest.NewRecorder()
mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) 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) tok := testToken(t, app, admin)
dept, _ := app.createDepartment(Department{Name: "Gate"}) 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. // Assign department via update.
w := httptest.NewRecorder() w := httptest.NewRecorder()
mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{ mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{
"name": "Hermia", "department_id": dept.ID, "department_id": dept.ID,
}, tok)) }, tok))
if w.Code != 200 { if w.Code != 200 {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) 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"}) dept, _ := app.createDepartment(Department{Name: "Build"})
deptID := dept.ID deptID := dept.ID
v, _ := app.createVolunteer(Volunteer{ p, _ := app.createParticipant(Participant{PreferredName: "Lysander", Email: "lys@test.com", EmailConfirmed: true})
Name: "Lysander", Email: "lys@test.com", v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
DepartmentID: &deptID, EmailConfirmed: true,
})
// Verify not confirmed before update. // Verify not confirmed before update.
got, _ := app.getVolunteer(v.ID) got, _ := app.getVolunteer(v.ID)
@ -125,7 +124,7 @@ func TestUpdateVolunteerCoLeadAutoConfirms(t *testing.T) {
// Update is_lead=true should auto-confirm. // Update is_lead=true should auto-confirm.
w := httptest.NewRecorder() w := httptest.NewRecorder()
mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{ 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)) }, tok))
if w.Code != 200 { if w.Code != 200 {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())