Files
scrabble-game/backend/internal/server/handlers_account.go
T
Ilia Denisov 57c778f9b2
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
feat(telegram,game): single bot + per-user variant preferences
Collapse the two per-language Telegram bots into one unified bot and
replace language-based variant gating with explicit per-user variant
preferences.

- Telegram: one bot; drop service_language and the supported_languages
  set everywhere (DB, account, auth, FlatBuffers Session wire, gateway,
  connector proto). The single bot renders chat and out-of-app push in
  the recipient's preferred_language; remove the game-language push
  routing override (notify Intent.Language / push Event.language).
- Preferences: new accounts.variant_preferences (text[], DB default
  {erudit_ru}, CHECK non-empty + subset of the three variants). Gates
  the New Game picker, vs-AI and the friend invitation the player
  creates, enforced server-side (HTTP 400 otherwise); an invited friend
  may still accept any variant. Edited on the Settings screen; variants
  are Erudit-first everywhere.
- Admin: drop the per-bot language selectors (broadcast / send-to-user)
  and the feedback channel_lang column/field.
- Env/CI: collapse TELEGRAM_BOT_TOKEN_{EN,RU}, TELEGRAM_GAME_CHANNEL_ID_{EN,RU},
  VITE_TELEGRAM_LINK{,_EN,_RU} and VITE_TELEGRAM_GAME_CHANNEL_NAME_{EN,RU}
  to single unsuffixed names; drop GATEWAY_DEFAULT_SUPPORTED_LANGUAGES.
- Docs updated (ARCHITECTURE, FUNCTIONAL + _ru, platform/telegram, gateway,
  backend, ui, UI_DESIGN, PRERELEASE).

The migration squash is deferred to a follow-up PR.
2026-06-20 14:23:25 +02:00

177 lines
5.9 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"`
VariantPreferences []string `json:"variant_preferences"`
}
// statsDTO is a durable account's lifetime statistics (the derived games-played and
// win-rate are computed client-side). BestMoves breaks the best move down per variant,
// carrying the word itself; it is absent for an account with no recorded play and never
// lists a variant the account has not played.
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"`
Moves int `json:"moves"`
HintsUsed int `json:"hints_used"`
BestMoves []bestMoveDTO `json:"best_moves,omitempty"`
}
// bestMoveDTO is one variant's best play: the variant label, the play's total score and
// its main word as ordered tiles (letter, value, blank — value 0 for a blank), so the
// client renders it as game tiles without the variant's alphabet table.
type bestMoveDTO struct {
Variant string `json:"variant"`
Score int `json:"score"`
Word []account.BestMoveTile `json:"word"`
}
// 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,
VariantPreferences: req.VariantPreferences,
})
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, s.profileResponse(c.Request.Context(), 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
}
var best []bestMoveDTO
if len(st.BestMoves) > 0 {
best = make([]bestMoveDTO, len(st.BestMoves))
for i, b := range st.BestMoves {
best[i] = bestMoveDTO{Variant: b.Variant, Score: b.Score, Word: b.Tiles}
}
}
c.JSON(http.StatusOK, statsDTO{
Wins: st.Wins,
Losses: st.Losses,
Draws: st.Draws,
MaxGamePoints: st.MaxGamePoints,
MaxWordPoints: st.MaxWordPoints,
Moves: st.Moves,
HintsUsed: st.HintsUsed,
BestMoves: best,
})
}