81b9e1529e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Make a per-user block one-directional and non-destructive: the blocker stops
receiving everything from the blocked user (chat, nudge, friend requests,
invitations) and the matchmaker never pairs them, while the blocked user
notices nothing — their sends still persist by the normal rules but are never
delivered or surfaced (born-read). A block no longer deletes the friendship
(an unblock cleanly restores it) and instant-reads any unread the blocked user
had left for the blocker.
- backend: a directional blockExists guard across chat/nudge/friends/invitations
(store-but-hide for the blocked->blocker direction, refuse blocker->blocked);
the matchmaker excludes a block-related pair (both directions) from auto-match;
user_blocked/user_unblocked notifications to the blocker only (in-app only).
- ui: the opponent score card gains a block ✖️ control mirroring add-friend
(red "Block?" confirm, mutual-hide while confirming, struck name, hidden chat
composer when blocked); optimistic apply + event confirm + rollback for both.
- admin: the user card gains cross-linked blocks / blocked-by / friends lists.
- docs: FUNCTIONAL(+ru), ARCHITECTURE §10 + decision record, UI_DESIGN, PRERELEASE.
531 lines
15 KiB
Go
531 lines
15 KiB
Go
package adminconsole
|
|
|
|
import "html/template"
|
|
|
|
// The *View types are the display models the gin handlers fill and the templates
|
|
// render. Time values are pre-formatted to strings by the handlers so the
|
|
// templates stay logic-free.
|
|
|
|
// Pager is the shared list pagination state.
|
|
type Pager struct {
|
|
Page int
|
|
PageSize int
|
|
Total int
|
|
HasPrev bool
|
|
HasNext bool
|
|
PrevPage int
|
|
NextPage int
|
|
}
|
|
|
|
// NewPager builds the pagination state for a 1-based page of pageSize over total
|
|
// items.
|
|
func NewPager(page, pageSize, total int) Pager {
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
p := Pager{Page: page, PageSize: pageSize, Total: total, PrevPage: page - 1, NextPage: page + 1}
|
|
p.HasPrev = page > 1
|
|
p.HasNext = page*pageSize < total
|
|
return p
|
|
}
|
|
|
|
// VariantVersions lists the dictionary versions resident for one variant.
|
|
type VariantVersions struct {
|
|
Variant string
|
|
Latest string
|
|
Versions []string
|
|
}
|
|
|
|
// DashboardView is the landing-page summary.
|
|
type DashboardView struct {
|
|
Accounts int
|
|
Games int
|
|
ActiveGames int
|
|
OpenComplaints int
|
|
OpenFeedback int
|
|
PendingChanges int
|
|
// ActiveVersion is the dictionary version new games pin (the persisted active
|
|
// version), distinct from the per-variant resident versions.
|
|
ActiveVersion string
|
|
Variants []VariantVersions
|
|
}
|
|
|
|
// UsersView is the paginated account list.
|
|
type UsersView struct {
|
|
Items []UserRow
|
|
Pager Pager
|
|
// Robots is the active people/robots toggle; NameMask/ExternalIDMask are the current
|
|
// glob filters; FilterQuery is those URL-encoded for the pager links. It is an
|
|
// already-escaped query fragment (url.Values.Encode), so it is typed template.URL to
|
|
// be emitted verbatim — interpolated as a plain string it would have its "=" and "&"
|
|
// percent-encoded again by the contextual escaper.
|
|
Robots bool
|
|
NameMask string
|
|
ExternalIDMask string
|
|
FilterQuery template.URL
|
|
}
|
|
|
|
// UserRow is one account row in the list. MoveMin/Avg/Max are the account's
|
|
// pre-formatted move-duration summary (empty when it has no timed move);
|
|
// FlaggedHighRate marks the soft high-rate badge.
|
|
type UserRow struct {
|
|
ID string
|
|
DisplayName string
|
|
Kind string
|
|
Language string
|
|
Guest bool
|
|
FlaggedHighRate bool
|
|
CreatedAt string
|
|
HasMoveStats bool
|
|
MoveMin string
|
|
MoveAvg string
|
|
MoveMax string
|
|
}
|
|
|
|
// MessagesView is the paginated chat-message moderation list. NameMask/ExtMask are the
|
|
// current sender glob filters; GameID/UserID pin the list to one game / sender (set from a
|
|
// game or user card); FilterQuery is the active filters URL-encoded for the pager and CSV
|
|
// links — an already-escaped query fragment, hence template.URL so it is not re-encoded
|
|
// inside the link (see UsersView.FilterQuery).
|
|
type MessagesView struct {
|
|
Items []MessageRow
|
|
Pager Pager
|
|
NameMask string
|
|
ExtMask string
|
|
GameID string
|
|
UserID string
|
|
UnreadOnly bool
|
|
FilterQuery template.URL
|
|
}
|
|
|
|
// MessageRow is one chat message in the moderation list: its sender (linked to the user
|
|
// card), source, IP, body, game (linked to the game card), time, and whether it is still
|
|
// unread by at least one recipient.
|
|
type MessageRow struct {
|
|
ID string
|
|
SenderID string
|
|
SenderName string
|
|
Source string
|
|
IP string
|
|
Body string
|
|
GameID string
|
|
CreatedAt string
|
|
Unread bool
|
|
}
|
|
|
|
// ChatMessageDetailView is one chat message with its per-seat read breakdown, for the
|
|
// console message card.
|
|
type ChatMessageDetailView struct {
|
|
ID string
|
|
GameID string
|
|
SenderID string
|
|
SenderName string
|
|
Source string
|
|
Kind string
|
|
Body string
|
|
IP string
|
|
CreatedAt string
|
|
Unread bool
|
|
Seats []ChatSeatStatusRow
|
|
}
|
|
|
|
// ChatSeatStatusRow is one seat's read status on the message card: the seat index, the
|
|
// occupant (linked to the user card) and its role ("sender", "read" or "unread").
|
|
type ChatSeatStatusRow struct {
|
|
Seat int
|
|
AccountID string
|
|
DisplayName string
|
|
Role string
|
|
}
|
|
|
|
// UserDetailView is one account with its stats, identities and recent games.
|
|
type UserDetailView struct {
|
|
ID string
|
|
DisplayName string
|
|
Language string
|
|
TimeZone string
|
|
Guest bool
|
|
NotificationsInAppOnly bool
|
|
PaidAccount bool
|
|
// MergedInto is the primary account id when this account has been retired by a
|
|
// merge, or empty for a live account.
|
|
MergedInto string
|
|
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
|
|
// empty for an unflagged account; the card shows it with the Clear action.
|
|
FlaggedHighRateAt string
|
|
HintBalance int
|
|
// HintGrantMax is the per-grant cap the operator's "add hints" form enforces (it mirrors the
|
|
// server's maxHintGrant), passed through so the policy value lives in one place.
|
|
HintGrantMax int
|
|
CreatedAt string
|
|
HasStats bool
|
|
Stats StatsRow
|
|
Identities []IdentityRow
|
|
Games []GameRow
|
|
TelegramID string
|
|
ConnectorEnabled bool
|
|
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
|
|
// time (min/mean/max), empty when the account has no timed move.
|
|
MoveChart template.HTML
|
|
// Suspension is the account's current manual-block state, shown with the block/unblock
|
|
// form; Reasons is the operator-editable reason picklist offered in the block form.
|
|
Suspension SuspensionView
|
|
Reasons []ReasonOption
|
|
// Roles is the account's current roles (each revocable); KnownRoles is the set the
|
|
// grant form offers. The first role is the feedback ban (see internal/account).
|
|
Roles []string
|
|
KnownRoles []string
|
|
// Blocks, BlockedBy and Friends are the social graph on the card: who this account has
|
|
// blocked, who currently blocks it, and its mutual friendships — each cross-linked to the
|
|
// other account with the date it happened. They are the full truth; the asymmetric block
|
|
// suppression that hides relationships from players never applies to the console.
|
|
Blocks []RelationRow
|
|
BlockedBy []RelationRow
|
|
Friends []RelationRow
|
|
}
|
|
|
|
// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends
|
|
// lists: the other account's id (the link target), its display name, and the pre-formatted date.
|
|
type RelationRow struct {
|
|
AccountID string
|
|
DisplayName string
|
|
Date string
|
|
}
|
|
|
|
// SuspensionView is an account's current manual-block state shown on the user card: whether it
|
|
// is blocked, whether the block is permanent, the pre-formatted expiry (empty when permanent),
|
|
// when it was applied, and the reason snapshot in both languages (empty when none was cited).
|
|
type SuspensionView struct {
|
|
Blocked bool
|
|
Permanent bool
|
|
Until string
|
|
BlockedAt string
|
|
ReasonEn string
|
|
ReasonRu string
|
|
}
|
|
|
|
// ReasonOption is one suspension-reason picklist entry offered in the block form's dropdown.
|
|
type ReasonOption struct {
|
|
ID string
|
|
TextEn string
|
|
TextRu string
|
|
}
|
|
|
|
// StatsRow is an account's lifetime statistics.
|
|
type StatsRow struct {
|
|
Wins int
|
|
Losses int
|
|
Draws int
|
|
MaxGamePoints int
|
|
MaxWordPoints int
|
|
Moves int
|
|
HintsUsed int
|
|
}
|
|
|
|
// IdentityRow is one platform/email identity of an account.
|
|
type IdentityRow struct {
|
|
Kind string
|
|
ExternalID string
|
|
Confirmed bool
|
|
CreatedAt string
|
|
}
|
|
|
|
// GameRow is one game row in a list.
|
|
type GameRow struct {
|
|
ID string
|
|
Variant string
|
|
Status string
|
|
Players int
|
|
UpdatedAt string
|
|
// VsAI marks an honest-AI game (rendered as 🤖 in the list's AI column).
|
|
VsAI bool
|
|
}
|
|
|
|
// GamesView is the paginated games list, optionally filtered by status.
|
|
type GamesView struct {
|
|
Items []GameRow
|
|
Status string
|
|
Pager Pager
|
|
}
|
|
|
|
// GameDetailView is one game with its seats.
|
|
type GameDetailView struct {
|
|
ID string
|
|
Variant string
|
|
DictVersion string
|
|
Status string
|
|
Players int
|
|
ToMove int
|
|
EndReason string
|
|
MoveCount int
|
|
CreatedAt string
|
|
UpdatedAt string
|
|
FinishedAt string
|
|
// VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
|
|
VsAI bool
|
|
Seats []SeatRow
|
|
// HasRobot is true when any seat is a robot, gating the robot-target caption;
|
|
// RobotTargetPct is the configured global play-to-win rate, in percent.
|
|
HasRobot bool
|
|
RobotTargetPct int
|
|
}
|
|
|
|
// SeatRow is one seat of a game. For a robot seat (IsRobot) RobotIntent is the game's
|
|
// deterministic play-to-win decision ("play to win"/"play to lose"), and NextMove is the
|
|
// scheduled next-move ETA shown only while it is that robot's turn in an active game.
|
|
type SeatRow struct {
|
|
Seat int
|
|
DisplayName string
|
|
AccountID string
|
|
Score int
|
|
HintsUsed int
|
|
Winner bool
|
|
IsRobot bool
|
|
RobotIntent string
|
|
NextMove string
|
|
}
|
|
|
|
// ComplaintsView is the paginated complaint review queue.
|
|
type ComplaintsView struct {
|
|
Items []ComplaintRow
|
|
Status string
|
|
Pager Pager
|
|
}
|
|
|
|
// ComplaintRow is one complaint row in the queue.
|
|
type ComplaintRow struct {
|
|
ID string
|
|
Word string
|
|
Variant string
|
|
WasValid bool
|
|
Status string
|
|
Disposition string
|
|
CreatedAt string
|
|
}
|
|
|
|
// ComplaintDetailView is one complaint with its resolution state and form.
|
|
type ComplaintDetailView struct {
|
|
ID string
|
|
Word string
|
|
Variant string
|
|
DictVersion string
|
|
WasValid bool
|
|
Note string
|
|
Status string
|
|
Disposition string
|
|
ResolutionNote string
|
|
CreatedAt string
|
|
ResolvedAt string
|
|
GameID string
|
|
Resolved bool
|
|
}
|
|
|
|
// DictionaryView lists the resident versions per variant, the active version new
|
|
// games pin, and the pending wordlist changes from accepted complaints.
|
|
type DictionaryView struct {
|
|
// ActiveVersion is the dictionary version new games pin; the update form sets it.
|
|
ActiveVersion string
|
|
Variants []VariantVersions
|
|
Changes []DictChangeRow
|
|
}
|
|
|
|
// DictChangeRow is one pending wordlist edit.
|
|
type DictChangeRow struct {
|
|
Variant string
|
|
Word string
|
|
Action string
|
|
ResolvedAt string
|
|
}
|
|
|
|
// DictionaryPreviewView is the second step of a dictionary update: the version
|
|
// parsed from the uploaded archive (editable before confirming), the staging token
|
|
// that names the uploaded files on disk, the active version the diff is against, and
|
|
// the per-variant word diff.
|
|
type DictionaryPreviewView struct {
|
|
Version string
|
|
Token string
|
|
ActiveVersion string
|
|
Variants []VariantDiffRow
|
|
}
|
|
|
|
// VariantDiffRow summarises one variant's word diff in the update preview.
|
|
// AddedCount/RemovedCount are the full totals; AddedSample/RemovedSample are the
|
|
// first words shown (capped); AddedTruncated/RemovedTruncated mark a capped list;
|
|
// LargeRemoval flags a removal large enough to warrant caution before confirming.
|
|
type VariantDiffRow struct {
|
|
Variant string
|
|
AddedCount int
|
|
RemovedCount int
|
|
AddedSample []string
|
|
RemovedSample []string
|
|
AddedTruncated bool
|
|
RemovedTruncated bool
|
|
LargeRemoval bool
|
|
}
|
|
|
|
// BroadcastView is the operator-broadcast form page.
|
|
type BroadcastView struct {
|
|
ConnectorEnabled bool
|
|
}
|
|
|
|
// ThrottledView is the rate-limit observability page: the recent gateway-reported
|
|
// throttle episodes (in-memory, reset on restart) and the accounts currently
|
|
// carrying the high-rate flag. FlagThreshold and FlagWindow caption the active
|
|
// auto-flag tuning.
|
|
type ThrottledView struct {
|
|
Episodes []ThrottleEpisodeRow
|
|
Flagged []FlaggedAccountRow
|
|
FlagThreshold int
|
|
FlagWindow string
|
|
}
|
|
|
|
// ThrottleEpisodeRow is one recently throttled limiter key. UserID links to the
|
|
// user card and is set only for the user class (the other classes key by IP).
|
|
type ThrottleEpisodeRow struct {
|
|
Class string
|
|
Key string
|
|
UserID string
|
|
Rejected int
|
|
FirstSeen string
|
|
LastSeen string
|
|
}
|
|
|
|
// FlaggedAccountRow is one account carrying the high-rate flag.
|
|
type FlaggedAccountRow struct {
|
|
ID string
|
|
DisplayName string
|
|
FlaggedAt string
|
|
}
|
|
|
|
// ReasonsView is the suspension-reason picklist management page: every editable reason entry.
|
|
type ReasonsView struct {
|
|
Items []ReasonRow
|
|
}
|
|
|
|
// ReasonRow is one editable suspension-reason entry, with its English and Russian text.
|
|
type ReasonRow struct {
|
|
ID string
|
|
TextEn string
|
|
TextRu string
|
|
CreatedAt string
|
|
}
|
|
|
|
// MessageView is the result page shown after a POST action.
|
|
type MessageView struct {
|
|
Heading string
|
|
Body 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
|
|
// unread/read/archived filter; NameMask/ExtMask are the sender glob filters;
|
|
// UserID pins the list to one account (the per-user link from /users);
|
|
// FilterQuery is the active filters URL-encoded for the pager links (already
|
|
// escaped, hence template.URL — see UsersView.FilterQuery).
|
|
type FeedbackView struct {
|
|
Items []FeedbackRow
|
|
Status string
|
|
NameMask string
|
|
ExtMask string
|
|
UserID string
|
|
Pager Pager
|
|
FilterQuery template.URL
|
|
}
|
|
|
|
// FeedbackRow is one feedback message in the queue: its sender (linked to the user
|
|
// card), source, channel, whether it has an attachment / a reply, its state and
|
|
// time.
|
|
type FeedbackRow struct {
|
|
ID string
|
|
AccountID string
|
|
SenderName string
|
|
Source string
|
|
Channel string
|
|
HasAttachment bool
|
|
Read bool
|
|
Replied bool
|
|
Archived bool
|
|
CreatedAt string
|
|
}
|
|
|
|
// FeedbackDetailView is one feedback message with its body, attachment, state and
|
|
// the reply / archive / delete forms. Body, AttachmentName, SenderName and IP are
|
|
// user-controlled and rendered as plain auto-escaped text. IsImage gates the inline
|
|
// <img> preview; Banned shows whether the sender already holds the feedback ban.
|
|
type FeedbackDetailView struct {
|
|
ID string
|
|
AccountID string
|
|
SenderName string
|
|
Source string
|
|
Channel string
|
|
// InterfaceLanguage is the sender's interface language (account preference);
|
|
// BotLanguage is the connector bot they last used (en/ru), set only for a
|
|
// message that arrived through an external connector (Telegram).
|
|
InterfaceLanguage string
|
|
BotLanguage string
|
|
IP string
|
|
Body string
|
|
HasAttachment bool
|
|
AttachmentName string
|
|
IsImage bool
|
|
Read bool
|
|
Archived bool
|
|
Replied bool
|
|
ReplyBody string
|
|
RepliedAt string
|
|
CreatedAt string
|
|
Banned bool
|
|
}
|