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 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 } // 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 }