Compare commits
No commits in common. "e640bf8bedbddd09341c79f381a4d4a268d00e15" and "fcf5bf1f34369da659490193fd39e04964c2df55" have entirely different histories.
e640bf8bed
...
fcf5bf1f34
10 changed files with 437 additions and 209 deletions
18
Makefile
18
Makefile
|
|
@ -1,9 +1,9 @@
|
||||||
.PHONY: build frontend-build dev clean test patch minor major
|
.PHONY: build frontend-build dev clean test patch minor major
|
||||||
|
|
||||||
LAST_TAG := $(shell git tag --sort=-v:refname | head -1)
|
LAST_TAG := $(shell git tag --sort=-v:refname | head -1)
|
||||||
MAJOR := $(shell echo $(LAST_TAG) | cut -d. -f1)
|
MAJOR := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f1)
|
||||||
MINOR := $(shell echo $(LAST_TAG) | cut -d. -f2)
|
MINOR := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f2)
|
||||||
PATCH := $(shell echo $(LAST_TAG) | cut -d. -f3)
|
PATCH := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f3)
|
||||||
|
|
||||||
build: frontend-build
|
build: frontend-build
|
||||||
CGO_ENABLED=0 go build -ldflags "-X main.buildID=$$(git rev-parse --short HEAD)" -o turnpike .
|
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
|
rm -rf frontend/dist
|
||||||
|
|
||||||
patch:
|
patch:
|
||||||
git tag $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))
|
git tag v$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))
|
||||||
@echo "Tagged $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))"
|
@echo "Tagged v$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))"
|
||||||
|
|
||||||
minor:
|
minor:
|
||||||
git tag $(MAJOR).$(shell echo $$(($(MINOR)+1))).0
|
git tag v$(MAJOR).$(shell echo $$(($(MINOR)+1))).0
|
||||||
@echo "Tagged $(MAJOR).$(shell echo $$(($(MINOR)+1))).0"
|
@echo "Tagged v$(MAJOR).$(shell echo $$(($(MINOR)+1))).0"
|
||||||
|
|
||||||
major:
|
major:
|
||||||
git tag $(shell echo $$(($(MAJOR)+1))).0.0
|
git tag v$(shell echo $$(($(MAJOR)+1))).0.0
|
||||||
@echo "Tagged $(shell echo $$(($(MAJOR)+1))).0.0"
|
@echo "Tagged v$(shell echo $$(($(MAJOR)+1))).0.0"
|
||||||
|
|
|
||||||
346
db.go
346
db.go
|
|
@ -87,23 +87,20 @@ 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,
|
||||||
participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
|
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,
|
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,
|
||||||
|
|
@ -122,7 +119,6 @@ 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)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -130,12 +126,9 @@ 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
|
||||||
|
|
@ -166,7 +159,208 @@ 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, ¬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 ---
|
// --- Types ---
|
||||||
|
|
@ -227,24 +421,26 @@ type Department struct {
|
||||||
|
|
||||||
type Volunteer struct {
|
type Volunteer struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
ParticipantID int `json:"participant_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"`
|
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 {
|
||||||
|
|
@ -255,8 +451,6 @@ 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"`
|
||||||
|
|
@ -690,7 +884,7 @@ func (app *App) attendeeCounts() (total, checkedIn int, err error) {
|
||||||
|
|
||||||
// --- Participants ---
|
// --- Participants ---
|
||||||
|
|
||||||
const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, email_confirmed, confirmation_token, created_at, updated_at, deleted_at`
|
const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, created_at, updated_at, deleted_at`
|
||||||
|
|
||||||
func (app *App) listParticipants(search, since string) ([]Participant, error) {
|
func (app *App) listParticipants(search, since string) ([]Participant, error) {
|
||||||
var q string
|
var q string
|
||||||
|
|
@ -730,8 +924,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, email_confirmed, confirmation_token, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
`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, boolInt(p.EmailConfirmed), p.ConfirmationToken, now(),
|
strings.ToLower(p.Email), p.PreferredName, p.TicketName, p.Phone, p.Pronouns, p.Note, now(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -786,19 +980,12 @@ 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()
|
||||||
|
|
@ -1030,13 +1217,23 @@ func queryDepartments(db *sql.DB, q string, args ...any) ([]Department, error) {
|
||||||
|
|
||||||
// --- Volunteers ---
|
// --- Volunteers ---
|
||||||
|
|
||||||
const volunteerSelect = `v.id, v.participant_id,
|
// volunteerSelect / volunteerFrom are used together for all volunteer queries.
|
||||||
p.preferred_name, p.email, p.phone, p.pronouns,
|
// Personal fields (name, email, phone, pronouns) come from the joined participant when available,
|
||||||
|
// falling back to the volunteer's own columns for legacy rows.
|
||||||
|
const volunteerSelect = `v.id, v.participant_id, v.attendee_id,
|
||||||
|
COALESCE(NULLIF(p.preferred_name,''), NULLIF(v.preferred_name,''), v.name),
|
||||||
|
COALESCE(NULLIF(p.preferred_name,''), NULLIF(v.preferred_name,''), v.name),
|
||||||
|
COALESCE(NULLIF(p.email,''), v.email),
|
||||||
|
COALESCE(NULLIF(p.phone,''), v.phone),
|
||||||
|
COALESCE(NULLIF(p.pronouns,''), v.pronouns),
|
||||||
v.department_id, v.is_lead, v.ready, v.ready_at,
|
v.department_id, v.is_lead, v.ready, v.ready_at,
|
||||||
v.confirmed, v.confirmed_at,
|
v.confirmed, v.confirmed_at,
|
||||||
p.email_confirmed, v.kiosk_code, v.note,
|
v.email_confirmed, v.confirmation_token, v.kiosk_code, v.note,
|
||||||
v.created_at, v.updated_at, v.deleted_at`
|
v.created_at, v.updated_at, v.deleted_at`
|
||||||
const volunteerFrom = `FROM volunteers v INNER JOIN participants p ON p.id = v.participant_id`
|
const volunteerFrom = `FROM volunteers v LEFT JOIN participants p ON p.id = v.participant_id`
|
||||||
|
|
||||||
|
// volunteerCols is kept for backward-compat references that expect unqualified column names.
|
||||||
|
const volunteerCols = `id, attendee_id, name, preferred_name, email, phone, pronouns, department_id, is_lead, ready, ready_at, email_confirmed, confirmation_token, note, created_at, updated_at, deleted_at`
|
||||||
|
|
||||||
func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) {
|
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`
|
||||||
|
|
@ -1048,15 +1245,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 (p.preferred_name LIKE ? OR p.email LIKE ?)`
|
q += ` AND (v.name LIKE ? OR v.email LIKE ? OR p.preferred_name LIKE ? OR p.email LIKE ?)`
|
||||||
s := "%" + search + "%"
|
s := "%" + search + "%"
|
||||||
args = append(args, s, s)
|
args = append(args, s, s, 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 p.preferred_name`
|
q += ` ORDER BY COALESCE(NULLIF(p.preferred_name,''), v.name)`
|
||||||
return queryVolunteers(app.db, q, args...)
|
return queryVolunteers(app.db, q, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1069,6 +1266,15 @@ 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)
|
||||||
|
|
@ -1080,9 +1286,10 @@ 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, department_id, is_lead, note, updated_at)
|
`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 (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
v.ParticipantID, v.DepartmentID, boolInt(v.IsLead), v.Note, now(),
|
v.ParticipantID, v.AttendeeID, v.Name, v.PreferredName, v.Email, v.Phone, v.Pronouns,
|
||||||
|
v.DepartmentID, boolInt(v.IsLead), boolInt(v.EmailConfirmed), v.ConfirmationToken, v.Note, now(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -1093,8 +1300,9 @@ 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 department_id=?, is_lead=?, note=?, updated_at=?
|
`UPDATE volunteers SET participant_id=?, attendee_id=?, name=?, preferred_name=?, email=?, phone=?, pronouns=?, department_id=?, is_lead=?, note=?, updated_at=?
|
||||||
WHERE id=? AND deleted_at IS NULL`,
|
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
|
||||||
|
|
@ -1107,6 +1315,8 @@ 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(
|
||||||
|
|
@ -1117,7 +1327,14 @@ func (app *App) markVolunteerReady(id, userID int) (*Volunteer, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return app.getVolunteer(id)
|
v, err := app.getVolunteer(id)
|
||||||
|
if err != nil || v == nil {
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
|
if v.AttendeeID != nil {
|
||||||
|
app.checkInAttendee(*v.AttendeeID, userID, 1)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) confirmVolunteer(id int) (*Volunteer, error) {
|
func (app *App) confirmVolunteer(id int) (*Volunteer, error) {
|
||||||
|
|
@ -1142,23 +1359,34 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
|
||||||
var result []Volunteer
|
var result []Volunteer
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var v Volunteer
|
var v Volunteer
|
||||||
var deptID sql.NullInt64
|
var participantID, attendeeID, deptID sql.NullInt64
|
||||||
var isLead, ready, confirmed, emailConfirmed int
|
var isLead, ready, confirmed, emailConfirmed int
|
||||||
var confirmedAt, kioskCode sql.NullString
|
var confirmationToken, confirmedAt, kioskCode sql.NullString
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&v.ID, &v.ParticipantID,
|
&v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName,
|
||||||
&v.Name, &v.Email, &v.Phone, &v.Pronouns,
|
&v.Email, &v.Phone, &v.Pronouns, &deptID,
|
||||||
&deptID, &isLead, &ready, &v.ReadyAt,
|
&isLead, &ready, &v.ReadyAt,
|
||||||
&confirmed, &confirmedAt,
|
&confirmed, &confirmedAt,
|
||||||
&emailConfirmed, &kioskCode, &v.Note,
|
&emailConfirmed, &confirmationToken, &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
|
||||||
}
|
}
|
||||||
|
|
@ -1176,7 +1404,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(p.email) = LOWER(?) AND v.deleted_at IS NULL LIMIT 1`, email)
|
`SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE LOWER(v.email) = LOWER(?) AND v.deleted_at IS NULL LIMIT 1`, email)
|
||||||
if err != nil || len(rows) == 0 {
|
if err != nil || len(rows) == 0 {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -1185,24 +1413,17 @@ 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 p.confirmation_token = ? AND v.deleted_at IS NULL LIMIT 1`, token)
|
`SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.confirmation_token = ? AND v.deleted_at IS NULL LIMIT 1`, token)
|
||||||
if err != nil || len(rows) == 0 {
|
if err != nil || len(rows) == 0 {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &rows[0], nil
|
return &rows[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) confirmParticipantEmail(participantID int) error {
|
func (app *App) confirmVolunteerEmail(id int) error {
|
||||||
_, err := app.db.Exec(
|
_, err := app.db.Exec(
|
||||||
`UPDATE participants SET email_confirmed = 1, confirmation_token = NULL, updated_at = ? WHERE id = ?`,
|
`UPDATE volunteers SET email_confirmed = 1, confirmation_token = NULL, updated_at = ? WHERE id = ?`,
|
||||||
now(), participantID)
|
now(), id)
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1221,10 +1442,11 @@ 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 p.email_confirmed = 1 AND v.kiosk_code IS NULL AND v.deleted_at IS NULL`)
|
WHERE v.email_confirmed = 1 AND v.kiosk_code IS NULL AND v.deleted_at IS NULL`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) generateVolunteerKioskCode() (string, error) {
|
func (app *App) generateVolunteerKioskCode() (string, error) {
|
||||||
|
|
@ -1436,7 +1658,7 @@ var errShiftFull = fmt.Errorf("shift is full")
|
||||||
|
|
||||||
func (app *App) unassignShift(volunteerID, shiftID int) error {
|
func (app *App) unassignShift(volunteerID, shiftID int) error {
|
||||||
_, err := app.db.Exec(
|
_, 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,
|
now(), now(), volunteerID, shiftID,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -197,8 +197,7 @@ 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})
|
||||||
p, _ := app.createParticipant(Participant{PreferredName: "Helena", Email: "helena@test.com"})
|
v, _ := app.createVolunteer(Volunteer{Name: "Helena", DepartmentID: &deptID})
|
||||||
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)
|
||||||
|
|
@ -222,8 +221,7 @@ func TestCheckShiftConflict(t *testing.T) {
|
||||||
|
|
||||||
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
||||||
deptID := dept.ID
|
deptID := dept.ID
|
||||||
p, _ := app.createParticipant(Participant{PreferredName: "Hermia", Email: "hermia@test.com"})
|
v, _ := app.createVolunteer(Volunteer{Name: "Hermia", DepartmentID: &deptID})
|
||||||
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"})
|
||||||
|
|
@ -252,8 +250,7 @@ func TestCheckShiftConflictMidnight(t *testing.T) {
|
||||||
|
|
||||||
dept, _ := app.createDepartment(Department{Name: "Sound"})
|
dept, _ := app.createDepartment(Department{Name: "Sound"})
|
||||||
deptID := dept.ID
|
deptID := dept.ID
|
||||||
p, _ := app.createParticipant(Participant{PreferredName: "Lysander", Email: "lysander@test.com"})
|
v, _ := app.createVolunteer(Volunteer{Name: "Lysander", DepartmentID: &deptID})
|
||||||
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"})
|
||||||
|
|
|
||||||
|
|
@ -46,11 +46,6 @@ 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 ?? ''
|
||||||
|
|
|
||||||
|
|
@ -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{ParticipantID: p.ID, DepartmentID: &deptID})
|
v, _ := app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: &p.ID, DepartmentID: &deptID})
|
||||||
token, _ := app.generateVolunteerKioskCode()
|
token, _ := app.generateVolunteerKioskCode()
|
||||||
app.assignKioskCode(v.ID, token)
|
app.assignKioskCode(v.ID, token)
|
||||||
|
|
||||||
|
|
@ -132,8 +132,7 @@ 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
|
||||||
otherP, _ := app.createParticipant(Participant{PreferredName: "Other", Email: "other@test.com"})
|
other, _ := app.createVolunteer(Volunteer{Name: "Other", DepartmentID: &deptID})
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,7 @@ 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"})
|
||||||
p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com"})
|
app.createVolunteer(Volunteer{Name: "Titania", DepartmentID: &deptID})
|
||||||
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{
|
||||||
|
|
@ -87,8 +86,7 @@ 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"})
|
||||||
p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com"})
|
app.createVolunteer(Volunteer{Name: "Titania", DepartmentID: &deptID})
|
||||||
app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
|
|
||||||
|
|
||||||
// Assign to first shift
|
// Assign to first shift
|
||||||
app.assignShift(1, 1)
|
app.assignShift(1, 1)
|
||||||
|
|
|
||||||
|
|
@ -89,12 +89,17 @@ 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 {
|
||||||
|
|
@ -131,7 +136,7 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := app.confirmParticipantEmail(vol.ParticipantID); err != nil {
|
if err := app.confirmVolunteerEmail(vol.ID); err != nil {
|
||||||
writeError(w, "internal error", http.StatusInternalServerError)
|
writeError(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -148,7 +153,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.Name, kioskLink); err != nil {
|
if err := app.sendShiftSignupEmail(vol.Email, vol.PreferredName, kioskLink); err != nil {
|
||||||
log.Printf("shift signup email to %s failed: %v", vol.Email, err)
|
log.Printf("shift signup email to %s failed: %v", vol.Email, err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
@ -193,7 +198,7 @@ func (app *App) openShiftSignups() {
|
||||||
// Email all email-confirmed volunteers that now have a kiosk code.
|
// 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 p.email_confirmed = 1 AND v.kiosk_code IS NOT NULL AND v.deleted_at IS NULL`)
|
WHERE v.email_confirmed = 1 AND v.kiosk_code IS NOT NULL AND v.deleted_at IS NULL`)
|
||||||
baseURL := app.resolveBaseURL()
|
baseURL := app.resolveBaseURL()
|
||||||
sent := 0
|
sent := 0
|
||||||
|
|
||||||
|
|
@ -202,7 +207,11 @@ func (app *App) openShiftSignups() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *v.KioskCode)
|
kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *v.KioskCode)
|
||||||
if err := app.sendShiftSignupEmail(v.Email, v.Name, kioskLink); err == nil {
|
name := v.PreferredName
|
||||||
|
if name == "" {
|
||||||
|
name = v.Name
|
||||||
|
}
|
||||||
|
if err := app.sendShiftSignupEmail(v.Email, name, kioskLink); err == nil {
|
||||||
sent++
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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.Name != "Titania" {
|
if vol.PreferredName != "Titania" {
|
||||||
t.Errorf("name = %q, want Titania", vol.Name)
|
t.Errorf("preferred_name = %q, want Titania", vol.PreferredName)
|
||||||
}
|
}
|
||||||
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 == 0 {
|
if vol.ParticipantID == nil {
|
||||||
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 == 0 || vol.ParticipantID != existing.ID {
|
if vol.ParticipantID == nil || *vol.ParticipantID != existing.ID {
|
||||||
t.Errorf("expected volunteer linked to existing participant %d, got %d", existing.ID, vol.ParticipantID)
|
t.Errorf("expected volunteer linked to existing participant %d, got %v", existing.ID, vol.ParticipantID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,8 +200,12 @@ func TestConfirmEmail(t *testing.T) {
|
||||||
mux := testMux(app)
|
mux := testMux(app)
|
||||||
|
|
||||||
token := "abc123def456"
|
token := "abc123def456"
|
||||||
p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token})
|
app.createVolunteer(Volunteer{
|
||||||
app.createVolunteer(Volunteer{ParticipantID: p.ID})
|
Name: "Titania",
|
||||||
|
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}))
|
||||||
|
|
@ -213,13 +217,12 @@ func TestConfirmEmail(t *testing.T) {
|
||||||
t.Errorf("expected confirmed, got %v", result["status"])
|
t.Errorf("expected confirmed, got %v", result["status"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify participant is email confirmed
|
// Verify volunteer is 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 show email confirmed via participant")
|
t.Error("volunteer should be email confirmed")
|
||||||
}
|
}
|
||||||
updatedP, _ := app.getParticipant(p.ID)
|
if vol.ConfirmationToken != nil {
|
||||||
if updatedP.ConfirmationToken != nil {
|
|
||||||
t.Error("confirmation token should be cleared after confirmation")
|
t.Error("confirmation token should be cleared after confirmation")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -244,8 +247,12 @@ func TestConfirmEmailAlreadyConfirmed(t *testing.T) {
|
||||||
mux := testMux(app)
|
mux := testMux(app)
|
||||||
|
|
||||||
token := "abc123def456"
|
token := "abc123def456"
|
||||||
p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token})
|
app.createVolunteer(Volunteer{
|
||||||
app.createVolunteer(Volunteer{ParticipantID: p.ID})
|
Name: "Titania",
|
||||||
|
PreferredName: "Titania",
|
||||||
|
Email: "titania@example.com",
|
||||||
|
ConfirmationToken: &token,
|
||||||
|
})
|
||||||
|
|
||||||
// Confirm first time
|
// Confirm first time
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
@ -270,9 +277,15 @@ func TestConfirmEmailWithSignupsOpen(t *testing.T) {
|
||||||
app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`)
|
app.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"
|
||||||
participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token})
|
app.createVolunteer(Volunteer{
|
||||||
app.createVolunteer(Volunteer{ParticipantID: participant.ID})
|
Name: "Titania",
|
||||||
|
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}))
|
||||||
|
|
@ -314,10 +327,10 @@ func TestPublicSignupTicketNameDoesNotOverwritePreferredName(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
vol, _ := app.getVolunteerByEmail("titania@example.com")
|
vol, _ := app.getVolunteerByEmail("titania@example.com")
|
||||||
if vol == nil || vol.ParticipantID == 0 {
|
if vol == nil || vol.ParticipantID == nil {
|
||||||
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")
|
||||||
}
|
}
|
||||||
|
|
@ -336,9 +349,15 @@ func TestConfirmEmailAssignsKioskCode(t *testing.T) {
|
||||||
app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`)
|
app.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"
|
||||||
participant, _ := app.createParticipant(Participant{PreferredName: "Titania", TicketName: "Titania Fairweather", Email: "titania@example.com", ConfirmationToken: &token})
|
app.createVolunteer(Volunteer{
|
||||||
app.createVolunteer(Volunteer{ParticipantID: participant.ID})
|
Name: "Titania",
|
||||||
|
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}))
|
||||||
|
|
|
||||||
|
|
@ -35,62 +35,54 @@ 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 {
|
||||||
Name string `json:"name"`
|
Volunteer
|
||||||
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
|
||||||
}
|
}
|
||||||
if body.Name == "" {
|
v := body.Volunteer
|
||||||
|
if v.Name == "" {
|
||||||
writeError(w, "name is required", http.StatusBadRequest)
|
writeError(w, "name is required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if body.Email == "" {
|
if v.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 body.DepartmentID == nil || !inSlice(*body.DepartmentID, claims.DeptIDs) {
|
if v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
|
||||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p, _ := app.getParticipantByEmail(body.Email)
|
if v.Email != "" && v.ParticipantID == nil {
|
||||||
|
p, _ := app.getParticipantByEmail(v.Email)
|
||||||
if p == nil {
|
if p == nil {
|
||||||
p, _ = app.createParticipant(Participant{PreferredName: body.Name, Email: body.Email, TicketName: body.TicketName})
|
p, _ = app.createParticipant(Participant{PreferredName: v.Name, Email: v.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 {
|
||||||
writeError(w, "failed to create participant", http.StatusInternalServerError)
|
v.ParticipantID = &p.ID
|
||||||
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
|
||||||
}
|
}
|
||||||
app.setParticipantConfirmationToken(p.ID, confirmToken)
|
v.ConfirmationToken = &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(body.Email, body.Name, confirmToken); err != nil {
|
if err := app.sendConfirmationEmail(v.Email, v.Name, confirmToken); err != nil {
|
||||||
log.Printf("confirmation email to %s failed: %v", body.Email, err)
|
log.Printf("confirmation email to %s failed: %v", v.Email, err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
|
@ -117,15 +109,15 @@ 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 body struct {
|
var v Volunteer
|
||||||
DepartmentID *int `json:"department_id"`
|
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
|
||||||
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)
|
writeError(w, "invalid request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if v.Name == "" {
|
||||||
|
writeError(w, "name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
claims := claimsFromContext(r)
|
claims := claimsFromContext(r)
|
||||||
if claims.Role == "colead" {
|
if claims.Role == "colead" {
|
||||||
existing, _ := app.getVolunteer(id)
|
existing, _ := app.getVolunteer(id)
|
||||||
|
|
@ -134,16 +126,12 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
v := Volunteer{
|
v.ID = id
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,10 @@ func TestConfirmVolunteer(t *testing.T) {
|
||||||
|
|
||||||
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
||||||
deptID := dept.ID
|
deptID := dept.ID
|
||||||
p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com", EmailConfirmed: true})
|
v, _ := app.createVolunteer(Volunteer{
|
||||||
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
|
Name: "Titania", Email: "titania@test.com",
|
||||||
|
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))
|
||||||
|
|
@ -44,8 +46,7 @@ func TestConfirmVolunteerIdempotent(t *testing.T) {
|
||||||
admin := testAdminUser(t, app)
|
admin := testAdminUser(t, app)
|
||||||
tok := testToken(t, app, admin)
|
tok := testToken(t, app, admin)
|
||||||
|
|
||||||
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com", EmailConfirmed: true})
|
v, _ := app.createVolunteer(Volunteer{Name: "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()
|
||||||
|
|
@ -69,8 +70,7 @@ 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)
|
||||||
|
|
||||||
p, _ := app.createParticipant(Participant{PreferredName: "Helena", EmailConfirmed: true})
|
v, _ := app.createVolunteer(Volunteer{Name: "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,13 +86,12 @@ 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"})
|
||||||
p, _ := app.createParticipant(Participant{PreferredName: "Hermia"})
|
v, _ := app.createVolunteer(Volunteer{Name: "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{
|
||||||
"department_id": dept.ID,
|
"name": "Hermia", "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())
|
||||||
|
|
@ -112,8 +111,10 @@ func TestUpdateVolunteerCoLeadAutoConfirms(t *testing.T) {
|
||||||
|
|
||||||
dept, _ := app.createDepartment(Department{Name: "Build"})
|
dept, _ := app.createDepartment(Department{Name: "Build"})
|
||||||
deptID := dept.ID
|
deptID := dept.ID
|
||||||
p, _ := app.createParticipant(Participant{PreferredName: "Lysander", Email: "lys@test.com", EmailConfirmed: true})
|
v, _ := app.createVolunteer(Volunteer{
|
||||||
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
|
Name: "Lysander", Email: "lys@test.com",
|
||||||
|
DepartmentID: &deptID, EmailConfirmed: true,
|
||||||
|
})
|
||||||
|
|
||||||
// Verify not confirmed before update.
|
// Verify not confirmed before update.
|
||||||
got, _ := app.getVolunteer(v.ID)
|
got, _ := app.getVolunteer(v.ID)
|
||||||
|
|
@ -124,7 +125,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{
|
||||||
"department_id": deptID, "is_lead": true,
|
"name": "Lysander", "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())
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue