Files
scrabble-game/backend/internal/server/handlers_account.go
T
Ilia Denisov dc582e9f73
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
fix(ads): restore reliable banner fades + keep banner on profile update
Two regressions from the previous banner pass:

- Fade (#2): the manual-opacity fade could paint opacity 0 and 1 in one frame and
  skip the transition — most visible for a single (default) campaign message,
  whose only fade is the first show. Revert the fade to Svelte transition:fade
  (which forces the from-state, so even the first/only message fades), keeping it
  on its own {#if} layer independent of the scroll. A freshly-mounted view onto a
  running cycle still renders the live message instantly (inFade duration 0 once),
  so navigation does not replay the fade. Verified by opacity sampling: advances
  fade, navigation stays at opacity 1.

- Profile update (#3): the banner block was attached only to GET /profile, so a
  profile.update (e.g. a language switch) returned a profile without it and the
  banner vanished until reload. A shared profileResponse() now attaches the banner
  to GET, PUT and the link/merge profile responses. Regression test added
  (TestBannerSurvivesProfileUpdate).

Still open: the scroll position is not preserved across navigation (the view
remounts); discussed separately.
2026-06-16 06:09:35 +02:00

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, 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
}
c.JSON(http.StatusOK, statsDTO{
Wins: st.Wins,
Losses: st.Losses,
Draws: st.Draws,
MaxGamePoints: st.MaxGamePoints,
MaxWordPoints: st.MaxWordPoints,
})
}