feat(ads): server-driven ad-banner backend, wire & admin console (PR1)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s

Turn the gated-off mock banner into a real advertising subsystem (backend +
admin half; the UI rotation lands in PR2).

- internal/ads: campaigns (percent weight + validity window; a perpetual,
  undeletable default that fills the remainder up to 100%), 1..N bilingual
  messages (en+ru), global display timings; ActiveSet computes the
  window-filtered, default-remainder, GCD-reduced, language-resolved rotation
  feed. Smooth-weighted-round-robin math is unit-tested.
- migration 00006 (+ jetgen): ad_campaigns / ad_messages / ad_settings, seeded
  default campaign + house message + default timings.
- eligibility = !paid_account && hint_balance==0 && !no_banner role (new role;
  guests qualify). The resolved feed rides the profile.get response (no new RPC,
  works for guests, nothing distinct to filter); language by service_language.
- live update: a notify `banner` sub-kind (re-poll signal) published when an
  operator grants hints or grants/revokes no_banner, so the client shows/hides
  in place.
- admin console /_gm/banners (+ /_gm/banner-settings): campaign + message CRUD
  with reorder, default protection, clamped timings.
- wire: fbs BannerInfo/BannerCampaign on Profile; gateway transcode forwards it.
- docs: ARCHITECTURE §10, FUNCTIONAL (+ _ru), backend README, PRERELEASE tracker
  (incl. the deferred app.load aggregator note).
This commit is contained in:
Ilia Denisov
2026-06-15 23:00:19 +02:00
parent f59c8dcd43
commit 0946a3f66c
45 changed files with 2993 additions and 28 deletions
@@ -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
}