d1ba666495
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
Operator-driven hard block, the counterpart to the soft high-rate flag: permanent or until a date, with an optional reason chosen from an editable en+ru picklist (snapshotted onto the block). A block forfeits the player's active games (opponent wins, as a resignation) and cancels their open matchmaking games. A backend gate refuses a blocked account on every /api/v1/user/* route except the block-status probe with 403 account_blocked, which threads through the gateway as the Execute result_code; the UI surfaces it as a terminal blocked screen and stops all push/poll. Temporary blocks self-expire; the operator can unblock at any time (lost games stay lost). Sessions are not revoked, so the blocked client can still reach the exempt block-status endpoint. Backend: migration 00003 (account_suspensions + suspension_reasons) + jet regen; account suspension store; game.ForfeitAllForAccount; requireNotSuspended gate + block-status endpoint; admin console block/unblock + Reasons CRUD. Wire: fbs BlockStatus + account.block_status gateway op. UI: blocked screen, app state, transport/codec, i18n. Docs: ARCHITECTURE, FUNCTIONAL(+ru), PRERELEASE (AB).
151 lines
4.7 KiB
Go
151 lines
4.7 KiB
Go
package server
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"scrabble/backend/internal/account"
|
|
)
|
|
|
|
// The /api/v1/user account handlers wire profile editing, email binding and the
|
|
// statistics read. They follow handlers_user.go: X-User-ID identity, a
|
|
// domain call, a JSON DTO. Profile editing overwrites the full editable set, so the
|
|
// client sends the complete desired profile.
|
|
|
|
// updateProfileRequest is the full editable profile. away_start/away_end are
|
|
// "HH:MM" local-time bounds of the daily away window.
|
|
type updateProfileRequest struct {
|
|
DisplayName string `json:"display_name"`
|
|
PreferredLanguage string `json:"preferred_language"`
|
|
TimeZone string `json:"time_zone"`
|
|
AwayStart string `json:"away_start"`
|
|
AwayEnd string `json:"away_end"`
|
|
BlockChat bool `json:"block_chat"`
|
|
BlockFriendRequests bool `json:"block_friend_requests"`
|
|
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
|
}
|
|
|
|
// statsDTO is a durable account's lifetime statistics (the derived games-played and
|
|
// win-rate are computed client-side).
|
|
type statsDTO struct {
|
|
Wins int `json:"wins"`
|
|
Losses int `json:"losses"`
|
|
Draws int `json:"draws"`
|
|
MaxGamePoints int `json:"max_game_points"`
|
|
MaxWordPoints int `json:"max_word_points"`
|
|
}
|
|
|
|
// parseAwayTime parses an "HH:MM" away-window bound.
|
|
func parseAwayTime(s string) (time.Time, bool) {
|
|
t, err := time.Parse(awayTimeLayout, strings.TrimSpace(s))
|
|
if err != nil {
|
|
return time.Time{}, false
|
|
}
|
|
return t, true
|
|
}
|
|
|
|
// handleUpdateProfile overwrites the caller's editable profile fields.
|
|
func (s *Server) handleUpdateProfile(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
var req updateProfileRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
abortBadRequest(c, "invalid request body")
|
|
return
|
|
}
|
|
awayStart, ok := parseAwayTime(req.AwayStart)
|
|
if !ok {
|
|
abortBadRequest(c, "away_start must be HH:MM")
|
|
return
|
|
}
|
|
awayEnd, ok := parseAwayTime(req.AwayEnd)
|
|
if !ok {
|
|
abortBadRequest(c, "away_end must be HH:MM")
|
|
return
|
|
}
|
|
acc, err := s.accounts.UpdateProfile(c.Request.Context(), uid, account.ProfileUpdate{
|
|
DisplayName: req.DisplayName,
|
|
PreferredLanguage: req.PreferredLanguage,
|
|
TimeZone: req.TimeZone,
|
|
AwayStart: awayStart,
|
|
AwayEnd: awayEnd,
|
|
BlockChat: req.BlockChat,
|
|
BlockFriendRequests: req.BlockFriendRequests,
|
|
NotificationsInAppOnly: req.NotificationsInAppOnly,
|
|
})
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, profileResponseFor(acc))
|
|
}
|
|
|
|
// blockStatusResponse reports the caller's current block to the client. Until is an RFC3339 UTC
|
|
// instant for a temporary block and empty for a permanent one (or when not blocked); Reason is
|
|
// the operator-set reason resolved to the account's language, empty when none was cited. It is
|
|
// the one payload a blocked client can still fetch, behind the suspension gate's exemption.
|
|
type blockStatusResponse struct {
|
|
Blocked bool `json:"blocked"`
|
|
Permanent bool `json:"permanent"`
|
|
Until string `json:"until,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
// handleBlockStatus reports whether the caller is blocked and, if so, the block's expiry and the
|
|
// reason resolved to the account's language. The suspension gate exempts this route so a blocked
|
|
// client can render the terminal blocked screen.
|
|
func (s *Server) handleBlockStatus(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
susp, blocked, err := s.accounts.CurrentSuspension(c.Request.Context(), uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
resp := blockStatusResponse{Blocked: blocked}
|
|
if blocked {
|
|
resp.Permanent = susp.Permanent()
|
|
if susp.BlockedUntil != nil {
|
|
resp.Until = susp.BlockedUntil.UTC().Format(time.RFC3339)
|
|
}
|
|
// Resolve the reason snapshot to the account's interface language; fall back to English
|
|
// if the account row cannot be read for some reason.
|
|
lang := "en"
|
|
if acc, err := s.accounts.GetByID(c.Request.Context(), uid); err == nil {
|
|
lang = acc.PreferredLanguage
|
|
}
|
|
resp.Reason = susp.LocalizedReason(lang)
|
|
}
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
// handleStats returns the caller's lifetime statistics.
|
|
func (s *Server) handleStats(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
st, err := s.accounts.GetStats(c.Request.Context(), uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, statsDTO{
|
|
Wins: st.Wins,
|
|
Losses: st.Losses,
|
|
Draws: st.Draws,
|
|
MaxGamePoints: st.MaxGamePoints,
|
|
MaxWordPoints: st.MaxWordPoints,
|
|
})
|
|
}
|