Files
scrabble-game/backend/internal/server/handlers_admin_console.go
T
Ilia Denisov 356bf1a5ba
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
feat(admin): search users by email + erase a bound email
Add an exact (strict) email filter to the /users list (UserFilter.EmailExact →
a kind='email' identity match) with a search input, and an 'Erase email' action on
the user card that deletes the bound email identity and its pending confirmations,
freeing the address. It refuses to remove the account's only identity
(ErrLastIdentity), which would leave it unreachable. Integration tests for both.
2026-07-03 05:30:30 +02:00

1250 lines
45 KiB
Go

package server
import (
"bytes"
"encoding/csv"
"errors"
"fmt"
"html/template"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/adminconsole"
"scrabble/backend/internal/dictadmin"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/robot"
"scrabble/backend/internal/social"
)
// adminPageSize is the page size of the admin console's paginated lists.
const adminPageSize = 50
// maxHintGrant caps a single operator hint grant. Grants are additive and can never lower a
// wallet, so a fat-fingered grant cannot be undone through this form; the cap bounds one mistake.
const maxHintGrant = 100
// registerConsole mounts the server-rendered admin console under /_gm. The gateway
// puts HTTP Basic-Auth in front of /_gm and reverse-proxies it verbatim; the
// backend trusts the gateway (as for all of /api) and adds only a same-origin guard
// on the state-changing POSTs (docs/ARCHITECTURE.md §12). The console reads the
// account, game and dictionary surfaces, so it mounts only when those are wired.
func (s *Server) registerConsole(router *gin.Engine) {
if s.accounts == nil || s.games == nil || s.registry == nil {
return
}
s.console = adminconsole.MustNewRenderer()
assets, err := adminconsole.Assets()
if err != nil {
panic(err)
}
gm := router.Group("/_gm")
gm.Use(requireSameOrigin())
gm.StaticFS("/assets", http.FS(assets))
gm.GET("/", s.consoleDashboard)
gm.GET("/users", s.consoleUsers)
gm.GET("/users/:id", s.consoleUserDetail)
gm.POST("/users/:id/message", s.consoleUserMessage)
gm.POST("/users/:id/clear-high-rate-flag", s.consoleClearHighRateFlag)
gm.POST("/users/:id/grant-hints", s.consoleGrantHints)
gm.POST("/users/:id/block", s.consoleBlockUser)
gm.POST("/users/:id/unblock", s.consoleUnblockUser)
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
gm.POST("/users/:id/remove-email", s.consoleRemoveEmail)
gm.GET("/reasons", s.consoleReasons)
gm.POST("/reasons", s.consoleCreateReason)
gm.POST("/reasons/:id/update", s.consoleUpdateReason)
gm.POST("/reasons/:id/delete", s.consoleDeleteReason)
gm.GET("/throttled", s.consoleThrottled)
gm.POST("/bans/unban", s.consoleUnban)
gm.GET("/games", s.consoleGames)
gm.GET("/games/:id", s.consoleGameDetail)
gm.GET("/complaints", s.consoleComplaints)
gm.GET("/complaints/:id", s.consoleComplaintDetail)
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
if s.feedback != nil {
gm.GET("/feedback", s.consoleFeedback)
gm.GET("/feedback/:id", s.consoleFeedbackDetail)
gm.GET("/feedback/:id/attachment", s.consoleFeedbackAttachment)
gm.POST("/feedback/:id/read", s.consoleFeedbackRead)
gm.POST("/feedback/:id/reply", s.consoleFeedbackReply)
gm.POST("/feedback/:id/archive", s.consoleFeedbackArchive)
gm.POST("/feedback/:id/delete", s.consoleFeedbackDelete)
gm.POST("/feedback/:id/delete-all", s.consoleFeedbackDeleteAll)
}
gm.GET("/messages", s.consoleMessages)
gm.GET("/messages.csv", s.consoleMessagesCSV)
gm.GET("/messages/:id", s.consoleChatMessageDetail)
gm.GET("/dictionary", s.consoleDictionary)
gm.POST("/dictionary/upload", s.consoleUploadDictionary)
gm.POST("/dictionary/install", s.consoleInstallDictionary)
gm.POST("/dictionary/changes/apply", s.consoleApplyChanges)
gm.GET("/broadcast", s.consoleBroadcast)
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
// dictionary versions.
func (s *Server) consoleDashboard(c *gin.Context) {
ctx := c.Request.Context()
view := adminconsole.DashboardView{Variants: s.variantVersions(), ActiveVersion: s.games.ActiveVersion()}
// The Users card counts people only: robots are pool infrastructure, not registered
// users, so an empty filter (Robots: false) excludes them.
view.Accounts, _ = s.accounts.CountUsers(ctx, account.UserFilter{})
view.Games, _ = s.games.CountGames(ctx, "")
view.ActiveGames, _ = s.games.CountGames(ctx, game.StatusActive)
view.OpenComplaints, _ = s.games.CountComplaints(ctx, game.StatusComplaintOpen)
if s.feedback != nil {
view.OpenFeedback, _ = s.feedback.CountUnread(ctx)
}
if changes, err := s.games.DictionaryChanges(ctx); err == nil {
view.PendingChanges = len(changes)
}
s.renderConsole(c, "dashboard", "dashboard", "Dashboard", view)
}
// consoleUsers renders the paginated account list.
func (s *Server) consoleUsers(c *gin.Context) {
ctx := c.Request.Context()
page := consolePage(c)
filter := account.UserFilter{
Robots: c.Query("kind") == "robots",
NameMask: c.Query("name"),
ExternalIDMask: c.Query("ext"),
EmailExact: c.Query("email"),
}
total, _ := s.accounts.CountUsers(ctx, filter)
items, err := s.accounts.ListUsers(ctx, filter, adminPageSize, (page-1)*adminPageSize)
if err != nil {
s.consoleError(c, err)
return
}
q := url.Values{}
if filter.Robots {
q.Set("kind", "robots")
}
if strings.TrimSpace(filter.NameMask) != "" {
q.Set("name", filter.NameMask)
}
if strings.TrimSpace(filter.ExternalIDMask) != "" {
q.Set("ext", filter.ExternalIDMask)
}
if strings.TrimSpace(filter.EmailExact) != "" {
q.Set("email", filter.EmailExact)
}
view := adminconsole.UsersView{
Pager: adminconsole.NewPager(page, adminPageSize, total),
Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
EmailExact: filter.EmailExact,
FilterQuery: template.URL(q.Encode()),
}
ids := make([]uuid.UUID, 0, len(items))
for _, it := range items {
kind := "registered"
if it.IsRobot {
kind = "robot"
} else if it.IsGuest {
kind = "guest"
}
view.Items = append(view.Items, adminconsole.UserRow{
ID: it.ID.String(), DisplayName: it.DisplayName, Kind: kind,
Language: it.PreferredLanguage, Guest: it.IsGuest,
FlaggedHighRate: !it.FlaggedHighRateAt.IsZero(), CreatedAt: fmtTime(it.CreatedAt),
})
ids = append(ids, it.ID)
}
if stats, err := s.games.MoveDurationStats(ctx, ids); err == nil {
for i := range view.Items {
if st, ok := stats[ids[i]]; ok && st.Moves > 0 {
view.Items[i].HasMoveStats = true
view.Items[i].MoveMin = adminconsole.FormatDuration(st.MinSecs)
view.Items[i].MoveAvg = adminconsole.FormatDuration(st.AvgSecs)
view.Items[i].MoveMax = adminconsole.FormatDuration(st.MaxSecs)
}
}
}
s.renderConsole(c, "users", "users", "Users", view)
}
// consoleMessages renders the paginated chat-message moderation list, optionally pinned to
// one game (?game=) or sender (?user=) and filtered by sender glob masks (?name / ?ext).
func (s *Server) consoleMessages(c *gin.Context) {
ctx := c.Request.Context()
page := consolePage(c)
gameID, _ := uuid.Parse(strings.TrimSpace(c.Query("game")))
userID, _ := uuid.Parse(strings.TrimSpace(c.Query("user")))
filter := social.AdminMessageFilter{
GameID: gameID,
SenderID: userID,
NameMask: c.Query("name"),
ExtMask: c.Query("ext"),
UnreadOnly: c.Query("unread") == "1",
}
total, _ := s.social.AdminCountMessages(ctx, filter)
items, err := s.social.AdminListMessages(ctx, filter, adminPageSize, (page-1)*adminPageSize)
if err != nil {
s.consoleError(c, err)
return
}
q := url.Values{}
if filter.GameID != uuid.Nil {
q.Set("game", filter.GameID.String())
}
if filter.SenderID != uuid.Nil {
q.Set("user", filter.SenderID.String())
}
if strings.TrimSpace(filter.NameMask) != "" {
q.Set("name", filter.NameMask)
}
if strings.TrimSpace(filter.ExtMask) != "" {
q.Set("ext", filter.ExtMask)
}
if filter.UnreadOnly {
q.Set("unread", "1")
}
view := adminconsole.MessagesView{
Pager: adminconsole.NewPager(page, adminPageSize, total),
NameMask: filter.NameMask,
ExtMask: filter.ExtMask,
UnreadOnly: filter.UnreadOnly,
FilterQuery: template.URL(q.Encode()),
}
if filter.GameID != uuid.Nil {
view.GameID = filter.GameID.String()
}
if filter.SenderID != uuid.Nil {
view.UserID = filter.SenderID.String()
}
for _, m := range items {
view.Items = append(view.Items, adminconsole.MessageRow{
ID: m.ID.String(), SenderID: m.SenderID.String(), SenderName: m.SenderName,
Source: m.Source, IP: m.SenderIP, Body: m.Body,
GameID: m.GameID.String(), CreatedAt: fmtTime(m.CreatedAt), Unread: m.UnreadSeats != 0,
})
}
s.renderConsole(c, "messages", "messages", "Messages", view)
}
// consoleChatMessageDetail renders one chat message with the per-seat read breakdown of
// its game (who has read it, who has not).
func (s *Server) consoleChatMessageDetail(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/messages")
if !ok {
return
}
card, err := s.social.AdminMessageDetail(c.Request.Context(), id)
if err != nil {
if errors.Is(err, social.ErrMessageNotFound) {
s.renderConsoleMessage(c, "Not found", "no such message", "/_gm/messages")
return
}
s.consoleError(c, err)
return
}
view := adminconsole.ChatMessageDetailView{
ID: card.ID.String(), GameID: card.GameID.String(), SenderID: card.SenderID.String(),
SenderName: card.SenderName, Source: card.Source, Kind: card.Kind, Body: card.Body,
IP: card.SenderIP, CreatedAt: fmtTime(card.CreatedAt), Unread: card.UnreadSeats != 0,
}
for _, st := range card.Seats {
view.Seats = append(view.Seats, adminconsole.ChatSeatStatusRow{
Seat: st.Seat, AccountID: st.AccountID.String(), DisplayName: st.DisplayName, Role: st.Role,
})
}
s.renderConsole(c, "chatmessage", "messages", "Message", view)
}
// adminMessagesExportCap bounds the CSV export row count (the moderated chat volume is small).
const adminMessagesExportCap = 100000
// consoleMessagesCSV exports the whole filtered chat-message list (ignoring pagination) as a
// CSV download, for offline moderation review.
func (s *Server) consoleMessagesCSV(c *gin.Context) {
ctx := c.Request.Context()
gameID, _ := uuid.Parse(strings.TrimSpace(c.Query("game")))
userID, _ := uuid.Parse(strings.TrimSpace(c.Query("user")))
filter := social.AdminMessageFilter{
GameID: gameID,
SenderID: userID,
NameMask: c.Query("name"),
ExtMask: c.Query("ext"),
UnreadOnly: c.Query("unread") == "1",
}
items, err := s.social.AdminListMessages(ctx, filter, adminMessagesExportCap, 0)
if err != nil {
s.consoleError(c, err)
return
}
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="messages.csv"`)
w := csv.NewWriter(c.Writer)
_ = w.Write([]string{"time", "source", "sender_id", "sender", "ip", "message", "game_id", "read"})
for _, m := range items {
read := "read"
if m.UnreadSeats != 0 {
read = "unread"
}
// The sender name and message body are user-controlled; defuse spreadsheet formula
// injection so a moderator opening the export can't trigger a formula.
_ = w.Write([]string{
fmtTime(m.CreatedAt), m.Source, m.SenderID.String(), csvSafe(m.SenderName), csvSafe(m.SenderIP), csvSafe(m.Body), m.GameID.String(), read,
})
}
w.Flush()
}
// csvSafe defuses CSV/spreadsheet formula injection: a value a spreadsheet would treat as a
// formula (a leading =, +, -, @, tab or CR) is prefixed with a single quote so it renders as
// plain text on open.
func csvSafe(s string) string {
if s == "" {
return s
}
switch s[0] {
case '=', '+', '-', '@', '\t', '\r':
return "'" + s
}
return s
}
// consoleUserDetail renders one account with its stats, identities and games.
func (s *Server) consoleUserDetail(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
acc, err := s.accounts.GetByID(ctx, id)
if err != nil {
s.consoleError(c, err)
return
}
view := adminconsole.UserDetailView{
ID: acc.ID.String(), DisplayName: acc.DisplayName, Language: acc.PreferredLanguage,
TimeZone: acc.TimeZone, Guest: acc.IsGuest, NotificationsInAppOnly: acc.NotificationsInAppOnly,
PaidAccount: acc.PaidAccount, HintBalance: acc.HintBalance, HintGrantMax: maxHintGrant,
CreatedAt: fmtTime(acc.CreatedAt), HasStats: !acc.IsGuest, ConnectorEnabled: s.connector != nil,
}
if acc.MergedInto != uuid.Nil {
view.MergedInto = acc.MergedInto.String()
}
if !acc.FlaggedHighRateAt.IsZero() {
view.FlaggedHighRateAt = fmtTime(acc.FlaggedHighRateAt)
}
if view.HasStats {
if st, err := s.accounts.GetStats(ctx, id); err == nil {
view.Stats = adminconsole.StatsRow{Wins: st.Wins, Losses: st.Losses, Draws: st.Draws, MaxGamePoints: st.MaxGamePoints, MaxWordPoints: st.MaxWordPoints, Moves: st.Moves, HintsUsed: st.HintsUsed}
}
}
if ids, err := s.accounts.Identities(ctx, id); err == nil {
for _, idn := range ids {
if idn.Kind == account.KindEmail {
view.HasEmail = true
}
view.Identities = append(view.Identities, adminconsole.IdentityRow{Kind: idn.Kind, ExternalID: idn.ExternalID, Confirmed: idn.Confirmed, CreatedAt: fmtTime(idn.CreatedAt)})
}
}
if tg, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram); err == nil {
view.TelegramID = tg
}
if vk, err := s.accounts.IdentityExternalID(ctx, id, account.KindVK); err == nil {
view.VKID = vk
}
if games, err := s.games.ListForAccount(ctx, id); err == nil {
for _, g := range games {
view.Games = append(view.Games, gameRow(g))
}
}
if pts, err := s.games.MoveDurationByOrdinal(ctx, id); err == nil && len(pts) > 0 {
cps := make([]adminconsole.ChartPoint, len(pts))
for i, p := range pts {
cps[i] = adminconsole.ChartPoint{Ordinal: p.Ordinal, Min: p.MinSecs, Max: p.MaxSecs, Avg: p.AvgSecs}
}
view.MoveChart = adminconsole.MoveDurationChart(cps)
}
if susp, blocked, err := s.accounts.CurrentSuspension(ctx, id); err == nil && blocked {
view.Suspension = adminconsole.SuspensionView{
Blocked: true, Permanent: susp.Permanent(), BlockedAt: fmtTime(susp.BlockedAt),
ReasonEn: susp.ReasonEn, ReasonRu: susp.ReasonRu,
}
if susp.BlockedUntil != nil {
view.Suspension.Until = fmtTime(*susp.BlockedUntil)
}
}
if reasons, err := s.accounts.ListReasons(ctx); err == nil {
for _, r := range reasons {
view.Reasons = append(view.Reasons, adminconsole.ReasonOption{ID: r.ID.String(), TextEn: r.TextEn, TextRu: r.TextRu})
}
}
if roles, err := s.accounts.ListRoles(ctx, id); err == nil {
view.Roles = roles
}
view.KnownRoles = account.KnownRoles
if s.social != nil {
if rels, err := s.social.AdminBlocksBy(ctx, id); err == nil {
view.Blocks = relationRows(rels)
}
if rels, err := s.social.AdminBlockedBy(ctx, id); err == nil {
view.BlockedBy = relationRows(rels)
}
if rels, err := s.social.AdminFriends(ctx, id); err == nil {
view.Friends = relationRows(rels)
}
}
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
}
// relationRows maps the social graph entries to the cross-linked, date-formatted rows the
// user card renders.
func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow {
out := make([]adminconsole.RelationRow, 0, len(rels))
for _, r := range rels {
out = append(out, adminconsole.RelationRow{AccountID: r.AccountID.String(), DisplayName: r.DisplayName, Date: fmtTime(r.At)})
}
return out
}
// consoleUserMessage sends an operator Telegram message to one user.
func (s *Server) consoleUserMessage(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
text := trimForm(c, "text")
switch {
case text == "":
s.renderConsoleMessage(c, "Nothing sent", "the message was empty", back)
case s.connector == nil:
s.renderConsoleMessage(c, "Not configured", "the connector is not configured (set BACKEND_CONNECTOR_ADDR)", back)
default:
ext, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram)
if err != nil {
s.renderConsoleMessage(c, "No Telegram", "this account has no Telegram identity", back)
return
}
delivered, err := s.connector.SendToUser(ctx, ext, text)
if err != nil {
s.consoleError(c, err)
return
}
body := "message delivered"
if !delivered {
body = "not delivered (the user may not have started the bot)"
}
s.renderConsoleMessage(c, "Sent", body, back)
}
}
// consoleGames renders the paginated games list, optionally filtered by status.
func (s *Server) consoleGames(c *gin.Context) {
ctx := c.Request.Context()
status := normalizeGameStatus(c.Query("status"))
page := consolePage(c)
total, _ := s.games.CountGames(ctx, status)
games, err := s.games.ListGames(ctx, status, adminPageSize, (page-1)*adminPageSize)
if err != nil {
s.consoleError(c, err)
return
}
view := adminconsole.GamesView{Status: status, Pager: adminconsole.NewPager(page, adminPageSize, total)}
for _, g := range games {
view.Items = append(view.Items, gameRow(g))
}
s.renderConsole(c, "games", "games", "Games", view)
}
// consoleGameDetail renders one game with its seats (display names resolved).
func (s *Server) consoleGameDetail(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/games")
if !ok {
return
}
g, err := s.games.GameByID(ctx, id)
if err != nil {
s.consoleError(c, err)
return
}
view := adminconsole.GameDetailView{
ID: g.ID.String(), Variant: g.Variant.String(), DictVersion: g.DictVersion,
Status: g.Status, Players: g.Players, ToMove: g.ToMove, EndReason: g.EndReason,
MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt),
FinishedAt: fmtTimePtr(g.FinishedAt), VsAI: g.VsAI,
}
// Resolve seats and detect robot seats; capture the human opponent's timezone, which
// anchors the robot's sleep window for the next-move ETA.
oppTZ := ""
for _, seat := range g.Seats {
row := adminconsole.SeatRow{Seat: seat.Seat, AccountID: seat.AccountID.String(), Score: seat.Score, HintsUsed: seat.HintsUsed, Winner: seat.IsWinner}
acc, accErr := s.accounts.GetByID(ctx, seat.AccountID)
if accErr == nil {
row.DisplayName = acc.DisplayName
}
if isRobot, _ := s.accounts.IsRobot(ctx, seat.AccountID); isRobot {
row.IsRobot = true
view.HasRobot = true
} else if accErr == nil {
oppTZ = acc.TimeZone
}
view.Seats = append(view.Seats, row)
}
// For each robot seat, surface the game's deterministic play-to-win intent and — while
// it is that robot's turn — the scheduled next-move ETA, both derived from the bag seed.
if view.HasRobot {
view.RobotTargetPct = robot.PlayToWinTargetPercent
if seed, turnStartedAt, schedErr := s.games.RobotSchedule(ctx, g.ID); schedErr == nil {
now := time.Now().UTC()
for i := range view.Seats {
if !view.Seats[i].IsRobot {
continue
}
if robot.PlayToWin(seed) {
view.Seats[i].RobotIntent = "play to win"
} else {
view.Seats[i].RobotIntent = "play to lose"
}
if g.Status == game.StatusActive && g.ToMove == view.Seats[i].Seat {
view.Seats[i].NextMove = robotETA(robot.NextMoveAt(seed, g.MoveCount, turnStartedAt, oppTZ), now)
}
}
}
}
// First-move draw (docs/ARCHITECTURE.md §6) and the step-by-step replay timeline. Both are
// best-effort: a game that predates the draw or cannot be replayed still renders its
// summary and seats.
names := make(map[string]string, len(view.Seats))
for _, st := range view.Seats {
names[st.AccountID] = st.DisplayName
}
if draws, derr := s.games.SetupDraws(ctx, g.ID); derr == nil {
for _, d := range draws {
row := adminconsole.SetupDrawRow{Round: d.Round, Letter: strings.ToUpper(d.Letter), Blank: d.Blank, Rank: d.Rank}
if d.Account == uuid.Nil {
row.Name = "(opponent)"
} else {
row.Name = names[d.Account.String()]
row.AccountID = d.Account.String()
}
view.SetupDraws = append(view.SetupDraws, row)
}
}
if len(view.Seats) > 0 {
view.FirstMover = view.Seats[0].DisplayName
}
if tl, terr := s.games.ReplayTimeline(ctx, g.ID); terr == nil {
if rj, jerr := buildReplayJSON(g.Variant, tl, view.Seats); jerr == nil {
view.ReplayJSON = rj
view.HasReplay = len(tl.Steps) > 0
}
}
s.renderConsole(c, "game_detail", "games", "Game", view)
}
// robotETA formats a robot's scheduled next-move instant as an absolute UTC time plus a
// relative estimate, e.g. "≈ 14:37 UTC (in ~7 min)"; a past instant reads "(due now)".
func robotETA(at, now time.Time) string {
mins := int(at.Sub(now).Round(time.Minute).Minutes())
rel := fmt.Sprintf("in ~%d min", mins)
if mins <= 0 {
rel = "due now"
}
return fmt.Sprintf("≈ %s UTC (%s)", at.UTC().Format("15:04"), rel)
}
// consoleComplaints renders the paginated complaint review queue.
func (s *Server) consoleComplaints(c *gin.Context) {
ctx := c.Request.Context()
status := normalizeComplaintStatus(c.Query("status"))
page := consolePage(c)
total, _ := s.games.CountComplaints(ctx, status)
rows, err := s.games.ListComplaints(ctx, status, adminPageSize, (page-1)*adminPageSize)
if err != nil {
s.consoleError(c, err)
return
}
view := adminconsole.ComplaintsView{Status: status, Pager: adminconsole.NewPager(page, adminPageSize, total)}
for _, cp := range rows {
view.Items = append(view.Items, adminconsole.ComplaintRow{
ID: cp.ID.String(), Word: cp.Word, Variant: cp.Variant.String(), WasValid: cp.WasValid,
Status: cp.Status, Disposition: cp.Disposition, CreatedAt: fmtTime(cp.CreatedAt),
})
}
s.renderConsole(c, "complaints", "complaints", "Complaints", view)
}
// consoleComplaintDetail renders one complaint with its resolution form.
func (s *Server) consoleComplaintDetail(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/complaints")
if !ok {
return
}
cp, err := s.games.GetComplaint(ctx, id)
if err != nil {
s.consoleError(c, err)
return
}
s.renderConsole(c, "complaint_detail", "complaints", "Complaint", adminconsole.ComplaintDetailView{
ID: cp.ID.String(), Word: cp.Word, Variant: cp.Variant.String(), DictVersion: cp.DictVersion,
WasValid: cp.WasValid, Note: cp.Note, Status: cp.Status, Disposition: cp.Disposition,
ResolutionNote: cp.ResolutionNote, CreatedAt: fmtTime(cp.CreatedAt), ResolvedAt: fmtTimePtr(cp.ResolvedAt),
GameID: cp.GameID.String(), Resolved: cp.Status == game.StatusComplaintResolved,
})
}
// consoleResolveComplaint resolves a complaint with the chosen disposition.
func (s *Server) consoleResolveComplaint(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/complaints")
if !ok {
return
}
disposition := c.PostForm("disposition")
if _, err := s.games.ResolveComplaint(ctx, id, disposition, trimForm(c, "note")); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Resolved", "complaint resolved as "+disposition, "/_gm/complaints/"+id.String())
}
// consoleDictionary renders the resident versions and the pending wordlist changes.
func (s *Server) consoleDictionary(c *gin.Context) {
view := adminconsole.DictionaryView{Variants: s.variantVersions(), ActiveVersion: s.games.ActiveVersion()}
if changes, err := s.games.DictionaryChanges(c.Request.Context()); err == nil {
for _, ch := range changes {
action := "remove"
if ch.Add {
action = "add"
}
view.Changes = append(view.Changes, adminconsole.DictChangeRow{Variant: ch.Variant.String(), Word: ch.Word, Action: action, ResolvedAt: fmtTime(ch.ResolvedAt)})
}
}
s.renderConsole(c, "dictionary", "dictionary", "Dictionary", view)
}
// previewSampleCap bounds how many added/removed words the update preview lists per
// variant; the full totals are always shown.
const previewSampleCap = 200
// largeRemovalThreshold flags a removal large enough that the operator should pause
// before confirming: a rebuilt dictionary normally drops a handful of words, not
// thousands.
const largeRemovalThreshold = 1000
// consoleUploadDictionary accepts a scrabble-dawg release archive, stages it under
// the dictionary directory and renders the per-variant word diff against the active
// dictionary. The operator confirms it with consoleInstallDictionary. The archive
// is bounded, its file name fixes the version, and a version already resident is
// rejected (versions are immutable).
func (s *Server) consoleUploadDictionary(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, dictadmin.MaxArchiveBytes)
file, header, err := c.Request.FormFile("archive")
if err != nil {
s.renderConsoleMessage(c, "Upload failed", "choose a scrabble-dawg-vX.Y.Z.tar.gz archive (max 64 MiB)", "/_gm/dictionary")
return
}
defer func() { _ = file.Close() }()
version, err := dictadmin.ParseVersionFromName(header.Filename)
if err != nil {
s.renderConsoleMessage(c, "Upload failed", "the file name must be scrabble-dawg-vX.Y.Z.tar.gz", "/_gm/dictionary")
return
}
if s.versionResident(version) {
s.renderConsoleMessage(c, "Already resident", fmt.Sprintf("version %s is already loaded; versions are immutable", version), "/_gm/dictionary")
return
}
mgr := dictadmin.New(s.dictDir)
token, variants, err := mgr.Stage(file)
if err != nil {
s.consoleError(c, err)
return
}
if len(variants) != len(engine.Variants()) {
mgr.Discard(token)
s.renderConsoleMessage(c, "Incomplete archive",
fmt.Sprintf("the archive carries %d of %d variants; a release must contain all of them", len(variants), len(engine.Variants())),
"/_gm/dictionary")
return
}
staged, err := mgr.StagedDir(token)
if err != nil {
s.consoleError(c, err)
return
}
rows, err := s.diffStaged(staged, variants)
if err != nil {
mgr.Discard(token)
s.consoleError(c, err)
return
}
s.renderConsole(c, "dictionary_preview", "dictionary", "Dictionary update", adminconsole.DictionaryPreviewView{
Version: version,
Token: token,
ActiveVersion: s.games.ActiveVersion(),
Variants: rows,
})
}
// consoleInstallDictionary promotes a staged upload into its version directory,
// loads it into the registry and makes it the active version new games pin. The
// version is taken from the (editable) form field, re-validated and checked for
// immutability.
func (s *Server) consoleInstallDictionary(c *gin.Context) {
token := trimForm(c, "token")
version := trimForm(c, "version")
if !dictadmin.ValidVersion(version) {
s.renderConsoleMessage(c, "Install failed", "the version must look like vX.Y.Z", "/_gm/dictionary")
return
}
if s.versionResident(version) {
s.renderConsoleMessage(c, "Already resident", fmt.Sprintf("version %s is already loaded; versions are immutable", version), "/_gm/dictionary")
return
}
mgr := dictadmin.New(s.dictDir)
dest, err := mgr.Install(token, version)
if err != nil {
s.consoleError(c, err)
return
}
loaded, err := s.registry.LoadAvailable(dest, version)
if err != nil {
s.consoleError(c, err)
return
}
if len(loaded) != len(engine.Variants()) {
s.renderConsoleMessage(c, "Install incomplete",
fmt.Sprintf("loaded %d of %d variants from %s", len(loaded), len(engine.Variants()), version), "/_gm/dictionary")
return
}
if err := s.games.SetActiveVersion(c.Request.Context(), version); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Updated",
fmt.Sprintf("dictionary %s installed and activated; new games now use it, games in progress keep their version", version),
"/_gm/dictionary")
}
// diffStaged computes the word diff of each staged variant against the active
// dictionary, projected into the preview rows.
func (s *Server) diffStaged(staged string, variants []engine.Variant) ([]adminconsole.VariantDiffRow, error) {
active := s.games.ActiveVersion()
rows := make([]adminconsole.VariantDiffRow, 0, len(variants))
for _, v := range variants {
newFinder, err := engine.OpenFinder(staged, v)
if err != nil {
return nil, err
}
oldFinder, err := s.registry.Finder(v, active)
if err != nil {
_ = newFinder.Close()
return nil, fmt.Errorf("active dictionary %s/%s not resident: %w", v, active, err)
}
diff, err := engine.DiffWords(v, oldFinder, newFinder)
_ = newFinder.Close()
if err != nil {
return nil, err
}
rows = append(rows, diffRow(v, diff))
}
return rows, nil
}
// diffRow projects a variant's word diff into its preview row, capping the sample
// lists and flagging a large removal.
func diffRow(v engine.Variant, d engine.WordDiff) adminconsole.VariantDiffRow {
added, addedTrunc := sampleWords(d.Added)
removed, removedTrunc := sampleWords(d.Removed)
return adminconsole.VariantDiffRow{
Variant: v.String(),
AddedCount: len(d.Added),
RemovedCount: len(d.Removed),
AddedSample: added,
RemovedSample: removed,
AddedTruncated: addedTrunc,
RemovedTruncated: removedTrunc,
LargeRemoval: len(d.Removed) >= largeRemovalThreshold,
}
}
// sampleWords returns at most previewSampleCap words and whether the list was cut.
func sampleWords(words []string) ([]string, bool) {
if len(words) <= previewSampleCap {
return words, false
}
return words[:previewSampleCap], true
}
// versionResident reports whether version is loaded for any variant (covering the
// flat seed and every uploaded subdirectory), backing the immutability guard.
func (s *Server) versionResident(version string) bool {
for _, v := range engine.Variants() {
if _, err := s.registry.Finder(v, version); err == nil {
return true
}
}
return false
}
// consoleApplyChanges marks a variant's pending accepted changes applied in a
// reloaded version.
func (s *Server) consoleApplyChanges(c *gin.Context) {
variant, err := engine.ParseVariant(c.PostForm("variant"))
if err != nil {
s.renderConsoleMessage(c, "Apply failed", "unknown variant", "/_gm/dictionary")
return
}
version := trimForm(c, "version")
if version == "" {
s.renderConsoleMessage(c, "Apply failed", "a version is required", "/_gm/dictionary")
return
}
n, err := s.games.MarkChangesApplied(c.Request.Context(), variant, version)
if err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Applied", fmt.Sprintf("marked %d change(s) applied in %q", n, version), "/_gm/dictionary")
}
// consoleBroadcast renders the operator-broadcast form.
func (s *Server) consoleBroadcast(c *gin.Context) {
s.renderConsole(c, "broadcast", "broadcast", "Broadcast", adminconsole.BroadcastView{ConnectorEnabled: s.connector != nil})
}
// consolePostBroadcast posts an operator message to the connector's game channel.
func (s *Server) consolePostBroadcast(c *gin.Context) {
text := trimForm(c, "text")
switch {
case text == "":
s.renderConsoleMessage(c, "Nothing sent", "the message was empty", "/_gm/broadcast")
case s.connector == nil:
s.renderConsoleMessage(c, "Not configured", "the connector is not configured (set BACKEND_CONNECTOR_ADDR)", "/_gm/broadcast")
default:
delivered, err := s.connector.SendToGameChannel(c.Request.Context(), text)
if err != nil {
s.consoleError(c, err)
return
}
body := "posted to the game channel"
if !delivered {
body = "not delivered (the bot has no game channel configured)"
}
s.renderConsoleMessage(c, "Broadcast", body, "/_gm/broadcast")
}
}
// consoleThrottled renders the rate-limit observability page: the recent
// gateway-reported throttle episodes (in-memory, reset on a backend restart)
// and the accounts currently carrying the soft high-rate flag.
func (s *Server) consoleThrottled(c *gin.Context) {
ctx := c.Request.Context()
var view adminconsole.ThrottledView
if s.ratewatch != nil {
cfg := s.ratewatch.Config()
view.FlagThreshold = cfg.FlagThreshold
view.FlagWindow = cfg.FlagWindow.String()
for _, ep := range s.ratewatch.Recent() {
row := adminconsole.ThrottleEpisodeRow{
Class: ep.Class, Key: ep.Key, Rejected: ep.Rejected,
FirstSeen: fmtTime(ep.FirstSeen), LastSeen: fmtTime(ep.LastSeen),
}
if ep.Class == ratewatch.ClassUser {
if id, err := uuid.Parse(ep.Key); err == nil {
row.UserID = id.String()
}
}
view.Episodes = append(view.Episodes, row)
}
}
if s.banview != nil {
for _, b := range s.banview.Recent() {
view.Bans = append(view.Bans, adminconsole.BanRow{
IP: b.IP, Reason: b.Reason, Since: fmtTime(b.Since), Expires: fmtTime(b.Expires),
})
}
}
flagged, err := s.accounts.ListFlaggedHighRate(ctx)
if err != nil {
s.consoleError(c, err)
return
}
for _, fa := range flagged {
view.Flagged = append(view.Flagged, adminconsole.FlaggedAccountRow{
ID: fa.ID.String(), DisplayName: fa.DisplayName, FlaggedAt: fmtTime(fa.FlaggedHighRateAt),
})
}
s.renderConsole(c, "throttled", "throttled", "Throttled", view)
}
// consoleUnban lifts a temporary IP ban — the operator's manual override. The
// gateway applies it on its next active-ban sync, so the ban clears within the
// sync interval rather than immediately.
func (s *Server) consoleUnban(c *gin.Context) {
ip := trimForm(c, "ip")
if ip == "" {
s.renderConsoleMessage(c, "Invalid", "an IP address is required", "/_gm/throttled")
return
}
if s.banview != nil {
s.banview.RequestUnban(ip)
}
s.renderConsoleMessage(c, "Unban requested", fmt.Sprintf("%s will be unbanned on the next gateway sync", ip), "/_gm/throttled")
}
// consoleClearHighRateFlag clears the soft high-rate marker — the operator's
// reversible review action.
func (s *Server) consoleClearHighRateFlag(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
if err := s.accounts.ClearHighRateFlag(c.Request.Context(), id); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Cleared", "high-rate flag cleared", "/_gm/users/"+id.String())
}
// consoleGrantHints adds hints to a user's wallet. The grant is additive (raise-only): it tops a
// player up and can never lower what they already hold, so blocking a reduction is inherent rather
// than a separate guard. A single grant is bounded by maxHintGrant.
func (s *Server) consoleGrantHints(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
n, err := strconv.Atoi(trimForm(c, "amount"))
if err != nil || n < 1 || n > maxHintGrant {
s.renderConsoleMessage(c, "Invalid amount", fmt.Sprintf("enter a whole number of hints to add, between 1 and %d", maxHintGrant), back)
return
}
balance, err := s.accounts.GrantHints(c.Request.Context(), id, n)
if err != nil {
s.consoleError(c, err)
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)
}
// consoleRemoveEmail deletes the account's bound email identity (and any pending
// confirmations), freeing the address. It refuses to remove the account's only
// identity, which would leave it unreachable.
func (s *Server) consoleRemoveEmail(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
switch err := s.accounts.RemoveEmailIdentity(c.Request.Context(), id); {
case errors.Is(err, account.ErrLastIdentity):
s.renderConsoleMessage(c, "Can't remove", "the email is this account's only identity — removing it would leave the account unreachable", back)
case err != nil:
s.consoleError(c, err)
default:
s.renderConsoleMessage(c, "Removed", "the email identity was erased and the address freed", back)
}
}
// consoleBlockUser manually blocks an account: it records the suspension (permanent or until a
// parsed deadline, snapshotting the chosen reason's en/ru text) and forfeits the player's active
// games, removing them from matchmaking. The block takes effect on the player's next request.
func (s *Server) consoleBlockUser(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
until, ok := parseSuspendUntil(trimForm(c, "duration"), trimForm(c, "until"))
if !ok {
s.renderConsoleMessage(c, "Invalid duration", "pick a preset or a future custom date/time (UTC)", back)
return
}
var reasonEn, reasonRu string
var reasonID *uuid.UUID
if rid := trimForm(c, "reason"); rid != "" {
parsed, err := uuid.Parse(rid)
if err != nil {
s.renderConsoleMessage(c, "Invalid reason", "the selected reason is not valid", back)
return
}
reason, err := s.accounts.GetReason(ctx, parsed)
if err != nil {
s.consoleError(c, err)
return
}
reasonEn, reasonRu, reasonID = reason.TextEn, reason.TextRu, &reason.ID
}
if _, err := s.accounts.Suspend(ctx, id, until, reasonEn, reasonRu, reasonID); err != nil {
s.consoleError(c, err)
return
}
forfeited, err := s.games.ForfeitAllForAccount(ctx, id)
if err != nil {
s.consoleError(c, err)
return
}
// Re-evaluate the player's moderated-chat write access: a block mutes them in
// the discussion chat if they are currently in it.
s.publishChatAccessChange(id)
s.renderConsoleMessage(c, "Blocked", fmt.Sprintf("account blocked; %d game(s) forfeited", forfeited), back)
}
// consoleUnblockUser lifts an account's block (temporary or permanent). Games already lost at
// block time are not restored.
func (s *Server) consoleUnblockUser(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
if err := s.accounts.LiftSuspension(c.Request.Context(), id); err != nil {
s.consoleError(c, err)
return
}
// Re-evaluate the player's moderated-chat write access: an unblock restores it
// (unless they are still chat-muted) for a member currently in the chat.
s.publishChatAccessChange(id)
s.renderConsoleMessage(c, "Unblocked", "the block was lifted; lost games are not restored", "/_gm/users/"+id.String())
}
// parseSuspendUntil maps the block form's duration choice to an expiry instant, returning nil for
// a permanent block. A custom choice parses the datetime-local value as UTC and must be in the
// future. It reports false for an unrecognised choice or an invalid/past custom date.
func parseSuspendUntil(choice, custom string) (*time.Time, bool) {
now := time.Now().UTC()
switch choice {
case "permanent":
return nil, true
case "1d":
t := now.AddDate(0, 0, 1)
return &t, true
case "3d":
t := now.AddDate(0, 0, 3)
return &t, true
case "1w":
t := now.AddDate(0, 0, 7)
return &t, true
case "1m":
t := now.AddDate(0, 1, 0)
return &t, true
case "custom":
for _, layout := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05"} {
if t, err := time.Parse(layout, custom); err == nil {
if !t.After(now) {
return nil, false
}
return &t, true
}
}
return nil, false
default:
return nil, false
}
}
// consoleReasons renders the operator-editable suspension-reason picklist.
func (s *Server) consoleReasons(c *gin.Context) {
reasons, err := s.accounts.ListReasons(c.Request.Context())
if err != nil {
s.consoleError(c, err)
return
}
var view adminconsole.ReasonsView
for _, r := range reasons {
view.Items = append(view.Items, adminconsole.ReasonRow{ID: r.ID.String(), TextEn: r.TextEn, TextRu: r.TextRu, CreatedAt: fmtTime(r.CreatedAt)})
}
s.renderConsole(c, "reasons", "reasons", "Reasons", view)
}
// consoleCreateReason adds a suspension-reason picklist entry; both languages are required.
func (s *Server) consoleCreateReason(c *gin.Context) {
en, ru := trimForm(c, "text_en"), trimForm(c, "text_ru")
if en == "" || ru == "" {
s.renderConsoleMessage(c, "Nothing added", "both English and Russian text are required", "/_gm/reasons")
return
}
if _, err := s.accounts.CreateReason(c.Request.Context(), en, ru); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Added", "reason added", "/_gm/reasons")
}
// consoleUpdateReason rewrites a reason's English and Russian text. Existing blocks keep their
// snapshot, so the change only affects future blocks.
func (s *Server) consoleUpdateReason(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/reasons")
if !ok {
return
}
en, ru := trimForm(c, "text_en"), trimForm(c, "text_ru")
if en == "" || ru == "" {
s.renderConsoleMessage(c, "Not changed", "both English and Russian text are required", "/_gm/reasons")
return
}
if _, err := s.accounts.UpdateReason(c.Request.Context(), id, en, ru); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Updated", "reason updated", "/_gm/reasons")
}
// consoleDeleteReason removes a reason from the picklist. Past blocks keep their text snapshot.
func (s *Server) consoleDeleteReason(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/reasons")
if !ok {
return
}
if err := s.accounts.DeleteReason(c.Request.Context(), id); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Deleted", "reason deleted", "/_gm/reasons")
}
// variantVersions builds the per-variant resident-version summary from the registry.
func (s *Server) variantVersions() []adminconsole.VariantVersions {
out := make([]adminconsole.VariantVersions, 0, len(engine.Variants()))
for _, v := range engine.Variants() {
vv := adminconsole.VariantVersions{Variant: v.String(), Versions: s.registry.Versions(v)}
if latest, _, err := s.registry.Latest(v); err == nil {
vv.Latest = latest
}
out = append(out, vv)
}
return out
}
// renderConsole renders a console page into a buffer, then writes it; a render
// failure is logged and reported as 500 without emitting a partial document.
func (s *Server) renderConsole(c *gin.Context, page, nav, title string, data any) {
var buf bytes.Buffer
if err := s.console.Render(&buf, page, adminconsole.PageData{Title: title, ActiveNav: nav, Data: data}); err != nil {
s.log.Error("admin console render", zap.String("page", page), zap.Error(err))
c.String(http.StatusInternalServerError, "render error")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
}
// renderConsoleMessage renders the post-action result page with a back link.
func (s *Server) renderConsoleMessage(c *gin.Context, heading, body, back string) {
s.renderConsole(c, "message", "", heading, adminconsole.MessageView{Heading: heading, Body: body, Back: back})
}
// consoleError logs an unexpected console error and renders it on the message page.
// The console is operator-only (gateway Basic-Auth), so the message is shown as-is.
func (s *Server) consoleError(c *gin.Context, err error) {
s.log.Error("admin console", zap.String("path", c.FullPath()), zap.Error(err))
s.renderConsole(c, "message", "", "Error", adminconsole.MessageView{Heading: "Error", Body: err.Error(), Back: "/_gm/"})
}
// consoleUUID parses the :id path parameter, rendering an invalid-id message and
// returning false when it is not a UUID.
func (s *Server) consoleUUID(c *gin.Context, back string) (uuid.UUID, bool) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
s.renderConsoleMessage(c, "Invalid id", "the id in the URL is not valid", back)
return uuid.UUID{}, false
}
return id, true
}
// gameRow projects a game summary into its console row.
func gameRow(g game.Game) adminconsole.GameRow {
return adminconsole.GameRow{ID: g.ID.String(), Variant: g.Variant.String(), Status: g.Status, Players: g.Players, UpdatedAt: fmtTime(g.UpdatedAt), VsAI: g.VsAI}
}
// trimForm returns the trimmed value of a posted form field.
func trimForm(c *gin.Context, name string) string {
return strings.TrimSpace(c.PostForm(name))
}
// consolePage parses the 1-based ?page query parameter, defaulting to 1.
func consolePage(c *gin.Context) int {
if n, err := strconv.Atoi(c.Query("page")); err == nil && n > 1 {
return n
}
return 1
}
// normalizeGameStatus keeps only a recognised game status filter, else "" (all).
func normalizeGameStatus(s string) string {
switch s {
case game.StatusActive, game.StatusFinished, game.StatusOpen:
return s
}
return ""
}
// normalizeComplaintStatus keeps only a recognised complaint status filter, else
// "" (all).
func normalizeComplaintStatus(s string) string {
switch s {
case game.StatusComplaintOpen, game.StatusComplaintResolved:
return s
}
return ""
}
// fmtTime formats a timestamp for display, or "" when zero.
func fmtTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format("2006-01-02 15:04")
}
// fmtTimeIn formats a timestamp in the given zone — a "±HH:MM" offset or an IANA name, resolved
// by account.ResolveZone (falling back to UTC when empty or unknown) — or "" when zero. Used to
// show a time in a user's local zone beside UTC; the offset form is what the profile editor and
// the feedback browser-tz snapshot store, so it must not go through time.LoadLocation alone.
func fmtTimeIn(t time.Time, tz string) string {
if t.IsZero() {
return ""
}
return t.In(account.ResolveZone(tz)).Format("2006-01-02 15:04")
}
// fmtTimePtr formats an optional timestamp for display, or "" when nil.
func fmtTimePtr(t *time.Time) string {
if t == nil {
return ""
}
return fmtTime(*t)
}