Files
scrabble-game/backend/internal/server/handlers_admin_banners.go
T
Ilia Denisov 0946a3f66c
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
feat(ads): server-driven ad-banner backend, wire & admin console (PR1)
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).
2026-06-15 23:00:19 +02:00

324 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}