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
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user