diff --git a/PRERELEASE.md b/PRERELEASE.md index 1d4544a..9bf751a 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -32,6 +32,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** | | AB | Manual account block (admin suspension): permanent/temporary with an editable en+ru reason picklist; a block forfeits the player's active games + cancels their open ones; a backend gate refuses a blocked account with **403 `account_blocked`**; the UI shows a terminal blocked screen and stops all push/poll; manual unblock; temporary blocks self-expire (migration `00003`) | owner ad-hoc | **done** | | AI | Honest AI opponent in quick game: an explicit 🤖 AI / 👤 random selector (AI default); the robot is seated and moves at once; 7-day inactivity loss (the per-turn timeout reused); chat/nudge disabled, no statistics; the opponent is shown as 🤖 everywhere | owner ad-hoc | **done** | +| AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | PR1 backend+admin **done**; PR2 UI rotation **next** | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | ## Key findings (these reshaped the raw list — read before starting a phase) diff --git a/backend/README.md b/backend/README.md index 663260d..4eb52a9 100644 --- a/backend/README.md +++ b/backend/README.md @@ -101,7 +101,13 @@ broadcast picks the delivering bot by an operator-chosen language. `accounts.ser holds the language tag of the bot a Telegram user last signed in through, written on every login and returned by `/internal/push-target` (falling back to `preferred_language`) so out-of-app push routes -to the right bot. The shared wire contracts live in the sibling [`../pkg`](../pkg) module. +to the right bot. The console also manages the **advertising banner** (`/_gm/banners` + +`/_gm/banner-settings`, `internal/ads`): operator campaigns with a percent weight, an optional +window and bilingual messages, plus the global display timings. `GET /api/v1/user/profile` attaches +the resolved, weighted campaign feed for an **eligible** viewer (`!paid_account && hint_balance == 0 +&& !no_banner` role, the message language picked by `service_language`); changing those inputs +publishes a `notify` `banner` re-poll signal so the client shows/hides it in place. The shared wire +contracts live in the sibling [`../pkg`](../pkg) module. **Account linking & merge** (`/api/v1/user/link/*`). `internal/link` orchestrates it: an email confirm-code or a gateway-validated Telegram identity is @@ -145,6 +151,7 @@ internal/feedback/ # user feedback: messages + attachment (bytea), anti-spam g internal/lobby/ # auto-match (DB-backed open games + robot substitution) + friend-game invitations internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm +internal/ads/ # advertising banner: campaigns + bilingual messages + display timings, weighted-rotation feed (ActiveSet) internal/connector/ # backend gRPC client to the Telegram connector (operator broadcasts) internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag ``` diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 00003a8..a925348 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -19,6 +19,7 @@ import ( "scrabble/backend/internal/account" "scrabble/backend/internal/accountmerge" + "scrabble/backend/internal/ads" "scrabble/backend/internal/config" "scrabble/backend/internal/connector" "scrabble/backend/internal/engine" @@ -200,6 +201,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { zap.Int("flag_threshold", cfg.RateWatch.FlagThreshold), zap.Duration("flag_window", cfg.RateWatch.FlagWindow)) + // Advertising-banner domain: campaign rotation feeding the profile.get banner + // block and the banner admin console section. + adsSvc := ads.NewService(ads.NewStore(db)) + srv := server.New(cfg.HTTPAddr, server.Deps{ Logger: logger, DB: db, @@ -218,6 +223,8 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { DictDir: cfg.Game.DictDir, Connector: conn, RateWatch: rateWatch, + Ads: adsSvc, + Notifier: hub, }) pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger) diff --git a/backend/internal/account/roles.go b/backend/internal/account/roles.go index 8c2f432..5aa2725 100644 --- a/backend/internal/account/roles.go +++ b/backend/internal/account/roles.go @@ -19,11 +19,16 @@ const ( // RoleFeedbackBanned forbids the account from submitting feedback (only that; // it is not a full account suspension). See internal/feedback. RoleFeedbackBanned = "feedback_banned" + + // RoleNoBanner suppresses the in-app advertising banner for the account + // unconditionally, overriding the usual eligibility (a free account with an + // empty hint wallet otherwise sees it). See internal/ads. + RoleNoBanner = "no_banner" ) // KnownRoles is the set of roles the console may grant or revoke; an operator // cannot assign an unrecognised role. -var KnownRoles = []string{RoleFeedbackBanned} +var KnownRoles = []string{RoleFeedbackBanned, RoleNoBanner} // IsKnownRole reports whether role is a recognised account role. func IsKnownRole(role string) bool { diff --git a/backend/internal/adminconsole/templates/layout.gohtml b/backend/internal/adminconsole/templates/layout.gohtml index 421cb71..4fab715 100644 --- a/backend/internal/adminconsole/templates/layout.gohtml +++ b/backend/internal/adminconsole/templates/layout.gohtml @@ -20,6 +20,7 @@ Messages Throttled Reasons + Banners Dictionary Broadcast Grafana ↗ diff --git a/backend/internal/adminconsole/templates/pages/banner_detail.gohtml b/backend/internal/adminconsole/templates/pages/banner_detail.gohtml new file mode 100644 index 0000000..90b154d --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/banner_detail.gohtml @@ -0,0 +1,49 @@ +{{define "content" -}} +{{with .Data}} +

← all campaigns

+

{{.Name}}{{if .IsDefault}} default{{end}}

+

Settings

+{{if .IsDefault}}

The default campaign is perpetual and fills the unsold remainder — only its name and messages are editable.

{{end}} +
+ +{{if not .IsDefault}} + + + + +{{end}} +
+
+{{if not .IsDefault}} +
+ +
+{{end}} +
+

Messages

+

Each message must be filled in both languages; the viewer sees the variant for the bot they play through. The messages of a campaign share its show weight, rotating in this order.

+{{range .Messages}} +
+
+ + +
+ +
+
+
+
+
+
+
+
+{{else}}

no messages yet

{{end}} +

Add message

+
+ + +
+
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/banner_settings.gohtml b/backend/internal/adminconsole/templates/pages/banner_settings.gohtml new file mode 100644 index 0000000..79e3d17 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/banner_settings.gohtml @@ -0,0 +1,18 @@ +{{define "content" -}} +

← all campaigns

+

Banner display timings

+{{with .Data}} +

Global timings the client's banner rotator reads. Out-of-range values are clamped on save. The transition between messages is fade-out, then a gap, then fade-in (skipped under reduce-motion).

+
+
+ + + + + + +
+
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/banners.gohtml b/backend/internal/adminconsole/templates/pages/banners.gohtml new file mode 100644 index 0000000..5a74958 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/banners.gohtml @@ -0,0 +1,32 @@ +{{define "content" -}} +

Advertising banners

+{{with .Data}} +

Campaigns compete for the one-line banner shown to free users (no paid account, an empty hint wallet, and no no_banner role). A campaign's weight is a show percent; the perpetual default campaign fills the remainder up to 100% and drops out of the rotation when timed campaigns already total 100%. Display timings →

+

Add campaign

+
+ + + + + +
+
+
+

Campaigns

+ + + +{{range .Items}} + + + + + + + +{{else}}{{end}} + +
NameWeightWindowMessagesStatus
{{.Name}}{{if .IsDefault}} default{{end}}{{if .IsDefault}}remainder{{else}}{{.Weight}}%{{end}}{{.Window}}{{.Messages}}{{if .ActiveNow}}live{{else if .Enabled}}idle{{else}}disabled{{end}}
no campaigns
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index f05c9e0..12c3fba 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -372,6 +372,58 @@ type MessageView struct { Back string } +// BannersView is the advertising-campaign list page. +type BannersView struct { + Items []BannerCampaignRow +} + +// BannerCampaignRow is one campaign in the list. Window is a human-readable +// validity window ("perpetual" for the default); ActiveNow reports whether it +// would rotate right now (enabled and within its window). +type BannerCampaignRow struct { + ID string + Name string + Weight int + IsDefault bool + Enabled bool + Window string + Messages int + ActiveNow bool +} + +// BannerDetailView is the campaign detail/edit page. StartsAt/EndsAt are the +// "YYYY-MM-DDTHH:MM" (UTC) values for the datetime-local inputs, empty when open. +type BannerDetailView struct { + ID string + Name string + Weight int + IsDefault bool + Enabled bool + StartsAt string + EndsAt string + Messages []BannerMessageRow +} + +// BannerMessageRow is one bilingual message of a campaign. First/Last drive the +// reorder buttons (disabled at the ends). +type BannerMessageRow struct { + ID string + BodyEn string + BodyRu string + First bool + Last bool +} + +// BannerSettingsView is the global display-timings form. +type BannerSettingsView struct { + HoldMs int + EdgePauseMs int + ScrollPxPerSec int + FadeOutMs int + GapMs int + FadeInMs int +} + // FeedbackView is the paginated user-feedback queue. Status is the active // unread/read/archived filter; NameMask/ExtMask are the sender glob filters; // UserID pins the list to one account (the per-user link from /users); diff --git a/backend/internal/ads/ads.go b/backend/internal/ads/ads.go new file mode 100644 index 0000000..91c3d80 --- /dev/null +++ b/backend/internal/ads/ads.go @@ -0,0 +1,188 @@ +// Package ads owns the server-driven advertising banner ("advertising network"): +// operator-managed campaigns, their bilingual messages and the global display +// timings the client's one-line announcement strip rotates through. +// +// A campaign is one placement order with a show weight (an integer percent). +// Simultaneously active campaigns compete for display slots in proportion to +// their weights; the single perpetual default campaign fills the unsold +// remainder up to 100%. The actual rotation runs client-side (a smooth weighted +// round-robin over campaigns, round-robin over a campaign's messages); the server +// only computes the effective weighted, language-resolved set a viewer should +// rotate, via ActiveSet. Who sees a banner at all is decided by Eligible. +package ads + +import ( + "errors" + "time" + + "github.com/google/uuid" +) + +// ErrNotFound is returned when a campaign or message does not exist. +var ErrNotFound = errors.New("ads: not found") + +// ErrDefaultImmutable is returned when an operation is refused on the perpetual +// default campaign (it cannot be deleted, disabled, windowed, or have its +// weight or default flag changed). +var ErrDefaultImmutable = errors.New("ads: the default campaign cannot be modified that way") + +// ErrValidation wraps an operator-facing validation failure (a bad weight, +// window or message body). +var ErrValidation = errors.New("ads: validation") + +// Campaign is one advertising placement order with its messages. +type Campaign struct { + ID uuid.UUID // uuid.Nil on create + Name string + Weight int // show percent, 1..100; nominal (ignored) for the default + IsDefault bool + Enabled bool + StartsAt *time.Time // nil = open-ended start; always nil for the default + EndsAt *time.Time // nil = open-ended end; always nil for the default + Messages []Message + CreatedAt time.Time + UpdatedAt time.Time +} + +// Message is one bilingual creative of a campaign. Both bodies are mandatory; +// the client shows the variant for the viewer's bot (service) language. Position +// orders the messages within a campaign (the round-robin order). +type Message struct { + ID uuid.UUID // uuid.Nil on create + CampaignID uuid.UUID + Position int + BodyEn string + BodyRu string +} + +// Timings are the global banner display timings, in milliseconds except +// ScrollPxPerSec. HoldMs is how long one message shows; EdgePauseMs and +// ScrollPxPerSec drive the scroll of a message wider than the strip; a +// transition between messages is FadeOutMs then GapMs then FadeInMs. +type Timings struct { + HoldMs int + EdgePauseMs int + ScrollPxPerSec int + FadeOutMs int + GapMs int + FadeInMs int +} + +// ActiveCampaign is one campaign in the resolved rotation feed sent to a client: +// its GCD-reduced show weight and its messages, already resolved to the viewer's +// language and in display (round-robin) order. +type ActiveCampaign struct { + Weight int + Messages []string +} + +// Eligible reports whether an account should be shown the advertising banner: a +// free account (not paid) with an empty hint wallet and without the no_banner +// role. The no_banner role suppresses the banner unconditionally; buying a paid +// account or any hints also removes it. +func Eligible(paidAccount bool, hintBalance int, hasNoBanner bool) bool { + return !paidAccount && hintBalance <= 0 && !hasNoBanner +} + +// computeActiveSet builds the resolved rotation feed from the enabled campaigns +// at time now, in language lang. Campaigns outside their validity window, and +// campaigns with no messages, are dropped. The default campaign's effective +// weight is the remainder up to 100% — max(0, 100 - sum of active timed +// weights) — so it fills unsold inventory and is dropped entirely when timed +// campaigns already reach 100%. Weights are then reduced by their GCD so the +// fair rotation cycle stays short. The input is expected to be the enabled +// campaigns (ActiveCampaigns); disabled ones must already be excluded. +func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []ActiveCampaign { + var timed []Campaign + var def *Campaign + for i := range campaigns { + c := campaigns[i] + if len(c.Messages) == 0 { + continue // a campaign with no creative cannot fill a slot + } + if c.IsDefault { + def = &campaigns[i] + continue + } + if withinWindow(c, now) { + timed = append(timed, c) + } + } + sumTimed := 0 + for _, c := range timed { + sumTimed += c.Weight + } + out := make([]ActiveCampaign, 0, len(timed)+1) + for _, c := range timed { + out = append(out, ActiveCampaign{Weight: c.Weight, Messages: resolveBodies(c.Messages, lang)}) + } + if def != nil { + if dw := 100 - sumTimed; dw > 0 { + out = append(out, ActiveCampaign{Weight: dw, Messages: resolveBodies(def.Messages, lang)}) + } + } + reduceByGCD(out) + return out +} + +// ActiveAt reports whether the campaign would rotate at time now: enabled, and +// either the perpetual default or within its validity window. It mirrors the +// filter computeActiveSet applies (a campaign with no messages still reports +// active here — that is a content gap the console surfaces, not an inactive +// campaign). +func (c Campaign) ActiveAt(now time.Time) bool { + return c.Enabled && (c.IsDefault || withinWindow(c, now)) +} + +// withinWindow reports whether now lies inside the campaign's validity window. A +// nil bound is open-ended on that side. +func withinWindow(c Campaign, now time.Time) bool { + if c.StartsAt != nil && now.Before(*c.StartsAt) { + return false + } + if c.EndsAt != nil && now.After(*c.EndsAt) { + return false + } + return true +} + +// resolveBodies projects each message to the body for lang ("ru" picks the +// Russian body; anything else picks English), preserving display order. +func resolveBodies(msgs []Message, lang string) []string { + out := make([]string, len(msgs)) + for i, m := range msgs { + if lang == "ru" { + out[i] = m.BodyRu + } else { + out[i] = m.BodyEn + } + } + return out +} + +// reduceByGCD divides every weight by the greatest common divisor of all weights +// (in place), shrinking a {50,30,20} set to {5,3,2} and a lone {100} to {1}. A +// no-op when the set is empty or already coprime. +func reduceByGCD(cs []ActiveCampaign) { + g := 0 + for _, c := range cs { + g = gcd(g, c.Weight) + } + if g <= 1 { + return + } + for i := range cs { + cs[i].Weight /= g + } +} + +// gcd returns the greatest common divisor of a and b (gcd(0, n) == n). +func gcd(a, b int) int { + for b != 0 { + a, b = b, a%b + } + if a < 0 { + return -a + } + return a +} diff --git a/backend/internal/ads/ads_test.go b/backend/internal/ads/ads_test.go new file mode 100644 index 0000000..9cb1efe --- /dev/null +++ b/backend/internal/ads/ads_test.go @@ -0,0 +1,183 @@ +package ads + +import ( + "reflect" + "testing" + "time" +) + +func TestEligible(t *testing.T) { + tests := []struct { + name string + paidAccount bool + hintBalance int + hasNoBanner bool + want bool + }{ + {name: "free, empty wallet, no role", want: true}, + {name: "paid", paidAccount: true, want: false}, + {name: "has hints", hintBalance: 3, want: false}, + {name: "no_banner role", hasNoBanner: true, want: false}, + {name: "paid and has hints", paidAccount: true, hintBalance: 5, want: false}, + {name: "no_banner overrides everything", hasNoBanner: true, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Eligible(tt.paidAccount, tt.hintBalance, tt.hasNoBanner); got != tt.want { + t.Errorf("Eligible(%v,%d,%v) = %v, want %v", tt.paidAccount, tt.hintBalance, tt.hasNoBanner, got, tt.want) + } + }) + } +} + +func TestComputeActiveSet(t *testing.T) { + now := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC) + past := now.Add(-24 * time.Hour) + future := now.Add(24 * time.Hour) + + // msg builds a one-message slice with distinct bodies so resolution and + // campaign identity are visible in assertions. + msg := func(tag string) []Message { + return []Message{{BodyEn: tag + "-en", BodyRu: tag + "-ru"}} + } + def := func(msgs []Message) Campaign { + return Campaign{Name: "default", Weight: 100, IsDefault: true, Enabled: true, Messages: msgs} + } + timed := func(name string, weight int, starts, ends *time.Time, msgs []Message) Campaign { + return Campaign{Name: name, Weight: weight, Enabled: true, StartsAt: starts, EndsAt: ends, Messages: msgs} + } + + tests := []struct { + name string + campaigns []Campaign + lang string + want []ActiveCampaign + }{ + { + name: "default only reduces to weight 1", + campaigns: []Campaign{def(msg("house"))}, + lang: "en", + want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}}, + }, + { + name: "default fills the remainder", + campaigns: []Campaign{def(msg("house")), timed("promo", 30, nil, nil, msg("promo"))}, + lang: "en", + // timed 30, default 100-30=70; gcd 10 -> 3 and 7; timed first, default last. + want: []ActiveCampaign{ + {Weight: 3, Messages: []string{"promo-en"}}, + {Weight: 7, Messages: []string{"house-en"}}, + }, + }, + { + name: "timed fills 100, default dropped", + campaigns: []Campaign{def(msg("house")), timed("promo", 100, nil, nil, msg("promo"))}, + lang: "en", + want: []ActiveCampaign{{Weight: 1, Messages: []string{"promo-en"}}}, + }, + { + name: "timed over 100, default dropped, proportional", + campaigns: []Campaign{ + def(msg("house")), + timed("a", 60, nil, nil, msg("a")), + timed("b", 80, nil, nil, msg("b")), + }, + lang: "en", + // default dropped (sum 140 >= 100); gcd(60,80)=20 -> 3 and 4. + want: []ActiveCampaign{ + {Weight: 3, Messages: []string{"a-en"}}, + {Weight: 4, Messages: []string{"b-en"}}, + }, + }, + { + name: "future and past windows exclude timed", + campaigns: []Campaign{ + def(msg("house")), + timed("future", 50, &future, nil, msg("future")), + timed("past", 50, nil, &past, msg("past")), + }, + lang: "en", + want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}}, + }, + { + name: "active window included", + campaigns: []Campaign{ + def(msg("house")), + timed("live", 25, &past, &future, msg("live")), + }, + lang: "en", + // timed 25, default 75; gcd 25 -> 1 and 3. + want: []ActiveCampaign{ + {Weight: 1, Messages: []string{"live-en"}}, + {Weight: 3, Messages: []string{"house-en"}}, + }, + }, + { + name: "campaign without messages is skipped and not counted", + campaigns: []Campaign{ + def(msg("house")), + timed("empty", 40, nil, nil, nil), + }, + lang: "en", + // empty campaign skipped; default fills full 100 -> reduced to 1. + want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}}, + }, + { + name: "russian bodies resolved", + campaigns: []Campaign{def(msg("house"))}, + lang: "ru", + want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-ru"}}}, + }, + { + name: "three timed split by gcd", + campaigns: []Campaign{ + def(msg("house")), + timed("a", 50, nil, nil, msg("a")), + timed("b", 30, nil, nil, msg("b")), + timed("c", 20, nil, nil, msg("c")), + }, + lang: "en", + // sum 100, default dropped; gcd 10 -> 5,3,2. + want: []ActiveCampaign{ + {Weight: 5, Messages: []string{"a-en"}}, + {Weight: 3, Messages: []string{"b-en"}}, + {Weight: 2, Messages: []string{"c-en"}}, + }, + }, + { + name: "campaign with multiple messages keeps order", + campaigns: []Campaign{ + def([]Message{{BodyEn: "one-en", BodyRu: "one-ru"}, {BodyEn: "two-en", BodyRu: "two-ru"}}), + }, + lang: "en", + want: []ActiveCampaign{{Weight: 1, Messages: []string{"one-en", "two-en"}}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := computeActiveSet(tt.campaigns, now, tt.lang) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", got, tt.want) + } + }) + } +} + +func TestComputeActiveSetEmpty(t *testing.T) { + // No campaigns at all yields an empty (non-nil-or-nil) feed without panicking. + if got := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 { + t.Errorf("computeActiveSet(nil) = %#v, want empty", got) + } +} + +func TestGCD(t *testing.T) { + tests := []struct{ a, b, want int }{ + {0, 5, 5}, {5, 0, 5}, {12, 18, 6}, {100, 100, 100}, {7, 13, 1}, + } + for _, tt := range tests { + if got := gcd(tt.a, tt.b); got != tt.want { + t.Errorf("gcd(%d,%d) = %d, want %d", tt.a, tt.b, got, tt.want) + } + } +} diff --git a/backend/internal/ads/service.go b/backend/internal/ads/service.go new file mode 100644 index 0000000..ff6a7e0 --- /dev/null +++ b/backend/internal/ads/service.go @@ -0,0 +1,265 @@ +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 a message's bilingual bodies (its position is +// unchanged). +func (s *Service) EditMessage(ctx context.Context, id uuid.UUID, bodyEn, bodyRu string) error { + en, ru, err := validBodies(bodyEn, bodyRu) + if err != nil { + return err + } + return s.store.UpdateMessageBodies(ctx, id, 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, refusing to remove the default campaign's last +// remaining message (the default must always have a creative to show). +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 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) +} + +// 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 +} diff --git a/backend/internal/ads/store.go b/backend/internal/ads/store.go new file mode 100644 index 0000000..4074418 --- /dev/null +++ b/backend/internal/ads/store.go @@ -0,0 +1,289 @@ +package ads + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/go-jet/jet/v2/postgres" + "github.com/go-jet/jet/v2/qrm" + "github.com/google/uuid" + + "scrabble/backend/internal/postgres/jet/backend/model" + "scrabble/backend/internal/postgres/jet/backend/table" +) + +// Store is the Postgres-backed query surface for advertising campaigns, their +// messages and the single global display-settings row. +type Store struct { + db *sql.DB +} + +// NewStore constructs a Store wrapping db. +func NewStore(db *sql.DB) *Store { return &Store{db: db} } + +// ListCampaigns returns every campaign with its messages, the default first then +// by creation time, for the admin console. +func (s *Store) ListCampaigns(ctx context.Context) ([]Campaign, error) { + return s.loadCampaigns(ctx, false) +} + +// ActiveCampaigns returns the enabled campaigns with their messages, the default +// first then by creation time. Window filtering and the default-remainder weight +// are applied by the Service (ActiveSet), not here. +func (s *Store) ActiveCampaigns(ctx context.Context) ([]Campaign, error) { + return s.loadCampaigns(ctx, true) +} + +// loadCampaigns reads campaigns (optionally only the enabled ones) and attaches +// each one's messages in display order, with a single messages query (the table +// is small, so this avoids both an N+1 and a join projection). +func (s *Store) loadCampaigns(ctx context.Context, enabledOnly bool) ([]Campaign, error) { + sel := postgres.SELECT(table.AdCampaigns.AllColumns).FROM(table.AdCampaigns) + if enabledOnly { + sel = sel.WHERE(table.AdCampaigns.Enabled.EQ(postgres.Bool(true))) + } + sel = sel.ORDER_BY(table.AdCampaigns.IsDefault.DESC(), table.AdCampaigns.CreatedAt.ASC()) + + var rows []model.AdCampaigns + if err := sel.QueryContext(ctx, s.db, &rows); err != nil { + return nil, fmt.Errorf("ads: list campaigns: %w", err) + } + byID, err := s.messagesByCampaign(ctx) + if err != nil { + return nil, err + } + out := make([]Campaign, 0, len(rows)) + for _, r := range rows { + c := modelToCampaign(r) + c.Messages = byID[r.CampaignID] + out = append(out, c) + } + return out, nil +} + +// Campaign returns one campaign with its messages, or ErrNotFound. +func (s *Store) Campaign(ctx context.Context, id uuid.UUID) (Campaign, error) { + sel := postgres.SELECT(table.AdCampaigns.AllColumns). + FROM(table.AdCampaigns). + WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(id))). + LIMIT(1) + var row model.AdCampaigns + if err := sel.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return Campaign{}, ErrNotFound + } + return Campaign{}, fmt.Errorf("ads: get campaign %s: %w", id, err) + } + c := modelToCampaign(row) + msgs, err := s.messagesFor(ctx, id) + if err != nil { + return Campaign{}, err + } + c.Messages = msgs + return c, nil +} + +// CreateCampaign inserts a campaign (always non-default) and returns its new id. +func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) { + id := uuid.New() + now := time.Now().UTC() + stmt := table.AdCampaigns.INSERT( + table.AdCampaigns.CampaignID, table.AdCampaigns.Name, table.AdCampaigns.Weight, + table.AdCampaigns.IsDefault, table.AdCampaigns.Enabled, + table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, + table.AdCampaigns.CreatedAt, table.AdCampaigns.UpdatedAt, + ).VALUES( + postgres.UUID(id), postgres.String(c.Name), postgres.Int(int64(c.Weight)), + postgres.Bool(false), postgres.Bool(c.Enabled), + tsOrNull(c.StartsAt), tsOrNull(c.EndsAt), + postgres.TimestampzT(now), postgres.TimestampzT(now), + ) + if _, err := stmt.ExecContext(ctx, s.db); err != nil { + return uuid.Nil, fmt.Errorf("ads: create campaign: %w", err) + } + return id, nil +} + +// UpdateCampaign updates a campaign's name, weight, enabled flag and validity +// window. The default flag is never touched here. Returns ErrNotFound when no +// campaign matches. +func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) error { + stmt := table.AdCampaigns.UPDATE( + table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.Enabled, + table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, table.AdCampaigns.UpdatedAt, + ).SET( + postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.Bool(c.Enabled), + tsOrNull(c.StartsAt), tsOrNull(c.EndsAt), postgres.TimestampzT(time.Now().UTC()), + ).WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(c.ID))) + return execOne(ctx, s.db, stmt, "update campaign") +} + +// DeleteCampaign removes a campaign (its messages cascade). Returns ErrNotFound +// when no campaign matches. The Service refuses to delete the default. +func (s *Store) DeleteCampaign(ctx context.Context, id uuid.UUID) error { + stmt := table.AdCampaigns.DELETE().WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(id))) + return execOne(ctx, s.db, stmt, "delete campaign") +} + +// AddMessage inserts a message into a campaign and returns its new id. +func (s *Store) AddMessage(ctx context.Context, m Message) (uuid.UUID, error) { + id := uuid.New() + now := time.Now().UTC() + stmt := table.AdMessages.INSERT( + table.AdMessages.MessageID, table.AdMessages.CampaignID, table.AdMessages.Position, + table.AdMessages.BodyEn, table.AdMessages.BodyRu, + table.AdMessages.CreatedAt, table.AdMessages.UpdatedAt, + ).VALUES( + postgres.UUID(id), postgres.UUID(m.CampaignID), postgres.Int(int64(m.Position)), + postgres.String(m.BodyEn), postgres.String(m.BodyRu), + postgres.TimestampzT(now), postgres.TimestampzT(now), + ) + if _, err := stmt.ExecContext(ctx, s.db); err != nil { + return uuid.Nil, fmt.Errorf("ads: add message: %w", err) + } + return id, nil +} + +// UpdateMessageBodies updates a message's bilingual bodies (its position is +// unchanged). Returns ErrNotFound when no message matches. +func (s *Store) UpdateMessageBodies(ctx context.Context, id uuid.UUID, bodyEn, bodyRu string) error { + stmt := table.AdMessages.UPDATE( + table.AdMessages.BodyEn, table.AdMessages.BodyRu, table.AdMessages.UpdatedAt, + ).SET( + postgres.String(bodyEn), postgres.String(bodyRu), postgres.TimestampzT(time.Now().UTC()), + ).WHERE(table.AdMessages.MessageID.EQ(postgres.UUID(id))) + return execOne(ctx, s.db, stmt, "update message") +} + +// SetMessagePosition updates only a message's position (used to reorder). +func (s *Store) SetMessagePosition(ctx context.Context, id uuid.UUID, position int) error { + stmt := table.AdMessages.UPDATE(table.AdMessages.Position, table.AdMessages.UpdatedAt). + SET(postgres.Int(int64(position)), postgres.TimestampzT(time.Now().UTC())). + WHERE(table.AdMessages.MessageID.EQ(postgres.UUID(id))) + return execOne(ctx, s.db, stmt, "reorder message") +} + +// DeleteMessage removes a message. Returns ErrNotFound when no message matches. +func (s *Store) DeleteMessage(ctx context.Context, id uuid.UUID) error { + stmt := table.AdMessages.DELETE().WHERE(table.AdMessages.MessageID.EQ(postgres.UUID(id))) + return execOne(ctx, s.db, stmt, "delete message") +} + +// Settings returns the global display timings. +func (s *Store) Settings(ctx context.Context) (Timings, error) { + sel := postgres.SELECT(table.AdSettings.AllColumns).FROM(table.AdSettings).LIMIT(1) + var row model.AdSettings + if err := sel.QueryContext(ctx, s.db, &row); err != nil { + return Timings{}, fmt.Errorf("ads: get settings: %w", err) + } + return Timings{ + HoldMs: int(row.HoldMs), + EdgePauseMs: int(row.EdgePauseMs), + ScrollPxPerSec: int(row.ScrollPxPerSec), + FadeOutMs: int(row.FadeOutMs), + GapMs: int(row.GapMs), + FadeInMs: int(row.FadeInMs), + }, nil +} + +// UpdateSettings overwrites the single global display-settings row. +func (s *Store) UpdateSettings(ctx context.Context, t Timings) error { + stmt := table.AdSettings.UPDATE( + table.AdSettings.HoldMs, table.AdSettings.EdgePauseMs, table.AdSettings.ScrollPxPerSec, + table.AdSettings.FadeOutMs, table.AdSettings.GapMs, table.AdSettings.FadeInMs, table.AdSettings.UpdatedAt, + ).SET( + postgres.Int(int64(t.HoldMs)), postgres.Int(int64(t.EdgePauseMs)), postgres.Int(int64(t.ScrollPxPerSec)), + postgres.Int(int64(t.FadeOutMs)), postgres.Int(int64(t.GapMs)), postgres.Int(int64(t.FadeInMs)), + postgres.TimestampzT(time.Now().UTC()), + ).WHERE(table.AdSettings.ID.EQ(postgres.Bool(true))) + if _, err := stmt.ExecContext(ctx, s.db); err != nil { + return fmt.Errorf("ads: update settings: %w", err) + } + return nil +} + +// messagesByCampaign loads every message grouped by campaign id, in display order. +func (s *Store) messagesByCampaign(ctx context.Context) (map[uuid.UUID][]Message, error) { + sel := postgres.SELECT(table.AdMessages.AllColumns). + FROM(table.AdMessages). + ORDER_BY(table.AdMessages.CampaignID.ASC(), table.AdMessages.Position.ASC(), table.AdMessages.CreatedAt.ASC()) + var rows []model.AdMessages + if err := sel.QueryContext(ctx, s.db, &rows); err != nil { + return nil, fmt.Errorf("ads: list messages: %w", err) + } + out := make(map[uuid.UUID][]Message, len(rows)) + for _, r := range rows { + out[r.CampaignID] = append(out[r.CampaignID], modelToMessage(r)) + } + return out, nil +} + +// messagesFor loads one campaign's messages in display order. +func (s *Store) messagesFor(ctx context.Context, campaignID uuid.UUID) ([]Message, error) { + sel := postgres.SELECT(table.AdMessages.AllColumns). + FROM(table.AdMessages). + WHERE(table.AdMessages.CampaignID.EQ(postgres.UUID(campaignID))). + ORDER_BY(table.AdMessages.Position.ASC(), table.AdMessages.CreatedAt.ASC()) + var rows []model.AdMessages + if err := sel.QueryContext(ctx, s.db, &rows); err != nil { + return nil, fmt.Errorf("ads: list messages for %s: %w", campaignID, err) + } + out := make([]Message, 0, len(rows)) + for _, r := range rows { + out = append(out, modelToMessage(r)) + } + return out, nil +} + +// execOne runs a statement expected to touch exactly one row, mapping a zero +// row-count to ErrNotFound. +func execOne(ctx context.Context, db qrm.Executable, stmt postgres.Statement, what string) error { + res, err := stmt.ExecContext(ctx, db) + if err != nil { + return fmt.Errorf("ads: %s: %w", what, err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("ads: %s rows: %w", what, err) + } + if n == 0 { + return ErrNotFound + } + return nil +} + +func modelToCampaign(r model.AdCampaigns) Campaign { + return Campaign{ + ID: r.CampaignID, + Name: r.Name, + Weight: int(r.Weight), + IsDefault: r.IsDefault, + Enabled: r.Enabled, + StartsAt: r.StartsAt, + EndsAt: r.EndsAt, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} + +func modelToMessage(r model.AdMessages) Message { + return Message{ + ID: r.MessageID, + CampaignID: r.CampaignID, + Position: int(r.Position), + BodyEn: r.BodyEn, + BodyRu: r.BodyRu, + } +} + +// tsOrNull renders a nullable validity-window bound: NULL for a nil pointer, +// otherwise the UTC timestamp. +func tsOrNull(t *time.Time) postgres.Expression { + if t == nil { + return postgres.NULL + } + return postgres.TimestampzT(t.UTC()) +} diff --git a/backend/internal/inttest/banner_console_test.go b/backend/internal/inttest/banner_console_test.go new file mode 100644 index 0000000..1cf0095 --- /dev/null +++ b/backend/internal/inttest/banner_console_test.go @@ -0,0 +1,206 @@ +//go:build integration + +package inttest + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/google/uuid" + "go.uber.org/zap" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/ads" + "scrabble/backend/internal/server" +) + +// bannerServer assembles a console-capable server with the ads domain wired. +func bannerServer(t *testing.T) (*server.Server, *ads.Service) { + t.Helper() + adsSvc := ads.NewService(ads.NewStore(testDB)) + srv := server.New(":0", server.Deps{ + Logger: zap.NewNop(), + Accounts: account.NewStore(testDB), + Games: newGameService(), + Registry: testRegistry, + DictDir: dictDir(), + Ads: adsSvc, + }) + return srv, adsSvc +} + +// findCampaign returns the id of the campaign with the given name, or fails. +func findCampaign(t *testing.T, svc *ads.Service, name string) uuid.UUID { + t.Helper() + all, err := svc.ListCampaigns(context.Background()) + if err != nil { + t.Fatalf("list campaigns: %v", err) + } + for _, c := range all { + if c.Name == name { + return c.ID + } + } + t.Fatalf("campaign %q not found", name) + return uuid.Nil +} + +// defaultCampaign returns the seeded perpetual default campaign. +func defaultCampaign(t *testing.T, svc *ads.Service) ads.Campaign { + t.Helper() + all, err := svc.ListCampaigns(context.Background()) + if err != nil { + t.Fatalf("list campaigns: %v", err) + } + for _, c := range all { + if c.IsDefault { + return c + } + } + t.Fatal("no default campaign seeded") + return ads.Campaign{} +} + +// TestBannerConsoleCRUD drives the /_gm/banners console over HTTP against real +// stores: pages render, the CSRF guard bites, validation and the default-campaign +// protection hold, and a campaign + message round-trips through create/edit/ +// reorder/delete. +func TestBannerConsoleCRUD(t *testing.T) { + ctx := context.Background() + srv, adsSvc := bannerServer(t) + h := srv.Handler() + base := "http://admin.test/_gm" + const origin = "http://admin.test" + + // The list renders and shows the seeded default campaign. + if code, body := consoleDo(h, http.MethodGet, base+"/banners", "", ""); code != http.StatusOK || !strings.Contains(body, "Default (house)") { + t.Fatalf("banners list = %d, has default=%v", code, strings.Contains(body, "Default (house)")) + } + + name := "Promo-" + uuid.NewString()[:8] + // CSRF: a create without a same-origin header is rejected. + if code, _ := consoleDo(h, http.MethodPost, base+"/banners", "name="+name+"&weight=30&enabled=on", ""); code != http.StatusForbidden { + t.Fatalf("create without origin = %d, want 403", code) + } + // Validation: weight out of range is refused. + if code, body := consoleDo(h, http.MethodPost, base+"/banners", "name=Bad&weight=0&enabled=on", origin); code != http.StatusOK || !strings.Contains(body, "Not saved") { + t.Fatalf("create weight=0 = %d, has 'Not saved'=%v", code, strings.Contains(body, "Not saved")) + } + // A valid create persists. + if code, body := consoleDo(h, http.MethodPost, base+"/banners", "name="+name+"&weight=30&enabled=on", origin); code != http.StatusOK || !strings.Contains(body, "Created") { + t.Fatalf("create = %d, has Created=%v", code, strings.Contains(body, "Created")) + } + id := findCampaign(t, adsSvc, name) + + // The detail page renders. + if code, body := consoleDo(h, http.MethodGet, base+"/banners/"+id.String(), "", ""); code != http.StatusOK || !strings.Contains(body, name) { + t.Fatalf("detail = %d, has name=%v", code, strings.Contains(body, name)) + } + // A message needs both languages. + if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages", "body_en=Hello&body_ru=", origin); code != http.StatusOK || !strings.Contains(body, "Not saved") { + t.Fatalf("add half message = %d, has 'Not saved'=%v", code, strings.Contains(body, "Not saved")) + } + // A bilingual message is added. + if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages", "body_en=Hello&body_ru=Привет", origin); code != http.StatusOK || !strings.Contains(body, "Added") { + t.Fatalf("add message = %d, has Added=%v", code, strings.Contains(body, "Added")) + } + cm, err := adsSvc.Campaign(ctx, id) + if err != nil || len(cm.Messages) != 1 { + t.Fatalf("campaign messages = %d (err %v), want 1", len(cm.Messages), err) + } + mid := cm.Messages[0].ID + // Edit it. + if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages/"+mid.String(), "body_en=Hi&body_ru=Привет!", origin); code != http.StatusOK { + t.Fatalf("edit message = %d", code) + } + if cm, _ := adsSvc.Campaign(ctx, id); cm.Messages[0].BodyEn != "Hi" { + t.Fatalf("edit did not persist: %q", cm.Messages[0].BodyEn) + } + // Reorder is a no-op for a single message but still succeeds. + if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages/"+mid.String()+"/move", "dir=down", origin); code != http.StatusOK { + t.Fatalf("move message = %d", code) + } + // Delete the message, then the campaign. + if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages/"+mid.String()+"/delete", "", origin); code != http.StatusOK { + t.Fatalf("delete message = %d", code) + } + if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Deleted") { + t.Fatalf("delete campaign = %d, has Deleted=%v", code, strings.Contains(body, "Deleted")) + } + if _, err := adsSvc.Campaign(ctx, id); err == nil { + t.Fatal("campaign still present after delete") + } + + // The default campaign cannot be deleted. + def := defaultCampaign(t, adsSvc) + if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+def.ID.String()+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Not saved") { + t.Fatalf("delete default = %d, has 'Not saved'=%v", code, strings.Contains(body, "Not saved")) + } + if _, err := adsSvc.Campaign(ctx, def.ID); err != nil { + t.Fatalf("default campaign gone after refused delete: %v", err) + } +} + +// profileBanner is the banner block of the profile.get JSON response. +type profileBanner struct { + Banner *struct { + Campaigns []struct { + Weight int `json:"weight"` + Messages []string `json:"messages"` + } `json:"campaigns"` + Timings struct { + HoldMs int `json:"hold_ms"` + } `json:"timings"` + } `json:"banner"` +} + +// TestBannerProfileEligibility checks the profile.get banner block follows +// eligibility: a free account sees it, the no_banner role and a non-empty hint +// wallet each suppress it. +func TestBannerProfileEligibility(t *testing.T) { + ctx := context.Background() + srv, _ := bannerServer(t) + accounts := account.NewStore(testDB) + id := provisionAccount(t) + + getBanner := func() profileBanner { + t.Helper() + rec := userGet(t, srv, "/api/v1/user/profile", id) + if rec.Code != http.StatusOK { + t.Fatalf("profile = %d, want 200", rec.Code) + } + var p profileBanner + if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil { + t.Fatalf("decode profile: %v", err) + } + return p + } + + // A free account with an empty hint wallet and no role is eligible. + p := getBanner() + if p.Banner == nil || len(p.Banner.Campaigns) == 0 || p.Banner.Timings.HoldMs <= 0 { + t.Fatalf("eligible account: banner=%v", p.Banner) + } + + // The no_banner role suppresses it unconditionally. + if err := accounts.GrantRole(ctx, id, account.RoleNoBanner); err != nil { + t.Fatalf("grant role: %v", err) + } + if p := getBanner(); p.Banner != nil { + t.Fatal("no_banner role still shows the banner") + } + if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil { + t.Fatalf("revoke role: %v", err) + } + + // A non-empty hint wallet suppresses it. + if _, err := accounts.GrantHints(ctx, id, 5); err != nil { + t.Fatalf("grant hints: %v", err) + } + if p := getBanner(); p.Banner != nil { + t.Fatal("a non-empty hint wallet still shows the banner") + } +} diff --git a/backend/internal/notify/events.go b/backend/internal/notify/events.go index 65ee14e..4c97aec 100644 --- a/backend/internal/notify/events.go +++ b/backend/internal/notify/events.go @@ -205,6 +205,13 @@ func notificationInvitation(userID uuid.UUID, sub string, inv InvitationSummary) return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()} } +// BannerChanged tells userID that its advertising-banner eligibility may have +// changed, so the client re-fetches profile.get to show or hide the banner. It +// carries no payload (the banner set rides the profile response). +func BannerChanged(userID uuid.UUID) Intent { + return Notification(userID, NotifyBanner) +} + // eventID returns a best-effort correlation id for one emitted event. func eventID() string { if id, err := uuid.NewV7(); err == nil { diff --git a/backend/internal/notify/notify.go b/backend/internal/notify/notify.go index dbe1bec..16b3ca0 100644 --- a/backend/internal/notify/notify.go +++ b/backend/internal/notify/notify.go @@ -57,6 +57,11 @@ const ( // the client raises the Settings/Info badge and re-fetches the reply. It carries // no payload (the reply is fetched on the feedback screen). In-app only. NotifyAdminReply = "admin_reply" + // NotifyBanner tells the client that the viewer's advertising-banner eligibility + // may have changed (an operator granted hints or toggled the no_banner role), so + // it re-fetches profile.get to show or hide the banner. It carries no payload (the + // banner set rides the profile response). In-app only. + NotifyBanner = "banner" ) // Intent is one live event destined for a single user. Payload is the diff --git a/backend/internal/postgres/jet/backend/model/ad_campaigns.go b/backend/internal/postgres/jet/backend/model/ad_campaigns.go new file mode 100644 index 0000000..718bab8 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/ad_campaigns.go @@ -0,0 +1,25 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type AdCampaigns struct { + CampaignID uuid.UUID `sql:"primary_key"` + Name string + Weight int32 + IsDefault bool + Enabled bool + StartsAt *time.Time + EndsAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/backend/internal/postgres/jet/backend/model/ad_messages.go b/backend/internal/postgres/jet/backend/model/ad_messages.go new file mode 100644 index 0000000..2b0e9ec --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/ad_messages.go @@ -0,0 +1,23 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type AdMessages struct { + MessageID uuid.UUID `sql:"primary_key"` + CampaignID uuid.UUID + Position int32 + BodyEn string + BodyRu string + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/backend/internal/postgres/jet/backend/model/ad_settings.go b/backend/internal/postgres/jet/backend/model/ad_settings.go new file mode 100644 index 0000000..454e7c2 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/ad_settings.go @@ -0,0 +1,23 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "time" +) + +type AdSettings struct { + ID bool `sql:"primary_key"` + HoldMs int32 + EdgePauseMs int32 + ScrollPxPerSec int32 + FadeOutMs int32 + GapMs int32 + FadeInMs int32 + UpdatedAt time.Time +} diff --git a/backend/internal/postgres/jet/backend/table/ad_campaigns.go b/backend/internal/postgres/jet/backend/table/ad_campaigns.go new file mode 100644 index 0000000..891586b --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/ad_campaigns.go @@ -0,0 +1,102 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var AdCampaigns = newAdCampaignsTable("backend", "ad_campaigns", "") + +type adCampaignsTable struct { + postgres.Table + + // Columns + CampaignID postgres.ColumnString + Name postgres.ColumnString + Weight postgres.ColumnInteger + IsDefault postgres.ColumnBool + Enabled postgres.ColumnBool + StartsAt postgres.ColumnTimestampz + EndsAt postgres.ColumnTimestampz + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type AdCampaignsTable struct { + adCampaignsTable + + EXCLUDED adCampaignsTable +} + +// AS creates new AdCampaignsTable with assigned alias +func (a AdCampaignsTable) AS(alias string) *AdCampaignsTable { + return newAdCampaignsTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new AdCampaignsTable with assigned schema name +func (a AdCampaignsTable) FromSchema(schemaName string) *AdCampaignsTable { + return newAdCampaignsTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new AdCampaignsTable with assigned table prefix +func (a AdCampaignsTable) WithPrefix(prefix string) *AdCampaignsTable { + return newAdCampaignsTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new AdCampaignsTable with assigned table suffix +func (a AdCampaignsTable) WithSuffix(suffix string) *AdCampaignsTable { + return newAdCampaignsTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newAdCampaignsTable(schemaName, tableName, alias string) *AdCampaignsTable { + return &AdCampaignsTable{ + adCampaignsTable: newAdCampaignsTableImpl(schemaName, tableName, alias), + EXCLUDED: newAdCampaignsTableImpl("", "excluded", ""), + } +} + +func newAdCampaignsTableImpl(schemaName, tableName, alias string) adCampaignsTable { + var ( + CampaignIDColumn = postgres.StringColumn("campaign_id") + NameColumn = postgres.StringColumn("name") + WeightColumn = postgres.IntegerColumn("weight") + IsDefaultColumn = postgres.BoolColumn("is_default") + EnabledColumn = postgres.BoolColumn("enabled") + StartsAtColumn = postgres.TimestampzColumn("starts_at") + EndsAtColumn = postgres.TimestampzColumn("ends_at") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{CampaignIDColumn, NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{IsDefaultColumn, EnabledColumn, CreatedAtColumn, UpdatedAtColumn} + ) + + return adCampaignsTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + CampaignID: CampaignIDColumn, + Name: NameColumn, + Weight: WeightColumn, + IsDefault: IsDefaultColumn, + Enabled: EnabledColumn, + StartsAt: StartsAtColumn, + EndsAt: EndsAtColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/ad_messages.go b/backend/internal/postgres/jet/backend/table/ad_messages.go new file mode 100644 index 0000000..c3a570a --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/ad_messages.go @@ -0,0 +1,96 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var AdMessages = newAdMessagesTable("backend", "ad_messages", "") + +type adMessagesTable struct { + postgres.Table + + // Columns + MessageID postgres.ColumnString + CampaignID postgres.ColumnString + Position postgres.ColumnInteger + BodyEn postgres.ColumnString + BodyRu postgres.ColumnString + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type AdMessagesTable struct { + adMessagesTable + + EXCLUDED adMessagesTable +} + +// AS creates new AdMessagesTable with assigned alias +func (a AdMessagesTable) AS(alias string) *AdMessagesTable { + return newAdMessagesTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new AdMessagesTable with assigned schema name +func (a AdMessagesTable) FromSchema(schemaName string) *AdMessagesTable { + return newAdMessagesTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new AdMessagesTable with assigned table prefix +func (a AdMessagesTable) WithPrefix(prefix string) *AdMessagesTable { + return newAdMessagesTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new AdMessagesTable with assigned table suffix +func (a AdMessagesTable) WithSuffix(suffix string) *AdMessagesTable { + return newAdMessagesTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newAdMessagesTable(schemaName, tableName, alias string) *AdMessagesTable { + return &AdMessagesTable{ + adMessagesTable: newAdMessagesTableImpl(schemaName, tableName, alias), + EXCLUDED: newAdMessagesTableImpl("", "excluded", ""), + } +} + +func newAdMessagesTableImpl(schemaName, tableName, alias string) adMessagesTable { + var ( + MessageIDColumn = postgres.StringColumn("message_id") + CampaignIDColumn = postgres.StringColumn("campaign_id") + PositionColumn = postgres.IntegerColumn("position") + BodyEnColumn = postgres.StringColumn("body_en") + BodyRuColumn = postgres.StringColumn("body_ru") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{MessageIDColumn, CampaignIDColumn, PositionColumn, BodyEnColumn, BodyRuColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{CampaignIDColumn, PositionColumn, BodyEnColumn, BodyRuColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{PositionColumn, CreatedAtColumn, UpdatedAtColumn} + ) + + return adMessagesTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + MessageID: MessageIDColumn, + CampaignID: CampaignIDColumn, + Position: PositionColumn, + BodyEn: BodyEnColumn, + BodyRu: BodyRuColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/ad_settings.go b/backend/internal/postgres/jet/backend/table/ad_settings.go new file mode 100644 index 0000000..29de5bb --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/ad_settings.go @@ -0,0 +1,99 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var AdSettings = newAdSettingsTable("backend", "ad_settings", "") + +type adSettingsTable struct { + postgres.Table + + // Columns + ID postgres.ColumnBool + HoldMs postgres.ColumnInteger + EdgePauseMs postgres.ColumnInteger + ScrollPxPerSec postgres.ColumnInteger + FadeOutMs postgres.ColumnInteger + GapMs postgres.ColumnInteger + FadeInMs postgres.ColumnInteger + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type AdSettingsTable struct { + adSettingsTable + + EXCLUDED adSettingsTable +} + +// AS creates new AdSettingsTable with assigned alias +func (a AdSettingsTable) AS(alias string) *AdSettingsTable { + return newAdSettingsTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new AdSettingsTable with assigned schema name +func (a AdSettingsTable) FromSchema(schemaName string) *AdSettingsTable { + return newAdSettingsTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new AdSettingsTable with assigned table prefix +func (a AdSettingsTable) WithPrefix(prefix string) *AdSettingsTable { + return newAdSettingsTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new AdSettingsTable with assigned table suffix +func (a AdSettingsTable) WithSuffix(suffix string) *AdSettingsTable { + return newAdSettingsTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newAdSettingsTable(schemaName, tableName, alias string) *AdSettingsTable { + return &AdSettingsTable{ + adSettingsTable: newAdSettingsTableImpl(schemaName, tableName, alias), + EXCLUDED: newAdSettingsTableImpl("", "excluded", ""), + } +} + +func newAdSettingsTableImpl(schemaName, tableName, alias string) adSettingsTable { + var ( + IDColumn = postgres.BoolColumn("id") + HoldMsColumn = postgres.IntegerColumn("hold_ms") + EdgePauseMsColumn = postgres.IntegerColumn("edge_pause_ms") + ScrollPxPerSecColumn = postgres.IntegerColumn("scroll_px_per_sec") + FadeOutMsColumn = postgres.IntegerColumn("fade_out_ms") + GapMsColumn = postgres.IntegerColumn("gap_ms") + FadeInMsColumn = postgres.IntegerColumn("fade_in_ms") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{IDColumn, HoldMsColumn, EdgePauseMsColumn, ScrollPxPerSecColumn, FadeOutMsColumn, GapMsColumn, FadeInMsColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{HoldMsColumn, EdgePauseMsColumn, ScrollPxPerSecColumn, FadeOutMsColumn, GapMsColumn, FadeInMsColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{IDColumn, UpdatedAtColumn} + ) + + return adSettingsTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + ID: IDColumn, + HoldMs: HoldMsColumn, + EdgePauseMs: EdgePauseMsColumn, + ScrollPxPerSec: ScrollPxPerSecColumn, + FadeOutMs: FadeOutMsColumn, + GapMs: GapMsColumn, + FadeInMs: FadeInMsColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/table_use_schema.go b/backend/internal/postgres/jet/backend/table/table_use_schema.go index 18e9bec..46921db 100644 --- a/backend/internal/postgres/jet/backend/table/table_use_schema.go +++ b/backend/internal/postgres/jet/backend/table/table_use_schema.go @@ -14,6 +14,9 @@ func UseSchema(schema string) { AccountStats = AccountStats.FromSchema(schema) AccountSuspensions = AccountSuspensions.FromSchema(schema) Accounts = Accounts.FromSchema(schema) + AdCampaigns = AdCampaigns.FromSchema(schema) + AdMessages = AdMessages.FromSchema(schema) + AdSettings = AdSettings.FromSchema(schema) Blocks = Blocks.FromSchema(schema) ChatMessages = ChatMessages.FromSchema(schema) Complaints = Complaints.FromSchema(schema) diff --git a/backend/internal/postgres/migrations/00006_ad_banners.sql b/backend/internal/postgres/migrations/00006_ad_banners.sql new file mode 100644 index 0000000..3b2ecac --- /dev/null +++ b/backend/internal/postgres/migrations/00006_ad_banners.sql @@ -0,0 +1,86 @@ +-- +goose Up +-- Server-driven advertising banner ("advertising network"): operator-managed campaigns rotated in +-- the client's one-line announcement strip. A campaign is one placement order with a show weight +-- (percent); simultaneously active campaigns compete for shows in proportion to their weights. The +-- single perpetual default campaign fills the unsold remainder up to 100%. Each campaign carries +-- one or more bilingual messages (en + ru, both mandatory), shown by the viewer's bot (service) +-- language. Display timings are global (one settings row). Eligibility (who sees a banner at all) +-- reuses account fields + the no_banner role, so it needs no schema here. See docs/ARCHITECTURE.md +-- and internal/ads. +SET search_path = backend, pg_catalog; + +-- One advertising campaign = one placement order. weight is the show percentage (1..100). is_default +-- marks the single perpetual house campaign that fills the remainder up to 100% and is never deleted; +-- it has no validity window (its stored weight is nominal — the rotation derives its effective weight +-- as max(0, 100 - sum of active timed weights)). A time-limited campaign is active while now() lies in +-- [starts_at, ends_at] (a null bound is open-ended on that side). enabled toggles a campaign without +-- deleting it. +CREATE TABLE ad_campaigns ( + campaign_id uuid PRIMARY KEY, + name text NOT NULL, + weight integer NOT NULL, + is_default boolean NOT NULL DEFAULT false, + enabled boolean NOT NULL DEFAULT true, + starts_at timestamptz, + ends_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ad_campaigns_weight_chk CHECK (weight BETWEEN 1 AND 100), + -- The default campaign is perpetual (no window). + CONSTRAINT ad_campaigns_default_window_chk CHECK (NOT is_default OR (starts_at IS NULL AND ends_at IS NULL)), + -- A closed window must not be inverted. + CONSTRAINT ad_campaigns_window_chk CHECK (starts_at IS NULL OR ends_at IS NULL OR starts_at <= ends_at) +); +-- At most one default campaign. +CREATE UNIQUE INDEX ad_campaigns_default_idx ON ad_campaigns (is_default) WHERE is_default; + +-- One bilingual message of a campaign. Both languages are mandatory: the client shows the variant for +-- the viewer's bot (service) language. position orders the messages within a campaign (the round-robin +-- order when the campaign wins a display slot). body_* is minimal markdown (text + links). +CREATE TABLE ad_messages ( + message_id uuid PRIMARY KEY, + campaign_id uuid NOT NULL REFERENCES ad_campaigns (campaign_id) ON DELETE CASCADE, + position integer NOT NULL DEFAULT 0, + body_en text NOT NULL, + body_ru text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +-- Messages of a campaign are read in display order. +CREATE INDEX ad_messages_campaign_idx ON ad_messages (campaign_id, position); + +-- Global banner display timings, a single row (id is always TRUE). The client rotator reads these: +-- hold_ms is how long one message shows; edge_pause_ms and scroll_px_per_sec drive the scroll of a +-- message wider than the strip; the transition between messages is fade_out_ms -> gap_ms -> fade_in_ms. +-- All are clamped in Go on update. +CREATE TABLE ad_settings ( + id boolean PRIMARY KEY DEFAULT true, + hold_ms integer NOT NULL, + edge_pause_ms integer NOT NULL, + scroll_px_per_sec integer NOT NULL, + fade_out_ms integer NOT NULL, + gap_ms integer NOT NULL, + fade_in_ms integer NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ad_settings_singleton_chk CHECK (id) +); + +-- Seed the perpetual default campaign with one bilingual house message, and the default timings +-- (the previous hardcoded UI defaults plus the new fade sequence). Fixed UUIDs keep the seed +-- deterministic across environments. +INSERT INTO ad_campaigns (campaign_id, name, weight, is_default, enabled) +VALUES ('00000000-0000-0000-0000-0000000000ad', 'Default (house)', 100, true, true); +INSERT INTO ad_messages (message_id, campaign_id, position, body_en, body_ru) +VALUES ( + '00000000-0000-0000-0000-0000000000a1', '00000000-0000-0000-0000-0000000000ad', 0, + 'Tip: a play using all 7 tiles earns a +50 bonus.', + 'Совет: ход всеми 7 фишками приносит бонус +50 очков.' +); +INSERT INTO ad_settings (id, hold_ms, edge_pause_ms, scroll_px_per_sec, fade_out_ms, gap_ms, fade_in_ms) +VALUES (true, 60000, 5000, 40, 1000, 250, 1000); + +-- +goose Down +SET search_path = backend, pg_catalog; +DROP TABLE IF EXISTS ad_settings; +DROP TABLE IF EXISTS ad_messages; +DROP TABLE IF EXISTS ad_campaigns; diff --git a/backend/internal/server/banner.go b/backend/internal/server/banner.go new file mode 100644 index 0000000..1fa0f94 --- /dev/null +++ b/backend/internal/server/banner.go @@ -0,0 +1,100 @@ +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"` +} + +// 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: the bot (service) +// language they last signed in through, falling back to the interface language +// and then English. +func bannerLang(acc account.Account) string { + if acc.ServiceLanguage == "ru" || acc.ServiceLanguage == "en" { + return acc.ServiceLanguage + } + 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)) +} diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 98def50..b73c3b5 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -49,6 +49,10 @@ type profileResponse struct { BlockFriendRequests bool `json:"block_friend_requests"` IsGuest bool `json:"is_guest"` NotificationsInAppOnly bool `json:"notifications_in_app_only"` + // Banner is the advertising-banner block: present only for a viewer eligible to + // see the banner (a free account with an empty hint wallet and without the + // no_banner role), absent otherwise. See banner.go. + Banner *bannerDTO `json:"banner,omitempty"` } // tileDTO is one placed (or to-place) tile. diff --git a/backend/internal/server/handlers_admin_banners.go b/backend/internal/server/handlers_admin_banners.go new file mode 100644 index 0000000..86b89a5 --- /dev/null +++ b/backend/internal/server/handlers_admin_banners.go @@ -0,0 +1,323 @@ +package server + +import ( + "errors" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "scrabble/backend/internal/adminconsole" + "scrabble/backend/internal/ads" +) + +// bannersBack is the campaign-list path the banner console actions return to. +const bannersBack = "/_gm/banners" + +// localTimeLayout is the datetime-local input/round-trip form of a validity-window +// bound; the operator's entry is interpreted as UTC. +const localTimeLayout = "2006-01-02T15:04" + +// consoleBanners lists the advertising campaigns (default first), each with its +// weight, validity window, enabled flag, message count and live-now status. +func (s *Server) consoleBanners(c *gin.Context) { + campaigns, err := s.ads.ListCampaigns(c.Request.Context()) + if err != nil { + s.consoleError(c, err) + return + } + now := time.Now().UTC() + var view adminconsole.BannersView + for _, cm := range campaigns { + view.Items = append(view.Items, adminconsole.BannerCampaignRow{ + ID: cm.ID.String(), + Name: cm.Name, + Weight: cm.Weight, + IsDefault: cm.IsDefault, + Enabled: cm.Enabled, + Window: windowLabel(cm), + Messages: len(cm.Messages), + ActiveNow: cm.ActiveAt(now), + }) + } + s.renderConsole(c, "banners", "banners", "Banners", view) +} + +// consoleBannerDetail renders one campaign's edit form and its messages. +func (s *Server) consoleBannerDetail(c *gin.Context) { + id, ok := s.consoleUUID(c, bannersBack) + if !ok { + return + } + cm, err := s.ads.Campaign(c.Request.Context(), id) + if err != nil { + s.bannerError(c, err, bannersBack) + return + } + view := adminconsole.BannerDetailView{ + ID: cm.ID.String(), + Name: cm.Name, + Weight: cm.Weight, + IsDefault: cm.IsDefault, + Enabled: cm.Enabled, + StartsAt: localInput(cm.StartsAt), + EndsAt: localInput(cm.EndsAt), + } + for i, m := range cm.Messages { + view.Messages = append(view.Messages, adminconsole.BannerMessageRow{ + ID: m.ID.String(), + BodyEn: m.BodyEn, + BodyRu: m.BodyRu, + First: i == 0, + Last: i == len(cm.Messages)-1, + }) + } + s.renderConsole(c, "banner_detail", "banners", cm.Name, view) +} + +// consoleCreateBanner creates a new time-limited campaign. +func (s *Server) consoleCreateBanner(c *gin.Context) { + starts, ends, err := parseWindow(c) + if err != nil { + s.renderConsoleMessage(c, "Invalid window", err.Error(), bannersBack) + return + } + id, err := s.ads.CreateCampaign(c.Request.Context(), ads.Campaign{ + Name: trimForm(c, "name"), + Weight: atoiForm(c, "weight"), + Enabled: c.PostForm("enabled") != "", + StartsAt: starts, + EndsAt: ends, + }) + if err != nil { + s.bannerError(c, err, bannersBack) + return + } + s.renderConsoleMessage(c, "Created", "campaign created", "/_gm/banners/"+id.String()) +} + +// consoleUpdateBanner saves a campaign's editable fields. For the default +// campaign the service ignores everything but the name. +func (s *Server) consoleUpdateBanner(c *gin.Context) { + id, ok := s.consoleUUID(c, bannersBack) + if !ok { + return + } + back := "/_gm/banners/" + id.String() + starts, ends, err := parseWindow(c) + if err != nil { + s.renderConsoleMessage(c, "Invalid window", err.Error(), back) + return + } + err = s.ads.UpdateCampaign(c.Request.Context(), ads.Campaign{ + ID: id, + Name: trimForm(c, "name"), + Weight: atoiForm(c, "weight"), + Enabled: c.PostForm("enabled") != "", + StartsAt: starts, + EndsAt: ends, + }) + if err != nil { + s.bannerError(c, err, back) + return + } + s.renderConsoleMessage(c, "Saved", "campaign updated", back) +} + +// consoleDeleteBanner removes a campaign (the service refuses the default). +func (s *Server) consoleDeleteBanner(c *gin.Context) { + id, ok := s.consoleUUID(c, bannersBack) + if !ok { + return + } + if err := s.ads.DeleteCampaign(c.Request.Context(), id); err != nil { + s.bannerError(c, err, "/_gm/banners/"+id.String()) + return + } + s.renderConsoleMessage(c, "Deleted", "campaign deleted", bannersBack) +} + +// consoleAddBannerMessage appends a bilingual message to a campaign. +func (s *Server) consoleAddBannerMessage(c *gin.Context) { + id, ok := s.consoleUUID(c, bannersBack) + if !ok { + return + } + back := "/_gm/banners/" + id.String() + if _, err := s.ads.AddMessage(c.Request.Context(), id, trimForm(c, "body_en"), trimForm(c, "body_ru")); err != nil { + s.bannerError(c, err, back) + return + } + s.renderConsoleMessage(c, "Added", "message added", back) +} + +// consoleEditBannerMessage rewrites a message's bilingual bodies. +func (s *Server) consoleEditBannerMessage(c *gin.Context) { + cid, mid, ok := s.bannerMessageIDs(c) + if !ok { + return + } + back := "/_gm/banners/" + cid.String() + if err := s.ads.EditMessage(c.Request.Context(), mid, trimForm(c, "body_en"), trimForm(c, "body_ru")); err != nil { + s.bannerError(c, err, back) + return + } + s.renderConsoleMessage(c, "Saved", "message updated", back) +} + +// consoleDeleteBannerMessage removes a message (the service keeps the default's +// last one). +func (s *Server) consoleDeleteBannerMessage(c *gin.Context) { + cid, mid, ok := s.bannerMessageIDs(c) + if !ok { + return + } + back := "/_gm/banners/" + cid.String() + if err := s.ads.DeleteMessage(c.Request.Context(), cid, mid); err != nil { + s.bannerError(c, err, back) + return + } + s.renderConsoleMessage(c, "Deleted", "message deleted", back) +} + +// consoleMoveBannerMessage reorders a message up or down within its campaign. +func (s *Server) consoleMoveBannerMessage(c *gin.Context) { + cid, mid, ok := s.bannerMessageIDs(c) + if !ok { + return + } + back := "/_gm/banners/" + cid.String() + dir := 1 + if trimForm(c, "dir") == "up" { + dir = -1 + } + if err := s.ads.MoveMessage(c.Request.Context(), cid, mid, dir); err != nil { + s.bannerError(c, err, back) + return + } + s.renderConsoleMessage(c, "Moved", "message reordered", back) +} + +// consoleBannerSettings renders the global display-timings form. +func (s *Server) consoleBannerSettings(c *gin.Context) { + t, err := s.ads.Settings(c.Request.Context()) + if err != nil { + s.consoleError(c, err) + return + } + s.renderConsole(c, "banner_settings", "banners", "Banner settings", adminconsole.BannerSettingsView{ + HoldMs: t.HoldMs, + EdgePauseMs: t.EdgePauseMs, + ScrollPxPerSec: t.ScrollPxPerSec, + FadeOutMs: t.FadeOutMs, + GapMs: t.GapMs, + FadeInMs: t.FadeInMs, + }) +} + +// consoleUpdateBannerSettings saves the global display timings (clamped by the +// service into their safe ranges). +func (s *Server) consoleUpdateBannerSettings(c *gin.Context) { + err := s.ads.UpdateSettings(c.Request.Context(), ads.Timings{ + HoldMs: atoiForm(c, "hold_ms"), + EdgePauseMs: atoiForm(c, "edge_pause_ms"), + ScrollPxPerSec: atoiForm(c, "scroll_px_per_sec"), + FadeOutMs: atoiForm(c, "fade_out_ms"), + GapMs: atoiForm(c, "gap_ms"), + FadeInMs: atoiForm(c, "fade_in_ms"), + }) + if err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Saved", "display timings updated", "/_gm/banner-settings") +} + +// bannerMessageIDs parses the campaign :id and message :mid path parameters, +// rendering an invalid-id message and returning false when either is not a UUID. +func (s *Server) bannerMessageIDs(c *gin.Context) (campaignID, messageID uuid.UUID, ok bool) { + cid, err := uuid.Parse(c.Param("id")) + if err != nil { + s.renderConsoleMessage(c, "Invalid id", "the campaign id in the URL is not valid", bannersBack) + return uuid.Nil, uuid.Nil, false + } + mid, err := uuid.Parse(c.Param("mid")) + if err != nil { + s.renderConsoleMessage(c, "Invalid id", "the message id in the URL is not valid", "/_gm/banners/"+cid.String()) + return uuid.Nil, uuid.Nil, false + } + return cid, mid, true +} + +// bannerError maps an ads validation/immutability/not-found error to a friendly +// console message, and anything else to the generic error page. +func (s *Server) bannerError(c *gin.Context, err error, back string) { + switch { + case errors.Is(err, ads.ErrValidation), errors.Is(err, ads.ErrDefaultImmutable): + s.renderConsoleMessage(c, "Not saved", err.Error(), back) + case errors.Is(err, ads.ErrNotFound): + s.renderConsoleMessage(c, "Not found", "no such campaign or message", back) + default: + s.consoleError(c, err) + } +} + +// parseWindow reads the optional starts_at/ends_at datetime-local form fields, +// interpreting each as UTC; an empty field is an open-ended bound (nil). +func parseWindow(c *gin.Context) (starts, ends *time.Time, err error) { + if starts, err = parseLocalTime(trimForm(c, "starts_at")); err != nil { + return nil, nil, errors.New("the start time is not a valid date/time") + } + if ends, err = parseLocalTime(trimForm(c, "ends_at")); err != nil { + return nil, nil, errors.New("the end time is not a valid date/time") + } + return starts, ends, nil +} + +// parseLocalTime parses a datetime-local value as UTC, or returns nil for an +// empty string. +func parseLocalTime(s string) (*time.Time, error) { + if s == "" { + return nil, nil + } + t, err := time.Parse(localTimeLayout, s) + if err != nil { + return nil, err + } + t = t.UTC() + return &t, nil +} + +// localInput formats a validity-window bound for a datetime-local input, or "" +// when open-ended. +func localInput(t *time.Time) string { + if t == nil { + return "" + } + return t.UTC().Format(localTimeLayout) +} + +// windowLabel builds the human-readable validity window for the campaign list. +func windowLabel(c ads.Campaign) string { + if c.IsDefault { + return "perpetual" + } + switch { + case c.StartsAt == nil && c.EndsAt == nil: + return "always" + case c.StartsAt == nil: + return "until " + fmtTimePtr(c.EndsAt) + case c.EndsAt == nil: + return "from " + fmtTimePtr(c.StartsAt) + default: + return fmtTimePtr(c.StartsAt) + " – " + fmtTimePtr(c.EndsAt) + } +} + +// atoiForm returns the integer value of a posted form field, or 0 when missing +// or non-numeric (the ads service clamps timings and validates weights). +func atoiForm(c *gin.Context, name string) int { + n, _ := strconv.Atoi(trimForm(c, name)) + return n +} diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 51cd16c..3ad776f 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -88,6 +88,19 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.POST("/dictionary/changes/apply", s.consoleApplyChanges) gm.GET("/broadcast", s.consoleBroadcast) gm.POST("/broadcast", s.consolePostBroadcast) + if s.ads != nil { + gm.GET("/banners", s.consoleBanners) + gm.POST("/banners", s.consoleCreateBanner) + gm.GET("/banners/:id", s.consoleBannerDetail) + gm.POST("/banners/:id", s.consoleUpdateBanner) + gm.POST("/banners/:id/delete", s.consoleDeleteBanner) + gm.POST("/banners/:id/messages", s.consoleAddBannerMessage) + gm.POST("/banners/:id/messages/:mid", s.consoleEditBannerMessage) + gm.POST("/banners/:id/messages/:mid/delete", s.consoleDeleteBannerMessage) + gm.POST("/banners/:id/messages/:mid/move", s.consoleMoveBannerMessage) + gm.GET("/banner-settings", s.consoleBannerSettings) + gm.POST("/banner-settings", s.consoleUpdateBannerSettings) + } } // consoleDashboard renders the landing page: the top-line counts and the resident @@ -817,6 +830,8 @@ func (s *Server) consoleGrantHints(c *gin.Context) { s.consoleError(c, err) return } + // A non-empty hint wallet removes the banner: nudge an open client to re-check. + s.publishBannerChange(id) s.renderConsoleMessage(c, "Granted", fmt.Sprintf("added %d hint(s); the wallet is now %d", n, balance), back) } diff --git a/backend/internal/server/handlers_admin_feedback.go b/backend/internal/server/handlers_admin_feedback.go index 76e7e2f..4a04384 100644 --- a/backend/internal/server/handlers_admin_feedback.go +++ b/backend/internal/server/handlers_admin_feedback.go @@ -245,6 +245,9 @@ func (s *Server) consoleGrantRole(c *gin.Context) { s.consoleError(c, err) return } + if role == account.RoleNoBanner { + s.publishBannerChange(id) + } s.renderConsoleMessage(c, "Role granted", "granted "+role, back) } @@ -264,6 +267,9 @@ func (s *Server) consoleRevokeRole(c *gin.Context) { s.consoleError(c, err) return } + if role == account.RoleNoBanner { + s.publishBannerChange(id) + } s.renderConsoleMessage(c, "Role revoked", "revoked "+role, back) } diff --git a/backend/internal/server/handlers_user.go b/backend/internal/server/handlers_user.go index 642f759..e46e408 100644 --- a/backend/internal/server/handlers_user.go +++ b/backend/internal/server/handlers_user.go @@ -23,7 +23,9 @@ func (s *Server) handleProfile(c *gin.Context) { s.abortErr(c, err) return } - c.JSON(http.StatusOK, profileResponseFor(acc)) + resp := profileResponseFor(acc) + resp.Banner = s.bannerFor(c.Request.Context(), acc) + c.JSON(http.StatusOK, resp) } // submitPlayRequest places tiles on the player's turn; the engine infers the diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 981397e..81d1287 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -19,12 +19,14 @@ import ( "scrabble/backend/internal/account" "scrabble/backend/internal/adminconsole" + "scrabble/backend/internal/ads" "scrabble/backend/internal/connector" "scrabble/backend/internal/engine" "scrabble/backend/internal/feedback" "scrabble/backend/internal/game" "scrabble/backend/internal/link" "scrabble/backend/internal/lobby" + "scrabble/backend/internal/notify" "scrabble/backend/internal/ratewatch" "scrabble/backend/internal/session" "scrabble/backend/internal/social" @@ -81,6 +83,14 @@ type Deps struct { // admin console's throttled view + the high-rate auto-flag. A nil RateWatch // disables the internal report endpoint and the console view. RateWatch *ratewatch.Watch + // Ads is the advertising-banner domain service: campaign rotation feeding the + // profile.get banner block, plus the banner admin console section. A nil Ads + // omits the banner block and disables the banner console. + Ads *ads.Service + // Notifier publishes live-event intents — here the banner-eligibility re-poll + // signal the banner/hint/role console actions emit. A nil Notifier discards + // them (notify.Nop). + Notifier notify.Publisher } // Server owns the gin engine, the underlying HTTP server and the readiness @@ -105,6 +115,8 @@ type Server struct { dictDir string connector *connector.Client ratewatch *ratewatch.Watch + ads *ads.Service + notifier notify.Publisher console *adminconsole.Renderer public *gin.RouterGroup @@ -129,6 +141,11 @@ func New(addr string, deps Deps) *Server { engine.Use(gin.Recovery()) engine.Use(telemetry.Middleware(log)) + notifier := deps.Notifier + if notifier == nil { + notifier = notify.Nop{} + } + s := &Server{ log: log, db: deps.DB, @@ -147,6 +164,8 @@ func New(addr string, deps Deps) *Server { dictDir: deps.DictDir, connector: deps.Connector, ratewatch: deps.RateWatch, + ads: deps.Ads, + notifier: notifier, http: &http.Server{Addr: addr, Handler: engine}, } s.registerProbes(engine) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4b6fab0..fc19c3f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -40,8 +40,8 @@ Three executables plus per-platform side-services: in-memory mock transport (`pnpm start`) runs the whole slice with no backend. Embeddable in platform webviews; packageable to native (iOS/Android) via Capacitor. The client uses a mobile-app shell (a growing nav bar; content pinned to the bottom), - a one-line **announcement banner** under the nav (a client-side mock rotation, **gated off - in the build until polished after release** — a server-driven channel later, §10), + a one-line **advertising banner** under the nav (server-driven campaigns shown to eligible free + users, a weighted fair rotation — §10), and a client **board-style** setting (bonus-label mode). The visual/interaction design system is documented in [`UI_DESIGN.md`](UI_DESIGN.md). @@ -658,9 +658,34 @@ response/withdrawal lobby sync) never becomes a platform push. Operator broadcas **operator-chosen** language in the console, unrelated to the recipient's login. Session-revocation events and cursor-based stream resume stay deferred (single-instance MVP). -A separate **announcements channel** feeds the client's one-line banner (UI_DESIGN.md). -It is a client-side **mock** rotation today; a server-driven source (operational notices, -promotions) is future work and would deliver short markdown messages (text + links). +A separate **advertising-banner** channel feeds the client's one-line strip (UI_DESIGN.md), +server-driven by `internal/ads`. An operator manages **campaigns** (each one placement order) in +the admin console (`/_gm/banners`): a campaign has a show **weight** (integer percent 1..100), an +optional validity **window**, an `enabled` flag and one or more **bilingual messages** (en + ru, +both mandatory, minimal markdown). A single perpetual **default** campaign fills the unsold +remainder up to 100% and is undeletable. Eligibility — who sees a banner at all — is +`!paid_account && hint_balance == 0 && !has(no_banner)` (the `no_banner` account role suppresses +it unconditionally); guests qualify. The eligible viewer's banner block rides the **`profile.get`** +response (the one bootstrap every client fetches on open, authed or guest — no separate request, +nothing distinct for an advanced user to filter): the backend resolves each message to the viewer's +**service language** (the bot they signed in through, falling back to the interface language) and +computes the active set — window-filtered campaigns, the default's effective weight +(`max(0, 100 − Σ active timed weights)`, dropped at 0), GCD-reduced. The **client** rotates that set +with a smooth weighted round-robin (deterministic, fair: each campaign gets its weight share per +cycle), round-robining a campaign's messages within its slots; the global display **timings** (hold, +edge-pause, scroll speed, and the fade-out → gap → fade-in transition) are operator-set +(`/_gm/banner-settings`, clamped) and ride the same block. When an operator changes a viewer's +eligibility inputs (grants hints, grants/revokes `no_banner`; a future payment flow sets +`paid_account`), the backend emits a `notify` **`banner`** sub-kind (a payload-free re-poll signal), +and the open client re-fetches `profile.get` to show or hide the banner in place. Operator *content* +edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session. + +> A single `app.load` bootstrap aggregator (collapsing `profile.get` + lobby + badge fetches into +> one round-trip) was **considered and deferred**: client↔gateway is HTTP/2 (h2c), so the bootstrap +> RPCs already multiplex over one reused connection — the saving would be per-request fixed overhead, +> not connections, and it is a high-blast-radius cross-cutting refactor. Revisit only with evidence +> (`edge_request_duration`); if ever built, as a gateway-side fan-out/merge that keeps the per-domain +> backend handlers intact. ## 11. Observability diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index f83986f..a5449d8 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -264,3 +264,27 @@ in-app), archive it, delete it, or delete every message from that player — and stops only feedback submission). Roles are listed and granted/revoked on the user card. Opening a message does not mark it read — only the explicit actions do; message bodies and attachments are shown defensively (text escaped, attachments downloaded rather than rendered). + +### Advertising banner + +A one-line banner under the nav shows short promotional or operational messages to **free +players**: anyone who has **not** paid for a lifetime account, holds **no purchased hints**, and +does not carry the **`no_banner`** role (which suppresses it unconditionally). Buying a paid account +or any hints — or being granted `no_banner` — removes it; guests, the freest users, see it. When an +operator changes one of those properties, the banner appears or disappears **in place**, without a +reload. + +Messages come from operator-run **campaigns**, each a placement order with a **show weight** (a +percent) and an optional start/end **window**. Campaigns running at the same time **compete** for the +banner in proportion to their weights; a permanent **house (default)** campaign fills whatever share +paid campaigns leave unsold and steps aside entirely when they sell the full 100%. Each message is +written in **both languages** (English + Russian); a player sees the variant for the **bot they play +through**, regardless of their interface language. The client rotates the eligible campaigns +**fairly** — every campaign gets its weighted share each cycle, evenly spread rather than at random — +and a campaign with several messages shows them in turn. + +Operators manage all of this in the admin console at **`/_gm/banners`**: create, edit, enable/disable +and schedule campaigns, write each campaign's bilingual messages (reorder or remove them), and set +the global display **timings** (how long a message holds, the scroll of an over-long message, and the +fade-out → gap → fade-in transition between messages). The default campaign cannot be deleted and +keeps at least one message. diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 060b2c4..575ca4c 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -270,3 +270,26 @@ IP и вложением. Оператор может пометить сооб пользователя. Открытие сообщения не помечает его прочитанным — это делают только явные действия; тело сообщения и вложения показываются защищённо (текст экранируется, вложения отдаются на скачивание, а не рендерятся). + +### Рекламный баннер + +Однострочный баннер под навбаром показывает короткие рекламные или служебные сообщения **бесплатным +игрокам**: всем, кто **не** оплатил пожизненный аккаунт, **не** имеет купленных подсказок и **не** +несёт роль **`no_banner`** (она отключает баннер безусловно). Покупка платного аккаунта или подсказок — +либо выдача `no_banner` — убирает его; гости как самые «бесплатные» пользователи баннер видят. Когда +оператор меняет одно из этих свойств, баннер появляется или исчезает **на месте**, без перезагрузки. + +Сообщения берутся из управляемых оператором **кампаний**, каждая из которых — заказ на размещение со +**своим весом показа** (процент) и необязательным **окном** действия (начало/конец). Одновременно +идущие кампании **конкурируют** за баннер пропорционально весам; постоянная **дефолтная (домашняя)** +кампания добивает остаток, не выкупленный платными, и полностью уходит из ротации, когда те выкупают +все 100%. Каждое сообщение написано на **двух языках** (английский + русский); игрок видит вариант для +**бота, через которого играет**, независимо от языка интерфейса. Клиент ротирует подходящие кампании +**честно** — каждая получает свою долю по весу за цикл, равномерно размазанную, а не случайно — а +кампания с несколькими сообщениями показывает их по очереди. + +Всем этим оператор управляет в админ-консоли по адресу **`/_gm/banners`**: создаёт, редактирует, +включает/выключает и планирует кампании, пишет двуязычные сообщения кампании (переставляет и удаляет +их) и задаёт общие **тайминги** показа (сколько держится сообщение, прокрутка слишком длинного, и +переход fade-out → пауза → fade-in между сообщениями). Дефолтную кампанию нельзя удалить, и в ней +всегда остаётся хотя бы одно сообщение. diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 4255fcd..9b404b4 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -34,6 +34,33 @@ type ProfileResp struct { BlockFriendRequests bool `json:"block_friend_requests"` IsGuest bool `json:"is_guest"` NotificationsInAppOnly bool `json:"notifications_in_app_only"` + // Banner is the advertising-banner block, present only for a viewer eligible to + // see the banner. The gateway forwards it verbatim into the Profile payload. + Banner *BannerResp `json:"banner,omitempty"` +} + +// BannerResp is the advertising-banner block of an eligible viewer's profile: the +// campaigns to rotate and the global display timings the client rotator reads. +type BannerResp struct { + Campaigns []BannerCampaignResp `json:"campaigns"` + Timings BannerTimingsResp `json:"timings"` +} + +// BannerCampaignResp is one campaign in the rotation feed: a GCD-reduced show +// weight and its messages, in display order, already resolved to one language. +type BannerCampaignResp struct { + Weight int `json:"weight"` + Messages []string `json:"messages"` +} + +// BannerTimingsResp mirrors the backend's global display timings. +type BannerTimingsResp 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"` } // LinkResultResp is the result of an account link/merge step. Status is diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index d3c8ee0..98560e6 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -58,7 +58,8 @@ func encodeAck(ok bool) []byte { return b.FinishedBytes() } -// encodeProfile builds a Profile payload. +// encodeProfile builds a Profile payload, including the advertising-banner block +// when the backend marked the viewer eligible. func encodeProfile(p backendclient.ProfileResp) []byte { b := flatbuffers.NewBuilder(192) uid := b.CreateString(p.UserID) @@ -67,6 +68,12 @@ func encodeProfile(p backendclient.ProfileResp) []byte { tz := b.CreateString(p.TimeZone) awayStart := b.CreateString(p.AwayStart) awayEnd := b.CreateString(p.AwayEnd) + // Build the banner table (and its children) before opening Profile: FlatBuffers + // forbids a nested table while another is under construction. + var banner flatbuffers.UOffsetT + if p.Banner != nil { + banner = encodeBanner(b, *p.Banner) + } fb.ProfileStart(b) fb.ProfileAddUserId(b, uid) fb.ProfileAddDisplayName(b, name) @@ -79,10 +86,51 @@ func encodeProfile(p backendclient.ProfileResp) []byte { fb.ProfileAddAwayStart(b, awayStart) fb.ProfileAddAwayEnd(b, awayEnd) fb.ProfileAddNotificationsInAppOnly(b, p.NotificationsInAppOnly) + if p.Banner != nil { + fb.ProfileAddBanner(b, banner) + } b.Finish(fb.ProfileEnd(b)) return b.FinishedBytes() } +// encodeBanner builds a BannerInfo table from the resolved banner block and +// returns its offset. It is bottom-up: each campaign's messages vector and table +// are built first, then the campaigns vector, then the BannerInfo table. The +// caller must invoke it with no table under construction. +func encodeBanner(b *flatbuffers.Builder, banner backendclient.BannerResp) flatbuffers.UOffsetT { + campOffsets := make([]flatbuffers.UOffsetT, len(banner.Campaigns)) + for i, c := range banner.Campaigns { + msgOffsets := make([]flatbuffers.UOffsetT, len(c.Messages)) + for j, m := range c.Messages { + msgOffsets[j] = b.CreateString(m) + } + fb.BannerCampaignStartMessagesVector(b, len(msgOffsets)) + for j := len(msgOffsets) - 1; j >= 0; j-- { + b.PrependUOffsetT(msgOffsets[j]) + } + msgs := b.EndVector(len(msgOffsets)) + fb.BannerCampaignStart(b) + fb.BannerCampaignAddWeight(b, int32(c.Weight)) + fb.BannerCampaignAddMessages(b, msgs) + campOffsets[i] = fb.BannerCampaignEnd(b) + } + fb.BannerInfoStartCampaignsVector(b, len(campOffsets)) + for i := len(campOffsets) - 1; i >= 0; i-- { + b.PrependUOffsetT(campOffsets[i]) + } + camps := b.EndVector(len(campOffsets)) + t := banner.Timings + fb.BannerInfoStart(b) + fb.BannerInfoAddCampaigns(b, camps) + fb.BannerInfoAddHoldMs(b, int32(t.HoldMs)) + fb.BannerInfoAddEdgePauseMs(b, int32(t.EdgePauseMs)) + fb.BannerInfoAddScrollPxPerSec(b, int32(t.ScrollPxPerSec)) + fb.BannerInfoAddFadeOutMs(b, int32(t.FadeOutMs)) + fb.BannerInfoAddGapMs(b, int32(t.GapMs)) + fb.BannerInfoAddFadeInMs(b, int32(t.FadeInMs)) + return fb.BannerInfoEnd(b) +} + // encodeBlockStatus builds a BlockStatus payload. func encodeBlockStatus(s backendclient.BlockStatusResp) []byte { b := flatbuffers.NewBuilder(128) diff --git a/gateway/internal/transcode/transcode_banner_test.go b/gateway/internal/transcode/transcode_banner_test.go new file mode 100644 index 0000000..9c27dcf --- /dev/null +++ b/gateway/internal/transcode/transcode_banner_test.go @@ -0,0 +1,81 @@ +package transcode_test + +import ( + "context" + "net/http" + "testing" + + "scrabble/gateway/internal/transcode" + fb "scrabble/pkg/fbs/scrabblefb" +) + +// TestProfileGetEncodesBanner verifies the gateway forwards the backend's banner +// block verbatim into the Profile payload: the campaigns (weight + resolved +// messages, in order) and the global display timings. +func TestProfileGetEncodesBanner(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/v1/user/profile" { + t.Errorf("unexpected %s %q", r.Method, r.URL.Path) + } + _, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"en",` + + `"banner":{"campaigns":[` + + `{"weight":3,"messages":["promo-en"]},` + + `{"weight":7,"messages":["house-a","house-b"]}],` + + `"timings":{"hold_ms":60000,"edge_pause_ms":5000,"scroll_px_per_sec":40,` + + `"fade_out_ms":1000,"gap_ms":250,"fade_in_ms":1000}}}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, ok := reg.Lookup(transcode.MsgProfileGet) + if !ok { + t.Fatal("profile.get not registered") + } + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"}) + if err != nil { + t.Fatalf("handler: %v", err) + } + + p := fb.GetRootAsProfile(payload, 0) + banner := p.Banner(nil) + if banner == nil { + t.Fatal("profile carries no banner block") + } + if got := banner.CampaignsLength(); got != 2 { + t.Fatalf("campaigns = %d, want 2", got) + } + var c fb.BannerCampaign + banner.Campaigns(&c, 0) + if c.Weight() != 3 || c.MessagesLength() != 1 || string(c.Messages(0)) != "promo-en" { + t.Errorf("campaign 0 = weight %d, %d msgs, msg0=%q", c.Weight(), c.MessagesLength(), c.Messages(0)) + } + banner.Campaigns(&c, 1) + if c.Weight() != 7 || c.MessagesLength() != 2 || string(c.Messages(1)) != "house-b" { + t.Errorf("campaign 1 = weight %d, %d msgs, msg1=%q", c.Weight(), c.MessagesLength(), c.Messages(1)) + } + if banner.HoldMs() != 60000 || banner.EdgePauseMs() != 5000 || banner.ScrollPxPerSec() != 40 { + t.Errorf("timings = hold %d edge %d scroll %d", banner.HoldMs(), banner.EdgePauseMs(), banner.ScrollPxPerSec()) + } + if banner.FadeOutMs() != 1000 || banner.GapMs() != 250 || banner.FadeInMs() != 1000 { + t.Errorf("fade timings = out %d gap %d in %d", banner.FadeOutMs(), banner.GapMs(), banner.FadeInMs()) + } +} + +// TestProfileGetNoBanner verifies an ineligible viewer's profile carries no +// banner block (the backend omits it). +func TestProfileGetNoBanner(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"en"}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgProfileGet) + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"}) + if err != nil { + t.Fatalf("handler: %v", err) + } + if p := fb.GetRootAsProfile(payload, 0); p.Banner(nil) != nil { + t.Error("ineligible profile unexpectedly carries a banner block") + } +} diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 1c88274..d10d3ad 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -137,12 +137,39 @@ table Ack { ok:bool; } +// --- advertising banner --- + +// BannerCampaign is one campaign in the rotation feed: a GCD-reduced show weight +// (the client runs a smooth weighted round-robin over campaigns by this weight) +// and its messages in display order, each already resolved to the viewer's bot +// language (the stored en/ru pair is picked server-side). +table BannerCampaign { + weight:int; + messages:[string]; +} + +// BannerInfo is the advertising-banner block of an eligible viewer's profile: the +// campaigns to rotate and the global display timings the client rotator reads. +// It is present (set on Profile.banner) only when the viewer is eligible to see a +// banner; absent otherwise. The transition between messages is fade_out_ms, then +// gap_ms, then fade_in_ms. +table BannerInfo { + campaigns:[BannerCampaign]; + hold_ms:int; + edge_pause_ms:int; + scroll_px_per_sec:int; + fade_out_ms:int; + gap_ms:int; + fade_in_ms:int; +} + // --- profile (authenticated) --- // Profile is the authenticated account's own profile view. away_start/away_end are // the "HH:MM" daily away-window bounds. notifications_in_app_only (default true) -// suppresses out-of-app platform push, leaving only the in-app live stream (both -// added trailing — backward-compatible). +// suppresses out-of-app platform push, leaving only the in-app live stream. banner +// carries the advertising-banner block for an eligible viewer, absent otherwise +// (all added trailing — backward-compatible). table Profile { user_id:string; display_name:string; @@ -155,6 +182,7 @@ table Profile { away_start:string; away_end:string; notifications_in_app_only:bool = true; + banner:BannerInfo; } // BlockStatus reports the caller's current manual block. The UI fetches it after any operation diff --git a/pkg/fbs/scrabblefb/BannerCampaign.go b/pkg/fbs/scrabblefb/BannerCampaign.go new file mode 100644 index 0000000..2a01ac7 --- /dev/null +++ b/pkg/fbs/scrabblefb/BannerCampaign.go @@ -0,0 +1,87 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type BannerCampaign struct { + _tab flatbuffers.Table +} + +func GetRootAsBannerCampaign(buf []byte, offset flatbuffers.UOffsetT) *BannerCampaign { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &BannerCampaign{} + x.Init(buf, n+offset) + return x +} + +func FinishBannerCampaignBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsBannerCampaign(buf []byte, offset flatbuffers.UOffsetT) *BannerCampaign { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &BannerCampaign{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedBannerCampaignBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *BannerCampaign) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *BannerCampaign) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *BannerCampaign) Weight() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *BannerCampaign) MutateWeight(n int32) bool { + return rcv._tab.MutateInt32Slot(4, n) +} + +func (rcv *BannerCampaign) Messages(j int) []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + a := rcv._tab.Vector(o) + return rcv._tab.ByteVector(a + flatbuffers.UOffsetT(j*4)) + } + return nil +} + +func (rcv *BannerCampaign) MessagesLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func BannerCampaignStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func BannerCampaignAddWeight(builder *flatbuffers.Builder, weight int32) { + builder.PrependInt32Slot(0, weight, 0) +} +func BannerCampaignAddMessages(builder *flatbuffers.Builder, messages flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(messages), 0) +} +func BannerCampaignStartMessagesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func BannerCampaignEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/BannerInfo.go b/pkg/fbs/scrabblefb/BannerInfo.go new file mode 100644 index 0000000..dacd2f1 --- /dev/null +++ b/pkg/fbs/scrabblefb/BannerInfo.go @@ -0,0 +1,165 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type BannerInfo struct { + _tab flatbuffers.Table +} + +func GetRootAsBannerInfo(buf []byte, offset flatbuffers.UOffsetT) *BannerInfo { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &BannerInfo{} + x.Init(buf, n+offset) + return x +} + +func FinishBannerInfoBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsBannerInfo(buf []byte, offset flatbuffers.UOffsetT) *BannerInfo { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &BannerInfo{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedBannerInfoBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *BannerInfo) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *BannerInfo) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *BannerInfo) Campaigns(obj *BannerCampaign, j int) bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + x := rcv._tab.Vector(o) + x += flatbuffers.UOffsetT(j) * 4 + x = rcv._tab.Indirect(x) + obj.Init(rcv._tab.Bytes, x) + return true + } + return false +} + +func (rcv *BannerInfo) CampaignsLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func (rcv *BannerInfo) HoldMs() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *BannerInfo) MutateHoldMs(n int32) bool { + return rcv._tab.MutateInt32Slot(6, n) +} + +func (rcv *BannerInfo) EdgePauseMs() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *BannerInfo) MutateEdgePauseMs(n int32) bool { + return rcv._tab.MutateInt32Slot(8, n) +} + +func (rcv *BannerInfo) ScrollPxPerSec() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *BannerInfo) MutateScrollPxPerSec(n int32) bool { + return rcv._tab.MutateInt32Slot(10, n) +} + +func (rcv *BannerInfo) FadeOutMs() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *BannerInfo) MutateFadeOutMs(n int32) bool { + return rcv._tab.MutateInt32Slot(12, n) +} + +func (rcv *BannerInfo) GapMs() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *BannerInfo) MutateGapMs(n int32) bool { + return rcv._tab.MutateInt32Slot(14, n) +} + +func (rcv *BannerInfo) FadeInMs() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(16)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *BannerInfo) MutateFadeInMs(n int32) bool { + return rcv._tab.MutateInt32Slot(16, n) +} + +func BannerInfoStart(builder *flatbuffers.Builder) { + builder.StartObject(7) +} +func BannerInfoAddCampaigns(builder *flatbuffers.Builder, campaigns flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(campaigns), 0) +} +func BannerInfoStartCampaignsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func BannerInfoAddHoldMs(builder *flatbuffers.Builder, holdMs int32) { + builder.PrependInt32Slot(1, holdMs, 0) +} +func BannerInfoAddEdgePauseMs(builder *flatbuffers.Builder, edgePauseMs int32) { + builder.PrependInt32Slot(2, edgePauseMs, 0) +} +func BannerInfoAddScrollPxPerSec(builder *flatbuffers.Builder, scrollPxPerSec int32) { + builder.PrependInt32Slot(3, scrollPxPerSec, 0) +} +func BannerInfoAddFadeOutMs(builder *flatbuffers.Builder, fadeOutMs int32) { + builder.PrependInt32Slot(4, fadeOutMs, 0) +} +func BannerInfoAddGapMs(builder *flatbuffers.Builder, gapMs int32) { + builder.PrependInt32Slot(5, gapMs, 0) +} +func BannerInfoAddFadeInMs(builder *flatbuffers.Builder, fadeInMs int32) { + builder.PrependInt32Slot(6, fadeInMs, 0) +} +func BannerInfoEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/Profile.go b/pkg/fbs/scrabblefb/Profile.go index 5619a81..180e6bf 100644 --- a/pkg/fbs/scrabblefb/Profile.go +++ b/pkg/fbs/scrabblefb/Profile.go @@ -149,8 +149,21 @@ func (rcv *Profile) MutateNotificationsInAppOnly(n bool) bool { return rcv._tab.MutateBoolSlot(24, n) } +func (rcv *Profile) Banner(obj *BannerInfo) *BannerInfo { + o := flatbuffers.UOffsetT(rcv._tab.Offset(26)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(BannerInfo) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + func ProfileStart(builder *flatbuffers.Builder) { - builder.StartObject(11) + builder.StartObject(12) } func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0) @@ -185,6 +198,9 @@ func ProfileAddAwayEnd(builder *flatbuffers.Builder, awayEnd flatbuffers.UOffset func ProfileAddNotificationsInAppOnly(builder *flatbuffers.Builder, notificationsInAppOnly bool) { builder.PrependBoolSlot(10, notificationsInAppOnly, true) } +func ProfileAddBanner(builder *flatbuffers.Builder, banner flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(11, flatbuffers.UOffsetT(banner), 0) +} func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index ae8efe2..414ac35 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -3,6 +3,8 @@ export { AccountRef } from './scrabblefb/account-ref.js'; export { Ack } from './scrabblefb/ack.js'; export { AlphabetEntry } from './scrabblefb/alphabet-entry.js'; +export { BannerCampaign } from './scrabblefb/banner-campaign.js'; +export { BannerInfo } from './scrabblefb/banner-info.js'; export { BlockList } from './scrabblefb/block-list.js'; export { BlockStatus } from './scrabblefb/block-status.js'; export { ChatList } from './scrabblefb/chat-list.js'; diff --git a/ui/src/gen/fbs/scrabblefb/banner-campaign.ts b/ui/src/gen/fbs/scrabblefb/banner-campaign.ts new file mode 100644 index 0000000..a8ccfc8 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/banner-campaign.ts @@ -0,0 +1,75 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class BannerCampaign { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):BannerCampaign { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsBannerCampaign(bb:flatbuffers.ByteBuffer, obj?:BannerCampaign):BannerCampaign { + return (obj || new BannerCampaign()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsBannerCampaign(bb:flatbuffers.ByteBuffer, obj?:BannerCampaign):BannerCampaign { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new BannerCampaign()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +weight():number { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +messages(index: number):string +messages(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array +messages(index: number,optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null; +} + +messagesLength():number { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; +} + +static startBannerCampaign(builder:flatbuffers.Builder) { + builder.startObject(2); +} + +static addWeight(builder:flatbuffers.Builder, weight:number) { + builder.addFieldInt32(0, weight, 0); +} + +static addMessages(builder:flatbuffers.Builder, messagesOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, messagesOffset, 0); +} + +static createMessagesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) { + builder.addOffset(data[i]!); + } + return builder.endVector(); +} + +static startMessagesVector(builder:flatbuffers.Builder, numElems:number) { + builder.startVector(4, numElems, 4); +} + +static endBannerCampaign(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createBannerCampaign(builder:flatbuffers.Builder, weight:number, messagesOffset:flatbuffers.Offset):flatbuffers.Offset { + BannerCampaign.startBannerCampaign(builder); + BannerCampaign.addWeight(builder, weight); + BannerCampaign.addMessages(builder, messagesOffset); + return BannerCampaign.endBannerCampaign(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/banner-info.ts b/ui/src/gen/fbs/scrabblefb/banner-info.ts new file mode 100644 index 0000000..17b7a1b --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/banner-info.ts @@ -0,0 +1,126 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +import { BannerCampaign } from '../scrabblefb/banner-campaign.js'; + + +export class BannerInfo { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):BannerInfo { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsBannerInfo(bb:flatbuffers.ByteBuffer, obj?:BannerInfo):BannerInfo { + return (obj || new BannerInfo()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsBannerInfo(bb:flatbuffers.ByteBuffer, obj?:BannerInfo):BannerInfo { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new BannerInfo()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +campaigns(index: number, obj?:BannerCampaign):BannerCampaign|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? (obj || new BannerCampaign()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null; +} + +campaignsLength():number { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; +} + +holdMs():number { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +edgePauseMs():number { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +scrollPxPerSec():number { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +fadeOutMs():number { + const offset = this.bb!.__offset(this.bb_pos, 12); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +gapMs():number { + const offset = this.bb!.__offset(this.bb_pos, 14); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +fadeInMs():number { + const offset = this.bb!.__offset(this.bb_pos, 16); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +static startBannerInfo(builder:flatbuffers.Builder) { + builder.startObject(7); +} + +static addCampaigns(builder:flatbuffers.Builder, campaignsOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, campaignsOffset, 0); +} + +static createCampaignsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) { + builder.addOffset(data[i]!); + } + return builder.endVector(); +} + +static startCampaignsVector(builder:flatbuffers.Builder, numElems:number) { + builder.startVector(4, numElems, 4); +} + +static addHoldMs(builder:flatbuffers.Builder, holdMs:number) { + builder.addFieldInt32(1, holdMs, 0); +} + +static addEdgePauseMs(builder:flatbuffers.Builder, edgePauseMs:number) { + builder.addFieldInt32(2, edgePauseMs, 0); +} + +static addScrollPxPerSec(builder:flatbuffers.Builder, scrollPxPerSec:number) { + builder.addFieldInt32(3, scrollPxPerSec, 0); +} + +static addFadeOutMs(builder:flatbuffers.Builder, fadeOutMs:number) { + builder.addFieldInt32(4, fadeOutMs, 0); +} + +static addGapMs(builder:flatbuffers.Builder, gapMs:number) { + builder.addFieldInt32(5, gapMs, 0); +} + +static addFadeInMs(builder:flatbuffers.Builder, fadeInMs:number) { + builder.addFieldInt32(6, fadeInMs, 0); +} + +static endBannerInfo(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createBannerInfo(builder:flatbuffers.Builder, campaignsOffset:flatbuffers.Offset, holdMs:number, edgePauseMs:number, scrollPxPerSec:number, fadeOutMs:number, gapMs:number, fadeInMs:number):flatbuffers.Offset { + BannerInfo.startBannerInfo(builder); + BannerInfo.addCampaigns(builder, campaignsOffset); + BannerInfo.addHoldMs(builder, holdMs); + BannerInfo.addEdgePauseMs(builder, edgePauseMs); + BannerInfo.addScrollPxPerSec(builder, scrollPxPerSec); + BannerInfo.addFadeOutMs(builder, fadeOutMs); + BannerInfo.addGapMs(builder, gapMs); + BannerInfo.addFadeInMs(builder, fadeInMs); + return BannerInfo.endBannerInfo(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/profile.ts b/ui/src/gen/fbs/scrabblefb/profile.ts index 92493d6..8f13d8e 100644 --- a/ui/src/gen/fbs/scrabblefb/profile.ts +++ b/ui/src/gen/fbs/scrabblefb/profile.ts @@ -2,6 +2,9 @@ import * as flatbuffers from 'flatbuffers'; +import { BannerInfo } from '../scrabblefb/banner-info.js'; + + export class Profile { bb: flatbuffers.ByteBuffer|null = null; bb_pos = 0; @@ -87,8 +90,13 @@ notificationsInAppOnly():boolean { return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : true; } +banner(obj?:BannerInfo):BannerInfo|null { + const offset = this.bb!.__offset(this.bb_pos, 26); + return offset ? (obj || new BannerInfo()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + static startProfile(builder:flatbuffers.Builder) { - builder.startObject(11); + builder.startObject(12); } static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) { @@ -135,24 +143,13 @@ static addNotificationsInAppOnly(builder:flatbuffers.Builder, notificationsInApp builder.addFieldInt8(10, +notificationsInAppOnly, +true); } +static addBanner(builder:flatbuffers.Builder, bannerOffset:flatbuffers.Offset) { + builder.addFieldOffset(11, bannerOffset, 0); +} + static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createProfile(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, hintBalance:number, blockChat:boolean, blockFriendRequests:boolean, isGuest:boolean, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset, notificationsInAppOnly:boolean):flatbuffers.Offset { - Profile.startProfile(builder); - Profile.addUserId(builder, userIdOffset); - Profile.addDisplayName(builder, displayNameOffset); - Profile.addPreferredLanguage(builder, preferredLanguageOffset); - Profile.addTimeZone(builder, timeZoneOffset); - Profile.addHintBalance(builder, hintBalance); - Profile.addBlockChat(builder, blockChat); - Profile.addBlockFriendRequests(builder, blockFriendRequests); - Profile.addIsGuest(builder, isGuest); - Profile.addAwayStart(builder, awayStartOffset); - Profile.addAwayEnd(builder, awayEndOffset); - Profile.addNotificationsInAppOnly(builder, notificationsInAppOnly); - return Profile.endProfile(builder); -} }