package storage import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "github.com/alchemistkay/guestguard/internal/domain" ) type EventRepo struct { pool *pgxpool.Pool } func NewEventRepo(db *DB) *EventRepo { return &EventRepo{pool: db.Pool} } type CreateEventParams struct { HostID uuid.UUID Name string Slug string EventDate time.Time Venue string MaxCapacity int Settings map[string]any Status domain.EventStatus } func (r *EventRepo) Create(ctx context.Context, p CreateEventParams) (*domain.Event, error) { settings := p.Settings if settings == nil { settings = map[string]any{} } settingsJSON, err := json.Marshal(settings) if err != nil { return nil, fmt.Errorf("marshal settings: %w", err) } // Block C: every new event needs a row in event_collaborators pointing // the host at the owner role. We do both inserts in one transaction so // an event can never exist without its owner row (the migration backfill // handles legacy events but not new ones). tx, err := r.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) const q = ` INSERT INTO events (host_id, name, slug, event_date, venue, max_capacity, settings, status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at, fraud_medium_threshold, fraud_high_threshold, fraud_block_threshold ` row := tx.QueryRow(ctx, q, p.HostID, p.Name, p.Slug, p.EventDate, p.Venue, p.MaxCapacity, settingsJSON, p.Status, ) ev, err := scanEvent(row) if err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { return nil, domain.ErrSlugTaken } return nil, err } if _, err := tx.Exec(ctx, ` INSERT INTO event_collaborators (event_id, user_id, role, invited_at, accepted_at) VALUES ($1, $2, 'owner', now(), now()) `, ev.ID, p.HostID); err != nil { return nil, fmt.Errorf("seed owner collaborator: %w", err) } // Block F: auto-seed reminder messages so the host gets the // "we'll nudge people for you" experience without lifting a finger. // Rows whose send_at would fall in the past are skipped by // SeedAutoReminders — typical for events created close to the date. // Hosts can edit / cancel any of these from the Communications tab. for _, m := range domain.SeedAutoReminders(ev.ID, ev.EventDate) { if _, err := tx.Exec(ctx, ` INSERT INTO scheduled_messages (event_id, send_at, audience, channel, template_key, subject, body, status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) `, m.EventID, m.SendAt, m.Audience, m.Channel, m.TemplateKey, m.Subject, m.Body, m.Status); err != nil { return nil, fmt.Errorf("seed auto-reminder %s: %w", ifNilString(m.TemplateKey), err) } } if err := tx.Commit(ctx); err != nil { return nil, err } return ev, nil } // ifNilString is a tiny helper so the error message above stays readable // when an auto-reminder row somehow doesn't carry a template key. func ifNilString(p *string) string { if p == nil { return "" } return *p } func (r *EventRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Event, error) { const q = ` SELECT id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at, fraud_medium_threshold, fraud_high_threshold, fraud_block_threshold FROM events WHERE id = $1 ` ev, err := scanEvent(r.pool.QueryRow(ctx, q, id)) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, domain.ErrEventNotFound } return nil, err } return ev, nil } // GetForHost is the authz-aware variant of Get. It returns ErrEventNotFound // when the event either doesn't exist or doesn't belong to the host — by // merging both cases we avoid leaking existence on cross-tenant lookups. func (r *EventRepo) GetForHost(ctx context.Context, id, hostID uuid.UUID) (*domain.Event, error) { const q = ` SELECT id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at, fraud_medium_threshold, fraud_high_threshold, fraud_block_threshold FROM events WHERE id = $1 AND host_id = $2 ` ev, err := scanEvent(r.pool.QueryRow(ctx, q, id, hostID)) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, domain.ErrEventNotFound } return nil, err } return ev, nil } // ListForUser returns every event the user has any accepted role on. The // query unions events where the user is the legacy host_id with events // they collaborate on (via Block C). Duplicates are deduped on event id. // Block C — preferred over List for the dashboard since collaborators // should see shared events too. func (r *EventRepo) ListForUser(ctx context.Context, userID uuid.UUID, collabEventIDs []uuid.UUID, limit, offset int) ([]*domain.Event, error) { if limit <= 0 || limit > 200 { limit = 50 } if offset < 0 { offset = 0 } rows, err := r.pool.Query(ctx, ` SELECT DISTINCT id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at, fraud_medium_threshold, fraud_high_threshold, fraud_block_threshold FROM events WHERE host_id = $1 OR id = ANY($2::uuid[]) ORDER BY created_at DESC LIMIT $3 OFFSET $4 `, userID, collabEventIDs, limit, offset) if err != nil { return nil, err } defer rows.Close() var out []*domain.Event for rows.Next() { ev, err := scanEvent(rows) if err != nil { return nil, err } out = append(out, ev) } return out, rows.Err() } func (r *EventRepo) List(ctx context.Context, hostID uuid.UUID, limit, offset int) ([]*domain.Event, error) { if limit <= 0 || limit > 200 { limit = 50 } if offset < 0 { offset = 0 } var ( rows pgx.Rows err error ) if hostID == uuid.Nil { rows, err = r.pool.Query(ctx, ` SELECT id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at, fraud_medium_threshold, fraud_high_threshold, fraud_block_threshold FROM events ORDER BY created_at DESC LIMIT $1 OFFSET $2 `, limit, offset) } else { rows, err = r.pool.Query(ctx, ` SELECT id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at, fraud_medium_threshold, fraud_high_threshold, fraud_block_threshold FROM events WHERE host_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3 `, hostID, limit, offset) } if err != nil { return nil, err } defer rows.Close() var out []*domain.Event for rows.Next() { ev, err := scanEvent(rows) if err != nil { return nil, err } out = append(out, ev) } return out, rows.Err() } type UpdateEventParams struct { Name *string Slug *string EventDate *time.Time Venue *string MaxCapacity *int Settings *map[string]any Status *domain.EventStatus } // UpdateByID applies the patch without an authz scope. Block C handlers // run requireRole first, so re-checking host_id here would block legitimate // editor-collaborators on someone else's event. func (r *EventRepo) UpdateByID(ctx context.Context, id uuid.UUID, p UpdateEventParams) (*domain.Event, error) { return r.update(ctx, id, uuid.Nil, p, false) } // Update is the legacy host-scoped variant — preserved so a few owner-only // call sites stay terse. New code should prefer UpdateByID + requireRole. func (r *EventRepo) Update(ctx context.Context, id, hostID uuid.UUID, p UpdateEventParams) (*domain.Event, error) { return r.update(ctx, id, hostID, p, true) } func (r *EventRepo) update(ctx context.Context, id, hostID uuid.UUID, p UpdateEventParams, scopeToHost bool) (*domain.Event, error) { q := ` UPDATE events SET name = COALESCE($3, name), slug = COALESCE($4, slug), event_date = COALESCE($5, event_date), venue = COALESCE($6, venue), max_capacity = COALESCE($7, max_capacity), settings = COALESCE($8, settings), status = COALESCE($9, status), updated_at = now() WHERE id = $1` if scopeToHost { q += ` AND host_id = $2` } else { // Bind $2 anyway so the parameter count matches the call below. q += ` AND ($2::uuid IS NULL OR $2::uuid = host_id OR TRUE)` } q += ` RETURNING id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at, fraud_medium_threshold, fraud_high_threshold, fraud_block_threshold ` var settingsJSON []byte if p.Settings != nil { b, err := json.Marshal(*p.Settings) if err != nil { return nil, fmt.Errorf("marshal settings: %w", err) } settingsJSON = b } row := r.pool.QueryRow(ctx, q, id, hostID, p.Name, p.Slug, p.EventDate, p.Venue, p.MaxCapacity, settingsJSON, p.Status, ) ev, err := scanEvent(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, domain.ErrEventNotFound } var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { return nil, domain.ErrSlugTaken } return nil, err } return ev, nil } func (r *EventRepo) Delete(ctx context.Context, id, hostID uuid.UUID) error { tag, err := r.pool.Exec(ctx, `DELETE FROM events WHERE id = $1 AND host_id = $2`, id, hostID) if err != nil { return err } if tag.RowsAffected() == 0 { return domain.ErrEventNotFound } return nil } // DeleteByID is the post-Block-C path: requireRole(Owner) is enforced by // the handler, so this query doesn't double-check host_id. func (r *EventRepo) DeleteByID(ctx context.Context, id uuid.UUID) error { tag, err := r.pool.Exec(ctx, `DELETE FROM events WHERE id = $1`, id) if err != nil { return err } if tag.RowsAffected() == 0 { return domain.ErrEventNotFound } return nil } type rowScanner interface { Scan(dest ...any) error } func scanEvent(s rowScanner) (*domain.Event, error) { var ( ev domain.Event settingsJSON []byte ) err := s.Scan( &ev.ID, &ev.HostID, &ev.Name, &ev.Slug, &ev.EventDate, &ev.Venue, &ev.MaxCapacity, &settingsJSON, &ev.Status, &ev.CreatedAt, &ev.UpdatedAt, &ev.FraudMediumThreshold, &ev.FraudHighThreshold, &ev.FraudBlockThreshold, ) if err != nil { return nil, err } if len(settingsJSON) > 0 { if err := json.Unmarshal(settingsJSON, &ev.Settings); err != nil { return nil, fmt.Errorf("unmarshal settings: %w", err) } } else { ev.Settings = map[string]any{} } return &ev, nil }