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. The override_* colours are // present only for a campaign that carries a colour override: override_bg/fg/link // paint every theme, and the *_dark trio further overrides the dark theme (the // client resolves the cascade). They are absent for a plain campaign, which keeps // the neutral theme tokens. type bannerCampaignDTO struct { Weight int `json:"weight"` Messages []string `json:"messages"` OverrideBg string `json:"override_bg,omitempty"` OverrideFg string `json:"override_fg,omitempty"` OverrideLink string `json:"override_link,omitempty"` OverrideBgDark string `json:"override_bg_dark,omitempty"` OverrideFgDark string `json:"override_fg_dark,omitempty"` OverrideLinkDark string `json:"override_link_dark,omitempty"` } // 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) s.fillLinkedIdentities(ctx, &r, acc.ID) return r } // fillLinkedIdentities sets the profile's confirmed email address and platform-linked // flags from the account's identities, so the client offers the right link / unlink / // change-email controls. A read failure leaves them zero (no controls), logged as a // warning so the profile response still succeeds. func (s *Server) fillLinkedIdentities(ctx context.Context, r *profileResponse, accountID uuid.UUID) { ids, err := s.accounts.Identities(ctx, accountID) if err != nil { s.log.Warn("profile: identities read failed", zap.String("account", accountID.String()), zap.Error(err)) return } for _, id := range ids { switch id.Kind { case account.KindEmail: if id.Confirmed { r.Email = id.ExternalID } case account.KindTelegram: r.TelegramLinked = true case account.KindVK: r.VkLinked = true } } } // 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 } set, timings, urgent, 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 } // An urgent campaign is shown to every viewer; otherwise the normal eligibility // gate applies (a paid account, a non-empty hint wallet or the no_banner role // hides the banner). if !urgent { 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 } } campaigns := make([]bannerCampaignDTO, 0, len(set)) for _, c := range set { campaigns = append(campaigns, bannerCampaignFromActive(c)) } return &bannerDTO{ Campaigns: campaigns, Timings: bannerTimingsDTO{ HoldMs: timings.HoldMs, EdgePauseMs: timings.EdgePauseMs, ScrollPxPerSec: timings.ScrollPxPerSec, FadeOutMs: timings.FadeOutMs, GapMs: timings.GapMs, FadeInMs: timings.FadeInMs, }, } } // bannerCampaignFromActive flattens a resolved campaign into its wire DTO, // projecting each optional colour set into its three "#rrggbb" fields (empty when // the set is absent, so JSON omitempty drops them). func bannerCampaignFromActive(c ads.ActiveCampaign) bannerCampaignDTO { d := bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages} if c.OverrideAll != nil { d.OverrideBg, d.OverrideFg, d.OverrideLink = c.OverrideAll.Bg, c.OverrideAll.Fg, c.OverrideAll.Link } if c.OverrideDark != nil { d.OverrideBgDark, d.OverrideFgDark, d.OverrideLinkDark = c.OverrideDark.Bg, c.OverrideDark.Fg, c.OverrideDark.Link } return d } // 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)) }