d733ce3119
Wire the deferred Stage 7 surfaces end-to-end (UI -> gateway transcode -> backend REST -> existing domain services): friends (incl. one-time friend codes), per-user blocks, friend-game invitations, profile editing + email binding, the statistics screen, and the in-game history + GCG export. Friends gain two add paths (interview decision, a deliberate plan change): one-time 6-digit codes (friend_codes table, 12h TTL, single-use, rate-limited redeem); and play-gated requests (shared game required) where an explicit decline is permanent, an ignored request lapses after 30 days, and a code bypasses a decline. Migration 00006 widens friendships_status_chk and adds friend_codes. Lobby notification badge is poll + push: a new generic `notify` event drives it live; the client polls on open/focus. Language stays a single Settings control that writes through to the durable account's preferred_language. GCG export is finished-only (game.ErrGameActive) and shares/downloads the .gcg file. Tests: backend unit + inttest (friend gate/decline/code, ListInvitations, GetStats, GCG gate), gateway transcode round-trips + notify constructor, UI vitest (codecs, win-rate, share choice) + Playwright social specs. Docs: PLAN (Stage 8 done + refinements + TODO-5), ARCHITECTURE, FUNCTIONAL(+ru), UI_DESIGN, TESTING, module READMEs.
158 lines
4.3 KiB
Go
158 lines
4.3 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 (Stage 8). 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"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// emailBindRequestBody starts binding an email to the caller's account.
|
|
type emailBindRequestBody struct {
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
// emailBindConfirmBody completes binding an email with its confirm code.
|
|
type emailBindConfirmBody struct {
|
|
Email string `json:"email"`
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
// 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,
|
|
})
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, profileResponseFor(acc))
|
|
}
|
|
|
|
// 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,
|
|
})
|
|
}
|
|
|
|
// handleEmailBindRequest issues a confirm code to bind an email to the caller.
|
|
func (s *Server) handleEmailBindRequest(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
var req emailBindRequestBody
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
abortBadRequest(c, "invalid request body")
|
|
return
|
|
}
|
|
if err := s.emails.RequestCode(c.Request.Context(), uid, req.Email); err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, okResponse{OK: true})
|
|
}
|
|
|
|
// handleEmailBindConfirm verifies the code and binds the email, returning the
|
|
// updated profile.
|
|
func (s *Server) handleEmailBindConfirm(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
var req emailBindConfirmBody
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
abortBadRequest(c, "invalid request body")
|
|
return
|
|
}
|
|
acc, err := s.emails.ConfirmCode(c.Request.Context(), uid, req.Email, req.Code)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, profileResponseFor(acc))
|
|
}
|