package ads import ( "context" "database/sql" "errors" "fmt" "time" "github.com/go-jet/jet/v2/postgres" "github.com/go-jet/jet/v2/qrm" "github.com/google/uuid" "scrabble/backend/internal/postgres/jet/backend/model" "scrabble/backend/internal/postgres/jet/backend/table" ) // Store is the Postgres-backed query surface for advertising campaigns, their // messages and the single global display-settings row. type Store struct { db *sql.DB } // NewStore constructs a Store wrapping db. func NewStore(db *sql.DB) *Store { return &Store{db: db} } // ListCampaigns returns every campaign with its messages, the default first then // by creation time, for the admin console. func (s *Store) ListCampaigns(ctx context.Context) ([]Campaign, error) { return s.loadCampaigns(ctx, false) } // ActiveCampaigns returns the enabled campaigns with their messages, the default // first then by creation time. Window filtering and the default-remainder weight // are applied by the Service (ActiveSet), not here. func (s *Store) ActiveCampaigns(ctx context.Context) ([]Campaign, error) { return s.loadCampaigns(ctx, true) } // loadCampaigns reads campaigns (optionally only the enabled ones) and attaches // each one's messages in display order, with a single messages query (the table // is small, so this avoids both an N+1 and a join projection). func (s *Store) loadCampaigns(ctx context.Context, enabledOnly bool) ([]Campaign, error) { sel := postgres.SELECT(table.AdCampaigns.AllColumns).FROM(table.AdCampaigns) if enabledOnly { sel = sel.WHERE(table.AdCampaigns.Enabled.EQ(postgres.Bool(true))) } sel = sel.ORDER_BY(table.AdCampaigns.IsDefault.DESC(), table.AdCampaigns.CreatedAt.ASC()) var rows []model.AdCampaigns if err := sel.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("ads: list campaigns: %w", err) } byID, err := s.messagesByCampaign(ctx) if err != nil { return nil, err } out := make([]Campaign, 0, len(rows)) for _, r := range rows { c := modelToCampaign(r) c.Messages = byID[r.CampaignID] out = append(out, c) } return out, nil } // Campaign returns one campaign with its messages, or ErrNotFound. func (s *Store) Campaign(ctx context.Context, id uuid.UUID) (Campaign, error) { sel := postgres.SELECT(table.AdCampaigns.AllColumns). FROM(table.AdCampaigns). WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(id))). LIMIT(1) var row model.AdCampaigns if err := sel.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return Campaign{}, ErrNotFound } return Campaign{}, fmt.Errorf("ads: get campaign %s: %w", id, err) } c := modelToCampaign(row) msgs, err := s.messagesFor(ctx, id) if err != nil { return Campaign{}, err } c.Messages = msgs return c, nil } // CreateCampaign inserts a campaign (always non-default) and returns its new id. func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) { id := uuid.New() now := time.Now().UTC() stmt := table.AdCampaigns.INSERT( table.AdCampaigns.CampaignID, table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.IsDefault, table.AdCampaigns.Enabled, table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, table.AdCampaigns.CreatedAt, table.AdCampaigns.UpdatedAt, ).VALUES( postgres.UUID(id), postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.Bool(false), postgres.Bool(c.Enabled), tsOrNull(c.StartsAt), tsOrNull(c.EndsAt), postgres.TimestampzT(now), postgres.TimestampzT(now), ) if _, err := stmt.ExecContext(ctx, s.db); err != nil { return uuid.Nil, fmt.Errorf("ads: create campaign: %w", err) } return id, nil } // UpdateCampaign updates a campaign's name, weight, enabled flag and validity // window. The default flag is never touched here. Returns ErrNotFound when no // campaign matches. func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) error { stmt := table.AdCampaigns.UPDATE( table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.Enabled, table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, table.AdCampaigns.UpdatedAt, ).SET( postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.Bool(c.Enabled), tsOrNull(c.StartsAt), tsOrNull(c.EndsAt), postgres.TimestampzT(time.Now().UTC()), ).WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(c.ID))) return execOne(ctx, s.db, stmt, "update campaign") } // DeleteCampaign removes a campaign (its messages cascade). Returns ErrNotFound // when no campaign matches. The Service refuses to delete the default. func (s *Store) DeleteCampaign(ctx context.Context, id uuid.UUID) error { stmt := table.AdCampaigns.DELETE().WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(id))) return execOne(ctx, s.db, stmt, "delete campaign") } // AddMessage inserts a message into a campaign and returns its new id. func (s *Store) AddMessage(ctx context.Context, m Message) (uuid.UUID, error) { id := uuid.New() now := time.Now().UTC() stmt := table.AdMessages.INSERT( table.AdMessages.MessageID, table.AdMessages.CampaignID, table.AdMessages.Position, table.AdMessages.BodyEn, table.AdMessages.BodyRu, table.AdMessages.CreatedAt, table.AdMessages.UpdatedAt, ).VALUES( postgres.UUID(id), postgres.UUID(m.CampaignID), postgres.Int(int64(m.Position)), postgres.String(m.BodyEn), postgres.String(m.BodyRu), postgres.TimestampzT(now), postgres.TimestampzT(now), ) if _, err := stmt.ExecContext(ctx, s.db); err != nil { return uuid.Nil, fmt.Errorf("ads: add message: %w", err) } return id, nil } // UpdateMessageBodies updates a message's bilingual bodies (its position is // unchanged). Returns ErrNotFound when no message matches. func (s *Store) UpdateMessageBodies(ctx context.Context, id uuid.UUID, bodyEn, bodyRu string) error { stmt := table.AdMessages.UPDATE( table.AdMessages.BodyEn, table.AdMessages.BodyRu, table.AdMessages.UpdatedAt, ).SET( postgres.String(bodyEn), postgres.String(bodyRu), postgres.TimestampzT(time.Now().UTC()), ).WHERE(table.AdMessages.MessageID.EQ(postgres.UUID(id))) return execOne(ctx, s.db, stmt, "update message") } // SetMessagePosition updates only a message's position (used to reorder). func (s *Store) SetMessagePosition(ctx context.Context, id uuid.UUID, position int) error { stmt := table.AdMessages.UPDATE(table.AdMessages.Position, table.AdMessages.UpdatedAt). SET(postgres.Int(int64(position)), postgres.TimestampzT(time.Now().UTC())). WHERE(table.AdMessages.MessageID.EQ(postgres.UUID(id))) return execOne(ctx, s.db, stmt, "reorder message") } // DeleteMessage removes a message. Returns ErrNotFound when no message matches. func (s *Store) DeleteMessage(ctx context.Context, id uuid.UUID) error { stmt := table.AdMessages.DELETE().WHERE(table.AdMessages.MessageID.EQ(postgres.UUID(id))) return execOne(ctx, s.db, stmt, "delete message") } // Settings returns the global display timings. func (s *Store) Settings(ctx context.Context) (Timings, error) { sel := postgres.SELECT(table.AdSettings.AllColumns).FROM(table.AdSettings).LIMIT(1) var row model.AdSettings if err := sel.QueryContext(ctx, s.db, &row); err != nil { return Timings{}, fmt.Errorf("ads: get settings: %w", err) } return Timings{ HoldMs: int(row.HoldMs), EdgePauseMs: int(row.EdgePauseMs), ScrollPxPerSec: int(row.ScrollPxPerSec), FadeOutMs: int(row.FadeOutMs), GapMs: int(row.GapMs), FadeInMs: int(row.FadeInMs), }, nil } // UpdateSettings overwrites the single global display-settings row. func (s *Store) UpdateSettings(ctx context.Context, t Timings) error { stmt := table.AdSettings.UPDATE( table.AdSettings.HoldMs, table.AdSettings.EdgePauseMs, table.AdSettings.ScrollPxPerSec, table.AdSettings.FadeOutMs, table.AdSettings.GapMs, table.AdSettings.FadeInMs, table.AdSettings.UpdatedAt, ).SET( postgres.Int(int64(t.HoldMs)), postgres.Int(int64(t.EdgePauseMs)), postgres.Int(int64(t.ScrollPxPerSec)), postgres.Int(int64(t.FadeOutMs)), postgres.Int(int64(t.GapMs)), postgres.Int(int64(t.FadeInMs)), postgres.TimestampzT(time.Now().UTC()), ).WHERE(table.AdSettings.ID.EQ(postgres.Bool(true))) if _, err := stmt.ExecContext(ctx, s.db); err != nil { return fmt.Errorf("ads: update settings: %w", err) } return nil } // messagesByCampaign loads every message grouped by campaign id, in display order. func (s *Store) messagesByCampaign(ctx context.Context) (map[uuid.UUID][]Message, error) { sel := postgres.SELECT(table.AdMessages.AllColumns). FROM(table.AdMessages). ORDER_BY(table.AdMessages.CampaignID.ASC(), table.AdMessages.Position.ASC(), table.AdMessages.CreatedAt.ASC()) var rows []model.AdMessages if err := sel.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("ads: list messages: %w", err) } out := make(map[uuid.UUID][]Message, len(rows)) for _, r := range rows { out[r.CampaignID] = append(out[r.CampaignID], modelToMessage(r)) } return out, nil } // messagesFor loads one campaign's messages in display order. func (s *Store) messagesFor(ctx context.Context, campaignID uuid.UUID) ([]Message, error) { sel := postgres.SELECT(table.AdMessages.AllColumns). FROM(table.AdMessages). WHERE(table.AdMessages.CampaignID.EQ(postgres.UUID(campaignID))). ORDER_BY(table.AdMessages.Position.ASC(), table.AdMessages.CreatedAt.ASC()) var rows []model.AdMessages if err := sel.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("ads: list messages for %s: %w", campaignID, err) } out := make([]Message, 0, len(rows)) for _, r := range rows { out = append(out, modelToMessage(r)) } return out, nil } // execOne runs a statement expected to touch exactly one row, mapping a zero // row-count to ErrNotFound. func execOne(ctx context.Context, db qrm.Executable, stmt postgres.Statement, what string) error { res, err := stmt.ExecContext(ctx, db) if err != nil { return fmt.Errorf("ads: %s: %w", what, err) } n, err := res.RowsAffected() if err != nil { return fmt.Errorf("ads: %s rows: %w", what, err) } if n == 0 { return ErrNotFound } return nil } func modelToCampaign(r model.AdCampaigns) Campaign { return Campaign{ ID: r.CampaignID, Name: r.Name, Weight: int(r.Weight), IsDefault: r.IsDefault, Enabled: r.Enabled, StartsAt: r.StartsAt, EndsAt: r.EndsAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, } } func modelToMessage(r model.AdMessages) Message { return Message{ ID: r.MessageID, CampaignID: r.CampaignID, Position: int(r.Position), BodyEn: r.BodyEn, BodyRu: r.BodyRu, } } // tsOrNull renders a nullable validity-window bound: NULL for a nil pointer, // otherwise the UTC timestamp. func tsOrNull(t *time.Time) postgres.Expression { if t == nil { return postgres.NULL } return postgres.TimestampzT(t.UTC()) }