diff --git a/db.go b/db.go index 7857020..0315da8 100644 --- a/db.go +++ b/db.go @@ -49,28 +49,6 @@ func migrate(db *sql.DB) error { deleted_at TEXT ); - CREATE TABLE IF NOT EXISTS attendees ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - email TEXT NOT NULL DEFAULT '', - phone TEXT NOT NULL DEFAULT '', - ticket_id TEXT NOT NULL DEFAULT '', - ticket_type TEXT NOT NULL DEFAULT '', - volunteer_token TEXT UNIQUE, - party_size INTEGER NOT NULL DEFAULT 1, - checked_in INTEGER NOT NULL DEFAULT 0, - checked_in_count INTEGER NOT NULL DEFAULT 0, - checked_in_at TEXT, - checked_in_by INTEGER REFERENCES participants(id), - 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_attendees_name_ticket - ON attendees(name, ticket_id) WHERE deleted_at IS NULL; - CREATE TABLE IF NOT EXISTS volunteers ( id INTEGER PRIMARY KEY AUTOINCREMENT, participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE, @@ -122,6 +100,8 @@ func migrate(db *sql.DB) error { note TEXT NOT NULL DEFAULT '', email_confirmed INTEGER NOT NULL DEFAULT 0, confirmation_token TEXT, + password_hash TEXT, + login_enabled INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), deleted_at TEXT @@ -161,95 +141,11 @@ func migrate(db *sql.DB) error { PRIMARY KEY (participant_id, department_id) ); `) - if err != nil { - return err - } - if err := migrateAuth(db); err != nil { - return err - } - return nil -} - -func migrateAuth(db *sql.DB) error { - // Add auth columns to participants (idempotent — ignore "duplicate column" errors). - db.Exec(`ALTER TABLE participants ADD COLUMN password_hash TEXT`) - db.Exec(`ALTER TABLE participants ADD COLUMN login_enabled INTEGER NOT NULL DEFAULT 0`) - - // Migrate users → participants if the old users table exists. - var hasUsers int - if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'`).Scan(&hasUsers); err != nil || hasUsers == 0 { - return nil - } - - // Collect all users first (single connection — can't query and exec concurrently). - type oldUser struct { - id int - name string - hash string - role string - } - rows, err := db.Query(`SELECT id, username, password_hash, role FROM users`) - if err != nil { - return nil - } - var users []oldUser - for rows.Next() { - var u oldUser - if err := rows.Scan(&u.id, &u.name, &u.hash, &u.role); err != nil { - continue - } - if u.role == "ticketing" { - u.role = "admin" - } - users = append(users, u) - } - rows.Close() - - // Collect department assignments. - type deptAssign struct { - userID int - deptID int - } - deptRows, err := db.Query(`SELECT user_id, department_id FROM user_departments`) - var deptAssigns []deptAssign - if err == nil { - for deptRows.Next() { - var da deptAssign - deptRows.Scan(&da.userID, &da.deptID) - deptAssigns = append(deptAssigns, da) - } - deptRows.Close() - } - - // Now insert with the connection free. - for _, u := range users { - res, err := db.Exec( - `INSERT INTO participants (preferred_name, password_hash, login_enabled, updated_at) VALUES (?, ?, 1, ?)`, - u.name, u.hash, now(), - ) - if err != nil { - continue - } - pid, _ := res.LastInsertId() - db.Exec(`INSERT OR IGNORE INTO participant_roles (participant_id, role) VALUES (?, ?)`, pid, u.role) - for _, da := range deptAssigns { - if da.userID == u.id { - db.Exec(`INSERT OR IGNORE INTO participant_departments (participant_id, department_id) VALUES (?, ?)`, pid, da.deptID) - } - } - } - - db.Exec(`DROP TABLE IF EXISTS user_departments`) - db.Exec(`DROP TABLE IF EXISTS users`) - return nil + return err } // --- Types --- -const attendeeCols = `id, name, email, phone, ticket_id, ticket_type, volunteer_token, - party_size, checked_in, checked_in_count, checked_in_at, checked_in_by, - note, created_at, updated_at, deleted_at` - const shiftCols = `id, department_id, name, day, start_time, end_time, capacity, position, updated_at, deleted_at` const shiftColsS = `s.id, s.department_id, s.name, s.day, s.start_time, s.end_time, s.capacity, s.position, s.updated_at, s.deleted_at` @@ -273,25 +169,6 @@ type User struct { CreatedAt string `json:"created_at"` } -type Attendee struct { - ID int `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - Phone string `json:"phone"` - TicketID string `json:"ticket_id"` - TicketType string `json:"ticket_type"` - VolunteerToken *string `json:"volunteer_token,omitempty"` - PartySize int `json:"party_size"` - CheckedIn bool `json:"checked_in"` - CheckedInCount int `json:"checked_in_count"` - CheckedInAt *string `json:"checked_in_at,omitempty"` - CheckedInBy *int `json:"checked_in_by,omitempty"` - Note string `json:"note"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - DeletedAt *string `json:"deleted_at,omitempty"` -} - type Department struct { ID int `json:"id"` Name string `json:"name"` @@ -688,174 +565,6 @@ func (app *App) generateCodesForAll() (int, error) { return count, nil } -// 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 = ? - WHERE name = ? AND ticket_id = ? AND deleted_at IS NULL`, - now(), name, ticketID, - ) - if err != nil { - return false, err - } - n, _ := res.RowsAffected() - return n > 0, nil -} - -// --- Attendees --- - -func (app *App) listAttendees(search, ticketType, checkedIn string) ([]Attendee, error) { - q := `SELECT ` + attendeeCols + ` FROM attendees WHERE deleted_at IS NULL` - var args []any - if search != "" { - q += ` AND (name LIKE ? OR email LIKE ? OR ticket_id LIKE ?)` - s := "%" + search + "%" - args = append(args, s, s, s) - } - if ticketType != "" { - q += ` AND ticket_type = ?` - args = append(args, ticketType) - } - if checkedIn == "true" { - q += ` AND checked_in = 1` - } else if checkedIn == "false" { - q += ` AND checked_in = 0` - } - q += ` ORDER BY name ASC` - return queryAttendees(app.db, q, args...) -} - -func (app *App) getAttendee(id int) (*Attendee, error) { - rows, err := queryAttendees(app.db, - `SELECT `+attendeeCols+` FROM attendees WHERE id = ?`, id) - if err != nil || len(rows) == 0 { - return nil, err - } - return &rows[0], nil -} - -func (app *App) createAttendee(a Attendee) (*Attendee, error) { - res, err := app.db.Exec( - `INSERT INTO attendees (name, email, phone, ticket_id, ticket_type, note, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - a.Name, a.Email, a.Phone, a.TicketID, a.TicketType, a.Note, now(), - ) - if err != nil { - return nil, err - } - id, _ := res.LastInsertId() - return app.getAttendee(int(id)) -} - -func (app *App) updateAttendee(a Attendee) error { - _, err := app.db.Exec( - `UPDATE attendees SET name=?, email=?, phone=?, ticket_id=?, ticket_type=?, note=?, updated_at=? - WHERE id = ? AND deleted_at IS NULL`, - a.Name, a.Email, a.Phone, a.TicketID, a.TicketType, a.Note, now(), a.ID, - ) - return err -} - -func (app *App) deleteAttendee(id int) error { - _, err := app.db.Exec( - `UPDATE attendees SET deleted_at = ?, updated_at = ? WHERE id = ?`, now(), now(), id, - ) - return err -} - -// checkInAttendee increments checked_in_count by count (capped at party_size). -// Sets checked_in and checked_in_at on the first check-in. -func (app *App) checkInAttendee(id, userID, count int) (*Attendee, error) { - if count < 1 { - count = 1 - } - a, err := app.getAttendee(id) - if err != nil || a == nil { - return nil, err - } - remaining := a.PartySize - a.CheckedInCount - if count > remaining { - count = remaining - } - if count <= 0 { - return a, nil - } - t := now() - _, err = app.db.Exec(` - UPDATE attendees SET - checked_in_count = checked_in_count + ?, - checked_in = CASE WHEN checked_in = 0 THEN 1 ELSE checked_in END, - checked_in_at = CASE WHEN checked_in = 0 THEN ? ELSE checked_in_at END, - checked_in_by = CASE WHEN checked_in = 0 THEN ? ELSE checked_in_by END, - updated_at = ? - WHERE id = ? AND deleted_at IS NULL`, - count, t, userID, t, id, - ) - if err != nil { - return nil, err - } - return app.getAttendee(id) -} - -func (app *App) attendeesSince(since string) ([]Attendee, error) { - return queryAttendees(app.db, - `SELECT `+attendeeCols+` FROM attendees WHERE updated_at > ? ORDER BY updated_at ASC`, since) -} - -func queryAttendees(db *sql.DB, q string, args ...any) ([]Attendee, error) { - rows, err := db.Query(q, args...) - if err != nil { - return nil, err - } - defer rows.Close() - var result []Attendee - for rows.Next() { - var a Attendee - var checkedIn int - var token sql.NullString - if err := rows.Scan( - &a.ID, &a.Name, &a.Email, &a.Phone, &a.TicketID, &a.TicketType, - &token, &a.PartySize, &checkedIn, &a.CheckedInCount, - &a.CheckedInAt, &a.CheckedInBy, &a.Note, - &a.CreatedAt, &a.UpdatedAt, &a.DeletedAt, - ); err != nil { - return nil, err - } - if token.Valid && token.String != "" { - a.VolunteerToken = &token.String - } - a.CheckedIn = checkedIn == 1 - if a.PartySize < 1 { - a.PartySize = 1 - } - result = append(result, a) - } - return result, rows.Err() -} - -func (app *App) attendeeTicketTypes() ([]string, error) { - rows, err := app.db.Query( - `SELECT DISTINCT ticket_type FROM attendees 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() -} - -func (app *App) attendeeCounts() (total, checkedIn int, err error) { - app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE deleted_at IS NULL`).Scan(&total) - app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE checked_in=1 AND deleted_at IS NULL`).Scan(&checkedIn) - return -} - // --- Participants --- const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, email_confirmed, confirmation_token, created_at, updated_at, deleted_at` @@ -1021,15 +730,6 @@ func (app *App) getTicket(id int) (*Ticket, error) { 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) @@ -1066,16 +766,6 @@ func (app *App) deleteTicket(id int) error { 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 { @@ -1244,15 +934,6 @@ func (app *App) getVolunteer(id int) (*Volunteer, error) { 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 - } - return &rows[0], nil -} - func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { res, err := app.db.Exec( `INSERT INTO volunteers (participant_id, department_id, is_lead, note, updated_at) diff --git a/db_test.go b/db_test.go index edb2b6d..5755d08 100644 --- a/db_test.go +++ b/db_test.go @@ -7,7 +7,7 @@ import ( func TestMigrate(t *testing.T) { app := testApp(t) // Verify tables exist by querying each one - tables := []string{"event", "participants", "participant_roles", "attendees", "departments", "volunteers", "shifts", "volunteer_shifts"} + tables := []string{"event", "participants", "participant_roles", "departments", "volunteers", "shifts", "volunteer_shifts"} for _, table := range tables { var count int err := app.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) @@ -17,98 +17,6 @@ func TestMigrate(t *testing.T) { } } -func TestAttendeesCRUD(t *testing.T) { - app := testApp(t) - - a, err := app.createAttendee(Attendee{Name: "Titania", Email: "titania@test.com", TicketType: "GA"}) - if err != nil { - t.Fatal(err) - } - if a.ID == 0 || a.Name != "Titania" { - t.Errorf("create: got %+v", a) - } - - got, err := app.getAttendee(a.ID) - if err != nil || got == nil { - t.Fatal("get: not found") - } - if got.Email != "titania@test.com" { - t.Errorf("get: email = %q", got.Email) - } - - got.Name = "Titania Fairweather" - if err := app.updateAttendee(*got); err != nil { - t.Fatal(err) - } - got2, _ := app.getAttendee(a.ID) - if got2.Name != "Titania Fairweather" { - t.Errorf("update: name = %q", got2.Name) - } - - if err := app.deleteAttendee(a.ID); err != nil { - t.Fatal(err) - } - // getAttendee returns soft-deleted records; listAttendees filters them - attendees, _ := app.listAttendees("", "", "") - for _, at := range attendees { - if at.ID == a.ID { - t.Error("delete: still visible in list") - } - } -} - -func TestIncrementPartySize(t *testing.T) { - app := testApp(t) - - app.createAttendee(Attendee{Name: "Oberon", TicketID: "ORD-100"}) - - merged, err := app.incrementPartySize("Oberon", "ORD-100") - if err != nil || !merged { - t.Fatalf("increment: merged=%v, err=%v", merged, err) - } - - a, _ := app.getAttendee(1) - if a.PartySize != 2 { - t.Errorf("party_size = %d, want 2", a.PartySize) - } - - // Different ticket_id should not merge - merged2, _ := app.incrementPartySize("Oberon", "ORD-200") - if merged2 { - t.Error("should not merge different ticket_id") - } -} - -func TestCheckInAttendee(t *testing.T) { - app := testApp(t) - admin := testAdminUser(t, app) - - app.createAttendee(Attendee{Name: "Puck"}) - // Set party_size directly since createAttendee defaults to 1 - app.db.Exec(`UPDATE attendees SET party_size = 3 WHERE id = 1`) - - // Check in 1 - a, err := app.checkInAttendee(1, admin.ID, 1) - if err != nil { - t.Fatal(err) - } - if a.CheckedInCount != 1 || !a.CheckedIn { - t.Errorf("after 1: count=%d, checked_in=%v", a.CheckedInCount, a.CheckedIn) - } - - // Check in 2 more (should cap at party_size=3) - a, _ = app.checkInAttendee(1, admin.ID, 5) - if a.CheckedInCount != 3 { - t.Errorf("after cap: count=%d, want 3", a.CheckedInCount) - } - - // Check in again — already full, should stay at 3 - a, _ = app.checkInAttendee(1, admin.ID, 1) - if a.CheckedInCount != 3 { - t.Errorf("after full: count=%d, want 3", a.CheckedInCount) - } -} - func TestGenerateToken(t *testing.T) { token, err := generateToken() if err != nil {