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 }