feat(ads): server-driven ad-banner backend, wire & admin console (PR1)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s

Turn the gated-off mock banner into a real advertising subsystem (backend +
admin half; the UI rotation lands in PR2).

- internal/ads: campaigns (percent weight + validity window; a perpetual,
  undeletable default that fills the remainder up to 100%), 1..N bilingual
  messages (en+ru), global display timings; ActiveSet computes the
  window-filtered, default-remainder, GCD-reduced, language-resolved rotation
  feed. Smooth-weighted-round-robin math is unit-tested.
- migration 00006 (+ jetgen): ad_campaigns / ad_messages / ad_settings, seeded
  default campaign + house message + default timings.
- eligibility = !paid_account && hint_balance==0 && !no_banner role (new role;
  guests qualify). The resolved feed rides the profile.get response (no new RPC,
  works for guests, nothing distinct to filter); language by service_language.
- live update: a notify `banner` sub-kind (re-poll signal) published when an
  operator grants hints or grants/revokes no_banner, so the client shows/hides
  in place.
- admin console /_gm/banners (+ /_gm/banner-settings): campaign + message CRUD
  with reorder, default protection, clamped timings.
- wire: fbs BannerInfo/BannerCampaign on Profile; gateway transcode forwards it.
- docs: ARCHITECTURE §10, FUNCTIONAL (+ _ru), backend README, PRERELEASE tracker
  (incl. the deferred app.load aggregator note).
This commit is contained in:
Ilia Denisov
2026-06-15 23:00:19 +02:00
parent f59c8dcd43
commit 0946a3f66c
45 changed files with 2993 additions and 28 deletions
+188
View File
@@ -0,0 +1,188 @@
// Package ads owns the server-driven advertising banner ("advertising network"):
// operator-managed campaigns, their bilingual messages and the global display
// timings the client's one-line announcement strip rotates through.
//
// A campaign is one placement order with a show weight (an integer percent).
// Simultaneously active campaigns compete for display slots in proportion to
// their weights; the single perpetual default campaign fills the unsold
// remainder up to 100%. The actual rotation runs client-side (a smooth weighted
// round-robin over campaigns, round-robin over a campaign's messages); the server
// only computes the effective weighted, language-resolved set a viewer should
// rotate, via ActiveSet. Who sees a banner at all is decided by Eligible.
package ads
import (
"errors"
"time"
"github.com/google/uuid"
)
// ErrNotFound is returned when a campaign or message does not exist.
var ErrNotFound = errors.New("ads: not found")
// ErrDefaultImmutable is returned when an operation is refused on the perpetual
// default campaign (it cannot be deleted, disabled, windowed, or have its
// weight or default flag changed).
var ErrDefaultImmutable = errors.New("ads: the default campaign cannot be modified that way")
// ErrValidation wraps an operator-facing validation failure (a bad weight,
// window or message body).
var ErrValidation = errors.New("ads: validation")
// Campaign is one advertising placement order with its messages.
type Campaign struct {
ID uuid.UUID // uuid.Nil on create
Name string
Weight int // show percent, 1..100; nominal (ignored) for the default
IsDefault bool
Enabled bool
StartsAt *time.Time // nil = open-ended start; always nil for the default
EndsAt *time.Time // nil = open-ended end; always nil for the default
Messages []Message
CreatedAt time.Time
UpdatedAt time.Time
}
// Message is one bilingual creative of a campaign. Both bodies are mandatory;
// the client shows the variant for the viewer's bot (service) language. Position
// orders the messages within a campaign (the round-robin order).
type Message struct {
ID uuid.UUID // uuid.Nil on create
CampaignID uuid.UUID
Position int
BodyEn string
BodyRu string
}
// Timings are the global banner display timings, in milliseconds except
// ScrollPxPerSec. HoldMs is how long one message shows; EdgePauseMs and
// ScrollPxPerSec drive the scroll of a message wider than the strip; a
// transition between messages is FadeOutMs then GapMs then FadeInMs.
type Timings struct {
HoldMs int
EdgePauseMs int
ScrollPxPerSec int
FadeOutMs int
GapMs int
FadeInMs int
}
// ActiveCampaign is one campaign in the resolved rotation feed sent to a client:
// its GCD-reduced show weight and its messages, already resolved to the viewer's
// language and in display (round-robin) order.
type ActiveCampaign struct {
Weight int
Messages []string
}
// Eligible reports whether an account should be shown the advertising banner: a
// free account (not paid) with an empty hint wallet and without the no_banner
// role. The no_banner role suppresses the banner unconditionally; buying a paid
// account or any hints also removes it.
func Eligible(paidAccount bool, hintBalance int, hasNoBanner bool) bool {
return !paidAccount && hintBalance <= 0 && !hasNoBanner
}
// computeActiveSet builds the resolved rotation feed from the enabled campaigns
// at time now, in language lang. Campaigns outside their validity window, and
// campaigns with no messages, are dropped. The default campaign's effective
// weight is the remainder up to 100% — max(0, 100 - sum of active timed
// weights) — so it fills unsold inventory and is dropped entirely when timed
// campaigns already reach 100%. Weights are then reduced by their GCD so the
// fair rotation cycle stays short. The input is expected to be the enabled
// campaigns (ActiveCampaigns); disabled ones must already be excluded.
func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []ActiveCampaign {
var timed []Campaign
var def *Campaign
for i := range campaigns {
c := campaigns[i]
if len(c.Messages) == 0 {
continue // a campaign with no creative cannot fill a slot
}
if c.IsDefault {
def = &campaigns[i]
continue
}
if withinWindow(c, now) {
timed = append(timed, c)
}
}
sumTimed := 0
for _, c := range timed {
sumTimed += c.Weight
}
out := make([]ActiveCampaign, 0, len(timed)+1)
for _, c := range timed {
out = append(out, ActiveCampaign{Weight: c.Weight, Messages: resolveBodies(c.Messages, lang)})
}
if def != nil {
if dw := 100 - sumTimed; dw > 0 {
out = append(out, ActiveCampaign{Weight: dw, Messages: resolveBodies(def.Messages, lang)})
}
}
reduceByGCD(out)
return out
}
// ActiveAt reports whether the campaign would rotate at time now: enabled, and
// either the perpetual default or within its validity window. It mirrors the
// filter computeActiveSet applies (a campaign with no messages still reports
// active here — that is a content gap the console surfaces, not an inactive
// campaign).
func (c Campaign) ActiveAt(now time.Time) bool {
return c.Enabled && (c.IsDefault || withinWindow(c, now))
}
// withinWindow reports whether now lies inside the campaign's validity window. A
// nil bound is open-ended on that side.
func withinWindow(c Campaign, now time.Time) bool {
if c.StartsAt != nil && now.Before(*c.StartsAt) {
return false
}
if c.EndsAt != nil && now.After(*c.EndsAt) {
return false
}
return true
}
// resolveBodies projects each message to the body for lang ("ru" picks the
// Russian body; anything else picks English), preserving display order.
func resolveBodies(msgs []Message, lang string) []string {
out := make([]string, len(msgs))
for i, m := range msgs {
if lang == "ru" {
out[i] = m.BodyRu
} else {
out[i] = m.BodyEn
}
}
return out
}
// reduceByGCD divides every weight by the greatest common divisor of all weights
// (in place), shrinking a {50,30,20} set to {5,3,2} and a lone {100} to {1}. A
// no-op when the set is empty or already coprime.
func reduceByGCD(cs []ActiveCampaign) {
g := 0
for _, c := range cs {
g = gcd(g, c.Weight)
}
if g <= 1 {
return
}
for i := range cs {
cs[i].Weight /= g
}
}
// gcd returns the greatest common divisor of a and b (gcd(0, n) == n).
func gcd(a, b int) int {
for b != 0 {
a, b = b, a%b
}
if a < 0 {
return -a
}
return a
}
+183
View File
@@ -0,0 +1,183 @@
package ads
import (
"reflect"
"testing"
"time"
)
func TestEligible(t *testing.T) {
tests := []struct {
name string
paidAccount bool
hintBalance int
hasNoBanner bool
want bool
}{
{name: "free, empty wallet, no role", want: true},
{name: "paid", paidAccount: true, want: false},
{name: "has hints", hintBalance: 3, want: false},
{name: "no_banner role", hasNoBanner: true, want: false},
{name: "paid and has hints", paidAccount: true, hintBalance: 5, want: false},
{name: "no_banner overrides everything", hasNoBanner: true, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Eligible(tt.paidAccount, tt.hintBalance, tt.hasNoBanner); got != tt.want {
t.Errorf("Eligible(%v,%d,%v) = %v, want %v", tt.paidAccount, tt.hintBalance, tt.hasNoBanner, got, tt.want)
}
})
}
}
func TestComputeActiveSet(t *testing.T) {
now := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC)
past := now.Add(-24 * time.Hour)
future := now.Add(24 * time.Hour)
// msg builds a one-message slice with distinct bodies so resolution and
// campaign identity are visible in assertions.
msg := func(tag string) []Message {
return []Message{{BodyEn: tag + "-en", BodyRu: tag + "-ru"}}
}
def := func(msgs []Message) Campaign {
return Campaign{Name: "default", Weight: 100, IsDefault: true, Enabled: true, Messages: msgs}
}
timed := func(name string, weight int, starts, ends *time.Time, msgs []Message) Campaign {
return Campaign{Name: name, Weight: weight, Enabled: true, StartsAt: starts, EndsAt: ends, Messages: msgs}
}
tests := []struct {
name string
campaigns []Campaign
lang string
want []ActiveCampaign
}{
{
name: "default only reduces to weight 1",
campaigns: []Campaign{def(msg("house"))},
lang: "en",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}},
},
{
name: "default fills the remainder",
campaigns: []Campaign{def(msg("house")), timed("promo", 30, nil, nil, msg("promo"))},
lang: "en",
// timed 30, default 100-30=70; gcd 10 -> 3 and 7; timed first, default last.
want: []ActiveCampaign{
{Weight: 3, Messages: []string{"promo-en"}},
{Weight: 7, Messages: []string{"house-en"}},
},
},
{
name: "timed fills 100, default dropped",
campaigns: []Campaign{def(msg("house")), timed("promo", 100, nil, nil, msg("promo"))},
lang: "en",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"promo-en"}}},
},
{
name: "timed over 100, default dropped, proportional",
campaigns: []Campaign{
def(msg("house")),
timed("a", 60, nil, nil, msg("a")),
timed("b", 80, nil, nil, msg("b")),
},
lang: "en",
// default dropped (sum 140 >= 100); gcd(60,80)=20 -> 3 and 4.
want: []ActiveCampaign{
{Weight: 3, Messages: []string{"a-en"}},
{Weight: 4, Messages: []string{"b-en"}},
},
},
{
name: "future and past windows exclude timed",
campaigns: []Campaign{
def(msg("house")),
timed("future", 50, &future, nil, msg("future")),
timed("past", 50, nil, &past, msg("past")),
},
lang: "en",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}},
},
{
name: "active window included",
campaigns: []Campaign{
def(msg("house")),
timed("live", 25, &past, &future, msg("live")),
},
lang: "en",
// timed 25, default 75; gcd 25 -> 1 and 3.
want: []ActiveCampaign{
{Weight: 1, Messages: []string{"live-en"}},
{Weight: 3, Messages: []string{"house-en"}},
},
},
{
name: "campaign without messages is skipped and not counted",
campaigns: []Campaign{
def(msg("house")),
timed("empty", 40, nil, nil, nil),
},
lang: "en",
// empty campaign skipped; default fills full 100 -> reduced to 1.
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}},
},
{
name: "russian bodies resolved",
campaigns: []Campaign{def(msg("house"))},
lang: "ru",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-ru"}}},
},
{
name: "three timed split by gcd",
campaigns: []Campaign{
def(msg("house")),
timed("a", 50, nil, nil, msg("a")),
timed("b", 30, nil, nil, msg("b")),
timed("c", 20, nil, nil, msg("c")),
},
lang: "en",
// sum 100, default dropped; gcd 10 -> 5,3,2.
want: []ActiveCampaign{
{Weight: 5, Messages: []string{"a-en"}},
{Weight: 3, Messages: []string{"b-en"}},
{Weight: 2, Messages: []string{"c-en"}},
},
},
{
name: "campaign with multiple messages keeps order",
campaigns: []Campaign{
def([]Message{{BodyEn: "one-en", BodyRu: "one-ru"}, {BodyEn: "two-en", BodyRu: "two-ru"}}),
},
lang: "en",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"one-en", "two-en"}}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := computeActiveSet(tt.campaigns, now, tt.lang)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", got, tt.want)
}
})
}
}
func TestComputeActiveSetEmpty(t *testing.T) {
// No campaigns at all yields an empty (non-nil-or-nil) feed without panicking.
if got := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 {
t.Errorf("computeActiveSet(nil) = %#v, want empty", got)
}
}
func TestGCD(t *testing.T) {
tests := []struct{ a, b, want int }{
{0, 5, 5}, {5, 0, 5}, {12, 18, 6}, {100, 100, 100}, {7, 13, 1},
}
for _, tt := range tests {
if got := gcd(tt.a, tt.b); got != tt.want {
t.Errorf("gcd(%d,%d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
}
}
+265
View File
@@ -0,0 +1,265 @@
package ads
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// Operator-input bounds and the display-timing clamps. The timings are clamped
// (not rejected) on update so the client rotator can never be driven into a
// broken state by a typo in the console.
const (
maxCampaignName = 80
maxMessageBody = 500
minHoldMs = 3000 // 3s — below this the fade transition would dominate
maxHoldMs = 600000 // 10 min
maxEdgePauseMs = 60000
minScrollPxPerSec = 5
maxScrollPxPerSec = 1000
maxFadeMs = 5000
)
// Service is the domain layer over Store: it validates operator input, enforces
// the default campaign's invariants, clamps the display timings, and assembles
// the resolved rotation feed (ActiveSet) for a viewer.
type Service struct {
store *Store
}
// NewService constructs a Service over store.
func NewService(store *Store) *Service { return &Service{store: store} }
// ActiveSet returns the resolved rotation feed for a viewer in language lang
// (en/ru) together with the global display timings: the currently-active
// campaigns, each with its GCD-reduced show weight and its messages resolved to
// lang, ready for the client's weighted round-robin.
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, error) {
campaigns, err := s.store.ActiveCampaigns(ctx)
if err != nil {
return nil, Timings{}, err
}
timings, err := s.store.Settings(ctx)
if err != nil {
return nil, Timings{}, err
}
return computeActiveSet(campaigns, time.Now().UTC(), lang), timings, nil
}
// ListCampaigns returns every campaign with its messages, for the admin console.
func (s *Service) ListCampaigns(ctx context.Context) ([]Campaign, error) {
return s.store.ListCampaigns(ctx)
}
// Campaign returns one campaign with its messages, or ErrNotFound.
func (s *Service) Campaign(ctx context.Context, id uuid.UUID) (Campaign, error) {
return s.store.Campaign(ctx, id)
}
// CreateCampaign validates and creates a new time-limited campaign (never the
// default) and returns its id.
func (s *Service) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) {
name, err := validName(c.Name)
if err != nil {
return uuid.Nil, err
}
if err := validWeight(c.Weight); err != nil {
return uuid.Nil, err
}
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
return uuid.Nil, err
}
return s.store.CreateCampaign(ctx, Campaign{
Name: name, Weight: c.Weight, Enabled: c.Enabled, StartsAt: c.StartsAt, EndsAt: c.EndsAt,
})
}
// UpdateCampaign validates and updates a campaign. The default campaign keeps its
// weight (nominal), enabled flag and (empty) window — only its name is editable;
// any submitted weight/window/enabled are ignored for it.
func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
existing, err := s.store.Campaign(ctx, c.ID)
if err != nil {
return err
}
name, err := validName(c.Name)
if err != nil {
return err
}
upd := Campaign{ID: existing.ID, Name: name}
if existing.IsDefault {
upd.Weight = existing.Weight
upd.Enabled = true
upd.StartsAt = nil
upd.EndsAt = nil
} else {
if err := validWeight(c.Weight); err != nil {
return err
}
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
return err
}
upd.Weight = c.Weight
upd.Enabled = c.Enabled
upd.StartsAt = c.StartsAt
upd.EndsAt = c.EndsAt
}
return s.store.UpdateCampaign(ctx, upd)
}
// DeleteCampaign removes a campaign, refusing the perpetual default.
func (s *Service) DeleteCampaign(ctx context.Context, id uuid.UUID) error {
existing, err := s.store.Campaign(ctx, id)
if err != nil {
return err
}
if existing.IsDefault {
return ErrDefaultImmutable
}
return s.store.DeleteCampaign(ctx, id)
}
// AddMessage validates a bilingual message and appends it to a campaign.
func (s *Service) AddMessage(ctx context.Context, campaignID uuid.UUID, bodyEn, bodyRu string) (uuid.UUID, error) {
en, ru, err := validBodies(bodyEn, bodyRu)
if err != nil {
return uuid.Nil, err
}
c, err := s.store.Campaign(ctx, campaignID)
if err != nil {
return uuid.Nil, err
}
return s.store.AddMessage(ctx, Message{CampaignID: campaignID, Position: len(c.Messages), BodyEn: en, BodyRu: ru})
}
// EditMessage validates and updates a message's bilingual bodies (its position is
// unchanged).
func (s *Service) EditMessage(ctx context.Context, id uuid.UUID, bodyEn, bodyRu string) error {
en, ru, err := validBodies(bodyEn, bodyRu)
if err != nil {
return err
}
return s.store.UpdateMessageBodies(ctx, id, en, ru)
}
// MoveMessage reorders a message within its campaign by swapping its position
// with the adjacent message in direction dir (-1 up, +1 down). A move past an
// edge is a no-op.
func (s *Service) MoveMessage(ctx context.Context, campaignID, messageID uuid.UUID, dir int) error {
c, err := s.store.Campaign(ctx, campaignID)
if err != nil {
return err
}
idx := -1
for i, m := range c.Messages {
if m.ID == messageID {
idx = i
break
}
}
if idx < 0 {
return ErrNotFound
}
j := idx + dir
if j < 0 || j >= len(c.Messages) {
return nil // already at the edge
}
a, b := c.Messages[idx], c.Messages[j]
if err := s.store.SetMessagePosition(ctx, a.ID, j); err != nil {
return err
}
return s.store.SetMessagePosition(ctx, b.ID, idx)
}
// DeleteMessage removes a message, refusing to remove the default campaign's last
// remaining message (the default must always have a creative to show).
func (s *Service) DeleteMessage(ctx context.Context, campaignID, messageID uuid.UUID) error {
c, err := s.store.Campaign(ctx, campaignID)
if err != nil {
return err
}
if c.IsDefault && len(c.Messages) <= 1 {
return fmt.Errorf("%w: the default campaign must keep at least one message", ErrValidation)
}
return s.store.DeleteMessage(ctx, messageID)
}
// Settings returns the global display timings.
func (s *Service) Settings(ctx context.Context) (Timings, error) {
return s.store.Settings(ctx)
}
// UpdateSettings clamps every timing into its safe range and stores them.
func (s *Service) UpdateSettings(ctx context.Context, t Timings) error {
return s.store.UpdateSettings(ctx, clampTimings(t))
}
// validName trims and bounds a campaign name.
func validName(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", fmt.Errorf("%w: the campaign name is required", ErrValidation)
}
if len([]rune(name)) > maxCampaignName {
return "", fmt.Errorf("%w: the campaign name must be at most %d characters", ErrValidation, maxCampaignName)
}
return name, nil
}
// validWeight bounds a campaign show weight to the 1..100 percent range.
func validWeight(w int) error {
if w < 1 || w > 100 {
return fmt.Errorf("%w: the weight must be a percent between 1 and 100", ErrValidation)
}
return nil
}
// validWindow rejects an inverted validity window.
func validWindow(starts, ends *time.Time) error {
if starts != nil && ends != nil && ends.Before(*starts) {
return fmt.Errorf("%w: the end must not precede the start", ErrValidation)
}
return nil
}
// validBodies trims and bounds both mandatory language bodies of a message.
func validBodies(bodyEn, bodyRu string) (string, string, error) {
en := strings.TrimSpace(bodyEn)
ru := strings.TrimSpace(bodyRu)
if en == "" || ru == "" {
return "", "", fmt.Errorf("%w: both the English and Russian message bodies are required", ErrValidation)
}
if len([]rune(en)) > maxMessageBody || len([]rune(ru)) > maxMessageBody {
return "", "", fmt.Errorf("%w: a message body must be at most %d characters", ErrValidation, maxMessageBody)
}
return en, ru, nil
}
// clampTimings forces every display timing into its safe range.
func clampTimings(t Timings) Timings {
return Timings{
HoldMs: clampInt(t.HoldMs, minHoldMs, maxHoldMs),
EdgePauseMs: clampInt(t.EdgePauseMs, 0, maxEdgePauseMs),
ScrollPxPerSec: clampInt(t.ScrollPxPerSec, minScrollPxPerSec, maxScrollPxPerSec),
FadeOutMs: clampInt(t.FadeOutMs, 0, maxFadeMs),
GapMs: clampInt(t.GapMs, 0, maxFadeMs),
FadeInMs: clampInt(t.FadeInMs, 0, maxFadeMs),
}
}
func clampInt(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
+289
View File
@@ -0,0 +1,289 @@
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())
}