package storage import ( "context" "errors" "fmt" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/alchemistkay/guestguard/internal/domain" ) type GuestRepo struct { pool *pgxpool.Pool } func NewGuestRepo(db *DB) *GuestRepo { return &GuestRepo{pool: db.Pool} } type CreateGuestParams struct { EventID uuid.UUID Name string Email *string Phone *string PlusOnes int DietaryNotes *string TableNumber *int } func (r *GuestRepo) Create(ctx context.Context, p CreateGuestParams) (*domain.Guest, error) { const q = ` INSERT INTO guests (event_id, name, email, phone, plus_ones, dietary_notes, table_number) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, event_id, name, email, phone, plus_ones, dietary_notes, table_number, created_at ` row := r.pool.QueryRow(ctx, q, p.EventID, p.Name, p.Email, p.Phone, p.PlusOnes, p.DietaryNotes, p.TableNumber, ) return scanGuest(row) } func (r *GuestRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Guest, error) { const q = ` SELECT id, event_id, name, email, phone, plus_ones, dietary_notes, table_number, created_at FROM guests WHERE id = $1 ` g, err := scanGuest(r.pool.QueryRow(ctx, q, id)) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, domain.ErrGuestNotFound } return nil, err } return g, nil } func (r *GuestRepo) ListByEvent(ctx context.Context, eventID uuid.UUID, limit, offset int) ([]*domain.Guest, error) { if limit <= 0 || limit > 500 { limit = 100 } if offset < 0 { offset = 0 } const q = ` SELECT id, event_id, name, email, phone, plus_ones, dietary_notes, table_number, created_at FROM guests WHERE event_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3 ` rows, err := r.pool.Query(ctx, q, eventID, limit, offset) if err != nil { return nil, err } defer rows.Close() var out []*domain.Guest for rows.Next() { g, err := scanGuest(rows) if err != nil { return nil, err } out = append(out, g) } return out, rows.Err() } // BulkImportRow is one normalised guest in a CSV import batch. type BulkImportRow struct { Name string Email string // empty if absent Phone string // empty if absent PlusOnes int } // BulkImportResult reports the outcome of a single import call. The // SkippedEmails slice records the addresses we silently dropped because a // guest already exists on the event — useful for the success summary. type BulkImportResult struct { Added int Skipped int SkippedEmails []string } // BulkImportGuests inserts up to len(rows) guest rows into the event in a // single transaction. Rows whose email matches an existing guest on the // event are skipped (idempotent re-imports). Within the batch, duplicate // emails after the first are also skipped. Either the entire batch // commits or none of it does. // // Empty email is treated as "no email" and not deduped — those rows // always insert (the host might be entering phone-only guests). func (r *GuestRepo) BulkImportGuests(ctx context.Context, eventID uuid.UUID, rows []BulkImportRow) (BulkImportResult, error) { res := BulkImportResult{} if len(rows) == 0 { return res, nil } tx, err := r.pool.Begin(ctx) if err != nil { return res, err } defer tx.Rollback(ctx) // Fetch existing emails on the event into a set for O(1) dedup. existing := map[string]struct{}{} exRows, err := tx.Query(ctx, `SELECT lower(email) FROM guests WHERE event_id = $1 AND email IS NOT NULL AND email <> ''`, eventID) if err != nil { return res, fmt.Errorf("load existing emails: %w", err) } for exRows.Next() { var e string if err := exRows.Scan(&e); err != nil { exRows.Close() return res, err } existing[e] = struct{}{} } exRows.Close() const ins = ` INSERT INTO guests (event_id, name, email, phone, plus_ones) VALUES ($1, $2, NULLIF($3, ''), NULLIF($4, ''), $5) ` for _, row := range rows { email := strings.ToLower(strings.TrimSpace(row.Email)) if email != "" { if _, dup := existing[email]; dup { res.Skipped++ res.SkippedEmails = append(res.SkippedEmails, email) continue } existing[email] = struct{}{} } if _, err := tx.Exec(ctx, ins, eventID, row.Name, email, row.Phone, row.PlusOnes); err != nil { return BulkImportResult{}, fmt.Errorf("insert guest %q: %w", row.Name, err) } res.Added++ } if err := tx.Commit(ctx); err != nil { return BulkImportResult{}, err } return res, nil } func scanGuest(s rowScanner) (*domain.Guest, error) { var g domain.Guest err := s.Scan( &g.ID, &g.EventID, &g.Name, &g.Email, &g.Phone, &g.PlusOnes, &g.DietaryNotes, &g.TableNumber, &g.CreatedAt, ) if err != nil { return nil, err } return &g, nil } // UpdateGuestParams patches a guest. Nil fields are left untouched. // An empty string for Email / Phone clears the column to NULL, matching // the frontend "clear this field" UX. type UpdateGuestParams struct { Name *string Email *string Phone *string PlusOnes *int } // Update applies the patch to (guestID, eventID). Event scoping in the // WHERE clause prevents a host from patching guests on another host's // event even if they guess the guest_id. Returns ErrGuestNotFound when // the guest doesn't exist on the event. func (r *GuestRepo) Update(ctx context.Context, eventID, guestID uuid.UUID, p UpdateGuestParams) (*domain.Guest, error) { sets := []string{} args := []any{guestID, eventID} add := func(col string, val any) { args = append(args, val) sets = append(sets, fmt.Sprintf("%s = $%d", col, len(args))) } if p.Name != nil { add("name", strings.TrimSpace(*p.Name)) } if p.Email != nil { if strings.TrimSpace(*p.Email) == "" { sets = append(sets, "email = NULL") } else { add("email", strings.ToLower(strings.TrimSpace(*p.Email))) } } if p.Phone != nil { if strings.TrimSpace(*p.Phone) == "" { sets = append(sets, "phone = NULL") } else { add("phone", strings.TrimSpace(*p.Phone)) } } if p.PlusOnes != nil { add("plus_ones", *p.PlusOnes) } if len(sets) == 0 { return r.Get(ctx, guestID) } q := fmt.Sprintf(` UPDATE guests SET %s WHERE id = $1 AND event_id = $2 RETURNING id, event_id, name, email, phone, plus_ones, dietary_notes, table_number, created_at `, strings.Join(sets, ", ")) g, err := scanGuest(r.pool.QueryRow(ctx, q, args...)) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, domain.ErrGuestNotFound } return nil, err } return g, nil } // Delete removes a guest from an event. Cascade-deletes any tokens, // rsvps, access_logs, and notifications tied to the guest. Event scoping // in the WHERE clause stops cross-tenant deletes. func (r *GuestRepo) Delete(ctx context.Context, eventID, guestID uuid.UUID) error { tag, err := r.pool.Exec(ctx, `DELETE FROM guests WHERE id = $1 AND event_id = $2`, guestID, eventID) if err != nil { return err } if tag.RowsAffected() == 0 { return domain.ErrGuestNotFound } return nil } // GuestForInvitation is the minimum data the bulk-invite path needs about // each candidate. Pulled in a single query joined against tokens so the // caller knows up-front who's already received an invitation. type GuestForInvitation struct { ID uuid.UUID Name string Email string // empty when the guest has no email on file HasToken bool } // ListGuestsForInvitation returns every guest on `eventID`, joined with // the tokens table so the caller can skip guests that already have one. // When `onlyIDs` is non-nil/non-empty, the result is filtered to that // subset (used for explicit selection in the bulk-send UI). func (r *GuestRepo) ListGuestsForInvitation(ctx context.Context, eventID uuid.UUID, onlyIDs []uuid.UUID) ([]GuestForInvitation, error) { var ( rows pgx.Rows err error ) if len(onlyIDs) == 0 { rows, err = r.pool.Query(ctx, ` SELECT g.id, g.name, COALESCE(g.email,''), (t.id IS NOT NULL) AS has_token FROM guests g LEFT JOIN tokens t ON t.guest_id = g.id WHERE g.event_id = $1 ORDER BY g.created_at ASC `, eventID) } else { rows, err = r.pool.Query(ctx, ` SELECT g.id, g.name, COALESCE(g.email,''), (t.id IS NOT NULL) AS has_token FROM guests g LEFT JOIN tokens t ON t.guest_id = g.id WHERE g.event_id = $1 AND g.id = ANY($2) ORDER BY g.created_at ASC `, eventID, onlyIDs) } if err != nil { return nil, err } defer rows.Close() var out []GuestForInvitation for rows.Next() { var g GuestForInvitation if err := rows.Scan(&g.ID, &g.Name, &g.Email, &g.HasToken); err != nil { return nil, err } out = append(out, g) } return out, rows.Err() } // GuestWithRSVP is the dashboard view: a guest plus the RSVP submitted // against their token, if any. RSVP fields are nil when no response yet. type GuestWithRSVP struct { *domain.Guest RSVPResponse *string `json:"rsvp_response,omitempty"` RSVPPlusOnes *int `json:"rsvp_plus_ones,omitempty"` RSVPRiskScore *int `json:"rsvp_risk_score,omitempty"` RSVPSubmittedAt *time.Time `json:"rsvp_submitted_at,omitempty"` HasToken bool `json:"has_token"` } func (r *GuestRepo) ListByEventWithRSVP(ctx context.Context, eventID uuid.UUID, limit, offset int) ([]*GuestWithRSVP, error) { if limit <= 0 || limit > 500 { limit = 100 } if offset < 0 { offset = 0 } const q = ` SELECT g.id, g.event_id, g.name, g.email, g.phone, g.plus_ones, g.dietary_notes, g.table_number, g.created_at, r.response, r.plus_ones, r.risk_score, r.submitted_at, t.id IS NOT NULL AS has_token FROM guests g LEFT JOIN rsvps r ON r.guest_id = g.id LEFT JOIN tokens t ON t.guest_id = g.id WHERE g.event_id = $1 ORDER BY g.created_at DESC LIMIT $2 OFFSET $3 ` rows, err := r.pool.Query(ctx, q, eventID, limit, offset) if err != nil { return nil, err } defer rows.Close() var out []*GuestWithRSVP for rows.Next() { var ( g domain.Guest response *string rsvpPlusOnes *int riskScore *int submittedAt *time.Time hasToken bool ) if err := rows.Scan( &g.ID, &g.EventID, &g.Name, &g.Email, &g.Phone, &g.PlusOnes, &g.DietaryNotes, &g.TableNumber, &g.CreatedAt, &response, &rsvpPlusOnes, &riskScore, &submittedAt, &hasToken, ); err != nil { return nil, err } out = append(out, &GuestWithRSVP{ Guest: &g, RSVPResponse: response, RSVPPlusOnes: rsvpPlusOnes, RSVPRiskScore: riskScore, RSVPSubmittedAt: submittedAt, HasToken: hasToken, }) } return out, rows.Err() }