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

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:
Ilia Denisov
2026-07-05 15:36:35 +02:00
parent ac383880b7
commit 6db9178449
32 changed files with 1297 additions and 152 deletions
@@ -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;