6db9178449
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Non-default campaigns gain an optional colour override (background / text / link) in two sets — one for every theme, one for the dark theme only — and an "urgent" flag. - Colours ride profile.get as six trailing FlatBuffers strings on BannerCampaign (backward-compatible). The client resolves the cascade (dark <- dark ?? all, light <- all) per rendered theme and derives the strip border from the background in JS (no CSS color-mix, for the old Android WebView floor); AdBanner applies them as inline vars scoped to the strip. - Urgent is resolved entirely server-side: while any enabled, in-window urgent campaign exists, computeActiveSet returns only the urgent campaigns and bannerFor skips the eligibility gate — so a system notice reaches every viewer (paid / hint-holding / no_banner included) and preempts the ordinary feed. No wire field; it appears on each viewer's next profile.get. - Admin console (/_gm/banners): native colour pickers + a live light/dark preview of the strip, and an urgent toggle. The default campaign stays plain, enforced by the service and a DB CHECK. Migration 00009 is additive (nullable colour columns + a bool default + all-or-nothing / hex / default-plain CHECKs) — expand-contract, rollback-safe. Docs: ARCHITECTURE §10, UI_DESIGN, FUNCTIONAL (+ru). Tests: ads unit (urgent preempt + colour validation), codec + resolver unit, gateway transcode, and integration (colour round-trip + urgent bypass against real Postgres).
348 lines
11 KiB
Go
348 lines
11 KiB
Go
package ads
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"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. The bool result reports
|
|
// whether the feed is urgent — an urgent feed is shown to every viewer
|
|
// regardless of eligibility (the caller skips the eligibility gate for it).
|
|
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, bool, error) {
|
|
campaigns, err := s.store.ActiveCampaigns(ctx)
|
|
if err != nil {
|
|
return nil, Timings{}, false, err
|
|
}
|
|
timings, err := s.store.Settings(ctx)
|
|
if err != nil {
|
|
return nil, Timings{}, false, err
|
|
}
|
|
set, urgent := computeActiveSet(campaigns, time.Now().UTC(), lang)
|
|
return set, timings, urgent, 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
|
|
}
|
|
all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark)
|
|
if 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,
|
|
OverrideAll: all, OverrideDark: dark, Urgent: c.Urgent,
|
|
})
|
|
}
|
|
|
|
// 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
|
|
// The default (house) campaign stays plain: no colour overrides, never urgent.
|
|
} else {
|
|
if err := validWeight(c.Weight); err != nil {
|
|
return err
|
|
}
|
|
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
|
|
return err
|
|
}
|
|
all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
upd.Weight = c.Weight
|
|
upd.Enabled = c.Enabled
|
|
upd.StartsAt = c.StartsAt
|
|
upd.EndsAt = c.EndsAt
|
|
upd.OverrideAll = all
|
|
upd.OverrideDark = dark
|
|
upd.Urgent = c.Urgent
|
|
}
|
|
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 the bilingual bodies of a message that
|
|
// belongs to campaignID (its position is unchanged). It returns ErrNotFound when
|
|
// the message is not part of that campaign, so a mismatched campaign/message pair
|
|
// never edits an unrelated campaign's message.
|
|
func (s *Service) EditMessage(ctx context.Context, campaignID, messageID uuid.UUID, bodyEn, bodyRu string) error {
|
|
en, ru, err := validBodies(bodyEn, bodyRu)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c, err := s.store.Campaign(ctx, campaignID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !campaignOwnsMessage(c, messageID) {
|
|
return ErrNotFound
|
|
}
|
|
return s.store.UpdateMessageBodies(ctx, messageID, 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 that belongs to campaignID, refusing to remove
|
|
// the default campaign's last remaining message (the default must always have a
|
|
// creative to show). It returns ErrNotFound when the message is not part of that
|
|
// campaign — so a mismatched campaign/message pair can neither delete an
|
|
// unrelated campaign's message nor bypass the default's last-message guard.
|
|
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 !campaignOwnsMessage(c, messageID) {
|
|
return ErrNotFound
|
|
}
|
|
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)
|
|
}
|
|
|
|
// campaignOwnsMessage reports whether messageID is one of the campaign's messages.
|
|
func campaignOwnsMessage(c Campaign, messageID uuid.UUID) bool {
|
|
for _, m := range c.Messages {
|
|
if m.ID == messageID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// hexColor matches a "#rrggbb" colour — the format the console's native colour
|
|
// input emits and the wire carries.
|
|
var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
|
|
|
// validOverrides validates the two optional colour sets together and returns
|
|
// their normalised copies (nil when a set is absent), so a caller can store them
|
|
// directly.
|
|
func validOverrides(all, dark *ColorSet) (*ColorSet, *ColorSet, error) {
|
|
va, err := validColorSet(all)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
vd, err := validColorSet(dark)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return va, vd, nil
|
|
}
|
|
|
|
// validColorSet validates one optional colour override: a nil set passes (no
|
|
// override); otherwise all three colours must be present and "#rrggbb". It
|
|
// returns a trimmed, lower-cased copy.
|
|
func validColorSet(cs *ColorSet) (*ColorSet, error) {
|
|
if cs == nil {
|
|
return nil, nil
|
|
}
|
|
bg := strings.TrimSpace(cs.Bg)
|
|
fg := strings.TrimSpace(cs.Fg)
|
|
link := strings.TrimSpace(cs.Link)
|
|
if bg == "" || fg == "" || link == "" {
|
|
return nil, fmt.Errorf("%w: a colour override needs all of background, text and link", ErrValidation)
|
|
}
|
|
for _, h := range []string{bg, fg, link} {
|
|
if !hexColor.MatchString(h) {
|
|
return nil, fmt.Errorf("%w: colours must be #rrggbb hex", ErrValidation)
|
|
}
|
|
}
|
|
return &ColorSet{Bg: strings.ToLower(bg), Fg: strings.ToLower(fg), Link: strings.ToLower(link)}, 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
|
|
}
|