Files
scrabble-game/backend/internal/ads/service.go
T
Ilia Denisov 0946a3f66c
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
feat(ads): server-driven ad-banner backend, wire & admin console (PR1)
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).
2026-06-15 23:00:19 +02:00

266 lines
8.0 KiB
Go

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
}