package storage import ( "context" "errors" "fmt" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "github.com/alchemistkay/guestguard/internal/domain" ) // CheckInRepo holds the check_ins table. Tier 2 Block H. type CheckInRepo struct { pool *pgxpool.Pool } func NewCheckInRepo(db *DB) *CheckInRepo { return &CheckInRepo{pool: db.Pool} } type RecordCheckInParams struct { GuestID uuid.UUID CheckedInBy uuid.UUID ArrivalCount int Notes string WalkIn bool } // Record inserts a check-in. The UNIQUE on guest_id surfaces a // double-check-in as domain.ErrAlreadyCheckedIn so the scanner UI can // show a clear "already in" message instead of a generic 500. func (r *CheckInRepo) Record(ctx context.Context, p RecordCheckInParams) (*domain.CheckIn, error) { if p.ArrivalCount <= 0 { p.ArrivalCount = 1 } const q = ` INSERT INTO check_ins (guest_id, checked_in_by, arrival_count, notes, walk_in) VALUES ($1, $2, $3, NULLIF($4, ''), $5) RETURNING id, guest_id, checked_in_at, checked_in_by, arrival_count, notes, walk_in ` var c domain.CheckIn err := r.pool.QueryRow(ctx, q, p.GuestID, p.CheckedInBy, p.ArrivalCount, p.Notes, p.WalkIn, ).Scan(&c.ID, &c.GuestID, &c.CheckedInAt, &c.CheckedInBy, &c.ArrivalCount, &c.Notes, &c.WalkIn) if err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { return nil, domain.ErrAlreadyCheckedIn } return nil, fmt.Errorf("record check-in: %w", err) } return &c, nil } // ListByEvent returns every check-in on an event, newest first. Powers // the live arrivals list on the dashboard. func (r *CheckInRepo) ListByEvent(ctx context.Context, eventID uuid.UUID) ([]domain.CheckIn, error) { rows, err := r.pool.Query(ctx, ` SELECT c.id, c.guest_id, c.checked_in_at, c.checked_in_by, c.arrival_count, c.notes, c.walk_in FROM check_ins c JOIN guests g ON g.id = c.guest_id WHERE g.event_id = $1 ORDER BY c.checked_in_at DESC `, eventID) if err != nil { return nil, err } defer rows.Close() out := []domain.CheckIn{} for rows.Next() { var c domain.CheckIn if err := rows.Scan(&c.ID, &c.GuestID, &c.CheckedInAt, &c.CheckedInBy, &c.ArrivalCount, &c.Notes, &c.WalkIn); err != nil { return nil, err } out = append(out, c) } return out, rows.Err() } // Summary returns the headcount totals: how many people walked in, and // how many were expected (sum of attending RSVPs + their plus_ones). func (r *CheckInRepo) Summary(ctx context.Context, eventID uuid.UUID) (domain.CheckInSummary, error) { var s domain.CheckInSummary err := r.pool.QueryRow(ctx, ` SELECT COALESCE(SUM(c.arrival_count), 0) AS arrived_headcount, ( SELECT COALESCE(SUM(1 + r.plus_ones), 0) FROM rsvps r JOIN guests g ON g.id = r.guest_id WHERE g.event_id = $1 AND r.response = 'attending' ) AS expected_headcount, COUNT(c.id) AS guests_checked_in FROM check_ins c JOIN guests g ON g.id = c.guest_id WHERE g.event_id = $1 `, eventID).Scan(&s.ArrivedHeadcount, &s.ExpectedHeadcount, &s.GuestsCheckedIn) return s, err } // GuestBelongsToEvent confirms a guest is on the event before we record // their check-in. Belt-and-braces guard against a forged JWT pointing // at a guest from a different event — the JWT layer already binds // (event_id, guest_id) but a DB-level check is cheap insurance. func (r *CheckInRepo) GuestBelongsToEvent(ctx context.Context, guestID, eventID uuid.UUID) (bool, error) { var ok bool err := r.pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM guests WHERE id = $1 AND event_id = $2)`, guestID, eventID).Scan(&ok) return ok, err }