Merge pull request 'feat(ads): server-driven ad-banner backend, wire & admin console (PR1)' (#70) from feature/ad-network-backend into development
This commit was merged in pull request #70.
This commit is contained in:
@@ -32,6 +32,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
|
|||||||
| DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** |
|
| DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** |
|
||||||
| AB | Manual account block (admin suspension): permanent/temporary with an editable en+ru reason picklist; a block forfeits the player's active games + cancels their open ones; a backend gate refuses a blocked account with **403 `account_blocked`**; the UI shows a terminal blocked screen and stops all push/poll; manual unblock; temporary blocks self-expire (migration `00003`) | owner ad-hoc | **done** |
|
| AB | Manual account block (admin suspension): permanent/temporary with an editable en+ru reason picklist; a block forfeits the player's active games + cancels their open ones; a backend gate refuses a blocked account with **403 `account_blocked`**; the UI shows a terminal blocked screen and stops all push/poll; manual unblock; temporary blocks self-expire (migration `00003`) | owner ad-hoc | **done** |
|
||||||
| AI | Honest AI opponent in quick game: an explicit 🤖 AI / 👤 random selector (AI default); the robot is seated and moves at once; 7-day inactivity loss (the per-turn timeout reused); chat/nudge disabled, no statistics; the opponent is shown as 🤖 everywhere | owner ad-hoc | **done** |
|
| AI | Honest AI opponent in quick game: an explicit 🤖 AI / 👤 random selector (AI default); the robot is seated and moves at once; 7-day inactivity loss (the per-turn timeout reused); chat/nudge disabled, no statistics; the opponent is shown as 🤖 everywhere | owner ad-hoc | **done** |
|
||||||
|
| AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | PR1 backend+admin **done**; PR2 UI rotation **next** |
|
||||||
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
|
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
|
||||||
|
|
||||||
## Key findings (these reshaped the raw list — read before starting a phase)
|
## Key findings (these reshaped the raw list — read before starting a phase)
|
||||||
|
|||||||
+8
-1
@@ -101,7 +101,13 @@ broadcast picks the delivering bot by an operator-chosen language. `accounts.ser
|
|||||||
holds the language tag of the bot a Telegram
|
holds the language tag of the bot a Telegram
|
||||||
user last signed in through, written on every login and returned by
|
user last signed in through, written on every login and returned by
|
||||||
`/internal/push-target` (falling back to `preferred_language`) so out-of-app push routes
|
`/internal/push-target` (falling back to `preferred_language`) so out-of-app push routes
|
||||||
to the right bot. The shared wire contracts live in the sibling [`../pkg`](../pkg) module.
|
to the right bot. The console also manages the **advertising banner** (`/_gm/banners` +
|
||||||
|
`/_gm/banner-settings`, `internal/ads`): operator campaigns with a percent weight, an optional
|
||||||
|
window and bilingual messages, plus the global display timings. `GET /api/v1/user/profile` attaches
|
||||||
|
the resolved, weighted campaign feed for an **eligible** viewer (`!paid_account && hint_balance == 0
|
||||||
|
&& !no_banner` role, the message language picked by `service_language`); changing those inputs
|
||||||
|
publishes a `notify` `banner` re-poll signal so the client shows/hides it in place. The shared wire
|
||||||
|
contracts live in the sibling [`../pkg`](../pkg) module.
|
||||||
|
|
||||||
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
|
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
|
||||||
orchestrates it: an email confirm-code or a gateway-validated Telegram identity is
|
orchestrates it: an email confirm-code or a gateway-validated Telegram identity is
|
||||||
@@ -145,6 +151,7 @@ internal/feedback/ # user feedback: messages + attachment (bytea), anti-spam g
|
|||||||
internal/lobby/ # auto-match (DB-backed open games + robot substitution) + friend-game invitations
|
internal/lobby/ # auto-match (DB-backed open games + robot substitution) + friend-game invitations
|
||||||
internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver
|
internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver
|
||||||
internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm
|
internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm
|
||||||
|
internal/ads/ # advertising banner: campaigns + bilingual messages + display timings, weighted-rotation feed (ActiveSet)
|
||||||
internal/connector/ # backend gRPC client to the Telegram connector (operator broadcasts)
|
internal/connector/ # backend gRPC client to the Telegram connector (operator broadcasts)
|
||||||
internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag
|
internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/accountmerge"
|
"scrabble/backend/internal/accountmerge"
|
||||||
|
"scrabble/backend/internal/ads"
|
||||||
"scrabble/backend/internal/config"
|
"scrabble/backend/internal/config"
|
||||||
"scrabble/backend/internal/connector"
|
"scrabble/backend/internal/connector"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
@@ -200,6 +201,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
zap.Int("flag_threshold", cfg.RateWatch.FlagThreshold),
|
zap.Int("flag_threshold", cfg.RateWatch.FlagThreshold),
|
||||||
zap.Duration("flag_window", cfg.RateWatch.FlagWindow))
|
zap.Duration("flag_window", cfg.RateWatch.FlagWindow))
|
||||||
|
|
||||||
|
// Advertising-banner domain: campaign rotation feeding the profile.get banner
|
||||||
|
// block and the banner admin console section.
|
||||||
|
adsSvc := ads.NewService(ads.NewStore(db))
|
||||||
|
|
||||||
srv := server.New(cfg.HTTPAddr, server.Deps{
|
srv := server.New(cfg.HTTPAddr, server.Deps{
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
DB: db,
|
DB: db,
|
||||||
@@ -218,6 +223,8 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
DictDir: cfg.Game.DictDir,
|
DictDir: cfg.Game.DictDir,
|
||||||
Connector: conn,
|
Connector: conn,
|
||||||
RateWatch: rateWatch,
|
RateWatch: rateWatch,
|
||||||
|
Ads: adsSvc,
|
||||||
|
Notifier: hub,
|
||||||
})
|
})
|
||||||
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,16 @@ const (
|
|||||||
// RoleFeedbackBanned forbids the account from submitting feedback (only that;
|
// RoleFeedbackBanned forbids the account from submitting feedback (only that;
|
||||||
// it is not a full account suspension). See internal/feedback.
|
// it is not a full account suspension). See internal/feedback.
|
||||||
RoleFeedbackBanned = "feedback_banned"
|
RoleFeedbackBanned = "feedback_banned"
|
||||||
|
|
||||||
|
// RoleNoBanner suppresses the in-app advertising banner for the account
|
||||||
|
// unconditionally, overriding the usual eligibility (a free account with an
|
||||||
|
// empty hint wallet otherwise sees it). See internal/ads.
|
||||||
|
RoleNoBanner = "no_banner"
|
||||||
)
|
)
|
||||||
|
|
||||||
// KnownRoles is the set of roles the console may grant or revoke; an operator
|
// KnownRoles is the set of roles the console may grant or revoke; an operator
|
||||||
// cannot assign an unrecognised role.
|
// cannot assign an unrecognised role.
|
||||||
var KnownRoles = []string{RoleFeedbackBanned}
|
var KnownRoles = []string{RoleFeedbackBanned, RoleNoBanner}
|
||||||
|
|
||||||
// IsKnownRole reports whether role is a recognised account role.
|
// IsKnownRole reports whether role is a recognised account role.
|
||||||
func IsKnownRole(role string) bool {
|
func IsKnownRole(role string) bool {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
|
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
|
||||||
<a href="/_gm/throttled"{{if eq .ActiveNav "throttled"}} class="active"{{end}}>Throttled</a>
|
<a href="/_gm/throttled"{{if eq .ActiveNav "throttled"}} class="active"{{end}}>Throttled</a>
|
||||||
<a href="/_gm/reasons"{{if eq .ActiveNav "reasons"}} class="active"{{end}}>Reasons</a>
|
<a href="/_gm/reasons"{{if eq .ActiveNav "reasons"}} class="active"{{end}}>Reasons</a>
|
||||||
|
<a href="/_gm/banners"{{if eq .ActiveNav "banners"}} class="active"{{end}}>Banners</a>
|
||||||
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
|
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
|
||||||
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
||||||
<a href="/_gm/grafana/">Grafana ↗</a>
|
<a href="/_gm/grafana/">Grafana ↗</a>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{with .Data}}
|
||||||
|
<p class="note"><a href="/_gm/banners">← all campaigns</a></p>
|
||||||
|
<h1>{{.Name}}{{if .IsDefault}} <span class="pill">default</span>{{end}}</h1>
|
||||||
|
<section class="panel"><h2>Settings</h2>
|
||||||
|
{{if .IsDefault}}<p class="note">The default campaign is perpetual and fills the unsold remainder — only its name and messages are editable.</p>{{end}}
|
||||||
|
<form class="form col" method="post" action="/_gm/banners/{{.ID}}">
|
||||||
|
<label>Name <input type="text" name="name" value="{{.Name}}" maxlength="80" required></label>
|
||||||
|
{{if not .IsDefault}}
|
||||||
|
<label>Weight (%) <input type="number" name="weight" min="1" max="100" value="{{.Weight}}" required></label>
|
||||||
|
<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>
|
||||||
|
{{end}}
|
||||||
|
<div><button type="submit">Save</button></div>
|
||||||
|
</form>
|
||||||
|
{{if not .IsDefault}}
|
||||||
|
<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>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
<section class="panel"><h2>Messages</h2>
|
||||||
|
<p class="note">Each message must be filled in both languages; the viewer sees the variant for the bot they play through. The messages of a campaign share its show weight, rotating in this order.</p>
|
||||||
|
{{range .Messages}}
|
||||||
|
<div class="row">
|
||||||
|
<form class="form col" method="post" action="/_gm/banners/{{$.Data.ID}}/messages/{{.ID}}">
|
||||||
|
<label>English <input type="text" name="body_en" value="{{.BodyEn}}" maxlength="500" required></label>
|
||||||
|
<label>Russian <input type="text" name="body_ru" value="{{.BodyRu}}" maxlength="500" required></label>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="actions">
|
||||||
|
<form class="form" method="post" action="/_gm/banners/{{$.Data.ID}}/messages/{{.ID}}/move"><input type="hidden" name="dir" value="up"><button type="submit"{{if .First}} disabled{{end}}>↑</button></form>
|
||||||
|
<form class="form" method="post" action="/_gm/banners/{{$.Data.ID}}/messages/{{.ID}}/move"><input type="hidden" name="dir" value="down"><button type="submit"{{if .Last}} disabled{{end}}>↓</button></form>
|
||||||
|
<form class="form" method="post" action="/_gm/banners/{{$.Data.ID}}/messages/{{.ID}}/delete" onsubmit="return confirm('Delete this message?')"><button type="submit" class="danger">Delete</button></form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}<p class="note">no messages yet</p>{{end}}
|
||||||
|
<h3>Add message</h3>
|
||||||
|
<form class="form col" method="post" action="/_gm/banners/{{.ID}}/messages">
|
||||||
|
<label>English <input type="text" name="body_en" maxlength="500" required></label>
|
||||||
|
<label>Russian <input type="text" name="body_ru" maxlength="500" required></label>
|
||||||
|
<div><button type="submit">Add message</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<p class="note"><a href="/_gm/banners">← all campaigns</a></p>
|
||||||
|
<h1>Banner display timings</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<p class="note">Global timings the client's banner rotator reads. Out-of-range values are clamped on save. The transition between messages is fade-out, then a gap, then fade-in (skipped under reduce-motion).</p>
|
||||||
|
<section class="panel">
|
||||||
|
<form class="form col" method="post" action="/_gm/banner-settings">
|
||||||
|
<label>Hold (ms) — how long one message shows <input type="number" name="hold_ms" min="3000" max="600000" value="{{.HoldMs}}" required></label>
|
||||||
|
<label>Edge pause (ms) — pause at each end before/after scrolling a long message <input type="number" name="edge_pause_ms" min="0" max="60000" value="{{.EdgePauseMs}}" required></label>
|
||||||
|
<label>Scroll speed (px/s) — for a message wider than the strip <input type="number" name="scroll_px_per_sec" min="5" max="1000" value="{{.ScrollPxPerSec}}" required></label>
|
||||||
|
<label>Fade-out (ms) <input type="number" name="fade_out_ms" min="0" max="5000" value="{{.FadeOutMs}}" required></label>
|
||||||
|
<label>Gap (ms) — pause between fade-out and fade-in <input type="number" name="gap_ms" min="0" max="5000" value="{{.GapMs}}" required></label>
|
||||||
|
<label>Fade-in (ms) <input type="number" name="fade_in_ms" min="0" max="5000" value="{{.FadeInMs}}" required></label>
|
||||||
|
<div><button type="submit">Save</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Advertising banners</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<p class="note">Campaigns compete for the one-line banner shown to free users (no paid account, an empty hint wallet, and no <code>no_banner</code> role). A campaign's weight is a show percent; the perpetual <strong>default</strong> campaign fills the remainder up to 100% and drops out of the rotation when timed campaigns already total 100%. <a href="/_gm/banner-settings">Display timings →</a></p>
|
||||||
|
<section class="panel"><h2>Add campaign</h2>
|
||||||
|
<form class="form col" method="post" action="/_gm/banners">
|
||||||
|
<label>Name <input type="text" name="name" maxlength="80" required></label>
|
||||||
|
<label>Weight (%) <input type="number" name="weight" min="1" max="100" value="10" required></label>
|
||||||
|
<label>Starts (UTC) <input type="datetime-local" name="starts_at"></label>
|
||||||
|
<label>Ends (UTC) <input type="datetime-local" name="ends_at"></label>
|
||||||
|
<label><input type="checkbox" name="enabled" checked> Enabled</label>
|
||||||
|
<div><button type="submit">Add</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><h2>Campaigns</h2>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Name</th><th class="num">Weight</th><th>Window</th><th class="num">Messages</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Items}}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/_gm/banners/{{.ID}}">{{.Name}}</a>{{if .IsDefault}} <span class="pill">default</span>{{end}}</td>
|
||||||
|
<td class="num">{{if .IsDefault}}remainder{{else}}{{.Weight}}%{{end}}</td>
|
||||||
|
<td>{{.Window}}</td>
|
||||||
|
<td class="num">{{.Messages}}</td>
|
||||||
|
<td>{{if .ActiveNow}}live{{else if .Enabled}}idle{{else}}disabled{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}<tr><td colspan="5"><span class="note">no campaigns</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -372,6 +372,58 @@ type MessageView struct {
|
|||||||
Back string
|
Back string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BannersView is the advertising-campaign list page.
|
||||||
|
type BannersView struct {
|
||||||
|
Items []BannerCampaignRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// BannerCampaignRow is one campaign in the list. Window is a human-readable
|
||||||
|
// validity window ("perpetual" for the default); ActiveNow reports whether it
|
||||||
|
// would rotate right now (enabled and within its window).
|
||||||
|
type BannerCampaignRow struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Weight int
|
||||||
|
IsDefault bool
|
||||||
|
Enabled bool
|
||||||
|
Window string
|
||||||
|
Messages int
|
||||||
|
ActiveNow bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
type BannerDetailView struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Weight int
|
||||||
|
IsDefault bool
|
||||||
|
Enabled bool
|
||||||
|
StartsAt string
|
||||||
|
EndsAt string
|
||||||
|
Messages []BannerMessageRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// BannerMessageRow is one bilingual message of a campaign. First/Last drive the
|
||||||
|
// reorder buttons (disabled at the ends).
|
||||||
|
type BannerMessageRow struct {
|
||||||
|
ID string
|
||||||
|
BodyEn string
|
||||||
|
BodyRu string
|
||||||
|
First bool
|
||||||
|
Last bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// BannerSettingsView is the global display-timings form.
|
||||||
|
type BannerSettingsView struct {
|
||||||
|
HoldMs int
|
||||||
|
EdgePauseMs int
|
||||||
|
ScrollPxPerSec int
|
||||||
|
FadeOutMs int
|
||||||
|
GapMs int
|
||||||
|
FadeInMs int
|
||||||
|
}
|
||||||
|
|
||||||
// FeedbackView is the paginated user-feedback queue. Status is the active
|
// FeedbackView is the paginated user-feedback queue. Status is the active
|
||||||
// unread/read/archived filter; NameMask/ExtMask are the sender glob filters;
|
// unread/read/archived filter; NameMask/ExtMask are the sender glob filters;
|
||||||
// UserID pins the list to one account (the per-user link from /users);
|
// UserID pins the list to one account (the per-user link from /users);
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// Package ads owns the server-driven advertising banner ("advertising network"):
|
||||||
|
// operator-managed campaigns, their bilingual messages and the global display
|
||||||
|
// timings the client's one-line announcement strip rotates through.
|
||||||
|
//
|
||||||
|
// A campaign is one placement order with a show weight (an integer percent).
|
||||||
|
// Simultaneously active campaigns compete for display slots in proportion to
|
||||||
|
// their weights; the single perpetual default campaign fills the unsold
|
||||||
|
// remainder up to 100%. The actual rotation runs client-side (a smooth weighted
|
||||||
|
// round-robin over campaigns, round-robin over a campaign's messages); the server
|
||||||
|
// only computes the effective weighted, language-resolved set a viewer should
|
||||||
|
// rotate, via ActiveSet. Who sees a banner at all is decided by Eligible.
|
||||||
|
package ads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotFound is returned when a campaign or message does not exist.
|
||||||
|
var ErrNotFound = errors.New("ads: not found")
|
||||||
|
|
||||||
|
// ErrDefaultImmutable is returned when an operation is refused on the perpetual
|
||||||
|
// default campaign (it cannot be deleted, disabled, windowed, or have its
|
||||||
|
// weight or default flag changed).
|
||||||
|
var ErrDefaultImmutable = errors.New("ads: the default campaign cannot be modified that way")
|
||||||
|
|
||||||
|
// ErrValidation wraps an operator-facing validation failure (a bad weight,
|
||||||
|
// window or message body).
|
||||||
|
var ErrValidation = errors.New("ads: validation")
|
||||||
|
|
||||||
|
// Campaign is one advertising placement order with its messages.
|
||||||
|
type Campaign struct {
|
||||||
|
ID uuid.UUID // uuid.Nil on create
|
||||||
|
Name string
|
||||||
|
Weight int // show percent, 1..100; nominal (ignored) for the default
|
||||||
|
IsDefault bool
|
||||||
|
Enabled bool
|
||||||
|
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
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message is one bilingual creative of a campaign. Both bodies are mandatory;
|
||||||
|
// the client shows the variant for the viewer's bot (service) language. Position
|
||||||
|
// orders the messages within a campaign (the round-robin order).
|
||||||
|
type Message struct {
|
||||||
|
ID uuid.UUID // uuid.Nil on create
|
||||||
|
CampaignID uuid.UUID
|
||||||
|
Position int
|
||||||
|
BodyEn string
|
||||||
|
BodyRu string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timings are the global banner display timings, in milliseconds except
|
||||||
|
// ScrollPxPerSec. HoldMs is how long one message shows; EdgePauseMs and
|
||||||
|
// ScrollPxPerSec drive the scroll of a message wider than the strip; a
|
||||||
|
// transition between messages is FadeOutMs then GapMs then FadeInMs.
|
||||||
|
type Timings struct {
|
||||||
|
HoldMs int
|
||||||
|
EdgePauseMs int
|
||||||
|
ScrollPxPerSec int
|
||||||
|
FadeOutMs int
|
||||||
|
GapMs int
|
||||||
|
FadeInMs int
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
type ActiveCampaign struct {
|
||||||
|
Weight int
|
||||||
|
Messages []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eligible reports whether an account should be shown the advertising banner: a
|
||||||
|
// free account (not paid) with an empty hint wallet and without the no_banner
|
||||||
|
// role. The no_banner role suppresses the banner unconditionally; buying a paid
|
||||||
|
// account or any hints also removes it.
|
||||||
|
func Eligible(paidAccount bool, hintBalance int, hasNoBanner bool) bool {
|
||||||
|
return !paidAccount && hintBalance <= 0 && !hasNoBanner
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
var timed []Campaign
|
||||||
|
var def *Campaign
|
||||||
|
for i := range campaigns {
|
||||||
|
c := campaigns[i]
|
||||||
|
if len(c.Messages) == 0 {
|
||||||
|
continue // a campaign with no creative cannot fill a slot
|
||||||
|
}
|
||||||
|
if c.IsDefault {
|
||||||
|
def = &campaigns[i]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if withinWindow(c, now) {
|
||||||
|
timed = append(timed, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)})
|
||||||
|
}
|
||||||
|
if def != nil {
|
||||||
|
if dw := 100 - sumTimed; dw > 0 {
|
||||||
|
out = append(out, ActiveCampaign{Weight: dw, Messages: resolveBodies(def.Messages, lang)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reduceByGCD(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveAt reports whether the campaign would rotate at time now: enabled, and
|
||||||
|
// either the perpetual default or within its validity window. It mirrors the
|
||||||
|
// filter computeActiveSet applies (a campaign with no messages still reports
|
||||||
|
// active here — that is a content gap the console surfaces, not an inactive
|
||||||
|
// campaign).
|
||||||
|
func (c Campaign) ActiveAt(now time.Time) bool {
|
||||||
|
return c.Enabled && (c.IsDefault || withinWindow(c, now))
|
||||||
|
}
|
||||||
|
|
||||||
|
// withinWindow reports whether now lies inside the campaign's validity window. A
|
||||||
|
// nil bound is open-ended on that side.
|
||||||
|
func withinWindow(c Campaign, now time.Time) bool {
|
||||||
|
if c.StartsAt != nil && now.Before(*c.StartsAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if c.EndsAt != nil && now.After(*c.EndsAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveBodies projects each message to the body for lang ("ru" picks the
|
||||||
|
// Russian body; anything else picks English), preserving display order.
|
||||||
|
func resolveBodies(msgs []Message, lang string) []string {
|
||||||
|
out := make([]string, len(msgs))
|
||||||
|
for i, m := range msgs {
|
||||||
|
if lang == "ru" {
|
||||||
|
out[i] = m.BodyRu
|
||||||
|
} else {
|
||||||
|
out[i] = m.BodyEn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// reduceByGCD divides every weight by the greatest common divisor of all weights
|
||||||
|
// (in place), shrinking a {50,30,20} set to {5,3,2} and a lone {100} to {1}. A
|
||||||
|
// no-op when the set is empty or already coprime.
|
||||||
|
func reduceByGCD(cs []ActiveCampaign) {
|
||||||
|
g := 0
|
||||||
|
for _, c := range cs {
|
||||||
|
g = gcd(g, c.Weight)
|
||||||
|
}
|
||||||
|
if g <= 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range cs {
|
||||||
|
cs[i].Weight /= g
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gcd returns the greatest common divisor of a and b (gcd(0, n) == n).
|
||||||
|
func gcd(a, b int) int {
|
||||||
|
for b != 0 {
|
||||||
|
a, b = b, a%b
|
||||||
|
}
|
||||||
|
if a < 0 {
|
||||||
|
return -a
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package ads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEligible(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
paidAccount bool
|
||||||
|
hintBalance int
|
||||||
|
hasNoBanner bool
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "free, empty wallet, no role", want: true},
|
||||||
|
{name: "paid", paidAccount: true, want: false},
|
||||||
|
{name: "has hints", hintBalance: 3, want: false},
|
||||||
|
{name: "no_banner role", hasNoBanner: true, want: false},
|
||||||
|
{name: "paid and has hints", paidAccount: true, hintBalance: 5, want: false},
|
||||||
|
{name: "no_banner overrides everything", hasNoBanner: true, want: false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := Eligible(tt.paidAccount, tt.hintBalance, tt.hasNoBanner); got != tt.want {
|
||||||
|
t.Errorf("Eligible(%v,%d,%v) = %v, want %v", tt.paidAccount, tt.hintBalance, tt.hasNoBanner, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeActiveSet(t *testing.T) {
|
||||||
|
now := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC)
|
||||||
|
past := now.Add(-24 * time.Hour)
|
||||||
|
future := now.Add(24 * time.Hour)
|
||||||
|
|
||||||
|
// msg builds a one-message slice with distinct bodies so resolution and
|
||||||
|
// campaign identity are visible in assertions.
|
||||||
|
msg := func(tag string) []Message {
|
||||||
|
return []Message{{BodyEn: tag + "-en", BodyRu: tag + "-ru"}}
|
||||||
|
}
|
||||||
|
def := func(msgs []Message) Campaign {
|
||||||
|
return Campaign{Name: "default", Weight: 100, IsDefault: true, Enabled: true, Messages: msgs}
|
||||||
|
}
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
campaigns []Campaign
|
||||||
|
lang string
|
||||||
|
want []ActiveCampaign
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "default only reduces to weight 1",
|
||||||
|
campaigns: []Campaign{def(msg("house"))},
|
||||||
|
lang: "en",
|
||||||
|
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "default fills the remainder",
|
||||||
|
campaigns: []Campaign{def(msg("house")), timed("promo", 30, nil, nil, msg("promo"))},
|
||||||
|
lang: "en",
|
||||||
|
// timed 30, default 100-30=70; gcd 10 -> 3 and 7; timed first, default last.
|
||||||
|
want: []ActiveCampaign{
|
||||||
|
{Weight: 3, Messages: []string{"promo-en"}},
|
||||||
|
{Weight: 7, Messages: []string{"house-en"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "timed fills 100, default dropped",
|
||||||
|
campaigns: []Campaign{def(msg("house")), timed("promo", 100, nil, nil, msg("promo"))},
|
||||||
|
lang: "en",
|
||||||
|
want: []ActiveCampaign{{Weight: 1, Messages: []string{"promo-en"}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "timed over 100, default dropped, proportional",
|
||||||
|
campaigns: []Campaign{
|
||||||
|
def(msg("house")),
|
||||||
|
timed("a", 60, nil, nil, msg("a")),
|
||||||
|
timed("b", 80, nil, nil, msg("b")),
|
||||||
|
},
|
||||||
|
lang: "en",
|
||||||
|
// default dropped (sum 140 >= 100); gcd(60,80)=20 -> 3 and 4.
|
||||||
|
want: []ActiveCampaign{
|
||||||
|
{Weight: 3, Messages: []string{"a-en"}},
|
||||||
|
{Weight: 4, Messages: []string{"b-en"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "future and past windows exclude timed",
|
||||||
|
campaigns: []Campaign{
|
||||||
|
def(msg("house")),
|
||||||
|
timed("future", 50, &future, nil, msg("future")),
|
||||||
|
timed("past", 50, nil, &past, msg("past")),
|
||||||
|
},
|
||||||
|
lang: "en",
|
||||||
|
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "active window included",
|
||||||
|
campaigns: []Campaign{
|
||||||
|
def(msg("house")),
|
||||||
|
timed("live", 25, &past, &future, msg("live")),
|
||||||
|
},
|
||||||
|
lang: "en",
|
||||||
|
// timed 25, default 75; gcd 25 -> 1 and 3.
|
||||||
|
want: []ActiveCampaign{
|
||||||
|
{Weight: 1, Messages: []string{"live-en"}},
|
||||||
|
{Weight: 3, Messages: []string{"house-en"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "campaign without messages is skipped and not counted",
|
||||||
|
campaigns: []Campaign{
|
||||||
|
def(msg("house")),
|
||||||
|
timed("empty", 40, nil, nil, nil),
|
||||||
|
},
|
||||||
|
lang: "en",
|
||||||
|
// empty campaign skipped; default fills full 100 -> reduced to 1.
|
||||||
|
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "russian bodies resolved",
|
||||||
|
campaigns: []Campaign{def(msg("house"))},
|
||||||
|
lang: "ru",
|
||||||
|
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-ru"}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "three timed split by gcd",
|
||||||
|
campaigns: []Campaign{
|
||||||
|
def(msg("house")),
|
||||||
|
timed("a", 50, nil, nil, msg("a")),
|
||||||
|
timed("b", 30, nil, nil, msg("b")),
|
||||||
|
timed("c", 20, nil, nil, msg("c")),
|
||||||
|
},
|
||||||
|
lang: "en",
|
||||||
|
// sum 100, default dropped; gcd 10 -> 5,3,2.
|
||||||
|
want: []ActiveCampaign{
|
||||||
|
{Weight: 5, Messages: []string{"a-en"}},
|
||||||
|
{Weight: 3, Messages: []string{"b-en"}},
|
||||||
|
{Weight: 2, Messages: []string{"c-en"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "campaign with multiple messages keeps order",
|
||||||
|
campaigns: []Campaign{
|
||||||
|
def([]Message{{BodyEn: "one-en", BodyRu: "one-ru"}, {BodyEn: "two-en", BodyRu: "two-ru"}}),
|
||||||
|
},
|
||||||
|
lang: "en",
|
||||||
|
want: []ActiveCampaign{{Weight: 1, Messages: []string{"one-en", "two-en"}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := computeActiveSet(tt.campaigns, now, tt.lang)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGCD(t *testing.T) {
|
||||||
|
tests := []struct{ a, b, want int }{
|
||||||
|
{0, 5, 5}, {5, 0, 5}, {12, 18, 6}, {100, 100, 100}, {7, 13, 1},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := gcd(tt.a, tt.b); got != tt.want {
|
||||||
|
t.Errorf("gcd(%d,%d) = %d, want %d", tt.a, tt.b, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
package ads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Operator-input bounds and the display-timing clamps. The timings are clamped
|
||||||
|
// (not rejected) on update so the client rotator can never be driven into a
|
||||||
|
// broken state by a typo in the console.
|
||||||
|
const (
|
||||||
|
maxCampaignName = 80
|
||||||
|
maxMessageBody = 500
|
||||||
|
|
||||||
|
minHoldMs = 3000 // 3s — below this the fade transition would dominate
|
||||||
|
maxHoldMs = 600000 // 10 min
|
||||||
|
|
||||||
|
maxEdgePauseMs = 60000
|
||||||
|
|
||||||
|
minScrollPxPerSec = 5
|
||||||
|
maxScrollPxPerSec = 1000
|
||||||
|
|
||||||
|
maxFadeMs = 5000
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service is the domain layer over Store: it validates operator input, enforces
|
||||||
|
// the default campaign's invariants, clamps the display timings, and assembles
|
||||||
|
// the resolved rotation feed (ActiveSet) for a viewer.
|
||||||
|
type Service struct {
|
||||||
|
store *Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService constructs a Service over store.
|
||||||
|
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) {
|
||||||
|
campaigns, err := s.store.ActiveCampaigns(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, Timings{}, err
|
||||||
|
}
|
||||||
|
timings, err := s.store.Settings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, Timings{}, err
|
||||||
|
}
|
||||||
|
return computeActiveSet(campaigns, time.Now().UTC(), lang), timings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCampaigns returns every campaign with its messages, for the admin console.
|
||||||
|
func (s *Service) ListCampaigns(ctx context.Context) ([]Campaign, error) {
|
||||||
|
return s.store.ListCampaigns(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Campaign returns one campaign with its messages, or ErrNotFound.
|
||||||
|
func (s *Service) Campaign(ctx context.Context, id uuid.UUID) (Campaign, error) {
|
||||||
|
return s.store.Campaign(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCampaign validates and creates a new time-limited campaign (never the
|
||||||
|
// default) and returns its id.
|
||||||
|
func (s *Service) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) {
|
||||||
|
name, err := validName(c.Name)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
if err := validWeight(c.Weight); err != nil {
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
if err := validWindow(c.StartsAt, c.EndsAt); 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCampaign validates and updates a campaign. The default campaign keeps its
|
||||||
|
// weight (nominal), enabled flag and (empty) window — only its name is editable;
|
||||||
|
// any submitted weight/window/enabled are ignored for it.
|
||||||
|
func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
|
||||||
|
existing, err := s.store.Campaign(ctx, c.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name, err := validName(c.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
upd := Campaign{ID: existing.ID, Name: name}
|
||||||
|
if existing.IsDefault {
|
||||||
|
upd.Weight = existing.Weight
|
||||||
|
upd.Enabled = true
|
||||||
|
upd.StartsAt = nil
|
||||||
|
upd.EndsAt = nil
|
||||||
|
} else {
|
||||||
|
if err := validWeight(c.Weight); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
upd.Weight = c.Weight
|
||||||
|
upd.Enabled = c.Enabled
|
||||||
|
upd.StartsAt = c.StartsAt
|
||||||
|
upd.EndsAt = c.EndsAt
|
||||||
|
}
|
||||||
|
return s.store.UpdateCampaign(ctx, upd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCampaign removes a campaign, refusing the perpetual default.
|
||||||
|
func (s *Service) DeleteCampaign(ctx context.Context, id uuid.UUID) error {
|
||||||
|
existing, err := s.store.Campaign(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existing.IsDefault {
|
||||||
|
return ErrDefaultImmutable
|
||||||
|
}
|
||||||
|
return s.store.DeleteCampaign(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMessage validates a bilingual message and appends it to a campaign.
|
||||||
|
func (s *Service) AddMessage(ctx context.Context, campaignID uuid.UUID, bodyEn, bodyRu string) (uuid.UUID, error) {
|
||||||
|
en, ru, err := validBodies(bodyEn, bodyRu)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
c, err := s.store.Campaign(ctx, campaignID)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
return s.store.AddMessage(ctx, Message{CampaignID: campaignID, Position: len(c.Messages), BodyEn: en, BodyRu: ru})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditMessage validates and updates the bilingual bodies of a message that
|
||||||
|
// belongs to campaignID (its position is unchanged). It returns ErrNotFound when
|
||||||
|
// the message is not part of that campaign, so a mismatched campaign/message pair
|
||||||
|
// never edits an unrelated campaign's message.
|
||||||
|
func (s *Service) EditMessage(ctx context.Context, campaignID, messageID uuid.UUID, bodyEn, bodyRu string) error {
|
||||||
|
en, ru, err := validBodies(bodyEn, bodyRu)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c, err := s.store.Campaign(ctx, campaignID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !campaignOwnsMessage(c, messageID) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return s.store.UpdateMessageBodies(ctx, messageID, en, ru)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveMessage reorders a message within its campaign by swapping its position
|
||||||
|
// with the adjacent message in direction dir (-1 up, +1 down). A move past an
|
||||||
|
// edge is a no-op.
|
||||||
|
func (s *Service) MoveMessage(ctx context.Context, campaignID, messageID uuid.UUID, dir int) error {
|
||||||
|
c, err := s.store.Campaign(ctx, campaignID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
idx := -1
|
||||||
|
for i, m := range c.Messages {
|
||||||
|
if m.ID == messageID {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
j := idx + dir
|
||||||
|
if j < 0 || j >= len(c.Messages) {
|
||||||
|
return nil // already at the edge
|
||||||
|
}
|
||||||
|
a, b := c.Messages[idx], c.Messages[j]
|
||||||
|
if err := s.store.SetMessagePosition(ctx, a.ID, j); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.store.SetMessagePosition(ctx, b.ID, idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMessage removes a message that belongs to campaignID, refusing to remove
|
||||||
|
// the default campaign's last remaining message (the default must always have a
|
||||||
|
// creative to show). It returns ErrNotFound when the message is not part of that
|
||||||
|
// campaign — so a mismatched campaign/message pair can neither delete an
|
||||||
|
// unrelated campaign's message nor bypass the default's last-message guard.
|
||||||
|
func (s *Service) DeleteMessage(ctx context.Context, campaignID, messageID uuid.UUID) error {
|
||||||
|
c, err := s.store.Campaign(ctx, campaignID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !campaignOwnsMessage(c, messageID) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if c.IsDefault && len(c.Messages) <= 1 {
|
||||||
|
return fmt.Errorf("%w: the default campaign must keep at least one message", ErrValidation)
|
||||||
|
}
|
||||||
|
return s.store.DeleteMessage(ctx, messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// campaignOwnsMessage reports whether messageID is one of the campaign's messages.
|
||||||
|
func campaignOwnsMessage(c Campaign, messageID uuid.UUID) bool {
|
||||||
|
for _, m := range c.Messages {
|
||||||
|
if m.ID == messageID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings returns the global display timings.
|
||||||
|
func (s *Service) Settings(ctx context.Context) (Timings, error) {
|
||||||
|
return s.store.Settings(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettings clamps every timing into its safe range and stores them.
|
||||||
|
func (s *Service) UpdateSettings(ctx context.Context, t Timings) error {
|
||||||
|
return s.store.UpdateSettings(ctx, clampTimings(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
// validName trims and bounds a campaign name.
|
||||||
|
func validName(name string) (string, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return "", fmt.Errorf("%w: the campaign name is required", ErrValidation)
|
||||||
|
}
|
||||||
|
if len([]rune(name)) > maxCampaignName {
|
||||||
|
return "", fmt.Errorf("%w: the campaign name must be at most %d characters", ErrValidation, maxCampaignName)
|
||||||
|
}
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validWeight bounds a campaign show weight to the 1..100 percent range.
|
||||||
|
func validWeight(w int) error {
|
||||||
|
if w < 1 || w > 100 {
|
||||||
|
return fmt.Errorf("%w: the weight must be a percent between 1 and 100", ErrValidation)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validWindow rejects an inverted validity window.
|
||||||
|
func validWindow(starts, ends *time.Time) error {
|
||||||
|
if starts != nil && ends != nil && ends.Before(*starts) {
|
||||||
|
return fmt.Errorf("%w: the end must not precede the start", ErrValidation)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validBodies trims and bounds both mandatory language bodies of a message.
|
||||||
|
func validBodies(bodyEn, bodyRu string) (string, string, error) {
|
||||||
|
en := strings.TrimSpace(bodyEn)
|
||||||
|
ru := strings.TrimSpace(bodyRu)
|
||||||
|
if en == "" || ru == "" {
|
||||||
|
return "", "", fmt.Errorf("%w: both the English and Russian message bodies are required", ErrValidation)
|
||||||
|
}
|
||||||
|
if len([]rune(en)) > maxMessageBody || len([]rune(ru)) > maxMessageBody {
|
||||||
|
return "", "", fmt.Errorf("%w: a message body must be at most %d characters", ErrValidation, maxMessageBody)
|
||||||
|
}
|
||||||
|
return en, ru, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clampTimings forces every display timing into its safe range.
|
||||||
|
func clampTimings(t Timings) Timings {
|
||||||
|
return Timings{
|
||||||
|
HoldMs: clampInt(t.HoldMs, minHoldMs, maxHoldMs),
|
||||||
|
EdgePauseMs: clampInt(t.EdgePauseMs, 0, maxEdgePauseMs),
|
||||||
|
ScrollPxPerSec: clampInt(t.ScrollPxPerSec, minScrollPxPerSec, maxScrollPxPerSec),
|
||||||
|
FadeOutMs: clampInt(t.FadeOutMs, 0, maxFadeMs),
|
||||||
|
GapMs: clampInt(t.GapMs, 0, maxFadeMs),
|
||||||
|
FadeInMs: clampInt(t.FadeInMs, 0, maxFadeMs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampInt(v, lo, hi int) int {
|
||||||
|
if v < lo {
|
||||||
|
return lo
|
||||||
|
}
|
||||||
|
if v > hi {
|
||||||
|
return hi
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package ads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
"github.com/go-jet/jet/v2/qrm"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||||
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store is the Postgres-backed query surface for advertising campaigns, their
|
||||||
|
// messages and the single global display-settings row.
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStore constructs a Store wrapping db.
|
||||||
|
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||||
|
|
||||||
|
// ListCampaigns returns every campaign with its messages, the default first then
|
||||||
|
// by creation time, for the admin console.
|
||||||
|
func (s *Store) ListCampaigns(ctx context.Context) ([]Campaign, error) {
|
||||||
|
return s.loadCampaigns(ctx, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveCampaigns returns the enabled campaigns with their messages, the default
|
||||||
|
// first then by creation time. Window filtering and the default-remainder weight
|
||||||
|
// are applied by the Service (ActiveSet), not here.
|
||||||
|
func (s *Store) ActiveCampaigns(ctx context.Context) ([]Campaign, error) {
|
||||||
|
return s.loadCampaigns(ctx, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadCampaigns reads campaigns (optionally only the enabled ones) and attaches
|
||||||
|
// each one's messages in display order, with a single messages query (the table
|
||||||
|
// is small, so this avoids both an N+1 and a join projection).
|
||||||
|
func (s *Store) loadCampaigns(ctx context.Context, enabledOnly bool) ([]Campaign, error) {
|
||||||
|
sel := postgres.SELECT(table.AdCampaigns.AllColumns).FROM(table.AdCampaigns)
|
||||||
|
if enabledOnly {
|
||||||
|
sel = sel.WHERE(table.AdCampaigns.Enabled.EQ(postgres.Bool(true)))
|
||||||
|
}
|
||||||
|
sel = sel.ORDER_BY(table.AdCampaigns.IsDefault.DESC(), table.AdCampaigns.CreatedAt.ASC())
|
||||||
|
|
||||||
|
var rows []model.AdCampaigns
|
||||||
|
if err := sel.QueryContext(ctx, s.db, &rows); err != nil {
|
||||||
|
return nil, fmt.Errorf("ads: list campaigns: %w", err)
|
||||||
|
}
|
||||||
|
byID, err := s.messagesByCampaign(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]Campaign, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
c := modelToCampaign(r)
|
||||||
|
c.Messages = byID[r.CampaignID]
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Campaign returns one campaign with its messages, or ErrNotFound.
|
||||||
|
func (s *Store) Campaign(ctx context.Context, id uuid.UUID) (Campaign, error) {
|
||||||
|
sel := postgres.SELECT(table.AdCampaigns.AllColumns).
|
||||||
|
FROM(table.AdCampaigns).
|
||||||
|
WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(id))).
|
||||||
|
LIMIT(1)
|
||||||
|
var row model.AdCampaigns
|
||||||
|
if err := sel.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
if errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return Campaign{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return Campaign{}, fmt.Errorf("ads: get campaign %s: %w", id, err)
|
||||||
|
}
|
||||||
|
c := modelToCampaign(row)
|
||||||
|
msgs, err := s.messagesFor(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return Campaign{}, err
|
||||||
|
}
|
||||||
|
c.Messages = msgs
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCampaign inserts a campaign (always non-default) and returns its new id.
|
||||||
|
func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) {
|
||||||
|
id := uuid.New()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
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.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),
|
||||||
|
postgres.TimestampzT(now), postgres.TimestampzT(now),
|
||||||
|
)
|
||||||
|
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||||
|
return uuid.Nil, fmt.Errorf("ads: create campaign: %w", err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCampaign updates a campaign's name, weight, enabled flag and validity
|
||||||
|
// window. The default flag is never touched here. Returns ErrNotFound when no
|
||||||
|
// campaign matches.
|
||||||
|
func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) error {
|
||||||
|
stmt := table.AdCampaigns.UPDATE(
|
||||||
|
table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.Enabled,
|
||||||
|
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, 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()),
|
||||||
|
).WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(c.ID)))
|
||||||
|
return execOne(ctx, s.db, stmt, "update campaign")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCampaign removes a campaign (its messages cascade). Returns ErrNotFound
|
||||||
|
// when no campaign matches. The Service refuses to delete the default.
|
||||||
|
func (s *Store) DeleteCampaign(ctx context.Context, id uuid.UUID) error {
|
||||||
|
stmt := table.AdCampaigns.DELETE().WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(id)))
|
||||||
|
return execOne(ctx, s.db, stmt, "delete campaign")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMessage inserts a message into a campaign and returns its new id.
|
||||||
|
func (s *Store) AddMessage(ctx context.Context, m Message) (uuid.UUID, error) {
|
||||||
|
id := uuid.New()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
stmt := table.AdMessages.INSERT(
|
||||||
|
table.AdMessages.MessageID, table.AdMessages.CampaignID, table.AdMessages.Position,
|
||||||
|
table.AdMessages.BodyEn, table.AdMessages.BodyRu,
|
||||||
|
table.AdMessages.CreatedAt, table.AdMessages.UpdatedAt,
|
||||||
|
).VALUES(
|
||||||
|
postgres.UUID(id), postgres.UUID(m.CampaignID), postgres.Int(int64(m.Position)),
|
||||||
|
postgres.String(m.BodyEn), postgres.String(m.BodyRu),
|
||||||
|
postgres.TimestampzT(now), postgres.TimestampzT(now),
|
||||||
|
)
|
||||||
|
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||||
|
return uuid.Nil, fmt.Errorf("ads: add message: %w", err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMessageBodies updates a message's bilingual bodies (its position is
|
||||||
|
// unchanged). Returns ErrNotFound when no message matches.
|
||||||
|
func (s *Store) UpdateMessageBodies(ctx context.Context, id uuid.UUID, bodyEn, bodyRu string) error {
|
||||||
|
stmt := table.AdMessages.UPDATE(
|
||||||
|
table.AdMessages.BodyEn, table.AdMessages.BodyRu, table.AdMessages.UpdatedAt,
|
||||||
|
).SET(
|
||||||
|
postgres.String(bodyEn), postgres.String(bodyRu), postgres.TimestampzT(time.Now().UTC()),
|
||||||
|
).WHERE(table.AdMessages.MessageID.EQ(postgres.UUID(id)))
|
||||||
|
return execOne(ctx, s.db, stmt, "update message")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMessagePosition updates only a message's position (used to reorder).
|
||||||
|
func (s *Store) SetMessagePosition(ctx context.Context, id uuid.UUID, position int) error {
|
||||||
|
stmt := table.AdMessages.UPDATE(table.AdMessages.Position, table.AdMessages.UpdatedAt).
|
||||||
|
SET(postgres.Int(int64(position)), postgres.TimestampzT(time.Now().UTC())).
|
||||||
|
WHERE(table.AdMessages.MessageID.EQ(postgres.UUID(id)))
|
||||||
|
return execOne(ctx, s.db, stmt, "reorder message")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMessage removes a message. Returns ErrNotFound when no message matches.
|
||||||
|
func (s *Store) DeleteMessage(ctx context.Context, id uuid.UUID) error {
|
||||||
|
stmt := table.AdMessages.DELETE().WHERE(table.AdMessages.MessageID.EQ(postgres.UUID(id)))
|
||||||
|
return execOne(ctx, s.db, stmt, "delete message")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings returns the global display timings.
|
||||||
|
func (s *Store) Settings(ctx context.Context) (Timings, error) {
|
||||||
|
sel := postgres.SELECT(table.AdSettings.AllColumns).FROM(table.AdSettings).LIMIT(1)
|
||||||
|
var row model.AdSettings
|
||||||
|
if err := sel.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
return Timings{}, fmt.Errorf("ads: get settings: %w", err)
|
||||||
|
}
|
||||||
|
return Timings{
|
||||||
|
HoldMs: int(row.HoldMs),
|
||||||
|
EdgePauseMs: int(row.EdgePauseMs),
|
||||||
|
ScrollPxPerSec: int(row.ScrollPxPerSec),
|
||||||
|
FadeOutMs: int(row.FadeOutMs),
|
||||||
|
GapMs: int(row.GapMs),
|
||||||
|
FadeInMs: int(row.FadeInMs),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettings overwrites the single global display-settings row.
|
||||||
|
func (s *Store) UpdateSettings(ctx context.Context, t Timings) error {
|
||||||
|
stmt := table.AdSettings.UPDATE(
|
||||||
|
table.AdSettings.HoldMs, table.AdSettings.EdgePauseMs, table.AdSettings.ScrollPxPerSec,
|
||||||
|
table.AdSettings.FadeOutMs, table.AdSettings.GapMs, table.AdSettings.FadeInMs, table.AdSettings.UpdatedAt,
|
||||||
|
).SET(
|
||||||
|
postgres.Int(int64(t.HoldMs)), postgres.Int(int64(t.EdgePauseMs)), postgres.Int(int64(t.ScrollPxPerSec)),
|
||||||
|
postgres.Int(int64(t.FadeOutMs)), postgres.Int(int64(t.GapMs)), postgres.Int(int64(t.FadeInMs)),
|
||||||
|
postgres.TimestampzT(time.Now().UTC()),
|
||||||
|
).WHERE(table.AdSettings.ID.EQ(postgres.Bool(true)))
|
||||||
|
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||||
|
return fmt.Errorf("ads: update settings: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// messagesByCampaign loads every message grouped by campaign id, in display order.
|
||||||
|
func (s *Store) messagesByCampaign(ctx context.Context) (map[uuid.UUID][]Message, error) {
|
||||||
|
sel := postgres.SELECT(table.AdMessages.AllColumns).
|
||||||
|
FROM(table.AdMessages).
|
||||||
|
ORDER_BY(table.AdMessages.CampaignID.ASC(), table.AdMessages.Position.ASC(), table.AdMessages.CreatedAt.ASC())
|
||||||
|
var rows []model.AdMessages
|
||||||
|
if err := sel.QueryContext(ctx, s.db, &rows); err != nil {
|
||||||
|
return nil, fmt.Errorf("ads: list messages: %w", err)
|
||||||
|
}
|
||||||
|
out := make(map[uuid.UUID][]Message, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out[r.CampaignID] = append(out[r.CampaignID], modelToMessage(r))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// messagesFor loads one campaign's messages in display order.
|
||||||
|
func (s *Store) messagesFor(ctx context.Context, campaignID uuid.UUID) ([]Message, error) {
|
||||||
|
sel := postgres.SELECT(table.AdMessages.AllColumns).
|
||||||
|
FROM(table.AdMessages).
|
||||||
|
WHERE(table.AdMessages.CampaignID.EQ(postgres.UUID(campaignID))).
|
||||||
|
ORDER_BY(table.AdMessages.Position.ASC(), table.AdMessages.CreatedAt.ASC())
|
||||||
|
var rows []model.AdMessages
|
||||||
|
if err := sel.QueryContext(ctx, s.db, &rows); err != nil {
|
||||||
|
return nil, fmt.Errorf("ads: list messages for %s: %w", campaignID, err)
|
||||||
|
}
|
||||||
|
out := make([]Message, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, modelToMessage(r))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// execOne runs a statement expected to touch exactly one row, mapping a zero
|
||||||
|
// row-count to ErrNotFound.
|
||||||
|
func execOne(ctx context.Context, db qrm.Executable, stmt postgres.Statement, what string) error {
|
||||||
|
res, err := stmt.ExecContext(ctx, db)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ads: %s: %w", what, err)
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ads: %s rows: %w", what, err)
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelToMessage(r model.AdMessages) Message {
|
||||||
|
return Message{
|
||||||
|
ID: r.MessageID,
|
||||||
|
CampaignID: r.CampaignID,
|
||||||
|
Position: int(r.Position),
|
||||||
|
BodyEn: r.BodyEn,
|
||||||
|
BodyRu: r.BodyRu,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tsOrNull renders a nullable validity-window bound: NULL for a nil pointer,
|
||||||
|
// otherwise the UTC timestamp.
|
||||||
|
func tsOrNull(t *time.Time) postgres.Expression {
|
||||||
|
if t == nil {
|
||||||
|
return postgres.NULL
|
||||||
|
}
|
||||||
|
return postgres.TimestampzT(t.UTC())
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
"scrabble/backend/internal/ads"
|
||||||
|
"scrabble/backend/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bannerServer assembles a console-capable server with the ads domain wired.
|
||||||
|
func bannerServer(t *testing.T) (*server.Server, *ads.Service) {
|
||||||
|
t.Helper()
|
||||||
|
adsSvc := ads.NewService(ads.NewStore(testDB))
|
||||||
|
srv := server.New(":0", server.Deps{
|
||||||
|
Logger: zap.NewNop(),
|
||||||
|
Accounts: account.NewStore(testDB),
|
||||||
|
Games: newGameService(),
|
||||||
|
Registry: testRegistry,
|
||||||
|
DictDir: dictDir(),
|
||||||
|
Ads: adsSvc,
|
||||||
|
})
|
||||||
|
return srv, adsSvc
|
||||||
|
}
|
||||||
|
|
||||||
|
// findCampaign returns the id of the campaign with the given name, or fails.
|
||||||
|
func findCampaign(t *testing.T, svc *ads.Service, name string) uuid.UUID {
|
||||||
|
t.Helper()
|
||||||
|
all, err := svc.ListCampaigns(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list campaigns: %v", err)
|
||||||
|
}
|
||||||
|
for _, c := range all {
|
||||||
|
if c.Name == name {
|
||||||
|
return c.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("campaign %q not found", name)
|
||||||
|
return uuid.Nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultCampaign returns the seeded perpetual default campaign.
|
||||||
|
func defaultCampaign(t *testing.T, svc *ads.Service) ads.Campaign {
|
||||||
|
t.Helper()
|
||||||
|
all, err := svc.ListCampaigns(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list campaigns: %v", err)
|
||||||
|
}
|
||||||
|
for _, c := range all {
|
||||||
|
if c.IsDefault {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("no default campaign seeded")
|
||||||
|
return ads.Campaign{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBannerConsoleCRUD drives the /_gm/banners console over HTTP against real
|
||||||
|
// stores: pages render, the CSRF guard bites, validation and the default-campaign
|
||||||
|
// protection hold, and a campaign + message round-trips through create/edit/
|
||||||
|
// reorder/delete.
|
||||||
|
func TestBannerConsoleCRUD(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
srv, adsSvc := bannerServer(t)
|
||||||
|
h := srv.Handler()
|
||||||
|
base := "http://admin.test/_gm"
|
||||||
|
const origin = "http://admin.test"
|
||||||
|
|
||||||
|
// The list renders and shows the seeded default campaign.
|
||||||
|
if code, body := consoleDo(h, http.MethodGet, base+"/banners", "", ""); code != http.StatusOK || !strings.Contains(body, "Default (house)") {
|
||||||
|
t.Fatalf("banners list = %d, has default=%v", code, strings.Contains(body, "Default (house)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
name := "Promo-" + uuid.NewString()[:8]
|
||||||
|
// CSRF: a create without a same-origin header is rejected.
|
||||||
|
if code, _ := consoleDo(h, http.MethodPost, base+"/banners", "name="+name+"&weight=30&enabled=on", ""); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("create without origin = %d, want 403", code)
|
||||||
|
}
|
||||||
|
// Validation: weight out of range is refused.
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, base+"/banners", "name=Bad&weight=0&enabled=on", origin); code != http.StatusOK || !strings.Contains(body, "Not saved") {
|
||||||
|
t.Fatalf("create weight=0 = %d, has 'Not saved'=%v", code, strings.Contains(body, "Not saved"))
|
||||||
|
}
|
||||||
|
// A valid create persists.
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, base+"/banners", "name="+name+"&weight=30&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)
|
||||||
|
|
||||||
|
// The detail page renders.
|
||||||
|
if code, body := consoleDo(h, http.MethodGet, base+"/banners/"+id.String(), "", ""); code != http.StatusOK || !strings.Contains(body, name) {
|
||||||
|
t.Fatalf("detail = %d, has name=%v", code, strings.Contains(body, name))
|
||||||
|
}
|
||||||
|
// A message needs both languages.
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages", "body_en=Hello&body_ru=", origin); code != http.StatusOK || !strings.Contains(body, "Not saved") {
|
||||||
|
t.Fatalf("add half message = %d, has 'Not saved'=%v", code, strings.Contains(body, "Not saved"))
|
||||||
|
}
|
||||||
|
// A bilingual message is added.
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages", "body_en=Hello&body_ru=Привет", origin); code != http.StatusOK || !strings.Contains(body, "Added") {
|
||||||
|
t.Fatalf("add message = %d, has Added=%v", code, strings.Contains(body, "Added"))
|
||||||
|
}
|
||||||
|
cm, err := adsSvc.Campaign(ctx, id)
|
||||||
|
if err != nil || len(cm.Messages) != 1 {
|
||||||
|
t.Fatalf("campaign messages = %d (err %v), want 1", len(cm.Messages), err)
|
||||||
|
}
|
||||||
|
mid := cm.Messages[0].ID
|
||||||
|
// Edit it.
|
||||||
|
if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages/"+mid.String(), "body_en=Hi&body_ru=Привет!", origin); code != http.StatusOK {
|
||||||
|
t.Fatalf("edit message = %d", code)
|
||||||
|
}
|
||||||
|
if cm, _ := adsSvc.Campaign(ctx, id); cm.Messages[0].BodyEn != "Hi" {
|
||||||
|
t.Fatalf("edit did not persist: %q", cm.Messages[0].BodyEn)
|
||||||
|
}
|
||||||
|
// Reorder is a no-op for a single message but still succeeds.
|
||||||
|
if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages/"+mid.String()+"/move", "dir=down", origin); code != http.StatusOK {
|
||||||
|
t.Fatalf("move message = %d", code)
|
||||||
|
}
|
||||||
|
// Delete the message, then the campaign.
|
||||||
|
if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages/"+mid.String()+"/delete", "", origin); code != http.StatusOK {
|
||||||
|
t.Fatalf("delete message = %d", code)
|
||||||
|
}
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Deleted") {
|
||||||
|
t.Fatalf("delete campaign = %d, has Deleted=%v", code, strings.Contains(body, "Deleted"))
|
||||||
|
}
|
||||||
|
if _, err := adsSvc.Campaign(ctx, id); err == nil {
|
||||||
|
t.Fatal("campaign still present after delete")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The default campaign cannot be deleted.
|
||||||
|
def := defaultCampaign(t, adsSvc)
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+def.ID.String()+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Not saved") {
|
||||||
|
t.Fatalf("delete default = %d, has 'Not saved'=%v", code, strings.Contains(body, "Not saved"))
|
||||||
|
}
|
||||||
|
if _, err := adsSvc.Campaign(ctx, def.ID); err != nil {
|
||||||
|
t.Fatalf("default campaign gone after refused delete: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBannerMessageOwnership guards the campaign/message pairing: a message may
|
||||||
|
// only be edited or deleted through the URL of the campaign it belongs to. This
|
||||||
|
// closes a default-protection bypass — deleting the default's last message via an
|
||||||
|
// unrelated campaign's URL must be refused.
|
||||||
|
func TestBannerMessageOwnership(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
srv, adsSvc := bannerServer(t)
|
||||||
|
h := srv.Handler()
|
||||||
|
base := "http://admin.test/_gm"
|
||||||
|
const origin = "http://admin.test"
|
||||||
|
|
||||||
|
def := defaultCampaign(t, adsSvc)
|
||||||
|
if len(def.Messages) == 0 {
|
||||||
|
t.Fatal("default campaign has no seeded message")
|
||||||
|
}
|
||||||
|
defMsg := def.Messages[0].ID
|
||||||
|
|
||||||
|
// A separate timed campaign whose URL we will (mis)use.
|
||||||
|
otherID, err := adsSvc.CreateCampaign(ctx, ads.Campaign{Name: "Own-" + uuid.NewString()[:8], Weight: 10, Enabled: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create campaign: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, otherID) })
|
||||||
|
|
||||||
|
// Deleting the default's message through the other campaign's URL is refused,
|
||||||
|
// and the default keeps its message (the bypass is closed).
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+otherID.String()+"/messages/"+defMsg.String()+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Not found") {
|
||||||
|
t.Fatalf("cross-campaign delete = %d, has 'Not found'=%v", code, strings.Contains(body, "Not found"))
|
||||||
|
}
|
||||||
|
// Editing it through the wrong campaign is refused too.
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+otherID.String()+"/messages/"+defMsg.String(), "body_en=Hijacked&body_ru=Взлом", origin); code != http.StatusOK || !strings.Contains(body, "Not found") {
|
||||||
|
t.Fatalf("cross-campaign edit = %d, has 'Not found'=%v", code, strings.Contains(body, "Not found"))
|
||||||
|
}
|
||||||
|
// The default's message is intact.
|
||||||
|
again := defaultCampaign(t, adsSvc)
|
||||||
|
if len(again.Messages) != len(def.Messages) {
|
||||||
|
t.Fatalf("default messages = %d, want %d (unchanged)", len(again.Messages), len(def.Messages))
|
||||||
|
}
|
||||||
|
if again.Messages[0].BodyEn != def.Messages[0].BodyEn {
|
||||||
|
t.Fatalf("default message body changed: %q -> %q", def.Messages[0].BodyEn, again.Messages[0].BodyEn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// profileBanner is the banner block of the profile.get JSON response.
|
||||||
|
type profileBanner struct {
|
||||||
|
Banner *struct {
|
||||||
|
Campaigns []struct {
|
||||||
|
Weight int `json:"weight"`
|
||||||
|
Messages []string `json:"messages"`
|
||||||
|
} `json:"campaigns"`
|
||||||
|
Timings struct {
|
||||||
|
HoldMs int `json:"hold_ms"`
|
||||||
|
} `json:"timings"`
|
||||||
|
} `json:"banner"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBannerProfileEligibility checks the profile.get banner block follows
|
||||||
|
// eligibility: a free account sees it, the no_banner role and a non-empty hint
|
||||||
|
// wallet each suppress it.
|
||||||
|
func TestBannerProfileEligibility(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
srv, _ := bannerServer(t)
|
||||||
|
accounts := account.NewStore(testDB)
|
||||||
|
id := provisionAccount(t)
|
||||||
|
|
||||||
|
getBanner := func() profileBanner {
|
||||||
|
t.Helper()
|
||||||
|
rec := userGet(t, srv, "/api/v1/user/profile", id)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("profile = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
var p profileBanner
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
|
||||||
|
t.Fatalf("decode profile: %v", err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// A free account with an empty hint wallet and no role is eligible.
|
||||||
|
p := getBanner()
|
||||||
|
if p.Banner == nil || len(p.Banner.Campaigns) == 0 || p.Banner.Timings.HoldMs <= 0 {
|
||||||
|
t.Fatalf("eligible account: banner=%v", p.Banner)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The no_banner role suppresses it unconditionally.
|
||||||
|
if err := accounts.GrantRole(ctx, id, account.RoleNoBanner); err != nil {
|
||||||
|
t.Fatalf("grant role: %v", err)
|
||||||
|
}
|
||||||
|
if p := getBanner(); p.Banner != nil {
|
||||||
|
t.Fatal("no_banner role still shows the banner")
|
||||||
|
}
|
||||||
|
if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil {
|
||||||
|
t.Fatalf("revoke role: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-empty hint wallet suppresses it.
|
||||||
|
if _, err := accounts.GrantHints(ctx, id, 5); err != nil {
|
||||||
|
t.Fatalf("grant hints: %v", err)
|
||||||
|
}
|
||||||
|
if p := getBanner(); p.Banner != nil {
|
||||||
|
t.Fatal("a non-empty hint wallet still shows the banner")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -205,6 +205,13 @@ func notificationInvitation(userID uuid.UUID, sub string, inv InvitationSummary)
|
|||||||
return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()}
|
return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BannerChanged tells userID that its advertising-banner eligibility may have
|
||||||
|
// changed, so the client re-fetches profile.get to show or hide the banner. It
|
||||||
|
// carries no payload (the banner set rides the profile response).
|
||||||
|
func BannerChanged(userID uuid.UUID) Intent {
|
||||||
|
return Notification(userID, NotifyBanner)
|
||||||
|
}
|
||||||
|
|
||||||
// eventID returns a best-effort correlation id for one emitted event.
|
// eventID returns a best-effort correlation id for one emitted event.
|
||||||
func eventID() string {
|
func eventID() string {
|
||||||
if id, err := uuid.NewV7(); err == nil {
|
if id, err := uuid.NewV7(); err == nil {
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ const (
|
|||||||
// the client raises the Settings/Info badge and re-fetches the reply. It carries
|
// the client raises the Settings/Info badge and re-fetches the reply. It carries
|
||||||
// no payload (the reply is fetched on the feedback screen). In-app only.
|
// no payload (the reply is fetched on the feedback screen). In-app only.
|
||||||
NotifyAdminReply = "admin_reply"
|
NotifyAdminReply = "admin_reply"
|
||||||
|
// NotifyBanner tells the client that the viewer's advertising-banner eligibility
|
||||||
|
// may have changed (an operator granted hints or toggled the no_banner role), so
|
||||||
|
// it re-fetches profile.get to show or hide the banner. It carries no payload (the
|
||||||
|
// banner set rides the profile response). In-app only.
|
||||||
|
NotifyBanner = "banner"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Intent is one live event destined for a single user. Payload is the
|
// Intent is one live event destined for a single user. Payload is the
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdMessages struct {
|
||||||
|
MessageID uuid.UUID `sql:"primary_key"`
|
||||||
|
CampaignID uuid.UUID
|
||||||
|
Position int32
|
||||||
|
BodyEn string
|
||||||
|
BodyRu string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdSettings struct {
|
||||||
|
ID bool `sql:"primary_key"`
|
||||||
|
HoldMs int32
|
||||||
|
EdgePauseMs int32
|
||||||
|
ScrollPxPerSec int32
|
||||||
|
FadeOutMs int32
|
||||||
|
GapMs int32
|
||||||
|
FadeInMs int32
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var AdCampaigns = newAdCampaignsTable("backend", "ad_campaigns", "")
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
AllColumns postgres.ColumnList
|
||||||
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdCampaignsTable struct {
|
||||||
|
adCampaignsTable
|
||||||
|
|
||||||
|
EXCLUDED adCampaignsTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS creates new AdCampaignsTable with assigned alias
|
||||||
|
func (a AdCampaignsTable) AS(alias string) *AdCampaignsTable {
|
||||||
|
return newAdCampaignsTable(a.SchemaName(), a.TableName(), alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema creates new AdCampaignsTable with assigned schema name
|
||||||
|
func (a AdCampaignsTable) FromSchema(schemaName string) *AdCampaignsTable {
|
||||||
|
return newAdCampaignsTable(schemaName, a.TableName(), a.Alias())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix creates new AdCampaignsTable with assigned table prefix
|
||||||
|
func (a AdCampaignsTable) WithPrefix(prefix string) *AdCampaignsTable {
|
||||||
|
return newAdCampaignsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuffix creates new AdCampaignsTable with assigned table suffix
|
||||||
|
func (a AdCampaignsTable) WithSuffix(suffix string) *AdCampaignsTable {
|
||||||
|
return newAdCampaignsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAdCampaignsTable(schemaName, tableName, alias string) *AdCampaignsTable {
|
||||||
|
return &AdCampaignsTable{
|
||||||
|
adCampaignsTable: newAdCampaignsTableImpl(schemaName, tableName, alias),
|
||||||
|
EXCLUDED: newAdCampaignsTableImpl("", "excluded", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
|
||||||
|
AllColumns: allColumns,
|
||||||
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var AdMessages = newAdMessagesTable("backend", "ad_messages", "")
|
||||||
|
|
||||||
|
type adMessagesTable struct {
|
||||||
|
postgres.Table
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
MessageID postgres.ColumnString
|
||||||
|
CampaignID postgres.ColumnString
|
||||||
|
Position postgres.ColumnInteger
|
||||||
|
BodyEn postgres.ColumnString
|
||||||
|
BodyRu postgres.ColumnString
|
||||||
|
CreatedAt postgres.ColumnTimestampz
|
||||||
|
UpdatedAt postgres.ColumnTimestampz
|
||||||
|
|
||||||
|
AllColumns postgres.ColumnList
|
||||||
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdMessagesTable struct {
|
||||||
|
adMessagesTable
|
||||||
|
|
||||||
|
EXCLUDED adMessagesTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS creates new AdMessagesTable with assigned alias
|
||||||
|
func (a AdMessagesTable) AS(alias string) *AdMessagesTable {
|
||||||
|
return newAdMessagesTable(a.SchemaName(), a.TableName(), alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema creates new AdMessagesTable with assigned schema name
|
||||||
|
func (a AdMessagesTable) FromSchema(schemaName string) *AdMessagesTable {
|
||||||
|
return newAdMessagesTable(schemaName, a.TableName(), a.Alias())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix creates new AdMessagesTable with assigned table prefix
|
||||||
|
func (a AdMessagesTable) WithPrefix(prefix string) *AdMessagesTable {
|
||||||
|
return newAdMessagesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuffix creates new AdMessagesTable with assigned table suffix
|
||||||
|
func (a AdMessagesTable) WithSuffix(suffix string) *AdMessagesTable {
|
||||||
|
return newAdMessagesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAdMessagesTable(schemaName, tableName, alias string) *AdMessagesTable {
|
||||||
|
return &AdMessagesTable{
|
||||||
|
adMessagesTable: newAdMessagesTableImpl(schemaName, tableName, alias),
|
||||||
|
EXCLUDED: newAdMessagesTableImpl("", "excluded", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAdMessagesTableImpl(schemaName, tableName, alias string) adMessagesTable {
|
||||||
|
var (
|
||||||
|
MessageIDColumn = postgres.StringColumn("message_id")
|
||||||
|
CampaignIDColumn = postgres.StringColumn("campaign_id")
|
||||||
|
PositionColumn = postgres.IntegerColumn("position")
|
||||||
|
BodyEnColumn = postgres.StringColumn("body_en")
|
||||||
|
BodyRuColumn = postgres.StringColumn("body_ru")
|
||||||
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
|
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||||
|
allColumns = postgres.ColumnList{MessageIDColumn, CampaignIDColumn, PositionColumn, BodyEnColumn, BodyRuColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{CampaignIDColumn, PositionColumn, BodyEnColumn, BodyRuColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{PositionColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||||
|
)
|
||||||
|
|
||||||
|
return adMessagesTable{
|
||||||
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
|
//Columns
|
||||||
|
MessageID: MessageIDColumn,
|
||||||
|
CampaignID: CampaignIDColumn,
|
||||||
|
Position: PositionColumn,
|
||||||
|
BodyEn: BodyEnColumn,
|
||||||
|
BodyRu: BodyRuColumn,
|
||||||
|
CreatedAt: CreatedAtColumn,
|
||||||
|
UpdatedAt: UpdatedAtColumn,
|
||||||
|
|
||||||
|
AllColumns: allColumns,
|
||||||
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var AdSettings = newAdSettingsTable("backend", "ad_settings", "")
|
||||||
|
|
||||||
|
type adSettingsTable struct {
|
||||||
|
postgres.Table
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
ID postgres.ColumnBool
|
||||||
|
HoldMs postgres.ColumnInteger
|
||||||
|
EdgePauseMs postgres.ColumnInteger
|
||||||
|
ScrollPxPerSec postgres.ColumnInteger
|
||||||
|
FadeOutMs postgres.ColumnInteger
|
||||||
|
GapMs postgres.ColumnInteger
|
||||||
|
FadeInMs postgres.ColumnInteger
|
||||||
|
UpdatedAt postgres.ColumnTimestampz
|
||||||
|
|
||||||
|
AllColumns postgres.ColumnList
|
||||||
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdSettingsTable struct {
|
||||||
|
adSettingsTable
|
||||||
|
|
||||||
|
EXCLUDED adSettingsTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS creates new AdSettingsTable with assigned alias
|
||||||
|
func (a AdSettingsTable) AS(alias string) *AdSettingsTable {
|
||||||
|
return newAdSettingsTable(a.SchemaName(), a.TableName(), alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema creates new AdSettingsTable with assigned schema name
|
||||||
|
func (a AdSettingsTable) FromSchema(schemaName string) *AdSettingsTable {
|
||||||
|
return newAdSettingsTable(schemaName, a.TableName(), a.Alias())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix creates new AdSettingsTable with assigned table prefix
|
||||||
|
func (a AdSettingsTable) WithPrefix(prefix string) *AdSettingsTable {
|
||||||
|
return newAdSettingsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuffix creates new AdSettingsTable with assigned table suffix
|
||||||
|
func (a AdSettingsTable) WithSuffix(suffix string) *AdSettingsTable {
|
||||||
|
return newAdSettingsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAdSettingsTable(schemaName, tableName, alias string) *AdSettingsTable {
|
||||||
|
return &AdSettingsTable{
|
||||||
|
adSettingsTable: newAdSettingsTableImpl(schemaName, tableName, alias),
|
||||||
|
EXCLUDED: newAdSettingsTableImpl("", "excluded", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAdSettingsTableImpl(schemaName, tableName, alias string) adSettingsTable {
|
||||||
|
var (
|
||||||
|
IDColumn = postgres.BoolColumn("id")
|
||||||
|
HoldMsColumn = postgres.IntegerColumn("hold_ms")
|
||||||
|
EdgePauseMsColumn = postgres.IntegerColumn("edge_pause_ms")
|
||||||
|
ScrollPxPerSecColumn = postgres.IntegerColumn("scroll_px_per_sec")
|
||||||
|
FadeOutMsColumn = postgres.IntegerColumn("fade_out_ms")
|
||||||
|
GapMsColumn = postgres.IntegerColumn("gap_ms")
|
||||||
|
FadeInMsColumn = postgres.IntegerColumn("fade_in_ms")
|
||||||
|
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||||
|
allColumns = postgres.ColumnList{IDColumn, HoldMsColumn, EdgePauseMsColumn, ScrollPxPerSecColumn, FadeOutMsColumn, GapMsColumn, FadeInMsColumn, UpdatedAtColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{HoldMsColumn, EdgePauseMsColumn, ScrollPxPerSecColumn, FadeOutMsColumn, GapMsColumn, FadeInMsColumn, UpdatedAtColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{IDColumn, UpdatedAtColumn}
|
||||||
|
)
|
||||||
|
|
||||||
|
return adSettingsTable{
|
||||||
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
|
//Columns
|
||||||
|
ID: IDColumn,
|
||||||
|
HoldMs: HoldMsColumn,
|
||||||
|
EdgePauseMs: EdgePauseMsColumn,
|
||||||
|
ScrollPxPerSec: ScrollPxPerSecColumn,
|
||||||
|
FadeOutMs: FadeOutMsColumn,
|
||||||
|
GapMs: GapMsColumn,
|
||||||
|
FadeInMs: FadeInMsColumn,
|
||||||
|
UpdatedAt: UpdatedAtColumn,
|
||||||
|
|
||||||
|
AllColumns: allColumns,
|
||||||
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ func UseSchema(schema string) {
|
|||||||
AccountStats = AccountStats.FromSchema(schema)
|
AccountStats = AccountStats.FromSchema(schema)
|
||||||
AccountSuspensions = AccountSuspensions.FromSchema(schema)
|
AccountSuspensions = AccountSuspensions.FromSchema(schema)
|
||||||
Accounts = Accounts.FromSchema(schema)
|
Accounts = Accounts.FromSchema(schema)
|
||||||
|
AdCampaigns = AdCampaigns.FromSchema(schema)
|
||||||
|
AdMessages = AdMessages.FromSchema(schema)
|
||||||
|
AdSettings = AdSettings.FromSchema(schema)
|
||||||
Blocks = Blocks.FromSchema(schema)
|
Blocks = Blocks.FromSchema(schema)
|
||||||
ChatMessages = ChatMessages.FromSchema(schema)
|
ChatMessages = ChatMessages.FromSchema(schema)
|
||||||
Complaints = Complaints.FromSchema(schema)
|
Complaints = Complaints.FromSchema(schema)
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Server-driven advertising banner ("advertising network"): operator-managed campaigns rotated in
|
||||||
|
-- the client's one-line announcement strip. A campaign is one placement order with a show weight
|
||||||
|
-- (percent); simultaneously active campaigns compete for shows in proportion to their weights. The
|
||||||
|
-- single perpetual default campaign fills the unsold remainder up to 100%. Each campaign carries
|
||||||
|
-- one or more bilingual messages (en + ru, both mandatory), shown by the viewer's bot (service)
|
||||||
|
-- language. Display timings are global (one settings row). Eligibility (who sees a banner at all)
|
||||||
|
-- reuses account fields + the no_banner role, so it needs no schema here. See docs/ARCHITECTURE.md
|
||||||
|
-- and internal/ads.
|
||||||
|
SET search_path = backend, pg_catalog;
|
||||||
|
|
||||||
|
-- One advertising campaign = one placement order. weight is the show percentage (1..100). is_default
|
||||||
|
-- marks the single perpetual house campaign that fills the remainder up to 100% and is never deleted;
|
||||||
|
-- it has no validity window (its stored weight is nominal — the rotation derives its effective weight
|
||||||
|
-- as max(0, 100 - sum of active timed weights)). A time-limited campaign is active while now() lies in
|
||||||
|
-- [starts_at, ends_at] (a null bound is open-ended on that side). enabled toggles a campaign without
|
||||||
|
-- deleting it.
|
||||||
|
CREATE TABLE ad_campaigns (
|
||||||
|
campaign_id uuid PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
weight integer NOT NULL,
|
||||||
|
is_default boolean NOT NULL DEFAULT false,
|
||||||
|
enabled boolean NOT NULL DEFAULT true,
|
||||||
|
starts_at timestamptz,
|
||||||
|
ends_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT ad_campaigns_weight_chk CHECK (weight BETWEEN 1 AND 100),
|
||||||
|
-- The default campaign is perpetual (no window).
|
||||||
|
CONSTRAINT ad_campaigns_default_window_chk CHECK (NOT is_default OR (starts_at IS NULL AND ends_at IS NULL)),
|
||||||
|
-- A closed window must not be inverted.
|
||||||
|
CONSTRAINT ad_campaigns_window_chk CHECK (starts_at IS NULL OR ends_at IS NULL OR starts_at <= ends_at)
|
||||||
|
);
|
||||||
|
-- At most one default campaign.
|
||||||
|
CREATE UNIQUE INDEX ad_campaigns_default_idx ON ad_campaigns (is_default) WHERE is_default;
|
||||||
|
|
||||||
|
-- One bilingual message of a campaign. Both languages are mandatory: the client shows the variant for
|
||||||
|
-- the viewer's bot (service) language. position orders the messages within a campaign (the round-robin
|
||||||
|
-- order when the campaign wins a display slot). body_* is minimal markdown (text + links).
|
||||||
|
CREATE TABLE ad_messages (
|
||||||
|
message_id uuid PRIMARY KEY,
|
||||||
|
campaign_id uuid NOT NULL REFERENCES ad_campaigns (campaign_id) ON DELETE CASCADE,
|
||||||
|
position integer NOT NULL DEFAULT 0,
|
||||||
|
body_en text NOT NULL,
|
||||||
|
body_ru text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
-- Messages of a campaign are read in display order.
|
||||||
|
CREATE INDEX ad_messages_campaign_idx ON ad_messages (campaign_id, position);
|
||||||
|
|
||||||
|
-- Global banner display timings, a single row (id is always TRUE). The client rotator reads these:
|
||||||
|
-- hold_ms is how long one message shows; edge_pause_ms and scroll_px_per_sec drive the scroll of a
|
||||||
|
-- message wider than the strip; the transition between messages is fade_out_ms -> gap_ms -> fade_in_ms.
|
||||||
|
-- All are clamped in Go on update.
|
||||||
|
CREATE TABLE ad_settings (
|
||||||
|
id boolean PRIMARY KEY DEFAULT true,
|
||||||
|
hold_ms integer NOT NULL,
|
||||||
|
edge_pause_ms integer NOT NULL,
|
||||||
|
scroll_px_per_sec integer NOT NULL,
|
||||||
|
fade_out_ms integer NOT NULL,
|
||||||
|
gap_ms integer NOT NULL,
|
||||||
|
fade_in_ms integer NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT ad_settings_singleton_chk CHECK (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Seed the perpetual default campaign with one bilingual house message, and the default timings
|
||||||
|
-- (the previous hardcoded UI defaults plus the new fade sequence). Fixed UUIDs keep the seed
|
||||||
|
-- deterministic across environments.
|
||||||
|
INSERT INTO ad_campaigns (campaign_id, name, weight, is_default, enabled)
|
||||||
|
VALUES ('00000000-0000-0000-0000-0000000000ad', 'Default (house)', 100, true, true);
|
||||||
|
INSERT INTO ad_messages (message_id, campaign_id, position, body_en, body_ru)
|
||||||
|
VALUES (
|
||||||
|
'00000000-0000-0000-0000-0000000000a1', '00000000-0000-0000-0000-0000000000ad', 0,
|
||||||
|
'Tip: a play using all 7 tiles earns a +50 bonus.',
|
||||||
|
'Совет: ход всеми 7 фишками приносит бонус +50 очков.'
|
||||||
|
);
|
||||||
|
INSERT INTO ad_settings (id, hold_ms, edge_pause_ms, scroll_px_per_sec, fade_out_ms, gap_ms, fade_in_ms)
|
||||||
|
VALUES (true, 60000, 5000, 40, 1000, 250, 1000);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
SET search_path = backend, pg_catalog;
|
||||||
|
DROP TABLE IF EXISTS ad_settings;
|
||||||
|
DROP TABLE IF EXISTS ad_messages;
|
||||||
|
DROP TABLE IF EXISTS ad_campaigns;
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
"scrabble/backend/internal/ads"
|
||||||
|
"scrabble/backend/internal/notify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bannerDTO is the advertising-banner block attached to an eligible viewer's
|
||||||
|
// profile: the campaigns to rotate (each already resolved to the viewer's bot
|
||||||
|
// language) and the global display timings the client rotator reads. It is
|
||||||
|
// absent for a viewer who should see no banner.
|
||||||
|
type bannerDTO struct {
|
||||||
|
Campaigns []bannerCampaignDTO `json:"campaigns"`
|
||||||
|
Timings bannerTimingsDTO `json:"timings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
type bannerCampaignDTO struct {
|
||||||
|
Weight int `json:"weight"`
|
||||||
|
Messages []string `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// bannerTimingsDTO mirrors ads.Timings on the wire.
|
||||||
|
type bannerTimingsDTO struct {
|
||||||
|
HoldMs int `json:"hold_ms"`
|
||||||
|
EdgePauseMs int `json:"edge_pause_ms"`
|
||||||
|
ScrollPxPerSec int `json:"scroll_px_per_sec"`
|
||||||
|
FadeOutMs int `json:"fade_out_ms"`
|
||||||
|
GapMs int `json:"gap_ms"`
|
||||||
|
FadeInMs int `json:"fade_in_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// bannerFor builds the advertising-banner block for the account's profile, or
|
||||||
|
// nil when the ads service is not configured or the viewer is not eligible to
|
||||||
|
// see a banner. The message language follows the account's bot (service)
|
||||||
|
// language, falling back to its interface language and then English. A failure
|
||||||
|
// reading roles or campaigns is logged and treated as "no banner" so the profile
|
||||||
|
// response still succeeds.
|
||||||
|
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))
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
campaigns := make([]bannerCampaignDTO, 0, len(set))
|
||||||
|
for _, c := range set {
|
||||||
|
campaigns = append(campaigns, bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages})
|
||||||
|
}
|
||||||
|
return &bannerDTO{
|
||||||
|
Campaigns: campaigns,
|
||||||
|
Timings: bannerTimingsDTO{
|
||||||
|
HoldMs: timings.HoldMs,
|
||||||
|
EdgePauseMs: timings.EdgePauseMs,
|
||||||
|
ScrollPxPerSec: timings.ScrollPxPerSec,
|
||||||
|
FadeOutMs: timings.FadeOutMs,
|
||||||
|
GapMs: timings.GapMs,
|
||||||
|
FadeInMs: timings.FadeInMs,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bannerLang resolves the message language for a viewer: the bot (service)
|
||||||
|
// language they last signed in through, falling back to the interface language
|
||||||
|
// and then English.
|
||||||
|
func bannerLang(acc account.Account) string {
|
||||||
|
if acc.ServiceLanguage == "ru" || acc.ServiceLanguage == "en" {
|
||||||
|
return acc.ServiceLanguage
|
||||||
|
}
|
||||||
|
if acc.PreferredLanguage == "ru" {
|
||||||
|
return "ru"
|
||||||
|
}
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishBannerChange emits the in-app "banner eligibility may have changed"
|
||||||
|
// re-poll signal to the account, so an open client re-fetches profile.get and
|
||||||
|
// shows or hides the banner without a reload. Best-effort (notify.Nop when no
|
||||||
|
// notifier is wired).
|
||||||
|
func (s *Server) publishBannerChange(id uuid.UUID) {
|
||||||
|
s.notifier.Publish(notify.BannerChanged(id))
|
||||||
|
}
|
||||||
@@ -49,6 +49,10 @@ type profileResponse struct {
|
|||||||
BlockFriendRequests bool `json:"block_friend_requests"`
|
BlockFriendRequests bool `json:"block_friend_requests"`
|
||||||
IsGuest bool `json:"is_guest"`
|
IsGuest bool `json:"is_guest"`
|
||||||
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
||||||
|
// Banner is the advertising-banner block: present only for a viewer eligible to
|
||||||
|
// see the banner (a free account with an empty hint wallet and without the
|
||||||
|
// no_banner role), absent otherwise. See banner.go.
|
||||||
|
Banner *bannerDTO `json:"banner,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// tileDTO is one placed (or to-place) tile.
|
// tileDTO is one placed (or to-place) tile.
|
||||||
|
|||||||
@@ -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(), 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
|
||||||
|
}
|
||||||
@@ -88,6 +88,19 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
gm.POST("/dictionary/changes/apply", s.consoleApplyChanges)
|
gm.POST("/dictionary/changes/apply", s.consoleApplyChanges)
|
||||||
gm.GET("/broadcast", s.consoleBroadcast)
|
gm.GET("/broadcast", s.consoleBroadcast)
|
||||||
gm.POST("/broadcast", s.consolePostBroadcast)
|
gm.POST("/broadcast", s.consolePostBroadcast)
|
||||||
|
if s.ads != nil {
|
||||||
|
gm.GET("/banners", s.consoleBanners)
|
||||||
|
gm.POST("/banners", s.consoleCreateBanner)
|
||||||
|
gm.GET("/banners/:id", s.consoleBannerDetail)
|
||||||
|
gm.POST("/banners/:id", s.consoleUpdateBanner)
|
||||||
|
gm.POST("/banners/:id/delete", s.consoleDeleteBanner)
|
||||||
|
gm.POST("/banners/:id/messages", s.consoleAddBannerMessage)
|
||||||
|
gm.POST("/banners/:id/messages/:mid", s.consoleEditBannerMessage)
|
||||||
|
gm.POST("/banners/:id/messages/:mid/delete", s.consoleDeleteBannerMessage)
|
||||||
|
gm.POST("/banners/:id/messages/:mid/move", s.consoleMoveBannerMessage)
|
||||||
|
gm.GET("/banner-settings", s.consoleBannerSettings)
|
||||||
|
gm.POST("/banner-settings", s.consoleUpdateBannerSettings)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// consoleDashboard renders the landing page: the top-line counts and the resident
|
// consoleDashboard renders the landing page: the top-line counts and the resident
|
||||||
@@ -817,6 +830,8 @@ func (s *Server) consoleGrantHints(c *gin.Context) {
|
|||||||
s.consoleError(c, err)
|
s.consoleError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// A non-empty hint wallet removes the banner: nudge an open client to re-check.
|
||||||
|
s.publishBannerChange(id)
|
||||||
s.renderConsoleMessage(c, "Granted", fmt.Sprintf("added %d hint(s); the wallet is now %d", n, balance), back)
|
s.renderConsoleMessage(c, "Granted", fmt.Sprintf("added %d hint(s); the wallet is now %d", n, balance), back)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -245,6 +245,9 @@ func (s *Server) consoleGrantRole(c *gin.Context) {
|
|||||||
s.consoleError(c, err)
|
s.consoleError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if role == account.RoleNoBanner {
|
||||||
|
s.publishBannerChange(id)
|
||||||
|
}
|
||||||
s.renderConsoleMessage(c, "Role granted", "granted "+role, back)
|
s.renderConsoleMessage(c, "Role granted", "granted "+role, back)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +267,9 @@ func (s *Server) consoleRevokeRole(c *gin.Context) {
|
|||||||
s.consoleError(c, err)
|
s.consoleError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if role == account.RoleNoBanner {
|
||||||
|
s.publishBannerChange(id)
|
||||||
|
}
|
||||||
s.renderConsoleMessage(c, "Role revoked", "revoked "+role, back)
|
s.renderConsoleMessage(c, "Role revoked", "revoked "+role, back)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ func (s *Server) handleProfile(c *gin.Context) {
|
|||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, profileResponseFor(acc))
|
resp := profileResponseFor(acc)
|
||||||
|
resp.Banner = s.bannerFor(c.Request.Context(), acc)
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// submitPlayRequest places tiles on the player's turn; the engine infers the
|
// submitPlayRequest places tiles on the player's turn; the engine infers the
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ import (
|
|||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/adminconsole"
|
"scrabble/backend/internal/adminconsole"
|
||||||
|
"scrabble/backend/internal/ads"
|
||||||
"scrabble/backend/internal/connector"
|
"scrabble/backend/internal/connector"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/feedback"
|
"scrabble/backend/internal/feedback"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
"scrabble/backend/internal/link"
|
"scrabble/backend/internal/link"
|
||||||
"scrabble/backend/internal/lobby"
|
"scrabble/backend/internal/lobby"
|
||||||
|
"scrabble/backend/internal/notify"
|
||||||
"scrabble/backend/internal/ratewatch"
|
"scrabble/backend/internal/ratewatch"
|
||||||
"scrabble/backend/internal/session"
|
"scrabble/backend/internal/session"
|
||||||
"scrabble/backend/internal/social"
|
"scrabble/backend/internal/social"
|
||||||
@@ -81,6 +83,14 @@ type Deps struct {
|
|||||||
// admin console's throttled view + the high-rate auto-flag. A nil RateWatch
|
// admin console's throttled view + the high-rate auto-flag. A nil RateWatch
|
||||||
// disables the internal report endpoint and the console view.
|
// disables the internal report endpoint and the console view.
|
||||||
RateWatch *ratewatch.Watch
|
RateWatch *ratewatch.Watch
|
||||||
|
// Ads is the advertising-banner domain service: campaign rotation feeding the
|
||||||
|
// profile.get banner block, plus the banner admin console section. A nil Ads
|
||||||
|
// omits the banner block and disables the banner console.
|
||||||
|
Ads *ads.Service
|
||||||
|
// Notifier publishes live-event intents — here the banner-eligibility re-poll
|
||||||
|
// signal the banner/hint/role console actions emit. A nil Notifier discards
|
||||||
|
// them (notify.Nop).
|
||||||
|
Notifier notify.Publisher
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server owns the gin engine, the underlying HTTP server and the readiness
|
// Server owns the gin engine, the underlying HTTP server and the readiness
|
||||||
@@ -105,6 +115,8 @@ type Server struct {
|
|||||||
dictDir string
|
dictDir string
|
||||||
connector *connector.Client
|
connector *connector.Client
|
||||||
ratewatch *ratewatch.Watch
|
ratewatch *ratewatch.Watch
|
||||||
|
ads *ads.Service
|
||||||
|
notifier notify.Publisher
|
||||||
console *adminconsole.Renderer
|
console *adminconsole.Renderer
|
||||||
|
|
||||||
public *gin.RouterGroup
|
public *gin.RouterGroup
|
||||||
@@ -129,6 +141,11 @@ func New(addr string, deps Deps) *Server {
|
|||||||
engine.Use(gin.Recovery())
|
engine.Use(gin.Recovery())
|
||||||
engine.Use(telemetry.Middleware(log))
|
engine.Use(telemetry.Middleware(log))
|
||||||
|
|
||||||
|
notifier := deps.Notifier
|
||||||
|
if notifier == nil {
|
||||||
|
notifier = notify.Nop{}
|
||||||
|
}
|
||||||
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
log: log,
|
log: log,
|
||||||
db: deps.DB,
|
db: deps.DB,
|
||||||
@@ -147,6 +164,8 @@ func New(addr string, deps Deps) *Server {
|
|||||||
dictDir: deps.DictDir,
|
dictDir: deps.DictDir,
|
||||||
connector: deps.Connector,
|
connector: deps.Connector,
|
||||||
ratewatch: deps.RateWatch,
|
ratewatch: deps.RateWatch,
|
||||||
|
ads: deps.Ads,
|
||||||
|
notifier: notifier,
|
||||||
http: &http.Server{Addr: addr, Handler: engine},
|
http: &http.Server{Addr: addr, Handler: engine},
|
||||||
}
|
}
|
||||||
s.registerProbes(engine)
|
s.registerProbes(engine)
|
||||||
|
|||||||
+30
-5
@@ -40,8 +40,8 @@ Three executables plus per-platform side-services:
|
|||||||
in-memory mock transport (`pnpm start`) runs the whole slice with no backend.
|
in-memory mock transport (`pnpm start`) runs the whole slice with no backend.
|
||||||
Embeddable in platform webviews; packageable to native (iOS/Android) via Capacitor.
|
Embeddable in platform webviews; packageable to native (iOS/Android) via Capacitor.
|
||||||
The client uses a mobile-app shell (a growing nav bar; content pinned to the bottom),
|
The client uses a mobile-app shell (a growing nav bar; content pinned to the bottom),
|
||||||
a one-line **announcement banner** under the nav (a client-side mock rotation, **gated off
|
a one-line **advertising banner** under the nav (server-driven campaigns shown to eligible free
|
||||||
in the build until polished after release** — a server-driven channel later, §10),
|
users, a weighted fair rotation — §10),
|
||||||
and a client **board-style** setting (bonus-label
|
and a client **board-style** setting (bonus-label
|
||||||
mode). The visual/interaction design system is documented in
|
mode). The visual/interaction design system is documented in
|
||||||
[`UI_DESIGN.md`](UI_DESIGN.md).
|
[`UI_DESIGN.md`](UI_DESIGN.md).
|
||||||
@@ -658,9 +658,34 @@ response/withdrawal lobby sync) never becomes a platform push. Operator broadcas
|
|||||||
**operator-chosen** language in the console, unrelated to the recipient's login. Session-revocation events and
|
**operator-chosen** language in the console, unrelated to the recipient's login. Session-revocation events and
|
||||||
cursor-based stream resume stay deferred (single-instance MVP).
|
cursor-based stream resume stay deferred (single-instance MVP).
|
||||||
|
|
||||||
A separate **announcements channel** feeds the client's one-line banner (UI_DESIGN.md).
|
A separate **advertising-banner** channel feeds the client's one-line strip (UI_DESIGN.md),
|
||||||
It is a client-side **mock** rotation today; a server-driven source (operational notices,
|
server-driven by `internal/ads`. An operator manages **campaigns** (each one placement order) in
|
||||||
promotions) is future work and would deliver short markdown messages (text + links).
|
the admin console (`/_gm/banners`): a campaign has a show **weight** (integer percent 1..100), an
|
||||||
|
optional validity **window**, an `enabled` flag and one or more **bilingual messages** (en + ru,
|
||||||
|
both mandatory, minimal markdown). A single perpetual **default** campaign fills the unsold
|
||||||
|
remainder up to 100% and is undeletable. Eligibility — who sees a banner at all — is
|
||||||
|
`!paid_account && hint_balance == 0 && !has(no_banner)` (the `no_banner` account role suppresses
|
||||||
|
it unconditionally); guests qualify. The eligible viewer's banner block rides the **`profile.get`**
|
||||||
|
response (the one bootstrap every client fetches on open, authed or guest — no separate request,
|
||||||
|
nothing distinct for an advanced user to filter): the backend resolves each message to the viewer's
|
||||||
|
**service language** (the bot they signed in through, falling back to the interface language) and
|
||||||
|
computes the active set — window-filtered campaigns, the default's effective weight
|
||||||
|
(`max(0, 100 − Σ active timed weights)`, dropped at 0), GCD-reduced. The **client** rotates that set
|
||||||
|
with a smooth weighted round-robin (deterministic, fair: each campaign gets its weight share per
|
||||||
|
cycle), round-robining a campaign's messages within its slots; the global display **timings** (hold,
|
||||||
|
edge-pause, scroll speed, and the fade-out → gap → fade-in transition) are operator-set
|
||||||
|
(`/_gm/banner-settings`, clamped) and ride the same block. When an operator changes a viewer's
|
||||||
|
eligibility inputs (grants hints, grants/revokes `no_banner`; a future payment flow sets
|
||||||
|
`paid_account`), the backend emits a `notify` **`banner`** sub-kind (a payload-free re-poll signal),
|
||||||
|
and the open client re-fetches `profile.get` to show or hide the banner in place. Operator *content*
|
||||||
|
edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session.
|
||||||
|
|
||||||
|
> A single `app.load` bootstrap aggregator (collapsing `profile.get` + lobby + badge fetches into
|
||||||
|
> one round-trip) was **considered and deferred**: client↔gateway is HTTP/2 (h2c), so the bootstrap
|
||||||
|
> RPCs already multiplex over one reused connection — the saving would be per-request fixed overhead,
|
||||||
|
> not connections, and it is a high-blast-radius cross-cutting refactor. Revisit only with evidence
|
||||||
|
> (`edge_request_duration`); if ever built, as a gateway-side fan-out/merge that keeps the per-domain
|
||||||
|
> backend handlers intact.
|
||||||
|
|
||||||
## 11. Observability
|
## 11. Observability
|
||||||
|
|
||||||
|
|||||||
@@ -264,3 +264,27 @@ in-app), archive it, delete it, or delete every message from that player — and
|
|||||||
stops only feedback submission). Roles are listed and granted/revoked on the user card. Opening a
|
stops only feedback submission). Roles are listed and granted/revoked on the user card. Opening a
|
||||||
message does not mark it read — only the explicit actions do; message bodies and attachments are
|
message does not mark it read — only the explicit actions do; message bodies and attachments are
|
||||||
shown defensively (text escaped, attachments downloaded rather than rendered).
|
shown defensively (text escaped, attachments downloaded rather than rendered).
|
||||||
|
|
||||||
|
### Advertising banner
|
||||||
|
|
||||||
|
A one-line banner under the nav shows short promotional or operational messages to **free
|
||||||
|
players**: anyone who has **not** paid for a lifetime account, holds **no purchased hints**, and
|
||||||
|
does not carry the **`no_banner`** role (which suppresses it unconditionally). Buying a paid account
|
||||||
|
or any hints — or being granted `no_banner` — removes it; guests, the freest users, see it. When an
|
||||||
|
operator changes one of those properties, the banner appears or disappears **in place**, without a
|
||||||
|
reload.
|
||||||
|
|
||||||
|
Messages come from operator-run **campaigns**, each a placement order with a **show weight** (a
|
||||||
|
percent) and an optional start/end **window**. Campaigns running at the same time **compete** for the
|
||||||
|
banner in proportion to their weights; a permanent **house (default)** campaign fills whatever share
|
||||||
|
paid campaigns leave unsold and steps aside entirely when they sell the full 100%. Each message is
|
||||||
|
written in **both languages** (English + Russian); a player sees the variant for the **bot they play
|
||||||
|
through**, regardless of their interface language. The client rotates the eligible campaigns
|
||||||
|
**fairly** — every campaign gets its weighted share each cycle, evenly spread rather than at random —
|
||||||
|
and a campaign with several messages shows them in turn.
|
||||||
|
|
||||||
|
Operators manage all of this in the admin console at **`/_gm/banners`**: create, edit, enable/disable
|
||||||
|
and schedule campaigns, write each campaign's bilingual messages (reorder or remove them), and set
|
||||||
|
the global display **timings** (how long a message holds, the scroll of an over-long message, and the
|
||||||
|
fade-out → gap → fade-in transition between messages). The default campaign cannot be deleted and
|
||||||
|
keeps at least one message.
|
||||||
|
|||||||
@@ -270,3 +270,26 @@ IP и вложением. Оператор может пометить сооб
|
|||||||
пользователя. Открытие сообщения не помечает его прочитанным — это делают только явные действия; тело
|
пользователя. Открытие сообщения не помечает его прочитанным — это делают только явные действия; тело
|
||||||
сообщения и вложения показываются защищённо (текст экранируется, вложения отдаются на скачивание, а не
|
сообщения и вложения показываются защищённо (текст экранируется, вложения отдаются на скачивание, а не
|
||||||
рендерятся).
|
рендерятся).
|
||||||
|
|
||||||
|
### Рекламный баннер
|
||||||
|
|
||||||
|
Однострочный баннер под навбаром показывает короткие рекламные или служебные сообщения **бесплатным
|
||||||
|
игрокам**: всем, кто **не** оплатил пожизненный аккаунт, **не** имеет купленных подсказок и **не**
|
||||||
|
несёт роль **`no_banner`** (она отключает баннер безусловно). Покупка платного аккаунта или подсказок —
|
||||||
|
либо выдача `no_banner` — убирает его; гости как самые «бесплатные» пользователи баннер видят. Когда
|
||||||
|
оператор меняет одно из этих свойств, баннер появляется или исчезает **на месте**, без перезагрузки.
|
||||||
|
|
||||||
|
Сообщения берутся из управляемых оператором **кампаний**, каждая из которых — заказ на размещение со
|
||||||
|
**своим весом показа** (процент) и необязательным **окном** действия (начало/конец). Одновременно
|
||||||
|
идущие кампании **конкурируют** за баннер пропорционально весам; постоянная **дефолтная (домашняя)**
|
||||||
|
кампания добивает остаток, не выкупленный платными, и полностью уходит из ротации, когда те выкупают
|
||||||
|
все 100%. Каждое сообщение написано на **двух языках** (английский + русский); игрок видит вариант для
|
||||||
|
**бота, через которого играет**, независимо от языка интерфейса. Клиент ротирует подходящие кампании
|
||||||
|
**честно** — каждая получает свою долю по весу за цикл, равномерно размазанную, а не случайно — а
|
||||||
|
кампания с несколькими сообщениями показывает их по очереди.
|
||||||
|
|
||||||
|
Всем этим оператор управляет в админ-консоли по адресу **`/_gm/banners`**: создаёт, редактирует,
|
||||||
|
включает/выключает и планирует кампании, пишет двуязычные сообщения кампании (переставляет и удаляет
|
||||||
|
их) и задаёт общие **тайминги** показа (сколько держится сообщение, прокрутка слишком длинного, и
|
||||||
|
переход fade-out → пауза → fade-in между сообщениями). Дефолтную кампанию нельзя удалить, и в ней
|
||||||
|
всегда остаётся хотя бы одно сообщение.
|
||||||
|
|||||||
@@ -34,6 +34,33 @@ type ProfileResp struct {
|
|||||||
BlockFriendRequests bool `json:"block_friend_requests"`
|
BlockFriendRequests bool `json:"block_friend_requests"`
|
||||||
IsGuest bool `json:"is_guest"`
|
IsGuest bool `json:"is_guest"`
|
||||||
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
||||||
|
// Banner is the advertising-banner block, present only for a viewer eligible to
|
||||||
|
// see the banner. The gateway forwards it verbatim into the Profile payload.
|
||||||
|
Banner *BannerResp `json:"banner,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BannerResp is the advertising-banner block of an eligible viewer's profile: the
|
||||||
|
// campaigns to rotate and the global display timings the client rotator reads.
|
||||||
|
type BannerResp struct {
|
||||||
|
Campaigns []BannerCampaignResp `json:"campaigns"`
|
||||||
|
Timings BannerTimingsResp `json:"timings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BannerCampaignResp is one campaign in the rotation feed: a GCD-reduced show
|
||||||
|
// weight and its messages, in display order, already resolved to one language.
|
||||||
|
type BannerCampaignResp struct {
|
||||||
|
Weight int `json:"weight"`
|
||||||
|
Messages []string `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BannerTimingsResp mirrors the backend's global display timings.
|
||||||
|
type BannerTimingsResp struct {
|
||||||
|
HoldMs int `json:"hold_ms"`
|
||||||
|
EdgePauseMs int `json:"edge_pause_ms"`
|
||||||
|
ScrollPxPerSec int `json:"scroll_px_per_sec"`
|
||||||
|
FadeOutMs int `json:"fade_out_ms"`
|
||||||
|
GapMs int `json:"gap_ms"`
|
||||||
|
FadeInMs int `json:"fade_in_ms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LinkResultResp is the result of an account link/merge step. Status is
|
// LinkResultResp is the result of an account link/merge step. Status is
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ func encodeAck(ok bool) []byte {
|
|||||||
return b.FinishedBytes()
|
return b.FinishedBytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
// encodeProfile builds a Profile payload.
|
// encodeProfile builds a Profile payload, including the advertising-banner block
|
||||||
|
// when the backend marked the viewer eligible.
|
||||||
func encodeProfile(p backendclient.ProfileResp) []byte {
|
func encodeProfile(p backendclient.ProfileResp) []byte {
|
||||||
b := flatbuffers.NewBuilder(192)
|
b := flatbuffers.NewBuilder(192)
|
||||||
uid := b.CreateString(p.UserID)
|
uid := b.CreateString(p.UserID)
|
||||||
@@ -67,6 +68,12 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
|||||||
tz := b.CreateString(p.TimeZone)
|
tz := b.CreateString(p.TimeZone)
|
||||||
awayStart := b.CreateString(p.AwayStart)
|
awayStart := b.CreateString(p.AwayStart)
|
||||||
awayEnd := b.CreateString(p.AwayEnd)
|
awayEnd := b.CreateString(p.AwayEnd)
|
||||||
|
// Build the banner table (and its children) before opening Profile: FlatBuffers
|
||||||
|
// forbids a nested table while another is under construction.
|
||||||
|
var banner flatbuffers.UOffsetT
|
||||||
|
if p.Banner != nil {
|
||||||
|
banner = encodeBanner(b, *p.Banner)
|
||||||
|
}
|
||||||
fb.ProfileStart(b)
|
fb.ProfileStart(b)
|
||||||
fb.ProfileAddUserId(b, uid)
|
fb.ProfileAddUserId(b, uid)
|
||||||
fb.ProfileAddDisplayName(b, name)
|
fb.ProfileAddDisplayName(b, name)
|
||||||
@@ -79,10 +86,51 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
|||||||
fb.ProfileAddAwayStart(b, awayStart)
|
fb.ProfileAddAwayStart(b, awayStart)
|
||||||
fb.ProfileAddAwayEnd(b, awayEnd)
|
fb.ProfileAddAwayEnd(b, awayEnd)
|
||||||
fb.ProfileAddNotificationsInAppOnly(b, p.NotificationsInAppOnly)
|
fb.ProfileAddNotificationsInAppOnly(b, p.NotificationsInAppOnly)
|
||||||
|
if p.Banner != nil {
|
||||||
|
fb.ProfileAddBanner(b, banner)
|
||||||
|
}
|
||||||
b.Finish(fb.ProfileEnd(b))
|
b.Finish(fb.ProfileEnd(b))
|
||||||
return b.FinishedBytes()
|
return b.FinishedBytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// encodeBanner builds a BannerInfo table from the resolved banner block and
|
||||||
|
// returns its offset. It is bottom-up: each campaign's messages vector and table
|
||||||
|
// are built first, then the campaigns vector, then the BannerInfo table. The
|
||||||
|
// caller must invoke it with no table under construction.
|
||||||
|
func encodeBanner(b *flatbuffers.Builder, banner backendclient.BannerResp) flatbuffers.UOffsetT {
|
||||||
|
campOffsets := make([]flatbuffers.UOffsetT, len(banner.Campaigns))
|
||||||
|
for i, c := range banner.Campaigns {
|
||||||
|
msgOffsets := make([]flatbuffers.UOffsetT, len(c.Messages))
|
||||||
|
for j, m := range c.Messages {
|
||||||
|
msgOffsets[j] = b.CreateString(m)
|
||||||
|
}
|
||||||
|
fb.BannerCampaignStartMessagesVector(b, len(msgOffsets))
|
||||||
|
for j := len(msgOffsets) - 1; j >= 0; j-- {
|
||||||
|
b.PrependUOffsetT(msgOffsets[j])
|
||||||
|
}
|
||||||
|
msgs := b.EndVector(len(msgOffsets))
|
||||||
|
fb.BannerCampaignStart(b)
|
||||||
|
fb.BannerCampaignAddWeight(b, int32(c.Weight))
|
||||||
|
fb.BannerCampaignAddMessages(b, msgs)
|
||||||
|
campOffsets[i] = fb.BannerCampaignEnd(b)
|
||||||
|
}
|
||||||
|
fb.BannerInfoStartCampaignsVector(b, len(campOffsets))
|
||||||
|
for i := len(campOffsets) - 1; i >= 0; i-- {
|
||||||
|
b.PrependUOffsetT(campOffsets[i])
|
||||||
|
}
|
||||||
|
camps := b.EndVector(len(campOffsets))
|
||||||
|
t := banner.Timings
|
||||||
|
fb.BannerInfoStart(b)
|
||||||
|
fb.BannerInfoAddCampaigns(b, camps)
|
||||||
|
fb.BannerInfoAddHoldMs(b, int32(t.HoldMs))
|
||||||
|
fb.BannerInfoAddEdgePauseMs(b, int32(t.EdgePauseMs))
|
||||||
|
fb.BannerInfoAddScrollPxPerSec(b, int32(t.ScrollPxPerSec))
|
||||||
|
fb.BannerInfoAddFadeOutMs(b, int32(t.FadeOutMs))
|
||||||
|
fb.BannerInfoAddGapMs(b, int32(t.GapMs))
|
||||||
|
fb.BannerInfoAddFadeInMs(b, int32(t.FadeInMs))
|
||||||
|
return fb.BannerInfoEnd(b)
|
||||||
|
}
|
||||||
|
|
||||||
// encodeBlockStatus builds a BlockStatus payload.
|
// encodeBlockStatus builds a BlockStatus payload.
|
||||||
func encodeBlockStatus(s backendclient.BlockStatusResp) []byte {
|
func encodeBlockStatus(s backendclient.BlockStatusResp) []byte {
|
||||||
b := flatbuffers.NewBuilder(128)
|
b := flatbuffers.NewBuilder(128)
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package transcode_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"scrabble/gateway/internal/transcode"
|
||||||
|
fb "scrabble/pkg/fbs/scrabblefb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestProfileGetEncodesBanner verifies the gateway forwards the backend's banner
|
||||||
|
// block verbatim into the Profile payload: the campaigns (weight + resolved
|
||||||
|
// messages, in order) and the global display timings.
|
||||||
|
func TestProfileGetEncodesBanner(t *testing.T) {
|
||||||
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/user/profile" {
|
||||||
|
t.Errorf("unexpected %s %q", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"en",` +
|
||||||
|
`"banner":{"campaigns":[` +
|
||||||
|
`{"weight":3,"messages":["promo-en"]},` +
|
||||||
|
`{"weight":7,"messages":["house-a","house-b"]}],` +
|
||||||
|
`"timings":{"hold_ms":60000,"edge_pause_ms":5000,"scroll_px_per_sec":40,` +
|
||||||
|
`"fade_out_ms":1000,"gap_ms":250,"fade_in_ms":1000}}}`))
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
reg := transcode.NewRegistry(backend, nil)
|
||||||
|
op, ok := reg.Lookup(transcode.MsgProfileGet)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("profile.get not registered")
|
||||||
|
}
|
||||||
|
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handler: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := fb.GetRootAsProfile(payload, 0)
|
||||||
|
banner := p.Banner(nil)
|
||||||
|
if banner == nil {
|
||||||
|
t.Fatal("profile carries no banner block")
|
||||||
|
}
|
||||||
|
if got := banner.CampaignsLength(); got != 2 {
|
||||||
|
t.Fatalf("campaigns = %d, want 2", got)
|
||||||
|
}
|
||||||
|
var c fb.BannerCampaign
|
||||||
|
banner.Campaigns(&c, 0)
|
||||||
|
if c.Weight() != 3 || c.MessagesLength() != 1 || string(c.Messages(0)) != "promo-en" {
|
||||||
|
t.Errorf("campaign 0 = weight %d, %d msgs, msg0=%q", c.Weight(), c.MessagesLength(), c.Messages(0))
|
||||||
|
}
|
||||||
|
banner.Campaigns(&c, 1)
|
||||||
|
if c.Weight() != 7 || c.MessagesLength() != 2 || string(c.Messages(1)) != "house-b" {
|
||||||
|
t.Errorf("campaign 1 = weight %d, %d msgs, msg1=%q", c.Weight(), c.MessagesLength(), c.Messages(1))
|
||||||
|
}
|
||||||
|
if banner.HoldMs() != 60000 || banner.EdgePauseMs() != 5000 || banner.ScrollPxPerSec() != 40 {
|
||||||
|
t.Errorf("timings = hold %d edge %d scroll %d", banner.HoldMs(), banner.EdgePauseMs(), banner.ScrollPxPerSec())
|
||||||
|
}
|
||||||
|
if banner.FadeOutMs() != 1000 || banner.GapMs() != 250 || banner.FadeInMs() != 1000 {
|
||||||
|
t.Errorf("fade timings = out %d gap %d in %d", banner.FadeOutMs(), banner.GapMs(), banner.FadeInMs())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProfileGetNoBanner verifies an ineligible viewer's profile carries no
|
||||||
|
// banner block (the backend omits it).
|
||||||
|
func TestProfileGetNoBanner(t *testing.T) {
|
||||||
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"en"}`))
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
reg := transcode.NewRegistry(backend, nil)
|
||||||
|
op, _ := reg.Lookup(transcode.MsgProfileGet)
|
||||||
|
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handler: %v", err)
|
||||||
|
}
|
||||||
|
if p := fb.GetRootAsProfile(payload, 0); p.Banner(nil) != nil {
|
||||||
|
t.Error("ineligible profile unexpectedly carries a banner block")
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-2
@@ -137,12 +137,39 @@ table Ack {
|
|||||||
ok:bool;
|
ok:bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- advertising banner ---
|
||||||
|
|
||||||
|
// BannerCampaign is one campaign in the rotation feed: a GCD-reduced show weight
|
||||||
|
// (the client runs a smooth weighted round-robin over campaigns by this weight)
|
||||||
|
// and its messages in display order, each already resolved to the viewer's bot
|
||||||
|
// language (the stored en/ru pair is picked server-side).
|
||||||
|
table BannerCampaign {
|
||||||
|
weight:int;
|
||||||
|
messages:[string];
|
||||||
|
}
|
||||||
|
|
||||||
|
// BannerInfo is the advertising-banner block of an eligible viewer's profile: the
|
||||||
|
// campaigns to rotate and the global display timings the client rotator reads.
|
||||||
|
// It is present (set on Profile.banner) only when the viewer is eligible to see a
|
||||||
|
// banner; absent otherwise. The transition between messages is fade_out_ms, then
|
||||||
|
// gap_ms, then fade_in_ms.
|
||||||
|
table BannerInfo {
|
||||||
|
campaigns:[BannerCampaign];
|
||||||
|
hold_ms:int;
|
||||||
|
edge_pause_ms:int;
|
||||||
|
scroll_px_per_sec:int;
|
||||||
|
fade_out_ms:int;
|
||||||
|
gap_ms:int;
|
||||||
|
fade_in_ms:int;
|
||||||
|
}
|
||||||
|
|
||||||
// --- profile (authenticated) ---
|
// --- profile (authenticated) ---
|
||||||
|
|
||||||
// Profile is the authenticated account's own profile view. away_start/away_end are
|
// Profile is the authenticated account's own profile view. away_start/away_end are
|
||||||
// the "HH:MM" daily away-window bounds. notifications_in_app_only (default true)
|
// the "HH:MM" daily away-window bounds. notifications_in_app_only (default true)
|
||||||
// suppresses out-of-app platform push, leaving only the in-app live stream (both
|
// suppresses out-of-app platform push, leaving only the in-app live stream. banner
|
||||||
// added trailing — backward-compatible).
|
// carries the advertising-banner block for an eligible viewer, absent otherwise
|
||||||
|
// (all added trailing — backward-compatible).
|
||||||
table Profile {
|
table Profile {
|
||||||
user_id:string;
|
user_id:string;
|
||||||
display_name:string;
|
display_name:string;
|
||||||
@@ -155,6 +182,7 @@ table Profile {
|
|||||||
away_start:string;
|
away_start:string;
|
||||||
away_end:string;
|
away_end:string;
|
||||||
notifications_in_app_only:bool = true;
|
notifications_in_app_only:bool = true;
|
||||||
|
banner:BannerInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
// BlockStatus reports the caller's current manual block. The UI fetches it after any operation
|
// BlockStatus reports the caller's current manual block. The UI fetches it after any operation
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||||
|
|
||||||
|
package scrabblefb
|
||||||
|
|
||||||
|
import (
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BannerCampaign struct {
|
||||||
|
_tab flatbuffers.Table
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRootAsBannerCampaign(buf []byte, offset flatbuffers.UOffsetT) *BannerCampaign {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||||
|
x := &BannerCampaign{}
|
||||||
|
x.Init(buf, n+offset)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishBannerCampaignBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.Finish(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSizePrefixedRootAsBannerCampaign(buf []byte, offset flatbuffers.UOffsetT) *BannerCampaign {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||||
|
x := &BannerCampaign{}
|
||||||
|
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishSizePrefixedBannerCampaignBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.FinishSizePrefixed(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerCampaign) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||||
|
rcv._tab.Bytes = buf
|
||||||
|
rcv._tab.Pos = i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerCampaign) Table() flatbuffers.Table {
|
||||||
|
return rcv._tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerCampaign) Weight() int32 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerCampaign) MutateWeight(n int32) bool {
|
||||||
|
return rcv._tab.MutateInt32Slot(4, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerCampaign) Messages(j int) []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||||
|
if o != 0 {
|
||||||
|
a := rcv._tab.Vector(o)
|
||||||
|
return rcv._tab.ByteVector(a + flatbuffers.UOffsetT(j*4))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerCampaign) MessagesLength() int {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.VectorLen(o)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func BannerCampaignStart(builder *flatbuffers.Builder) {
|
||||||
|
builder.StartObject(2)
|
||||||
|
}
|
||||||
|
func BannerCampaignAddWeight(builder *flatbuffers.Builder, weight int32) {
|
||||||
|
builder.PrependInt32Slot(0, weight, 0)
|
||||||
|
}
|
||||||
|
func BannerCampaignAddMessages(builder *flatbuffers.Builder, messages flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(messages), 0)
|
||||||
|
}
|
||||||
|
func BannerCampaignStartMessagesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||||
|
return builder.StartVector(4, numElems, 4)
|
||||||
|
}
|
||||||
|
func BannerCampaignEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
|
return builder.EndObject()
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||||
|
|
||||||
|
package scrabblefb
|
||||||
|
|
||||||
|
import (
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BannerInfo struct {
|
||||||
|
_tab flatbuffers.Table
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRootAsBannerInfo(buf []byte, offset flatbuffers.UOffsetT) *BannerInfo {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||||
|
x := &BannerInfo{}
|
||||||
|
x.Init(buf, n+offset)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishBannerInfoBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.Finish(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSizePrefixedRootAsBannerInfo(buf []byte, offset flatbuffers.UOffsetT) *BannerInfo {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||||
|
x := &BannerInfo{}
|
||||||
|
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishSizePrefixedBannerInfoBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.FinishSizePrefixed(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerInfo) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||||
|
rcv._tab.Bytes = buf
|
||||||
|
rcv._tab.Pos = i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerInfo) Table() flatbuffers.Table {
|
||||||
|
return rcv._tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerInfo) Campaigns(obj *BannerCampaign, j int) bool {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||||
|
if o != 0 {
|
||||||
|
x := rcv._tab.Vector(o)
|
||||||
|
x += flatbuffers.UOffsetT(j) * 4
|
||||||
|
x = rcv._tab.Indirect(x)
|
||||||
|
obj.Init(rcv._tab.Bytes, x)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerInfo) CampaignsLength() int {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.VectorLen(o)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerInfo) HoldMs() int32 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerInfo) MutateHoldMs(n int32) bool {
|
||||||
|
return rcv._tab.MutateInt32Slot(6, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerInfo) EdgePauseMs() int32 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerInfo) MutateEdgePauseMs(n int32) bool {
|
||||||
|
return rcv._tab.MutateInt32Slot(8, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerInfo) ScrollPxPerSec() int32 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerInfo) MutateScrollPxPerSec(n int32) bool {
|
||||||
|
return rcv._tab.MutateInt32Slot(10, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerInfo) FadeOutMs() int32 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerInfo) MutateFadeOutMs(n int32) bool {
|
||||||
|
return rcv._tab.MutateInt32Slot(12, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerInfo) GapMs() int32 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerInfo) MutateGapMs(n int32) bool {
|
||||||
|
return rcv._tab.MutateInt32Slot(14, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerInfo) FadeInMs() int32 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(16))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerInfo) MutateFadeInMs(n int32) bool {
|
||||||
|
return rcv._tab.MutateInt32Slot(16, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BannerInfoStart(builder *flatbuffers.Builder) {
|
||||||
|
builder.StartObject(7)
|
||||||
|
}
|
||||||
|
func BannerInfoAddCampaigns(builder *flatbuffers.Builder, campaigns flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(campaigns), 0)
|
||||||
|
}
|
||||||
|
func BannerInfoStartCampaignsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||||
|
return builder.StartVector(4, numElems, 4)
|
||||||
|
}
|
||||||
|
func BannerInfoAddHoldMs(builder *flatbuffers.Builder, holdMs int32) {
|
||||||
|
builder.PrependInt32Slot(1, holdMs, 0)
|
||||||
|
}
|
||||||
|
func BannerInfoAddEdgePauseMs(builder *flatbuffers.Builder, edgePauseMs int32) {
|
||||||
|
builder.PrependInt32Slot(2, edgePauseMs, 0)
|
||||||
|
}
|
||||||
|
func BannerInfoAddScrollPxPerSec(builder *flatbuffers.Builder, scrollPxPerSec int32) {
|
||||||
|
builder.PrependInt32Slot(3, scrollPxPerSec, 0)
|
||||||
|
}
|
||||||
|
func BannerInfoAddFadeOutMs(builder *flatbuffers.Builder, fadeOutMs int32) {
|
||||||
|
builder.PrependInt32Slot(4, fadeOutMs, 0)
|
||||||
|
}
|
||||||
|
func BannerInfoAddGapMs(builder *flatbuffers.Builder, gapMs int32) {
|
||||||
|
builder.PrependInt32Slot(5, gapMs, 0)
|
||||||
|
}
|
||||||
|
func BannerInfoAddFadeInMs(builder *flatbuffers.Builder, fadeInMs int32) {
|
||||||
|
builder.PrependInt32Slot(6, fadeInMs, 0)
|
||||||
|
}
|
||||||
|
func BannerInfoEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
|
return builder.EndObject()
|
||||||
|
}
|
||||||
@@ -149,8 +149,21 @@ func (rcv *Profile) MutateNotificationsInAppOnly(n bool) bool {
|
|||||||
return rcv._tab.MutateBoolSlot(24, n)
|
return rcv._tab.MutateBoolSlot(24, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rcv *Profile) Banner(obj *BannerInfo) *BannerInfo {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(26))
|
||||||
|
if o != 0 {
|
||||||
|
x := rcv._tab.Indirect(o + rcv._tab.Pos)
|
||||||
|
if obj == nil {
|
||||||
|
obj = new(BannerInfo)
|
||||||
|
}
|
||||||
|
obj.Init(rcv._tab.Bytes, x)
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func ProfileStart(builder *flatbuffers.Builder) {
|
func ProfileStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(11)
|
builder.StartObject(12)
|
||||||
}
|
}
|
||||||
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
|
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
|
||||||
@@ -185,6 +198,9 @@ func ProfileAddAwayEnd(builder *flatbuffers.Builder, awayEnd flatbuffers.UOffset
|
|||||||
func ProfileAddNotificationsInAppOnly(builder *flatbuffers.Builder, notificationsInAppOnly bool) {
|
func ProfileAddNotificationsInAppOnly(builder *flatbuffers.Builder, notificationsInAppOnly bool) {
|
||||||
builder.PrependBoolSlot(10, notificationsInAppOnly, true)
|
builder.PrependBoolSlot(10, notificationsInAppOnly, true)
|
||||||
}
|
}
|
||||||
|
func ProfileAddBanner(builder *flatbuffers.Builder, banner flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(11, flatbuffers.UOffsetT(banner), 0)
|
||||||
|
}
|
||||||
func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
export { AccountRef } from './scrabblefb/account-ref.js';
|
export { AccountRef } from './scrabblefb/account-ref.js';
|
||||||
export { Ack } from './scrabblefb/ack.js';
|
export { Ack } from './scrabblefb/ack.js';
|
||||||
export { AlphabetEntry } from './scrabblefb/alphabet-entry.js';
|
export { AlphabetEntry } from './scrabblefb/alphabet-entry.js';
|
||||||
|
export { BannerCampaign } from './scrabblefb/banner-campaign.js';
|
||||||
|
export { BannerInfo } from './scrabblefb/banner-info.js';
|
||||||
export { BlockList } from './scrabblefb/block-list.js';
|
export { BlockList } from './scrabblefb/block-list.js';
|
||||||
export { BlockStatus } from './scrabblefb/block-status.js';
|
export { BlockStatus } from './scrabblefb/block-status.js';
|
||||||
export { ChatList } from './scrabblefb/chat-list.js';
|
export { ChatList } from './scrabblefb/chat-list.js';
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// automatically generated by the FlatBuffers compiler, do not modify
|
||||||
|
|
||||||
|
import * as flatbuffers from 'flatbuffers';
|
||||||
|
|
||||||
|
export class BannerCampaign {
|
||||||
|
bb: flatbuffers.ByteBuffer|null = null;
|
||||||
|
bb_pos = 0;
|
||||||
|
__init(i:number, bb:flatbuffers.ByteBuffer):BannerCampaign {
|
||||||
|
this.bb_pos = i;
|
||||||
|
this.bb = bb;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getRootAsBannerCampaign(bb:flatbuffers.ByteBuffer, obj?:BannerCampaign):BannerCampaign {
|
||||||
|
return (obj || new BannerCampaign()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSizePrefixedRootAsBannerCampaign(bb:flatbuffers.ByteBuffer, obj?:BannerCampaign):BannerCampaign {
|
||||||
|
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||||
|
return (obj || new BannerCampaign()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
weight():number {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||||
|
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
messages(index: number):string
|
||||||
|
messages(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
|
||||||
|
messages(index: number,optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||||
|
return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
messagesLength():number {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||||
|
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static startBannerCampaign(builder:flatbuffers.Builder) {
|
||||||
|
builder.startObject(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addWeight(builder:flatbuffers.Builder, weight:number) {
|
||||||
|
builder.addFieldInt32(0, weight, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addMessages(builder:flatbuffers.Builder, messagesOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(1, messagesOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static createMessagesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||||
|
builder.startVector(4, data.length, 4);
|
||||||
|
for (let i = data.length - 1; i >= 0; i--) {
|
||||||
|
builder.addOffset(data[i]!);
|
||||||
|
}
|
||||||
|
return builder.endVector();
|
||||||
|
}
|
||||||
|
|
||||||
|
static startMessagesVector(builder:flatbuffers.Builder, numElems:number) {
|
||||||
|
builder.startVector(4, numElems, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
static endBannerCampaign(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
|
const offset = builder.endObject();
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
static createBannerCampaign(builder:flatbuffers.Builder, weight:number, messagesOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
|
BannerCampaign.startBannerCampaign(builder);
|
||||||
|
BannerCampaign.addWeight(builder, weight);
|
||||||
|
BannerCampaign.addMessages(builder, messagesOffset);
|
||||||
|
return BannerCampaign.endBannerCampaign(builder);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// automatically generated by the FlatBuffers compiler, do not modify
|
||||||
|
|
||||||
|
import * as flatbuffers from 'flatbuffers';
|
||||||
|
|
||||||
|
import { BannerCampaign } from '../scrabblefb/banner-campaign.js';
|
||||||
|
|
||||||
|
|
||||||
|
export class BannerInfo {
|
||||||
|
bb: flatbuffers.ByteBuffer|null = null;
|
||||||
|
bb_pos = 0;
|
||||||
|
__init(i:number, bb:flatbuffers.ByteBuffer):BannerInfo {
|
||||||
|
this.bb_pos = i;
|
||||||
|
this.bb = bb;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getRootAsBannerInfo(bb:flatbuffers.ByteBuffer, obj?:BannerInfo):BannerInfo {
|
||||||
|
return (obj || new BannerInfo()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSizePrefixedRootAsBannerInfo(bb:flatbuffers.ByteBuffer, obj?:BannerInfo):BannerInfo {
|
||||||
|
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||||
|
return (obj || new BannerInfo()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
campaigns(index: number, obj?:BannerCampaign):BannerCampaign|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||||
|
return offset ? (obj || new BannerCampaign()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
campaignsLength():number {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||||
|
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
holdMs():number {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||||
|
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
edgePauseMs():number {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||||
|
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollPxPerSec():number {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 10);
|
||||||
|
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fadeOutMs():number {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 12);
|
||||||
|
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
gapMs():number {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 14);
|
||||||
|
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fadeInMs():number {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 16);
|
||||||
|
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static startBannerInfo(builder:flatbuffers.Builder) {
|
||||||
|
builder.startObject(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addCampaigns(builder:flatbuffers.Builder, campaignsOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(0, campaignsOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static createCampaignsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||||
|
builder.startVector(4, data.length, 4);
|
||||||
|
for (let i = data.length - 1; i >= 0; i--) {
|
||||||
|
builder.addOffset(data[i]!);
|
||||||
|
}
|
||||||
|
return builder.endVector();
|
||||||
|
}
|
||||||
|
|
||||||
|
static startCampaignsVector(builder:flatbuffers.Builder, numElems:number) {
|
||||||
|
builder.startVector(4, numElems, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addHoldMs(builder:flatbuffers.Builder, holdMs:number) {
|
||||||
|
builder.addFieldInt32(1, holdMs, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addEdgePauseMs(builder:flatbuffers.Builder, edgePauseMs:number) {
|
||||||
|
builder.addFieldInt32(2, edgePauseMs, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addScrollPxPerSec(builder:flatbuffers.Builder, scrollPxPerSec:number) {
|
||||||
|
builder.addFieldInt32(3, scrollPxPerSec, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addFadeOutMs(builder:flatbuffers.Builder, fadeOutMs:number) {
|
||||||
|
builder.addFieldInt32(4, fadeOutMs, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addGapMs(builder:flatbuffers.Builder, gapMs:number) {
|
||||||
|
builder.addFieldInt32(5, gapMs, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addFadeInMs(builder:flatbuffers.Builder, fadeInMs:number) {
|
||||||
|
builder.addFieldInt32(6, fadeInMs, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static endBannerInfo(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
|
const offset = builder.endObject();
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
static createBannerInfo(builder:flatbuffers.Builder, campaignsOffset:flatbuffers.Offset, holdMs:number, edgePauseMs:number, scrollPxPerSec:number, fadeOutMs:number, gapMs:number, fadeInMs:number):flatbuffers.Offset {
|
||||||
|
BannerInfo.startBannerInfo(builder);
|
||||||
|
BannerInfo.addCampaigns(builder, campaignsOffset);
|
||||||
|
BannerInfo.addHoldMs(builder, holdMs);
|
||||||
|
BannerInfo.addEdgePauseMs(builder, edgePauseMs);
|
||||||
|
BannerInfo.addScrollPxPerSec(builder, scrollPxPerSec);
|
||||||
|
BannerInfo.addFadeOutMs(builder, fadeOutMs);
|
||||||
|
BannerInfo.addGapMs(builder, gapMs);
|
||||||
|
BannerInfo.addFadeInMs(builder, fadeInMs);
|
||||||
|
return BannerInfo.endBannerInfo(builder);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import * as flatbuffers from 'flatbuffers';
|
import * as flatbuffers from 'flatbuffers';
|
||||||
|
|
||||||
|
import { BannerInfo } from '../scrabblefb/banner-info.js';
|
||||||
|
|
||||||
|
|
||||||
export class Profile {
|
export class Profile {
|
||||||
bb: flatbuffers.ByteBuffer|null = null;
|
bb: flatbuffers.ByteBuffer|null = null;
|
||||||
bb_pos = 0;
|
bb_pos = 0;
|
||||||
@@ -87,8 +90,13 @@ notificationsInAppOnly():boolean {
|
|||||||
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : true;
|
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
banner(obj?:BannerInfo):BannerInfo|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 26);
|
||||||
|
return offset ? (obj || new BannerInfo()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
|
||||||
|
}
|
||||||
|
|
||||||
static startProfile(builder:flatbuffers.Builder) {
|
static startProfile(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(11);
|
builder.startObject(12);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
|
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
|
||||||
@@ -135,24 +143,13 @@ static addNotificationsInAppOnly(builder:flatbuffers.Builder, notificationsInApp
|
|||||||
builder.addFieldInt8(10, +notificationsInAppOnly, +true);
|
builder.addFieldInt8(10, +notificationsInAppOnly, +true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addBanner(builder:flatbuffers.Builder, bannerOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(11, bannerOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
static createProfile(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, hintBalance:number, blockChat:boolean, blockFriendRequests:boolean, isGuest:boolean, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset, notificationsInAppOnly:boolean):flatbuffers.Offset {
|
|
||||||
Profile.startProfile(builder);
|
|
||||||
Profile.addUserId(builder, userIdOffset);
|
|
||||||
Profile.addDisplayName(builder, displayNameOffset);
|
|
||||||
Profile.addPreferredLanguage(builder, preferredLanguageOffset);
|
|
||||||
Profile.addTimeZone(builder, timeZoneOffset);
|
|
||||||
Profile.addHintBalance(builder, hintBalance);
|
|
||||||
Profile.addBlockChat(builder, blockChat);
|
|
||||||
Profile.addBlockFriendRequests(builder, blockFriendRequests);
|
|
||||||
Profile.addIsGuest(builder, isGuest);
|
|
||||||
Profile.addAwayStart(builder, awayStartOffset);
|
|
||||||
Profile.addAwayEnd(builder, awayEndOffset);
|
|
||||||
Profile.addNotificationsInAppOnly(builder, notificationsInAppOnly);
|
|
||||||
return Profile.endProfile(builder);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user