Established Participants and Tickets model. Migrated concepts.
This commit is contained in:
parent
0df93e1886
commit
cd8e1e3b3b
22 changed files with 1345 additions and 191 deletions
523
db.go
523
db.go
|
|
@ -121,6 +121,40 @@ func migrate(db *sql.DB) error {
|
|||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (volunteer_id, shift_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS participants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
preferred_name TEXT NOT NULL DEFAULT '',
|
||||
phone TEXT NOT NULL DEFAULT '',
|
||||
pronouns TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_participants_email
|
||||
ON participants(email) WHERE deleted_at IS NULL AND email != '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
participant_id INTEGER REFERENCES participants(id) ON DELETE SET NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
ticket_type TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
external_id TEXT NOT NULL DEFAULT '',
|
||||
order_id TEXT NOT NULL DEFAULT '',
|
||||
code TEXT UNIQUE,
|
||||
checked_in_at TEXT,
|
||||
checked_in_by INTEGER REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_source_external
|
||||
ON tickets(source, external_id) WHERE external_id != '' AND deleted_at IS NULL;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -143,6 +177,89 @@ func migrateV2(db *sql.DB) error {
|
|||
// Widen the uniqueness constraint from name-only to (name, ticket_id).
|
||||
db.Exec(`DROP INDEX IF EXISTS idx_attendees_name`)
|
||||
db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_attendees_name_ticket ON attendees(name, ticket_id) WHERE deleted_at IS NULL`)
|
||||
return migrateV3(db)
|
||||
}
|
||||
|
||||
// migrateV3 populates participants + tickets from attendees/volunteers,
|
||||
// and links volunteers to participants via participant_id.
|
||||
func migrateV3(db *sql.DB) error {
|
||||
addColumnIfMissing(db, "volunteers", "participant_id INTEGER REFERENCES participants(id)")
|
||||
|
||||
// Seed participants from volunteers first (better name data: preferred_name).
|
||||
db.Exec(`
|
||||
INSERT OR IGNORE INTO participants (email, preferred_name, phone, pronouns, created_at, updated_at)
|
||||
SELECT
|
||||
LOWER(email),
|
||||
CASE WHEN preferred_name != '' THEN preferred_name ELSE name END,
|
||||
phone,
|
||||
pronouns,
|
||||
created_at,
|
||||
created_at
|
||||
FROM volunteers
|
||||
WHERE email != '' AND deleted_at IS NULL`)
|
||||
|
||||
// Fill in from attendees for emails not yet in participants.
|
||||
db.Exec(`
|
||||
INSERT OR IGNORE INTO participants (email, preferred_name, phone, created_at, updated_at)
|
||||
SELECT LOWER(email), name, phone, created_at, created_at
|
||||
FROM attendees
|
||||
WHERE email != '' AND deleted_at IS NULL`)
|
||||
|
||||
// Attendees with no email: create a placeholder participant so tickets aren't orphaned.
|
||||
rows, _ := db.Query(`SELECT id, name, created_at FROM attendees WHERE email = '' AND deleted_at IS NULL`)
|
||||
if rows != nil {
|
||||
type stub struct {
|
||||
id, name, createdAt string
|
||||
}
|
||||
var stubs []stub
|
||||
for rows.Next() {
|
||||
var s stub
|
||||
rows.Scan(&s.id, &s.name, &s.createdAt)
|
||||
stubs = append(stubs, s)
|
||||
}
|
||||
rows.Close()
|
||||
for _, s := range stubs {
|
||||
placeholder := fmt.Sprintf("ticket-%s@unknown", s.id)
|
||||
db.Exec(`INSERT OR IGNORE INTO participants (email, preferred_name, created_at, updated_at) VALUES (?, ?, ?, ?)`,
|
||||
placeholder, s.name, s.createdAt, s.createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
// Link volunteers to participants via email.
|
||||
db.Exec(`
|
||||
UPDATE volunteers SET participant_id = (
|
||||
SELECT p.id FROM participants p WHERE LOWER(p.email) = LOWER(volunteers.email)
|
||||
)
|
||||
WHERE participant_id IS NULL AND email != ''`)
|
||||
|
||||
// Seed tickets from attendees (1 ticket per attendee row).
|
||||
db.Exec(`
|
||||
INSERT OR IGNORE INTO tickets (participant_id, name, ticket_type, source, external_id, order_id, code, checked_in_at, checked_in_by, created_at, updated_at, deleted_at)
|
||||
SELECT
|
||||
p.id,
|
||||
a.name,
|
||||
a.ticket_type,
|
||||
CASE WHEN a.ticket_id != '' THEN 'crowdwork' ELSE 'manual' END,
|
||||
a.ticket_id,
|
||||
a.ticket_id,
|
||||
a.volunteer_token,
|
||||
a.checked_in_at,
|
||||
a.checked_in_by,
|
||||
a.created_at,
|
||||
a.updated_at,
|
||||
a.deleted_at
|
||||
FROM attendees a
|
||||
JOIN participants p ON LOWER(p.email) = LOWER(a.email) OR p.email = 'ticket-' || a.id || '@unknown'`)
|
||||
|
||||
// Volunteers whose participant has no ticket: create a stub ticket so they can get a kiosk code.
|
||||
db.Exec(`
|
||||
INSERT OR IGNORE INTO tickets (participant_id, source, created_at, updated_at)
|
||||
SELECT DISTINCT v.participant_id, 'manual', v.created_at, v.created_at
|
||||
FROM volunteers v
|
||||
WHERE v.participant_id IS NOT NULL
|
||||
AND v.deleted_at IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM tickets t WHERE t.participant_id = v.participant_id AND t.deleted_at IS NULL)`)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -223,7 +340,8 @@ type Department struct {
|
|||
|
||||
type Volunteer struct {
|
||||
ID int `json:"id"`
|
||||
AttendeeID *int `json:"attendee_id,omitempty"`
|
||||
ParticipantID *int `json:"participant_id,omitempty"`
|
||||
AttendeeID *int `json:"attendee_id,omitempty"` // deprecated; kept for migration compat
|
||||
Name string `json:"name"`
|
||||
PreferredName string `json:"preferred_name"`
|
||||
TicketName string `json:"ticket_name"`
|
||||
|
|
@ -242,6 +360,34 @@ type Volunteer struct {
|
|||
DeletedAt *string `json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
type Participant struct {
|
||||
ID int `json:"id"`
|
||||
Email string `json:"email"`
|
||||
PreferredName string `json:"preferred_name"`
|
||||
Phone string `json:"phone"`
|
||||
Pronouns string `json:"pronouns"`
|
||||
Note string `json:"note"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
DeletedAt *string `json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
type Ticket struct {
|
||||
ID int `json:"id"`
|
||||
ParticipantID *int `json:"participant_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
TicketType string `json:"ticket_type"`
|
||||
Source string `json:"source"`
|
||||
ExternalID string `json:"external_id"`
|
||||
OrderID string `json:"order_id"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
CheckedInAt *string `json:"checked_in_at,omitempty"`
|
||||
CheckedInBy *int `json:"checked_in_by,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
DeletedAt *string `json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
type Shift struct {
|
||||
ID int `json:"id"`
|
||||
DepartmentID int `json:"department_id"`
|
||||
|
|
@ -444,7 +590,7 @@ func (app *App) generateUniqueToken() (string, error) {
|
|||
return "", err
|
||||
}
|
||||
var count int
|
||||
if err := app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE volunteer_token = ?`, t).Scan(&count); err != nil {
|
||||
if err := app.db.QueryRow(`SELECT COUNT(*) FROM tickets WHERE code = ?`, t).Scan(&count); err != nil {
|
||||
return "", fmt.Errorf("check token uniqueness: %w", err)
|
||||
}
|
||||
if count == 0 {
|
||||
|
|
@ -454,19 +600,10 @@ func (app *App) generateUniqueToken() (string, error) {
|
|||
return "", fmt.Errorf("failed to generate unique token")
|
||||
}
|
||||
|
||||
func (app *App) getAttendeeByToken(token string) (*Attendee, error) {
|
||||
rows, err := queryAttendees(app.db,
|
||||
`SELECT `+attendeeCols+` FROM attendees WHERE volunteer_token = ? AND deleted_at IS NULL`, token)
|
||||
if err != nil || len(rows) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &rows[0], nil
|
||||
}
|
||||
|
||||
// generateTokensForAll creates tokens for every attendee that doesn't have one yet.
|
||||
func (app *App) generateTokensForAll() (int, error) {
|
||||
// generateCodesForAll generates codes for every ticket that doesn't have one yet.
|
||||
func (app *App) generateCodesForAll() (int, error) {
|
||||
rows, err := app.db.Query(
|
||||
`SELECT id FROM attendees WHERE volunteer_token IS NULL AND deleted_at IS NULL`,
|
||||
`SELECT id FROM tickets WHERE code IS NULL AND deleted_at IS NULL`,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
|
|
@ -476,7 +613,7 @@ func (app *App) generateTokensForAll() (int, error) {
|
|||
for rows.Next() {
|
||||
var id int
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return 0, fmt.Errorf("scan attendee id: %w", err)
|
||||
return 0, fmt.Errorf("scan ticket id: %w", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
|
@ -487,14 +624,13 @@ func (app *App) generateTokensForAll() (int, error) {
|
|||
if err != nil {
|
||||
continue
|
||||
}
|
||||
app.db.Exec(`UPDATE attendees SET volunteer_token=?, updated_at=? WHERE id=?`, t, now(), id)
|
||||
app.db.Exec(`UPDATE tickets SET code=?, updated_at=? WHERE id=?`, t, now(), id)
|
||||
count++
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// incrementPartySize bumps party_size for an existing attendee matched by name+ticket_id.
|
||||
// Used during import to handle duplicate ticket rows from the same order.
|
||||
// incrementPartySize is kept for backward compatibility with existing tests.
|
||||
func (app *App) incrementPartySize(name, ticketID string) (bool, error) {
|
||||
res, err := app.db.Exec(
|
||||
`UPDATE attendees SET party_size = party_size + 1, updated_at = ?
|
||||
|
|
@ -662,6 +798,274 @@ func (app *App) attendeeCounts() (total, checkedIn int, err error) {
|
|||
return
|
||||
}
|
||||
|
||||
// --- Participants ---
|
||||
|
||||
const participantCols = `id, email, preferred_name, phone, pronouns, note, created_at, updated_at, deleted_at`
|
||||
|
||||
func (app *App) listParticipants(search, since string) ([]Participant, error) {
|
||||
var q string
|
||||
var args []any
|
||||
if since != "" {
|
||||
q = `SELECT ` + participantCols + ` FROM participants WHERE updated_at > ? ORDER BY preferred_name, email`
|
||||
args = append(args, since)
|
||||
} else {
|
||||
q = `SELECT ` + participantCols + ` FROM participants WHERE deleted_at IS NULL`
|
||||
if search != "" {
|
||||
q += ` AND (preferred_name LIKE ? OR email LIKE ?)`
|
||||
s := "%" + search + "%"
|
||||
args = append(args, s, s)
|
||||
}
|
||||
q += ` ORDER BY preferred_name, email`
|
||||
}
|
||||
return queryParticipants(app.db, q, args...)
|
||||
}
|
||||
|
||||
func (app *App) getParticipant(id int) (*Participant, error) {
|
||||
rows, err := queryParticipants(app.db,
|
||||
`SELECT `+participantCols+` FROM participants WHERE id = ?`, id)
|
||||
if err != nil || len(rows) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &rows[0], nil
|
||||
}
|
||||
|
||||
func (app *App) getParticipantByEmail(email string) (*Participant, error) {
|
||||
rows, err := queryParticipants(app.db,
|
||||
`SELECT `+participantCols+` FROM participants WHERE LOWER(email) = LOWER(?) AND deleted_at IS NULL LIMIT 1`, email)
|
||||
if err != nil || len(rows) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &rows[0], nil
|
||||
}
|
||||
|
||||
func (app *App) createParticipant(p Participant) (*Participant, error) {
|
||||
res, err := app.db.Exec(
|
||||
`INSERT INTO participants (email, preferred_name, phone, pronouns, note, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
strings.ToLower(p.Email), p.PreferredName, p.Phone, p.Pronouns, p.Note, now(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return app.getParticipant(int(id))
|
||||
}
|
||||
|
||||
func (app *App) updateParticipant(p Participant) error {
|
||||
_, err := app.db.Exec(
|
||||
`UPDATE participants SET email=?, preferred_name=?, phone=?, pronouns=?, note=?, updated_at=?
|
||||
WHERE id=? AND deleted_at IS NULL`,
|
||||
strings.ToLower(p.Email), p.PreferredName, p.Phone, p.Pronouns, p.Note, now(), p.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (app *App) deleteParticipant(id int) error {
|
||||
_, err := app.db.Exec(
|
||||
`UPDATE participants SET deleted_at=?, updated_at=? WHERE id=?`, now(), now(), id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// mergeParticipants reassigns all tickets and volunteers from other → canonical, then soft-deletes other.
|
||||
func (app *App) mergeParticipants(canonicalID, otherID int) error {
|
||||
ts := now()
|
||||
if _, err := app.db.Exec(
|
||||
`UPDATE tickets SET participant_id=?, updated_at=? WHERE participant_id=? AND deleted_at IS NULL`,
|
||||
canonicalID, ts, otherID,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := app.db.Exec(
|
||||
`UPDATE volunteers SET participant_id=?, updated_at=? WHERE participant_id=? AND deleted_at IS NULL`,
|
||||
canonicalID, ts, otherID,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := app.db.Exec(
|
||||
`UPDATE participants SET deleted_at=?, updated_at=? WHERE id=?`, ts, ts, otherID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func queryParticipants(db *sql.DB, q string, args ...any) ([]Participant, error) {
|
||||
rows, err := db.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []Participant
|
||||
for rows.Next() {
|
||||
var p Participant
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.Email, &p.PreferredName, &p.Phone, &p.Pronouns, &p.Note,
|
||||
&p.CreatedAt, &p.UpdatedAt, &p.DeletedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, p)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// upsertParticipant finds a participant by email or creates one.
|
||||
// Returns the participant and whether it was newly created.
|
||||
func (app *App) upsertParticipant(email, name string) (*Participant, bool, error) {
|
||||
p, err := app.getParticipantByEmail(email)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if p != nil {
|
||||
return p, false, nil
|
||||
}
|
||||
created, err := app.createParticipant(Participant{
|
||||
Email: email,
|
||||
PreferredName: name,
|
||||
})
|
||||
return created, true, err
|
||||
}
|
||||
|
||||
// --- Tickets ---
|
||||
|
||||
const ticketCols = `id, participant_id, name, ticket_type, source, external_id, order_id, code, checked_in_at, checked_in_by, created_at, updated_at, deleted_at`
|
||||
|
||||
func (app *App) listTickets(participantID *int, since string) ([]Ticket, error) {
|
||||
q := `SELECT ` + ticketCols + ` FROM tickets WHERE 1=1`
|
||||
var args []any
|
||||
if since != "" {
|
||||
q += ` AND updated_at > ?`
|
||||
args = append(args, since)
|
||||
} else {
|
||||
q += ` AND deleted_at IS NULL`
|
||||
}
|
||||
if participantID != nil {
|
||||
q += ` AND participant_id = ?`
|
||||
args = append(args, *participantID)
|
||||
}
|
||||
q += ` ORDER BY created_at`
|
||||
return queryTickets(app.db, q, args...)
|
||||
}
|
||||
|
||||
func (app *App) getTicket(id int) (*Ticket, error) {
|
||||
rows, err := queryTickets(app.db,
|
||||
`SELECT `+ticketCols+` FROM tickets WHERE id = ?`, id)
|
||||
if err != nil || len(rows) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &rows[0], nil
|
||||
}
|
||||
|
||||
func (app *App) getTicketByCode(code string) (*Ticket, error) {
|
||||
rows, err := queryTickets(app.db,
|
||||
`SELECT `+ticketCols+` FROM tickets WHERE code = ? AND deleted_at IS NULL`, code)
|
||||
if err != nil || len(rows) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &rows[0], nil
|
||||
}
|
||||
|
||||
func (app *App) createTicket(t Ticket) (*Ticket, error) {
|
||||
res, err := app.db.Exec(
|
||||
`INSERT INTO tickets (participant_id, name, ticket_type, source, external_id, order_id, code, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
t.ParticipantID, t.Name, t.TicketType, t.Source, t.ExternalID, t.OrderID, t.Code, now(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return app.getTicket(int(id))
|
||||
}
|
||||
|
||||
func (app *App) checkInTicket(id, userID int) (*Ticket, error) {
|
||||
t := now()
|
||||
_, err := app.db.Exec(`
|
||||
UPDATE tickets SET
|
||||
checked_in_at = CASE WHEN checked_in_at IS NULL THEN ? ELSE checked_in_at END,
|
||||
checked_in_by = CASE WHEN checked_in_at IS NULL THEN ? ELSE checked_in_by END,
|
||||
updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL`,
|
||||
t, userID, t, id,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return app.getTicket(id)
|
||||
}
|
||||
|
||||
func (app *App) deleteTicket(id int) error {
|
||||
_, err := app.db.Exec(
|
||||
`UPDATE tickets SET deleted_at=?, updated_at=? WHERE id=?`, now(), now(), id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (app *App) ticketsSince(since string) ([]Ticket, error) {
|
||||
return queryTickets(app.db,
|
||||
`SELECT `+ticketCols+` FROM tickets WHERE updated_at > ? ORDER BY updated_at ASC`, since)
|
||||
}
|
||||
|
||||
func (app *App) participantsSince(since string) ([]Participant, error) {
|
||||
return queryParticipants(app.db,
|
||||
`SELECT `+participantCols+` FROM participants WHERE updated_at > ? ORDER BY updated_at ASC`, since)
|
||||
}
|
||||
|
||||
func queryTickets(db *sql.DB, q string, args ...any) ([]Ticket, error) {
|
||||
rows, err := db.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []Ticket
|
||||
for rows.Next() {
|
||||
var t Ticket
|
||||
var participantID, checkedInBy sql.NullInt64
|
||||
var code sql.NullString
|
||||
if err := rows.Scan(
|
||||
&t.ID, &participantID, &t.Name, &t.TicketType, &t.Source, &t.ExternalID, &t.OrderID,
|
||||
&code, &t.CheckedInAt, &checkedInBy, &t.CreatedAt, &t.UpdatedAt, &t.DeletedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if participantID.Valid {
|
||||
id := int(participantID.Int64)
|
||||
t.ParticipantID = &id
|
||||
}
|
||||
if checkedInBy.Valid {
|
||||
id := int(checkedInBy.Int64)
|
||||
t.CheckedInBy = &id
|
||||
}
|
||||
if code.Valid && code.String != "" {
|
||||
t.Code = &code.String
|
||||
}
|
||||
result = append(result, t)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ticketCounts returns total and checked-in ticket counts for participants page.
|
||||
func (app *App) ticketCounts() (total, checkedIn int, err error) {
|
||||
app.db.QueryRow(`SELECT COUNT(*) FROM tickets WHERE deleted_at IS NULL`).Scan(&total)
|
||||
app.db.QueryRow(`SELECT COUNT(*) FROM tickets WHERE checked_in_at IS NOT NULL AND deleted_at IS NULL`).Scan(&checkedIn)
|
||||
return
|
||||
}
|
||||
|
||||
func (app *App) ticketTypes() ([]string, error) {
|
||||
rows, err := app.db.Query(
|
||||
`SELECT DISTINCT ticket_type FROM tickets WHERE ticket_type != '' AND deleted_at IS NULL ORDER BY ticket_type`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var types []string
|
||||
for rows.Next() {
|
||||
var t string
|
||||
rows.Scan(&t)
|
||||
types = append(types, t)
|
||||
}
|
||||
return types, rows.Err()
|
||||
}
|
||||
|
||||
// --- Departments ---
|
||||
|
||||
func (app *App) listDepartments(since string) ([]Department, error) {
|
||||
|
|
@ -729,33 +1133,49 @@ func queryDepartments(db *sql.DB, q string, args ...any) ([]Department, error) {
|
|||
|
||||
// --- Volunteers ---
|
||||
|
||||
// volunteerSelect / volunteerFrom are used together for all volunteer queries.
|
||||
// Personal fields (name, email, phone, pronouns) come from the joined participant when available,
|
||||
// falling back to the volunteer's own columns for legacy rows.
|
||||
const volunteerSelect = `v.id, v.participant_id, v.attendee_id,
|
||||
COALESCE(NULLIF(p.preferred_name,''), NULLIF(v.preferred_name,''), v.name),
|
||||
COALESCE(NULLIF(p.preferred_name,''), NULLIF(v.preferred_name,''), v.name),
|
||||
v.ticket_name,
|
||||
COALESCE(NULLIF(p.email,''), v.email),
|
||||
COALESCE(NULLIF(p.phone,''), v.phone),
|
||||
COALESCE(NULLIF(p.pronouns,''), v.pronouns),
|
||||
v.department_id, v.is_lead, v.checked_in, v.checked_in_at,
|
||||
v.email_confirmed, v.confirmation_token, v.note,
|
||||
v.created_at, v.updated_at, v.deleted_at`
|
||||
const volunteerFrom = `FROM volunteers v LEFT JOIN participants p ON p.id = v.participant_id`
|
||||
|
||||
// volunteerCols is kept for backward-compat references that expect unqualified column names.
|
||||
const volunteerCols = `id, attendee_id, name, preferred_name, ticket_name, email, phone, pronouns, department_id, is_lead, checked_in, checked_in_at, email_confirmed, confirmation_token, note, created_at, updated_at, deleted_at`
|
||||
|
||||
func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) {
|
||||
q := `SELECT ` + volunteerCols + ` FROM volunteers WHERE 1=1`
|
||||
q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1`
|
||||
var args []any
|
||||
if since != "" {
|
||||
q += ` AND updated_at > ?`
|
||||
q += ` AND v.updated_at > ?`
|
||||
args = append(args, since)
|
||||
} else {
|
||||
q += ` AND deleted_at IS NULL`
|
||||
q += ` AND v.deleted_at IS NULL`
|
||||
}
|
||||
if search != "" {
|
||||
q += ` AND (name LIKE ? OR email LIKE ?)`
|
||||
q += ` AND (v.name LIKE ? OR v.email LIKE ? OR p.preferred_name LIKE ? OR p.email LIKE ?)`
|
||||
s := "%" + search + "%"
|
||||
args = append(args, s, s)
|
||||
args = append(args, s, s, s, s)
|
||||
}
|
||||
if deptID != nil {
|
||||
q += ` AND department_id = ?`
|
||||
q += ` AND v.department_id = ?`
|
||||
args = append(args, *deptID)
|
||||
}
|
||||
q += ` ORDER BY name`
|
||||
q += ` ORDER BY COALESCE(NULLIF(p.preferred_name,''), v.name)`
|
||||
return queryVolunteers(app.db, q, args...)
|
||||
}
|
||||
|
||||
func (app *App) getVolunteer(id int) (*Volunteer, error) {
|
||||
rows, err := queryVolunteers(app.db,
|
||||
`SELECT `+volunteerCols+` FROM volunteers WHERE id = ?`, id)
|
||||
`SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.id = ?`, id)
|
||||
if err != nil || len(rows) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -764,7 +1184,16 @@ func (app *App) getVolunteer(id int) (*Volunteer, error) {
|
|||
|
||||
func (app *App) getVolunteerByAttendeeID(attendeeID int) (*Volunteer, error) {
|
||||
rows, err := queryVolunteers(app.db,
|
||||
`SELECT `+volunteerCols+` FROM volunteers WHERE attendee_id = ? AND deleted_at IS NULL LIMIT 1`, attendeeID)
|
||||
`SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.attendee_id = ? AND v.deleted_at IS NULL LIMIT 1`, attendeeID)
|
||||
if err != nil || len(rows) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &rows[0], nil
|
||||
}
|
||||
|
||||
func (app *App) getVolunteerByParticipantID(participantID int) (*Volunteer, error) {
|
||||
rows, err := queryVolunteers(app.db,
|
||||
`SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.participant_id = ? AND v.deleted_at IS NULL LIMIT 1`, participantID)
|
||||
if err != nil || len(rows) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -773,9 +1202,9 @@ func (app *App) getVolunteerByAttendeeID(attendeeID int) (*Volunteer, error) {
|
|||
|
||||
func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) {
|
||||
res, err := app.db.Exec(
|
||||
`INSERT INTO volunteers (attendee_id, name, preferred_name, ticket_name, email, phone, pronouns, department_id, is_lead, email_confirmed, confirmation_token, note, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
v.AttendeeID, v.Name, v.PreferredName, v.TicketName, v.Email, v.Phone, v.Pronouns,
|
||||
`INSERT INTO volunteers (participant_id, attendee_id, name, preferred_name, ticket_name, email, phone, pronouns, department_id, is_lead, email_confirmed, confirmation_token, note, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
v.ParticipantID, v.AttendeeID, v.Name, v.PreferredName, v.TicketName, v.Email, v.Phone, v.Pronouns,
|
||||
v.DepartmentID, boolInt(v.IsLead), boolInt(v.EmailConfirmed), v.ConfirmationToken, v.Note, now(),
|
||||
)
|
||||
if err != nil {
|
||||
|
|
@ -787,9 +1216,9 @@ func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) {
|
|||
|
||||
func (app *App) updateVolunteer(v Volunteer) error {
|
||||
_, err := app.db.Exec(
|
||||
`UPDATE volunteers SET attendee_id=?, name=?, preferred_name=?, ticket_name=?, email=?, phone=?, pronouns=?, department_id=?, is_lead=?, note=?, updated_at=?
|
||||
`UPDATE volunteers SET participant_id=?, attendee_id=?, name=?, preferred_name=?, ticket_name=?, email=?, phone=?, pronouns=?, department_id=?, is_lead=?, note=?, updated_at=?
|
||||
WHERE id=? AND deleted_at IS NULL`,
|
||||
v.AttendeeID, v.Name, v.PreferredName, v.TicketName, v.Email, v.Phone, v.Pronouns,
|
||||
v.ParticipantID, v.AttendeeID, v.Name, v.PreferredName, v.TicketName, v.Email, v.Phone, v.Pronouns,
|
||||
v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID,
|
||||
)
|
||||
return err
|
||||
|
|
@ -833,11 +1262,11 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
|
|||
var result []Volunteer
|
||||
for rows.Next() {
|
||||
var v Volunteer
|
||||
var attendeeID, deptID sql.NullInt64
|
||||
var participantID, attendeeID, deptID sql.NullInt64
|
||||
var isLead, checkedIn, emailConfirmed int
|
||||
var confirmationToken sql.NullString
|
||||
if err := rows.Scan(
|
||||
&v.ID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName,
|
||||
&v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName,
|
||||
&v.Email, &v.Phone, &v.Pronouns, &deptID,
|
||||
&isLead, &checkedIn, &v.CheckedInAt,
|
||||
&emailConfirmed, &confirmationToken, &v.Note,
|
||||
|
|
@ -845,6 +1274,10 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
|
|||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if participantID.Valid {
|
||||
id := int(participantID.Int64)
|
||||
v.ParticipantID = &id
|
||||
}
|
||||
if attendeeID.Valid {
|
||||
id := int(attendeeID.Int64)
|
||||
v.AttendeeID = &id
|
||||
|
|
@ -866,7 +1299,7 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
|
|||
|
||||
func (app *App) getVolunteerByEmail(email string) (*Volunteer, error) {
|
||||
rows, err := queryVolunteers(app.db,
|
||||
`SELECT `+volunteerCols+` FROM volunteers WHERE LOWER(email) = LOWER(?) AND deleted_at IS NULL LIMIT 1`, email)
|
||||
`SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE LOWER(v.email) = LOWER(?) AND v.deleted_at IS NULL LIMIT 1`, email)
|
||||
if err != nil || len(rows) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -875,7 +1308,7 @@ func (app *App) getVolunteerByEmail(email string) (*Volunteer, error) {
|
|||
|
||||
func (app *App) getVolunteerByConfirmationToken(token string) (*Volunteer, error) {
|
||||
rows, err := queryVolunteers(app.db,
|
||||
`SELECT `+volunteerCols+` FROM volunteers WHERE confirmation_token = ? AND deleted_at IS NULL LIMIT 1`, token)
|
||||
`SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.confirmation_token = ? AND v.deleted_at IS NULL LIMIT 1`, token)
|
||||
if err != nil || len(rows) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -889,13 +1322,19 @@ func (app *App) confirmVolunteerEmail(id int) error {
|
|||
return err
|
||||
}
|
||||
|
||||
func (app *App) listConfirmedVolunteersWithoutKioskToken() ([]Volunteer, error) {
|
||||
// listConfirmedVolunteersNeedingCode returns confirmed volunteers whose participant
|
||||
// has no ticket with a code yet.
|
||||
func (app *App) listConfirmedVolunteersNeedingCode() ([]Volunteer, error) {
|
||||
return queryVolunteers(app.db, `
|
||||
SELECT `+volunteerCols+`
|
||||
FROM volunteers
|
||||
WHERE email_confirmed = 1 AND deleted_at IS NULL
|
||||
AND attendee_id IS NOT NULL
|
||||
AND (SELECT a.volunteer_token FROM attendees a WHERE a.id = volunteers.attendee_id) IS NULL`)
|
||||
SELECT `+volunteerSelect+` `+volunteerFrom+`
|
||||
WHERE v.email_confirmed = 1 AND v.deleted_at IS NULL
|
||||
AND v.participant_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM tickets t
|
||||
WHERE t.participant_id = v.participant_id
|
||||
AND t.code IS NOT NULL
|
||||
AND t.deleted_at IS NULL
|
||||
)`)
|
||||
}
|
||||
|
||||
func generateConfirmationToken() (string, error) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue