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
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).
367 lines
12 KiB
Go
367 lines
12 KiB
Go
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),
|
||
}
|
||
}
|