Files
scrabble-game/backend/internal/server/handlers_admin_banners.go
T
Ilia Denisov 6db9178449
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
feat(banner): per-campaign colour overrides and urgent alerts
Non-default campaigns gain an optional colour override (background / text /
link) in two sets — one for every theme, one for the dark theme only — and an
"urgent" flag.

- Colours ride profile.get as six trailing FlatBuffers strings on
  BannerCampaign (backward-compatible). The client resolves the cascade
  (dark <- dark ?? all, light <- all) per rendered theme and derives the strip
  border from the background in JS (no CSS color-mix, for the old Android
  WebView floor); AdBanner applies them as inline vars scoped to the strip.
- Urgent is resolved entirely server-side: while any enabled, in-window urgent
  campaign exists, computeActiveSet returns only the urgent campaigns and
  bannerFor skips the eligibility gate — so a system notice reaches every viewer
  (paid / hint-holding / no_banner included) and preempts the ordinary feed. No
  wire field; it appears on each viewer's next profile.get.
- Admin console (/_gm/banners): native colour pickers + a live light/dark
  preview of the strip, and an urgent toggle. The default campaign stays plain,
  enforced by the service and a DB CHECK.

Migration 00009 is additive (nullable colour columns + a bool default +
all-or-nothing / hex / default-plain CHECKs) — expand-contract, rollback-safe.

Docs: ARCHITECTURE §10, UI_DESIGN, FUNCTIONAL (+ru). Tests: ads unit (urgent
preempt + colour validation), codec + resolver unit, gateway transcode, and
integration (colour round-trip + urgent bypass against real Postgres).
2026-07-05 15:36:35 +02:00

367 lines
12 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"
// Neutral banner theme tokens, mirrored from ui/src/app.css (--ad-bg,
// --text-muted, --accent for the light and dark themes). They seed the console's
// colour pickers and the preview fallback when a campaign has no override, so the
// operator always edits against the real neutral colours.
const (
tokenLightBg, tokenLightFg, tokenLightLink = "#e3e7ee", "#6b7280", "#2f6df6"
tokenDarkBg, tokenDarkFg, tokenDarkLink = "#272f3c", "#9aa3b2", "#5b8cff"
)
// 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),
Urgent: cm.Urgent,
OverrideAllOn: cm.OverrideAll != nil,
OverrideDarkOn: cm.OverrideDark != nil,
}
view.AllBg, view.AllFg, view.AllLink = seedColors(cm.OverrideAll, tokenLightBg, tokenLightFg, tokenLightLink)
view.DarkBg, view.DarkFg, view.DarkLink = seedColors(cm.OverrideDark, tokenDarkBg, tokenDarkFg, tokenDarkLink)
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,
Urgent: c.PostForm("urgent") != "",
OverrideAll: colorSetForm(c, "override_all_on", "override_bg", "override_fg", "override_link"),
OverrideDark: colorSetForm(c, "override_dark_on", "override_bg_dark", "override_fg_dark", "override_link_dark"),
})
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(), cid, 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
}
// seedColors returns the three colour-input seed values for a campaign's optional
// override: the stored colours when the set is present, otherwise the given
// neutral theme tokens (so the native picker starts sensibly and the preview
// shows the real fallback).
func seedColors(cs *ads.ColorSet, tokenBg, tokenFg, tokenLink string) (bg, fg, link string) {
if cs == nil {
return tokenBg, tokenFg, tokenLink
}
return cs.Bg, cs.Fg, cs.Link
}
// colorSetForm reads an optional colour override from the form: the on flag gates
// it (an unchecked set is absent, nil), otherwise the three posted colours are
// returned for the ads service to validate. The colour inputs are disabled in the
// browser while the set is off, so they are not posted then.
func colorSetForm(c *gin.Context, on, bg, fg, link string) *ads.ColorSet {
if c.PostForm(on) == "" {
return nil
}
return &ads.ColorSet{
Bg: trimForm(c, bg),
Fg: trimForm(c, fg),
Link: trimForm(c, link),
}
}