package storage import ( "context" "errors" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/alchemistkay/guestguard/internal/domain" ) // BrandingRepo holds the per-event customisation row. Updates are upserts — // a host's first PATCH to the branding endpoint inserts the row, subsequent // PATCHes update only the fields the host sent. Tier 2 Block D. type BrandingRepo struct { pool *pgxpool.Pool } func NewBrandingRepo(db *DB) *BrandingRepo { return &BrandingRepo{pool: db.Pool} } // Get returns the branding row for `eventID`, or ErrBrandingNotFound when // the event has no customisation yet. The caller should render the default // theme in that case — a missing row isn't an error condition. func (r *BrandingRepo) Get(ctx context.Context, eventID uuid.UUID) (*domain.Branding, error) { const q = ` SELECT event_id, primary_color, accent_color, logo_url, cover_image_url, font_family, greeting_message, updated_at FROM event_branding WHERE event_id = $1 ` var b domain.Branding err := r.pool.QueryRow(ctx, q, eventID).Scan( &b.EventID, &b.PrimaryColor, &b.AccentColor, &b.LogoURL, &b.CoverImageURL, &b.FontFamily, &b.GreetingMessage, &b.UpdatedAt, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, domain.ErrBrandingNotFound } return nil, err } return &b, nil } // UpsertParams holds the patchable fields. Nil pointers leave the existing // value untouched on update; empty strings clear the column to NULL (so // hosts can revert to defaults without deleting the whole row). type UpsertBrandingParams struct { EventID uuid.UUID PrimaryColor *string AccentColor *string LogoURL *string CoverImageURL *string FontFamily *string GreetingMessage *string } // Upsert inserts or partially updates the branding row. The COALESCE + // NULLIF idiom in the UPDATE branch means a nil pointer maps to NULL in // SQL and is coalesced away (= keep existing), while an empty string is // kept as-is and stored as NULL (= clear). That lets the API support both // "no change" and "reset to default" cleanly. func (r *BrandingRepo) Upsert(ctx context.Context, p UpsertBrandingParams) (*domain.Branding, error) { const q = ` INSERT INTO event_branding ( event_id, primary_color, accent_color, logo_url, cover_image_url, font_family, greeting_message, updated_at ) VALUES ( $1, NULLIF($2, ''), NULLIF($3, ''), NULLIF($4, ''), NULLIF($5, ''), NULLIF($6, ''), NULLIF($7, ''), now() ) ON CONFLICT (event_id) DO UPDATE SET primary_color = COALESCE($2, event_branding.primary_color), accent_color = COALESCE($3, event_branding.accent_color), logo_url = COALESCE($4, event_branding.logo_url), cover_image_url = COALESCE($5, event_branding.cover_image_url), font_family = COALESCE($6, event_branding.font_family), greeting_message = COALESCE($7, event_branding.greeting_message), updated_at = now() RETURNING event_id, primary_color, accent_color, logo_url, cover_image_url, font_family, greeting_message, updated_at ` // Convert "" → NULL handling: we pass the same *string into both // COALESCE (the keep-existing path) and NULLIF (the reset path). The // caller passes nil to mean "leave alone" — we surface that as NULL // pgx side which COALESCE swallows. var b domain.Branding err := r.pool.QueryRow(ctx, q, p.EventID, nilOrPtr(p.PrimaryColor), nilOrPtr(p.AccentColor), nilOrPtr(p.LogoURL), nilOrPtr(p.CoverImageURL), nilOrPtr(p.FontFamily), nilOrPtr(p.GreetingMessage), ).Scan( &b.EventID, &b.PrimaryColor, &b.AccentColor, &b.LogoURL, &b.CoverImageURL, &b.FontFamily, &b.GreetingMessage, &b.UpdatedAt, ) if err != nil { return nil, err } return &b, nil } // nilOrPtr passes nil through as nil (pgx → NULL); otherwise unwraps the // string so we get a TEXT param instead of pgx receiving a *string and // double-wrapping it. func nilOrPtr(s *string) any { if s == nil { return nil } return *s }