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, }) }