feat(banner): per-campaign colour overrides and urgent alerts
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
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).
This commit is contained in:
@@ -193,3 +193,24 @@ code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
|
||||
.replay-log { margin: 0.4rem 0 0; padding-left: 1.4rem; max-height: 14rem; overflow: auto; font-size: 0.85rem; }
|
||||
.replay-log li { color: var(--ink-dim); padding: 0.1rem 0; }
|
||||
.replay-log li.cur { color: var(--ink); font-weight: 600; }
|
||||
|
||||
/* Banner colour override editor + live preview (banner_detail). The override
|
||||
fieldsets group the enable toggle with the native colour swatches; the preview
|
||||
renders a sample strip on both themes from those inputs (see the inline script). */
|
||||
.ovr { border: 1px solid var(--line); border-radius: 6px; padding: 0.3rem 0.8rem 0.7rem; margin: 0.2rem 0; }
|
||||
.ovr legend { padding: 0 0.3rem; font-size: 0.85rem; color: var(--ink); }
|
||||
.ovr legend label { flex-direction: row; align-items: center; gap: 0.4rem; color: var(--ink); }
|
||||
.ovr .note { margin: 0.2rem 0 0.4rem; }
|
||||
.ovr .swatches { display: flex; flex-wrap: wrap; gap: 1rem; }
|
||||
.ovr .swatches label { flex-direction: column; gap: 0.25rem; align-items: flex-start; }
|
||||
.ovr input[type=color] { width: 3rem; height: 1.8rem; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: var(--bg); cursor: pointer; }
|
||||
.ovr input[type=color]:disabled { opacity: 0.4; cursor: default; }
|
||||
.ovr .hex { font-size: 0.72rem; color: var(--ink-dim); font-variant-numeric: tabular-nums; }
|
||||
.banner-preview { display: flex; flex-wrap: wrap; gap: 0.8rem; margin: 0.7rem 0 0.2rem; }
|
||||
.banner-preview .bp { flex: 1 1 18rem; }
|
||||
.banner-preview .bp-label { display: block; font-size: 0.75rem; color: var(--ink-dim); margin-bottom: 0.25rem; }
|
||||
.ad-frame { padding: 0.7rem; border-radius: 6px; border: 1px solid var(--line); }
|
||||
.ad-frame.light { background: #f4f6f9; }
|
||||
.ad-frame.dark { background: #0f1420; }
|
||||
.ad-sample { padding: 0.35rem 0.7rem; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ad-sample .ad-link { text-decoration: underline; }
|
||||
|
||||
@@ -11,10 +11,72 @@
|
||||
<label>Starts (UTC) <input type="datetime-local" name="starts_at" value="{{.StartsAt}}"></label>
|
||||
<label>Ends (UTC) <input type="datetime-local" name="ends_at" value="{{.EndsAt}}"></label>
|
||||
<label><input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}> Enabled</label>
|
||||
<fieldset class="ovr">
|
||||
<legend><label><input type="checkbox" name="urgent"{{if .Urgent}} checked{{end}}> Urgent</label></legend>
|
||||
<p class="note">Shows to <em>everyone</em>, always — bypassing paid accounts, hint wallets and the no-banner role. While any urgent campaign is live it is the only thing the strip shows (other campaigns and the default are suppressed). For system alerts.</p>
|
||||
</fieldset>
|
||||
<fieldset class="ovr" data-ovr-group>
|
||||
<legend><label><input type="checkbox" name="override_all_on" data-ovr="all"{{if .OverrideAllOn}} checked{{end}}> Colour override — all themes</label></legend>
|
||||
<div class="swatches">
|
||||
<label>Background <input type="color" name="override_bg" value="{{.AllBg}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
|
||||
<label>Text <input type="color" name="override_fg" value="{{.AllFg}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
|
||||
<label>Link <input type="color" name="override_link" value="{{.AllLink}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="ovr" data-ovr-group>
|
||||
<legend><label><input type="checkbox" name="override_dark_on" data-ovr="dark"{{if .OverrideDarkOn}} checked{{end}}> Colour override — dark theme only</label></legend>
|
||||
<div class="swatches">
|
||||
<label>Background <input type="color" name="override_bg_dark" value="{{.DarkBg}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
|
||||
<label>Text <input type="color" name="override_fg_dark" value="{{.DarkFg}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
|
||||
<label>Link <input type="color" name="override_link_dark" value="{{.DarkLink}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
{{end}}
|
||||
<div><button type="submit">Save</button></div>
|
||||
</form>
|
||||
{{if not .IsDefault}}
|
||||
<div class="banner-preview">
|
||||
<div class="bp"><span class="bp-label">Light theme</span><div class="ad-frame light"><div class="ad-sample" id="prev-light"><span>Sample banner text — <span class="ad-link">a link</span></span></div></div></div>
|
||||
<div class="bp"><span class="bp-label">Dark theme</span><div class="ad-frame dark"><div class="ad-sample" id="prev-dark"><span>Sample banner text — <span class="ad-link">a link</span></span></div></div></div>
|
||||
</div>
|
||||
<p class="note">Live preview of the strip on both themes. An empty override falls back to the neutral theme colours; the top/bottom border is derived from the background.</p>
|
||||
<script>
|
||||
(function(){
|
||||
// Neutral fallbacks — mirror ui/src/app.css (--ad-bg / --text-muted / --accent).
|
||||
var TOK={light:{bg:'#e3e7ee',fg:'#6b7280',link:'#2f6df6'},dark:{bg:'#272f3c',fg:'#9aa3b2',link:'#5b8cff'}};
|
||||
function byName(n){return document.querySelector('[name="'+n+'"]');}
|
||||
var allOn=byName('override_all_on'), darkOn=byName('override_dark_on');
|
||||
if(!allOn||!darkOn){return;}
|
||||
var f={ab:byName('override_bg'),af:byName('override_fg'),al:byName('override_link'),db:byName('override_bg_dark'),df:byName('override_fg_dark'),dl:byName('override_link_dark')};
|
||||
var lightEl=document.getElementById('prev-light'), darkEl=document.getElementById('prev-dark');
|
||||
function hexToRgb(h){h=h.replace('#','');return [parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16)];}
|
||||
function pad(x){x=Math.max(0,Math.min(255,Math.round(x))).toString(16);return x.length<2?'0'+x:x;}
|
||||
function rgbToHex(r){return '#'+pad(r[0])+pad(r[1])+pad(r[2]);}
|
||||
function mix(a,b,t){return [a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t,a[2]+(b[2]-a[2])*t];}
|
||||
function lum(r){return (0.2126*r[0]+0.7152*r[1]+0.0722*r[2])/255;}
|
||||
// Derived border: nudge the background 14% toward black on a light bg, toward white on a dark bg.
|
||||
function border(bg){var r=hexToRgb(bg);return rgbToHex(mix(r, lum(r)>0.5?[0,0,0]:[255,255,255], 0.14));}
|
||||
function paint(el,c){
|
||||
el.style.background=c.bg; el.style.color=c.fg;
|
||||
el.style.borderTop='1px solid '+border(c.bg); el.style.borderBottom='1px solid '+border(c.bg);
|
||||
var a=el.querySelector('.ad-link'); if(a){a.style.color=c.link;}
|
||||
}
|
||||
function resolve(){
|
||||
var light=allOn.checked?{bg:f.ab.value,fg:f.af.value,link:f.al.value}:TOK.light;
|
||||
var dark=darkOn.checked?{bg:f.db.value,fg:f.df.value,link:f.dl.value}
|
||||
:(allOn.checked?{bg:f.ab.value,fg:f.af.value,link:f.al.value}:TOK.dark);
|
||||
paint(lightEl,light); paint(darkEl,dark);
|
||||
document.querySelectorAll('.ovr .swatches label').forEach(function(lab){
|
||||
var inp=lab.querySelector('input[type=color]'), hx=lab.querySelector('.hex');
|
||||
if(inp&&hx){hx.textContent=inp.value;}
|
||||
});
|
||||
}
|
||||
function toggleGroup(chk){chk.closest('fieldset').querySelectorAll('[data-ovr-color]').forEach(function(inp){inp.disabled=!chk.checked;});}
|
||||
[allOn,darkOn].forEach(function(chk){chk.addEventListener('change',function(){toggleGroup(chk);resolve();});});
|
||||
Object.keys(f).forEach(function(k){f[k].addEventListener('input',resolve);});
|
||||
resolve();
|
||||
})();
|
||||
</script>
|
||||
<form class="form" method="post" action="/_gm/banners/{{.ID}}/delete" onsubmit="return confirm('Delete this campaign and its messages?')">
|
||||
<button type="submit" class="danger">Delete campaign</button>
|
||||
</form>
|
||||
|
||||
@@ -498,15 +498,30 @@ type BannerCampaignRow struct {
|
||||
|
||||
// BannerDetailView is the campaign detail/edit page. StartsAt/EndsAt are the
|
||||
// "YYYY-MM-DDTHH:MM" (UTC) values for the datetime-local inputs, empty when open.
|
||||
//
|
||||
// The colour-override and urgent fields drive the non-default campaign's editor:
|
||||
// OverrideAllOn/OverrideDarkOn report whether each colour set is active, and the
|
||||
// six *Bg/*Fg/*Link values seed the native colour inputs — the stored override
|
||||
// when a set is on, otherwise the neutral theme token so the picker starts from a
|
||||
// sensible colour and the live preview shows the real fallback.
|
||||
type BannerDetailView struct {
|
||||
ID string
|
||||
Name string
|
||||
Weight int
|
||||
IsDefault bool
|
||||
Enabled bool
|
||||
StartsAt string
|
||||
EndsAt string
|
||||
Messages []BannerMessageRow
|
||||
ID string
|
||||
Name string
|
||||
Weight int
|
||||
IsDefault bool
|
||||
Enabled bool
|
||||
StartsAt string
|
||||
EndsAt string
|
||||
Urgent bool
|
||||
OverrideAllOn bool
|
||||
AllBg string
|
||||
AllFg string
|
||||
AllLink string
|
||||
OverrideDarkOn bool
|
||||
DarkBg string
|
||||
DarkFg string
|
||||
DarkLink string
|
||||
Messages []BannerMessageRow
|
||||
}
|
||||
|
||||
// BannerMessageRow is one bilingual message of a campaign. First/Last drive the
|
||||
|
||||
+77
-13
@@ -30,6 +30,15 @@ var ErrDefaultImmutable = errors.New("ads: the default campaign cannot be modifi
|
||||
// window or message body).
|
||||
var ErrValidation = errors.New("ads: validation")
|
||||
|
||||
// ColorSet is an optional per-campaign colour override for the banner strip:
|
||||
// background, foreground (text) and link, each a "#rrggbb" hex string. The three
|
||||
// are set together or the whole set is absent (a nil *ColorSet).
|
||||
type ColorSet struct {
|
||||
Bg string
|
||||
Fg string
|
||||
Link string
|
||||
}
|
||||
|
||||
// Campaign is one advertising placement order with its messages.
|
||||
type Campaign struct {
|
||||
ID uuid.UUID // uuid.Nil on create
|
||||
@@ -40,6 +49,16 @@ type Campaign struct {
|
||||
StartsAt *time.Time // nil = open-ended start; always nil for the default
|
||||
EndsAt *time.Time // nil = open-ended end; always nil for the default
|
||||
Messages []Message
|
||||
// OverrideAll paints the strip on every theme; OverrideDark, when set, further
|
||||
// overrides the dark theme (the client resolves dark ← dark ?? all ?? token,
|
||||
// light ← all ?? token). Both are nil for the default campaign and for a
|
||||
// campaign that keeps the neutral theme tokens. Non-default only.
|
||||
OverrideAll *ColorSet
|
||||
OverrideDark *ColorSet
|
||||
// Urgent forces the banner on every viewer (bypassing eligibility) and, while
|
||||
// any urgent campaign is active, suppresses every non-urgent campaign and the
|
||||
// default remainder. Non-default only; always false for the default campaign.
|
||||
Urgent bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -70,10 +89,15 @@ type Timings struct {
|
||||
|
||||
// ActiveCampaign is one campaign in the resolved rotation feed sent to a client:
|
||||
// its GCD-reduced show weight and its messages, already resolved to the viewer's
|
||||
// language and in display (round-robin) order.
|
||||
// language and in display (round-robin) order, plus the optional colour overrides
|
||||
// the client applies to the strip (nil = the neutral theme tokens). Urgency is not
|
||||
// carried here: it is resolved server-side into the set's membership and the
|
||||
// eligibility bypass, so the client only ever renders what it is sent.
|
||||
type ActiveCampaign struct {
|
||||
Weight int
|
||||
Messages []string
|
||||
Weight int
|
||||
Messages []string
|
||||
OverrideAll *ColorSet
|
||||
OverrideDark *ColorSet
|
||||
}
|
||||
|
||||
// Eligible reports whether an account should be shown the advertising banner: a
|
||||
@@ -85,14 +109,20 @@ func Eligible(paidAccount bool, hintBalance int, hasNoBanner bool) bool {
|
||||
}
|
||||
|
||||
// computeActiveSet builds the resolved rotation feed from the enabled campaigns
|
||||
// at time now, in language lang. Campaigns outside their validity window, and
|
||||
// campaigns with no messages, are dropped. The default campaign's effective
|
||||
// weight is the remainder up to 100% — max(0, 100 - sum of active timed
|
||||
// weights) — so it fills unsold inventory and is dropped entirely when timed
|
||||
// campaigns already reach 100%. Weights are then reduced by their GCD so the
|
||||
// fair rotation cycle stays short. The input is expected to be the enabled
|
||||
// campaigns (ActiveCampaigns); disabled ones must already be excluded.
|
||||
func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []ActiveCampaign {
|
||||
// at time now, in language lang, and reports whether the feed is an urgent one.
|
||||
// Campaigns outside their validity window, and campaigns with no messages, are
|
||||
// dropped.
|
||||
//
|
||||
// While any active campaign is urgent, the feed is the urgent campaigns alone —
|
||||
// every non-urgent timed campaign and the default remainder are suppressed for
|
||||
// the duration (and the caller shows the feed to every viewer, bypassing
|
||||
// eligibility). Otherwise the default campaign's effective weight is the
|
||||
// remainder up to 100% — max(0, 100 - sum of active timed weights) — so it fills
|
||||
// unsold inventory and is dropped entirely when timed campaigns already reach
|
||||
// 100%. Weights are then reduced by their GCD so the fair rotation cycle stays
|
||||
// short. The input is expected to be the enabled campaigns (ActiveCampaigns);
|
||||
// disabled ones must already be excluded.
|
||||
func computeActiveSet(campaigns []Campaign, now time.Time, lang string) ([]ActiveCampaign, bool) {
|
||||
var timed []Campaign
|
||||
var def *Campaign
|
||||
for i := range campaigns {
|
||||
@@ -108,20 +138,54 @@ func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []Active
|
||||
timed = append(timed, c)
|
||||
}
|
||||
}
|
||||
if urgent := filterUrgent(timed); len(urgent) > 0 {
|
||||
out := make([]ActiveCampaign, 0, len(urgent))
|
||||
for _, c := range urgent {
|
||||
out = append(out, activeFrom(c, lang))
|
||||
}
|
||||
reduceByGCD(out)
|
||||
return out, true
|
||||
}
|
||||
sumTimed := 0
|
||||
for _, c := range timed {
|
||||
sumTimed += c.Weight
|
||||
}
|
||||
out := make([]ActiveCampaign, 0, len(timed)+1)
|
||||
for _, c := range timed {
|
||||
out = append(out, ActiveCampaign{Weight: c.Weight, Messages: resolveBodies(c.Messages, lang)})
|
||||
out = append(out, activeFrom(c, lang))
|
||||
}
|
||||
if def != nil {
|
||||
if dw := 100 - sumTimed; dw > 0 {
|
||||
out = append(out, ActiveCampaign{Weight: dw, Messages: resolveBodies(def.Messages, lang)})
|
||||
a := activeFrom(*def, lang)
|
||||
a.Weight = dw // the default's stored weight is nominal; it fills the remainder
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
reduceByGCD(out)
|
||||
return out, false
|
||||
}
|
||||
|
||||
// activeFrom projects a campaign to its rotation-feed entry: its show weight, its
|
||||
// language-resolved messages and its colour overrides (carried through by
|
||||
// reference, they are read-only).
|
||||
func activeFrom(c Campaign, lang string) ActiveCampaign {
|
||||
return ActiveCampaign{
|
||||
Weight: c.Weight,
|
||||
Messages: resolveBodies(c.Messages, lang),
|
||||
OverrideAll: c.OverrideAll,
|
||||
OverrideDark: c.OverrideDark,
|
||||
}
|
||||
}
|
||||
|
||||
// filterUrgent returns the urgent campaigns among cs, preserving order. It is the
|
||||
// preempt selector: a non-empty result makes the whole feed urgent.
|
||||
func filterUrgent(cs []Campaign) []Campaign {
|
||||
var out []Campaign
|
||||
for _, c := range cs {
|
||||
if c.Urgent {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -46,12 +46,19 @@ func TestComputeActiveSet(t *testing.T) {
|
||||
timed := func(name string, weight int, starts, ends *time.Time, msgs []Message) Campaign {
|
||||
return Campaign{Name: name, Weight: weight, Enabled: true, StartsAt: starts, EndsAt: ends, Messages: msgs}
|
||||
}
|
||||
urgent := func(name string, weight int, msgs []Message) Campaign {
|
||||
c := timed(name, weight, nil, nil, msgs)
|
||||
c.Urgent = true
|
||||
return c
|
||||
}
|
||||
red := &ColorSet{Bg: "#aa0000", Fg: "#ffffff", Link: "#ffdd00"}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
campaigns []Campaign
|
||||
lang string
|
||||
want []ActiveCampaign
|
||||
name string
|
||||
campaigns []Campaign
|
||||
lang string
|
||||
want []ActiveCampaign
|
||||
wantUrgent bool
|
||||
}{
|
||||
{
|
||||
name: "default only reduces to weight 1",
|
||||
@@ -152,22 +159,71 @@ func TestComputeActiveSet(t *testing.T) {
|
||||
lang: "en",
|
||||
want: []ActiveCampaign{{Weight: 1, Messages: []string{"one-en", "two-en"}}},
|
||||
},
|
||||
{
|
||||
name: "urgent preempts default and normal timed",
|
||||
campaigns: []Campaign{
|
||||
def(msg("house")),
|
||||
timed("promo", 40, nil, nil, msg("promo")),
|
||||
urgent("alert", 50, msg("alert")),
|
||||
},
|
||||
lang: "en",
|
||||
// only the urgent campaign survives; a lone weight reduces to 1.
|
||||
want: []ActiveCampaign{{Weight: 1, Messages: []string{"alert-en"}}},
|
||||
wantUrgent: true,
|
||||
},
|
||||
{
|
||||
name: "multiple urgent share the feed by gcd",
|
||||
campaigns: []Campaign{
|
||||
def(msg("house")),
|
||||
urgent("a", 60, msg("a")),
|
||||
urgent("b", 40, msg("b")),
|
||||
},
|
||||
lang: "en",
|
||||
// default and any non-urgent dropped; gcd(60,40)=20 -> 3 and 2.
|
||||
want: []ActiveCampaign{
|
||||
{Weight: 3, Messages: []string{"a-en"}},
|
||||
{Weight: 2, Messages: []string{"b-en"}},
|
||||
},
|
||||
wantUrgent: true,
|
||||
},
|
||||
{
|
||||
name: "out-of-window urgent does not preempt",
|
||||
campaigns: []Campaign{
|
||||
def(msg("house")),
|
||||
func() Campaign { c := urgent("future", 50, msg("future")); c.StartsAt = &future; return c }(),
|
||||
},
|
||||
lang: "en",
|
||||
// the urgent campaign is not yet live, so the normal default feed stands.
|
||||
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}},
|
||||
},
|
||||
{
|
||||
name: "colour overrides ride the active campaign",
|
||||
campaigns: []Campaign{
|
||||
def(msg("house")),
|
||||
func() Campaign { c := timed("promo", 100, nil, nil, msg("promo")); c.OverrideAll = red; return c }(),
|
||||
},
|
||||
lang: "en",
|
||||
want: []ActiveCampaign{{Weight: 1, Messages: []string{"promo-en"}, OverrideAll: red}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := computeActiveSet(tt.campaigns, now, tt.lang)
|
||||
got, gotUrgent := computeActiveSet(tt.campaigns, now, tt.lang)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", got, tt.want)
|
||||
}
|
||||
if gotUrgent != tt.wantUrgent {
|
||||
t.Errorf("computeActiveSet() urgent = %v, want %v", gotUrgent, tt.wantUrgent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeActiveSetEmpty(t *testing.T) {
|
||||
// No campaigns at all yields an empty (non-nil-or-nil) feed without panicking.
|
||||
if got := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 {
|
||||
t.Errorf("computeActiveSet(nil) = %#v, want empty", got)
|
||||
if got, urgent := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 || urgent {
|
||||
t.Errorf("computeActiveSet(nil) = %#v urgent=%v, want empty non-urgent", got, urgent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package ads
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -40,17 +41,20 @@ func NewService(store *Store) *Service { return &Service{store: store} }
|
||||
// ActiveSet returns the resolved rotation feed for a viewer in language lang
|
||||
// (en/ru) together with the global display timings: the currently-active
|
||||
// campaigns, each with its GCD-reduced show weight and its messages resolved to
|
||||
// lang, ready for the client's weighted round-robin.
|
||||
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, error) {
|
||||
// lang, ready for the client's weighted round-robin. The bool result reports
|
||||
// whether the feed is urgent — an urgent feed is shown to every viewer
|
||||
// regardless of eligibility (the caller skips the eligibility gate for it).
|
||||
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, bool, error) {
|
||||
campaigns, err := s.store.ActiveCampaigns(ctx)
|
||||
if err != nil {
|
||||
return nil, Timings{}, err
|
||||
return nil, Timings{}, false, err
|
||||
}
|
||||
timings, err := s.store.Settings(ctx)
|
||||
if err != nil {
|
||||
return nil, Timings{}, err
|
||||
return nil, Timings{}, false, err
|
||||
}
|
||||
return computeActiveSet(campaigns, time.Now().UTC(), lang), timings, nil
|
||||
set, urgent := computeActiveSet(campaigns, time.Now().UTC(), lang)
|
||||
return set, timings, urgent, nil
|
||||
}
|
||||
|
||||
// ListCampaigns returns every campaign with its messages, for the admin console.
|
||||
@@ -76,8 +80,13 @@ func (s *Service) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, er
|
||||
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return s.store.CreateCampaign(ctx, Campaign{
|
||||
Name: name, Weight: c.Weight, Enabled: c.Enabled, StartsAt: c.StartsAt, EndsAt: c.EndsAt,
|
||||
OverrideAll: all, OverrideDark: dark, Urgent: c.Urgent,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -99,6 +108,7 @@ func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
|
||||
upd.Enabled = true
|
||||
upd.StartsAt = nil
|
||||
upd.EndsAt = nil
|
||||
// The default (house) campaign stays plain: no colour overrides, never urgent.
|
||||
} else {
|
||||
if err := validWeight(c.Weight); err != nil {
|
||||
return err
|
||||
@@ -106,10 +116,17 @@ func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
|
||||
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
|
||||
return err
|
||||
}
|
||||
all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
upd.Weight = c.Weight
|
||||
upd.Enabled = c.Enabled
|
||||
upd.StartsAt = c.StartsAt
|
||||
upd.EndsAt = c.EndsAt
|
||||
upd.OverrideAll = all
|
||||
upd.OverrideDark = dark
|
||||
upd.Urgent = c.Urgent
|
||||
}
|
||||
return s.store.UpdateCampaign(ctx, upd)
|
||||
}
|
||||
@@ -254,6 +271,46 @@ func validWindow(starts, ends *time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// hexColor matches a "#rrggbb" colour — the format the console's native colour
|
||||
// input emits and the wire carries.
|
||||
var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||
|
||||
// validOverrides validates the two optional colour sets together and returns
|
||||
// their normalised copies (nil when a set is absent), so a caller can store them
|
||||
// directly.
|
||||
func validOverrides(all, dark *ColorSet) (*ColorSet, *ColorSet, error) {
|
||||
va, err := validColorSet(all)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
vd, err := validColorSet(dark)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return va, vd, nil
|
||||
}
|
||||
|
||||
// validColorSet validates one optional colour override: a nil set passes (no
|
||||
// override); otherwise all three colours must be present and "#rrggbb". It
|
||||
// returns a trimmed, lower-cased copy.
|
||||
func validColorSet(cs *ColorSet) (*ColorSet, error) {
|
||||
if cs == nil {
|
||||
return nil, nil
|
||||
}
|
||||
bg := strings.TrimSpace(cs.Bg)
|
||||
fg := strings.TrimSpace(cs.Fg)
|
||||
link := strings.TrimSpace(cs.Link)
|
||||
if bg == "" || fg == "" || link == "" {
|
||||
return nil, fmt.Errorf("%w: a colour override needs all of background, text and link", ErrValidation)
|
||||
}
|
||||
for _, h := range []string{bg, fg, link} {
|
||||
if !hexColor.MatchString(h) {
|
||||
return nil, fmt.Errorf("%w: colours must be #rrggbb hex", ErrValidation)
|
||||
}
|
||||
}
|
||||
return &ColorSet{Bg: strings.ToLower(bg), Fg: strings.ToLower(fg), Link: strings.ToLower(link)}, nil
|
||||
}
|
||||
|
||||
// validBodies trims and bounds both mandatory language bodies of a message.
|
||||
func validBodies(bodyEn, bodyRu string) (string, string, error) {
|
||||
en := strings.TrimSpace(bodyEn)
|
||||
|
||||
@@ -90,15 +90,23 @@ func (s *Store) Campaign(ctx context.Context, id uuid.UUID) (Campaign, error) {
|
||||
func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) {
|
||||
id := uuid.New()
|
||||
now := time.Now().UTC()
|
||||
abg, afg, alink := colorCols(c.OverrideAll)
|
||||
dbg, dfg, dlink := colorCols(c.OverrideDark)
|
||||
stmt := table.AdCampaigns.INSERT(
|
||||
table.AdCampaigns.CampaignID, table.AdCampaigns.Name, table.AdCampaigns.Weight,
|
||||
table.AdCampaigns.IsDefault, table.AdCampaigns.Enabled,
|
||||
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt,
|
||||
table.AdCampaigns.OverrideBg, table.AdCampaigns.OverrideFg, table.AdCampaigns.OverrideLink,
|
||||
table.AdCampaigns.OverrideBgDark, table.AdCampaigns.OverrideFgDark, table.AdCampaigns.OverrideLinkDark,
|
||||
table.AdCampaigns.Urgent,
|
||||
table.AdCampaigns.CreatedAt, table.AdCampaigns.UpdatedAt,
|
||||
).VALUES(
|
||||
postgres.UUID(id), postgres.String(c.Name), postgres.Int(int64(c.Weight)),
|
||||
postgres.Bool(false), postgres.Bool(c.Enabled),
|
||||
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
|
||||
abg, afg, alink,
|
||||
dbg, dfg, dlink,
|
||||
postgres.Bool(c.Urgent),
|
||||
postgres.TimestampzT(now), postgres.TimestampzT(now),
|
||||
)
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
@@ -111,12 +119,20 @@ func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, erro
|
||||
// window. The default flag is never touched here. Returns ErrNotFound when no
|
||||
// campaign matches.
|
||||
func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) error {
|
||||
abg, afg, alink := colorCols(c.OverrideAll)
|
||||
dbg, dfg, dlink := colorCols(c.OverrideDark)
|
||||
stmt := table.AdCampaigns.UPDATE(
|
||||
table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.Enabled,
|
||||
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, table.AdCampaigns.UpdatedAt,
|
||||
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt,
|
||||
table.AdCampaigns.OverrideBg, table.AdCampaigns.OverrideFg, table.AdCampaigns.OverrideLink,
|
||||
table.AdCampaigns.OverrideBgDark, table.AdCampaigns.OverrideFgDark, table.AdCampaigns.OverrideLinkDark,
|
||||
table.AdCampaigns.Urgent, table.AdCampaigns.UpdatedAt,
|
||||
).SET(
|
||||
postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.Bool(c.Enabled),
|
||||
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt), postgres.TimestampzT(time.Now().UTC()),
|
||||
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
|
||||
abg, afg, alink,
|
||||
dbg, dfg, dlink,
|
||||
postgres.Bool(c.Urgent), postgres.TimestampzT(time.Now().UTC()),
|
||||
).WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(c.ID)))
|
||||
return execOne(ctx, s.db, stmt, "update campaign")
|
||||
}
|
||||
@@ -257,18 +273,40 @@ func execOne(ctx context.Context, db qrm.Executable, stmt postgres.Statement, wh
|
||||
|
||||
func modelToCampaign(r model.AdCampaigns) Campaign {
|
||||
return Campaign{
|
||||
ID: r.CampaignID,
|
||||
Name: r.Name,
|
||||
Weight: int(r.Weight),
|
||||
IsDefault: r.IsDefault,
|
||||
Enabled: r.Enabled,
|
||||
StartsAt: r.StartsAt,
|
||||
EndsAt: r.EndsAt,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
ID: r.CampaignID,
|
||||
Name: r.Name,
|
||||
Weight: int(r.Weight),
|
||||
IsDefault: r.IsDefault,
|
||||
Enabled: r.Enabled,
|
||||
StartsAt: r.StartsAt,
|
||||
EndsAt: r.EndsAt,
|
||||
OverrideAll: colorSetFrom(r.OverrideBg, r.OverrideFg, r.OverrideLink),
|
||||
OverrideDark: colorSetFrom(r.OverrideBgDark, r.OverrideFgDark, r.OverrideLinkDark),
|
||||
Urgent: r.Urgent,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// colorSetFrom rebuilds an optional ColorSet from three nullable colour columns.
|
||||
// The all-or-nothing CHECK keeps the trio consistent, so a nil in any one means
|
||||
// the set is absent.
|
||||
func colorSetFrom(bg, fg, link *string) *ColorSet {
|
||||
if bg == nil || fg == nil || link == nil {
|
||||
return nil
|
||||
}
|
||||
return &ColorSet{Bg: *bg, Fg: *fg, Link: *link}
|
||||
}
|
||||
|
||||
// colorCols renders a *ColorSet as its three column value-expressions in
|
||||
// bg, fg, link order — NULL for each when the set is absent.
|
||||
func colorCols(cs *ColorSet) (bg, fg, link postgres.Expression) {
|
||||
if cs == nil {
|
||||
return postgres.NULL, postgres.NULL, postgres.NULL
|
||||
}
|
||||
return postgres.String(cs.Bg), postgres.String(cs.Fg), postgres.String(cs.Link)
|
||||
}
|
||||
|
||||
func modelToMessage(r model.AdMessages) Message {
|
||||
return Message{
|
||||
ID: r.MessageID,
|
||||
|
||||
@@ -192,8 +192,9 @@ func TestBannerMessageOwnership(t *testing.T) {
|
||||
type profileBanner struct {
|
||||
Banner *struct {
|
||||
Campaigns []struct {
|
||||
Weight int `json:"weight"`
|
||||
Messages []string `json:"messages"`
|
||||
Weight int `json:"weight"`
|
||||
Messages []string `json:"messages"`
|
||||
OverrideBg string `json:"override_bg"`
|
||||
} `json:"campaigns"`
|
||||
Timings struct {
|
||||
HoldMs int `json:"hold_ms"`
|
||||
@@ -274,3 +275,142 @@ func TestBannerSurvivesProfileUpdate(t *testing.T) {
|
||||
t.Fatalf("profile update dropped the banner block: %v", p.Banner)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBannerConsoleColorsAndUrgent drives the colour-override + urgent editor over
|
||||
// HTTP against real Postgres: an incomplete or malformed colour set is refused, a
|
||||
// full two-set override + urgent round-trips through the store (exercising the new
|
||||
// columns and CHECK constraints), clearing the sets removes the override, and the
|
||||
// default campaign stays plain (colours + urgent are ignored for it).
|
||||
func TestBannerConsoleColorsAndUrgent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv, adsSvc := bannerServer(t)
|
||||
h := srv.Handler()
|
||||
base := "http://admin.test/_gm"
|
||||
const origin = "http://admin.test"
|
||||
|
||||
name := "Brand-" + uuid.NewString()[:8]
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/banners", "name="+name+"&weight=20&enabled=on", origin); code != http.StatusOK || !strings.Contains(body, "Created") {
|
||||
t.Fatalf("create = %d, has Created=%v", code, strings.Contains(body, "Created"))
|
||||
}
|
||||
id := findCampaign(t, adsSvc, name)
|
||||
t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, id) })
|
||||
detail := base + "/banners/" + id.String()
|
||||
|
||||
// A partial colour set (a missing colour) is refused by validation.
|
||||
partial := "name=" + name + "&weight=20&enabled=on&override_all_on=on&override_bg=%23aa0000&override_fg=&override_link=%23ffdd00"
|
||||
if code, b := consoleDo(h, http.MethodPost, detail, partial, origin); code != http.StatusOK || !strings.Contains(b, "Not saved") {
|
||||
t.Fatalf("partial colour = %d, has 'Not saved'=%v", code, strings.Contains(b, "Not saved"))
|
||||
}
|
||||
// A malformed hex is refused.
|
||||
badHex := "name=" + name + "&weight=20&enabled=on&override_all_on=on&override_bg=red&override_fg=%23ffffff&override_link=%23ffdd00"
|
||||
if code, b := consoleDo(h, http.MethodPost, detail, badHex, origin); code != http.StatusOK || !strings.Contains(b, "Not saved") {
|
||||
t.Fatalf("bad hex = %d, has 'Not saved'=%v", code, strings.Contains(b, "Not saved"))
|
||||
}
|
||||
// Both colour sets + urgent persist and round-trip through the store.
|
||||
full := "name=" + name + "&weight=20&enabled=on&urgent=on" +
|
||||
"&override_all_on=on&override_bg=%23aa0000&override_fg=%23ffffff&override_link=%23ffdd00" +
|
||||
"&override_dark_on=on&override_bg_dark=%23330000&override_fg_dark=%23eeeeee&override_link_dark=%23ffcc00"
|
||||
if code, b := consoleDo(h, http.MethodPost, detail, full, origin); code != http.StatusOK || !strings.Contains(b, "Saved") {
|
||||
t.Fatalf("full save = %d, has Saved=%v", code, strings.Contains(b, "Saved"))
|
||||
}
|
||||
cm, err := adsSvc.Campaign(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("read campaign: %v", err)
|
||||
}
|
||||
if cm.OverrideAll == nil || cm.OverrideAll.Bg != "#aa0000" || cm.OverrideAll.Fg != "#ffffff" || cm.OverrideAll.Link != "#ffdd00" {
|
||||
t.Fatalf("override all = %+v", cm.OverrideAll)
|
||||
}
|
||||
if cm.OverrideDark == nil || cm.OverrideDark.Bg != "#330000" || cm.OverrideDark.Fg != "#eeeeee" || cm.OverrideDark.Link != "#ffcc00" {
|
||||
t.Fatalf("override dark = %+v", cm.OverrideDark)
|
||||
}
|
||||
if !cm.Urgent {
|
||||
t.Fatal("urgent not persisted")
|
||||
}
|
||||
// Clearing the sets (both checkboxes off, urgent off) removes the override.
|
||||
if code, _ := consoleDo(h, http.MethodPost, detail, "name="+name+"&weight=20&enabled=on", origin); code != http.StatusOK {
|
||||
t.Fatalf("clear = %d", code)
|
||||
}
|
||||
if cm, _ := adsSvc.Campaign(ctx, id); cm.OverrideAll != nil || cm.OverrideDark != nil || cm.Urgent {
|
||||
t.Fatalf("override not cleared: all=%v dark=%v urgent=%v", cm.OverrideAll, cm.OverrideDark, cm.Urgent)
|
||||
}
|
||||
|
||||
// The default campaign stays plain: submitted colours + urgent are ignored for it.
|
||||
def := defaultCampaign(t, adsSvc)
|
||||
defForm := "name=Default+%28house%29&urgent=on&override_all_on=on&override_bg=%23aa0000&override_fg=%23ffffff&override_link=%23ffdd00"
|
||||
if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+def.ID.String(), defForm, origin); code != http.StatusOK {
|
||||
t.Fatalf("update default = %d", code)
|
||||
}
|
||||
if again := defaultCampaign(t, adsSvc); again.OverrideAll != nil || again.OverrideDark != nil || again.Urgent {
|
||||
t.Fatalf("default not plain: all=%v dark=%v urgent=%v", again.OverrideAll, again.OverrideDark, again.Urgent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBannerUrgentBypassesEligibility checks the urgent flag at the profile level:
|
||||
// an urgent campaign preempts the feed (only it shows, with its colours) and reaches
|
||||
// every viewer — including the no_banner role and a non-empty hint wallet that
|
||||
// otherwise suppress the banner.
|
||||
func TestBannerUrgentBypassesEligibility(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv, adsSvc := bannerServer(t)
|
||||
accounts := account.NewStore(testDB)
|
||||
id := provisionAccount(t)
|
||||
|
||||
urgentID, err := adsSvc.CreateCampaign(ctx, ads.Campaign{
|
||||
Name: "Alert-" + uuid.NewString()[:8], Weight: 10, Enabled: true, Urgent: true,
|
||||
OverrideAll: &ads.ColorSet{Bg: "#aa0000", Fg: "#ffffff", Link: "#ffdd00"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create urgent: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, urgentID) })
|
||||
if _, err := adsSvc.AddMessage(ctx, urgentID, "System maintenance tonight", "Ночью тех.работы"); err != nil {
|
||||
t.Fatalf("add urgent message: %v", err)
|
||||
}
|
||||
|
||||
get := func() profileBanner {
|
||||
t.Helper()
|
||||
rec := userGet(t, srv, "/api/v1/user/profile", id)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("profile = %d", rec.Code)
|
||||
}
|
||||
var p profileBanner
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
assertUrgentOnly := func(what string, p profileBanner) {
|
||||
t.Helper()
|
||||
if p.Banner == nil {
|
||||
t.Fatalf("%s: no banner", what)
|
||||
}
|
||||
if len(p.Banner.Campaigns) != 1 {
|
||||
t.Fatalf("%s: %d campaigns, want only the urgent one (default preempted)", what, len(p.Banner.Campaigns))
|
||||
}
|
||||
c := p.Banner.Campaigns[0]
|
||||
if len(c.Messages) != 1 || c.Messages[0] != "System maintenance tonight" {
|
||||
t.Fatalf("%s: messages = %v, want only the urgent one", what, c.Messages)
|
||||
}
|
||||
if c.OverrideBg != "#aa0000" {
|
||||
t.Fatalf("%s: override_bg = %q, want #aa0000", what, c.OverrideBg)
|
||||
}
|
||||
}
|
||||
|
||||
// A normal viewer sees only the urgent campaign (the default is preempted).
|
||||
assertUrgentOnly("eligible", get())
|
||||
|
||||
// The no_banner role no longer suppresses it — urgent reaches everyone.
|
||||
if err := accounts.GrantRole(ctx, id, account.RoleNoBanner); err != nil {
|
||||
t.Fatalf("grant role: %v", err)
|
||||
}
|
||||
assertUrgentOnly("no_banner", get())
|
||||
if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil {
|
||||
t.Fatalf("revoke role: %v", err)
|
||||
}
|
||||
|
||||
// A non-empty hint wallet no longer suppresses it either.
|
||||
if _, err := accounts.GrantHints(ctx, id, 5); err != nil {
|
||||
t.Fatalf("grant hints: %v", err)
|
||||
}
|
||||
assertUrgentOnly("hints", get())
|
||||
}
|
||||
|
||||
@@ -13,13 +13,20 @@ import (
|
||||
)
|
||||
|
||||
type AdCampaigns struct {
|
||||
CampaignID uuid.UUID `sql:"primary_key"`
|
||||
Name string
|
||||
Weight int32
|
||||
IsDefault bool
|
||||
Enabled bool
|
||||
StartsAt *time.Time
|
||||
EndsAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
CampaignID uuid.UUID `sql:"primary_key"`
|
||||
Name string
|
||||
Weight int32
|
||||
IsDefault bool
|
||||
Enabled bool
|
||||
StartsAt *time.Time
|
||||
EndsAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
OverrideBg *string
|
||||
OverrideFg *string
|
||||
OverrideLink *string
|
||||
OverrideBgDark *string
|
||||
OverrideFgDark *string
|
||||
OverrideLinkDark *string
|
||||
Urgent bool
|
||||
}
|
||||
|
||||
@@ -17,15 +17,22 @@ type adCampaignsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
CampaignID postgres.ColumnString
|
||||
Name postgres.ColumnString
|
||||
Weight postgres.ColumnInteger
|
||||
IsDefault postgres.ColumnBool
|
||||
Enabled postgres.ColumnBool
|
||||
StartsAt postgres.ColumnTimestampz
|
||||
EndsAt postgres.ColumnTimestampz
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
CampaignID postgres.ColumnString
|
||||
Name postgres.ColumnString
|
||||
Weight postgres.ColumnInteger
|
||||
IsDefault postgres.ColumnBool
|
||||
Enabled postgres.ColumnBool
|
||||
StartsAt postgres.ColumnTimestampz
|
||||
EndsAt postgres.ColumnTimestampz
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
OverrideBg postgres.ColumnString
|
||||
OverrideFg postgres.ColumnString
|
||||
OverrideLink postgres.ColumnString
|
||||
OverrideBgDark postgres.ColumnString
|
||||
OverrideFgDark postgres.ColumnString
|
||||
OverrideLinkDark postgres.ColumnString
|
||||
Urgent postgres.ColumnBool
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -67,33 +74,47 @@ func newAdCampaignsTable(schemaName, tableName, alias string) *AdCampaignsTable
|
||||
|
||||
func newAdCampaignsTableImpl(schemaName, tableName, alias string) adCampaignsTable {
|
||||
var (
|
||||
CampaignIDColumn = postgres.StringColumn("campaign_id")
|
||||
NameColumn = postgres.StringColumn("name")
|
||||
WeightColumn = postgres.IntegerColumn("weight")
|
||||
IsDefaultColumn = postgres.BoolColumn("is_default")
|
||||
EnabledColumn = postgres.BoolColumn("enabled")
|
||||
StartsAtColumn = postgres.TimestampzColumn("starts_at")
|
||||
EndsAtColumn = postgres.TimestampzColumn("ends_at")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
allColumns = postgres.ColumnList{CampaignIDColumn, NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{IsDefaultColumn, EnabledColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
CampaignIDColumn = postgres.StringColumn("campaign_id")
|
||||
NameColumn = postgres.StringColumn("name")
|
||||
WeightColumn = postgres.IntegerColumn("weight")
|
||||
IsDefaultColumn = postgres.BoolColumn("is_default")
|
||||
EnabledColumn = postgres.BoolColumn("enabled")
|
||||
StartsAtColumn = postgres.TimestampzColumn("starts_at")
|
||||
EndsAtColumn = postgres.TimestampzColumn("ends_at")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
OverrideBgColumn = postgres.StringColumn("override_bg")
|
||||
OverrideFgColumn = postgres.StringColumn("override_fg")
|
||||
OverrideLinkColumn = postgres.StringColumn("override_link")
|
||||
OverrideBgDarkColumn = postgres.StringColumn("override_bg_dark")
|
||||
OverrideFgDarkColumn = postgres.StringColumn("override_fg_dark")
|
||||
OverrideLinkDarkColumn = postgres.StringColumn("override_link_dark")
|
||||
UrgentColumn = postgres.BoolColumn("urgent")
|
||||
allColumns = postgres.ColumnList{CampaignIDColumn, NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn, OverrideBgColumn, OverrideFgColumn, OverrideLinkColumn, OverrideBgDarkColumn, OverrideFgDarkColumn, OverrideLinkDarkColumn, UrgentColumn}
|
||||
mutableColumns = postgres.ColumnList{NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn, OverrideBgColumn, OverrideFgColumn, OverrideLinkColumn, OverrideBgDarkColumn, OverrideFgDarkColumn, OverrideLinkDarkColumn, UrgentColumn}
|
||||
defaultColumns = postgres.ColumnList{IsDefaultColumn, EnabledColumn, CreatedAtColumn, UpdatedAtColumn, UrgentColumn}
|
||||
)
|
||||
|
||||
return adCampaignsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
CampaignID: CampaignIDColumn,
|
||||
Name: NameColumn,
|
||||
Weight: WeightColumn,
|
||||
IsDefault: IsDefaultColumn,
|
||||
Enabled: EnabledColumn,
|
||||
StartsAt: StartsAtColumn,
|
||||
EndsAt: EndsAtColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
CampaignID: CampaignIDColumn,
|
||||
Name: NameColumn,
|
||||
Weight: WeightColumn,
|
||||
IsDefault: IsDefaultColumn,
|
||||
Enabled: EnabledColumn,
|
||||
StartsAt: StartsAtColumn,
|
||||
EndsAt: EndsAtColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
OverrideBg: OverrideBgColumn,
|
||||
OverrideFg: OverrideFgColumn,
|
||||
OverrideLink: OverrideLinkColumn,
|
||||
OverrideBgDark: OverrideBgDarkColumn,
|
||||
OverrideFgDark: OverrideFgDarkColumn,
|
||||
OverrideLinkDark: OverrideLinkDarkColumn,
|
||||
Urgent: UrgentColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
-- Per-campaign banner colour overrides and the urgent (always-show) flag.
|
||||
--
|
||||
-- ad_campaigns gains two optional colour sets and an urgent flag, all for
|
||||
-- non-default campaigns only:
|
||||
-- override_bg/fg/link — paints the strip on every theme
|
||||
-- override_bg/fg/link_dark — further overrides the dark theme
|
||||
-- urgent — force the banner on every viewer (bypassing
|
||||
-- eligibility) and suppress all non-urgent
|
||||
-- campaigns while any urgent one is active
|
||||
--
|
||||
-- Each colour set is all-or-nothing (bg+fg+link together, or absent) and every
|
||||
-- colour is a "#rrggbb" hex string. The default (house) campaign stays plain: no
|
||||
-- colours, never urgent.
|
||||
--
|
||||
-- Expand-contract: additive (nullable columns plus one NOT NULL boolean with a
|
||||
-- default), so a backend image rollback stays DB-safe — older code ignores them.
|
||||
-- The ad_campaigns table shape changes, so its generated go-jet model is
|
||||
-- regenerated (by hand here, to keep the diff to this one table).
|
||||
|
||||
-- +goose Up
|
||||
ALTER TABLE backend.ad_campaigns
|
||||
ADD COLUMN override_bg text,
|
||||
ADD COLUMN override_fg text,
|
||||
ADD COLUMN override_link text,
|
||||
ADD COLUMN override_bg_dark text,
|
||||
ADD COLUMN override_fg_dark text,
|
||||
ADD COLUMN override_link_dark text,
|
||||
ADD COLUMN urgent boolean DEFAULT false NOT NULL,
|
||||
ADD CONSTRAINT ad_campaigns_override_all_chk CHECK (
|
||||
(override_bg IS NULL AND override_fg IS NULL AND override_link IS NULL)
|
||||
OR (override_bg IS NOT NULL AND override_fg IS NOT NULL AND override_link IS NOT NULL)
|
||||
),
|
||||
ADD CONSTRAINT ad_campaigns_override_dark_chk CHECK (
|
||||
(override_bg_dark IS NULL AND override_fg_dark IS NULL AND override_link_dark IS NULL)
|
||||
OR (override_bg_dark IS NOT NULL AND override_fg_dark IS NOT NULL AND override_link_dark IS NOT NULL)
|
||||
),
|
||||
ADD CONSTRAINT ad_campaigns_override_hex_chk CHECK (
|
||||
(override_bg IS NULL OR override_bg ~ '^#[0-9a-fA-F]{6}$')
|
||||
AND (override_fg IS NULL OR override_fg ~ '^#[0-9a-fA-F]{6}$')
|
||||
AND (override_link IS NULL OR override_link ~ '^#[0-9a-fA-F]{6}$')
|
||||
AND (override_bg_dark IS NULL OR override_bg_dark ~ '^#[0-9a-fA-F]{6}$')
|
||||
AND (override_fg_dark IS NULL OR override_fg_dark ~ '^#[0-9a-fA-F]{6}$')
|
||||
AND (override_link_dark IS NULL OR override_link_dark ~ '^#[0-9a-fA-F]{6}$')
|
||||
),
|
||||
ADD CONSTRAINT ad_campaigns_default_plain_chk CHECK (
|
||||
(NOT is_default)
|
||||
OR (override_bg IS NULL AND override_fg IS NULL AND override_link IS NULL
|
||||
AND override_bg_dark IS NULL AND override_fg_dark IS NULL AND override_link_dark IS NULL
|
||||
AND NOT urgent)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE backend.ad_campaigns
|
||||
DROP COLUMN override_bg,
|
||||
DROP COLUMN override_fg,
|
||||
DROP COLUMN override_link,
|
||||
DROP COLUMN override_bg_dark,
|
||||
DROP COLUMN override_fg_dark,
|
||||
DROP COLUMN override_link_dark,
|
||||
DROP COLUMN urgent;
|
||||
@@ -22,10 +22,20 @@ type bannerDTO struct {
|
||||
|
||||
// bannerCampaignDTO is one campaign in the rotation feed: its GCD-reduced show
|
||||
// weight (for the client's smooth weighted round-robin) and its messages, in
|
||||
// display order, already resolved to one language.
|
||||
// display order, already resolved to one language. The override_* colours are
|
||||
// present only for a campaign that carries a colour override: override_bg/fg/link
|
||||
// paint every theme, and the *_dark trio further overrides the dark theme (the
|
||||
// client resolves the cascade). They are absent for a plain campaign, which keeps
|
||||
// the neutral theme tokens.
|
||||
type bannerCampaignDTO struct {
|
||||
Weight int `json:"weight"`
|
||||
Messages []string `json:"messages"`
|
||||
Weight int `json:"weight"`
|
||||
Messages []string `json:"messages"`
|
||||
OverrideBg string `json:"override_bg,omitempty"`
|
||||
OverrideFg string `json:"override_fg,omitempty"`
|
||||
OverrideLink string `json:"override_link,omitempty"`
|
||||
OverrideBgDark string `json:"override_bg_dark,omitempty"`
|
||||
OverrideFgDark string `json:"override_fg_dark,omitempty"`
|
||||
OverrideLinkDark string `json:"override_link_dark,omitempty"`
|
||||
}
|
||||
|
||||
// bannerTimingsDTO mirrors ads.Timings on the wire.
|
||||
@@ -83,22 +93,27 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO
|
||||
if s.ads == nil {
|
||||
return nil
|
||||
}
|
||||
hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner)
|
||||
if err != nil {
|
||||
s.log.Warn("banner: role check failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
if !ads.Eligible(acc.PaidAccount, acc.HintBalance, hasNoBanner) {
|
||||
return nil
|
||||
}
|
||||
set, timings, err := s.ads.ActiveSet(ctx, bannerLang(acc))
|
||||
set, timings, urgent, err := s.ads.ActiveSet(ctx, bannerLang(acc))
|
||||
if err != nil {
|
||||
s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
// An urgent campaign is shown to every viewer; otherwise the normal eligibility
|
||||
// gate applies (a paid account, a non-empty hint wallet or the no_banner role
|
||||
// hides the banner).
|
||||
if !urgent {
|
||||
hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner)
|
||||
if err != nil {
|
||||
s.log.Warn("banner: role check failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
if !ads.Eligible(acc.PaidAccount, acc.HintBalance, hasNoBanner) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
campaigns := make([]bannerCampaignDTO, 0, len(set))
|
||||
for _, c := range set {
|
||||
campaigns = append(campaigns, bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages})
|
||||
campaigns = append(campaigns, bannerCampaignFromActive(c))
|
||||
}
|
||||
return &bannerDTO{
|
||||
Campaigns: campaigns,
|
||||
@@ -113,6 +128,20 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO
|
||||
}
|
||||
}
|
||||
|
||||
// bannerCampaignFromActive flattens a resolved campaign into its wire DTO,
|
||||
// projecting each optional colour set into its three "#rrggbb" fields (empty when
|
||||
// the set is absent, so JSON omitempty drops them).
|
||||
func bannerCampaignFromActive(c ads.ActiveCampaign) bannerCampaignDTO {
|
||||
d := bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages}
|
||||
if c.OverrideAll != nil {
|
||||
d.OverrideBg, d.OverrideFg, d.OverrideLink = c.OverrideAll.Bg, c.OverrideAll.Fg, c.OverrideAll.Link
|
||||
}
|
||||
if c.OverrideDark != nil {
|
||||
d.OverrideBgDark, d.OverrideFgDark, d.OverrideLinkDark = c.OverrideDark.Bg, c.OverrideDark.Fg, c.OverrideDark.Link
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// bannerLang resolves the message language for a viewer: their interface language
|
||||
// (Russian, or English by default).
|
||||
func bannerLang(acc account.Account) string {
|
||||
|
||||
@@ -19,6 +19,15 @@ const bannersBack = "/_gm/banners"
|
||||
// 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) {
|
||||
@@ -56,14 +65,19 @@ func (s *Server) consoleBannerDetail(c *gin.Context) {
|
||||
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),
|
||||
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(),
|
||||
@@ -111,12 +125,15 @@ func (s *Server) consoleUpdateBanner(c *gin.Context) {
|
||||
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,
|
||||
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)
|
||||
@@ -321,3 +338,29 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user