package server import ( "context" "github.com/google/uuid" "go.uber.org/zap" "scrabble/backend/internal/account" "scrabble/backend/internal/ads" "scrabble/backend/internal/notify" ) // bannerDTO is the advertising-banner block attached to an eligible viewer's // profile: the campaigns to rotate (each already resolved to the viewer's bot // language) and the global display timings the client rotator reads. It is // absent for a viewer who should see no banner. type bannerDTO struct { Campaigns []bannerCampaignDTO `json:"campaigns"` Timings bannerTimingsDTO `json:"timings"` } // bannerCampaignDTO is one campaign in the rotation feed: its GCD-reduced show // weight (for the client's smooth weighted round-robin) and its messages, in // display order, already resolved to one language. type bannerCampaignDTO struct { Weight int `json:"weight"` Messages []string `json:"messages"` } // bannerTimingsDTO mirrors ads.Timings on the wire. type bannerTimingsDTO struct { HoldMs int `json:"hold_ms"` EdgePauseMs int `json:"edge_pause_ms"` ScrollPxPerSec int `json:"scroll_px_per_sec"` FadeOutMs int `json:"fade_out_ms"` GapMs int `json:"gap_ms"` FadeInMs int `json:"fade_in_ms"` } // profileResponse builds the account's profile DTO with the advertising-banner block attached when // the viewer is eligible. Every endpoint returning the caller's own profile (get, update, link) // uses it, so the banner never drops off the client's profile after a non-get profile change (e.g. // a language switch). func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse { r := profileResponseFor(acc) r.Banner = s.bannerFor(ctx, acc) return r } // bannerFor builds the advertising-banner block for the account's profile, or // nil when the ads service is not configured or the viewer is not eligible to // see a banner. The message language follows the account's bot (service) // language, falling back to its interface language and then English. A failure // reading roles or campaigns is logged and treated as "no banner" so the profile // response still succeeds. func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO { if s.ads == nil { return nil } hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner) if err != nil { s.log.Warn("banner: role check failed", zap.String("account", acc.ID.String()), zap.Error(err)) return nil } if !ads.Eligible(acc.PaidAccount, acc.HintBalance, hasNoBanner) { return nil } set, timings, err := s.ads.ActiveSet(ctx, bannerLang(acc)) if err != nil { s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err)) return nil } campaigns := make([]bannerCampaignDTO, 0, len(set)) for _, c := range set { campaigns = append(campaigns, bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages}) } return &bannerDTO{ Campaigns: campaigns, Timings: bannerTimingsDTO{ HoldMs: timings.HoldMs, EdgePauseMs: timings.EdgePauseMs, ScrollPxPerSec: timings.ScrollPxPerSec, FadeOutMs: timings.FadeOutMs, GapMs: timings.GapMs, FadeInMs: timings.FadeInMs, }, } } // bannerLang resolves the message language for a viewer: their interface language // (Russian, or English by default). func bannerLang(acc account.Account) string { if acc.PreferredLanguage == "ru" { return "ru" } return "en" } // publishBannerChange emits the in-app "banner eligibility may have changed" // re-poll signal to the account, so an open client re-fetches profile.get and // shows or hides the banner without a reload. Best-effort (notify.Nop when no // notifier is wired). func (s *Server) publishBannerChange(id uuid.UUID) { s.notifier.Publish(notify.BannerChanged(id)) }