Files
scrabble-game/backend/internal/server/handlers_user.go
T
Ilia Denisov 9471341a0e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 29s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
feat(social): hide social controls on a deleted opponent's seat
A deleted account keeps its seats in every shared game, so its opponents
still saw the add-friend and block controls on the scoreboard (deletion
drops the friendship, so the 🤝 even reappeared for a former friend) and
a chat composer nobody was behind. A friend request sent that way was
accepted by the server and stayed pending forever.

The per-viewer game views now mark such a seat (SeatView.deleted,
resolved beside the seat display names by a batch accounts.deleted_at
lookup) and the client hides every control aimed at it: add-friend,
block, and the chat composer (message + nudge) once no reachable
opponent is left. Live events carry the game domain's seat standings and
so leave the mark unset, so the delta reducers preserve the cached one.
SendFriendRequest and Block against a tombstone are refused with
social.ErrAccountDeleted (410 account_deleted) — the source of truth for
an older client.
2026-07-27 17:06:10 +02:00

246 lines
7.1 KiB
Go

package server
import (
"net/http"
"slices"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/gamelimits"
)
// The /api/v1/user/* endpoints require X-User-ID (RequireUserID middleware). The
// backend treats that header as the sole identity input.
// handleProfile returns the authenticated account's own profile.
func (s *Server) handleProfile(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
acc, err := s.accounts.GetByID(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
// The SPA fetches the profile once per cold app-load, so stamp the account's last
// login time and client IP here (throttled to at most once an hour). Best-effort: it
// feeds the deletion dossier, never blocks the profile read.
_ = s.accounts.StampLastLogin(c.Request.Context(), uid, clientIP(c))
c.JSON(http.StatusOK, s.profileResponse(c.Request.Context(), acc))
}
// submitPlayRequest places tiles on the player's turn; the engine infers the
// play's orientation from the tiles and the board. Each tile's Letter is a wire
// alphabet index; for a blank it is the designated letter's index.
type submitPlayRequest struct {
Tiles []struct {
Row int `json:"row"`
Col int `json:"col"`
Letter int `json:"letter"`
Blank bool `json:"blank"`
} `json:"tiles"`
}
// tilesFromRequest maps a play/evaluate request's index-addressed tiles to engine tile
// records for the game's variant (a placed blank carries its designated letter's
// index with Blank set). An out-of-range index surfaces as engine.ErrIllegalPlay (HTTP 400).
func tilesFromRequest(variant engine.Variant, req submitPlayRequest) ([]engine.TileRecord, error) {
tiles := make([]engine.TileRecord, 0, len(req.Tiles))
for _, t := range req.Tiles {
letter, err := engine.LetterForIndex(variant, t.Letter)
if err != nil {
return nil, err
}
tiles = append(tiles, engine.TileRecord{Row: t.Row, Col: t.Col, Letter: letter, Blank: t.Blank})
}
return tiles, nil
}
// handleSubmitPlay validates, scores and commits a placement.
func (s *Server) handleSubmitPlay(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
gameID, ok := gameIDParam(c)
if !ok {
abortBadRequest(c, "invalid game id")
return
}
var req submitPlayRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
variant, err := s.games.GameVariant(c.Request.Context(), gameID)
if err != nil {
s.abortErr(c, err)
return
}
tiles, err := tilesFromRequest(variant, req)
if err != nil {
s.abortErr(c, err)
return
}
res, err := s.games.SubmitPlay(c.Request.Context(), gameID, uid, tiles)
if err != nil {
s.abortErr(c, err)
return
}
s.writeMoveResult(c, res)
}
// handleGameState returns the player's view of a game.
// handleHideGame hides a finished game from the caller's own lobby list.
func (s *Server) handleHideGame(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
gameID, ok := gameIDParam(c)
if !ok {
abortBadRequest(c, "invalid game id")
return
}
if err := s.games.HideGame(c.Request.Context(), uid, gameID); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
func (s *Server) handleGameState(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
gameID, ok := gameIDParam(c)
if !ok {
abortBadRequest(c, "invalid game id")
return
}
view, err := s.games.GameState(c.Request.Context(), gameID, uid)
if err != nil {
s.abortErr(c, err)
return
}
dto, err := stateDTOFrom(view, c.Query("include_alphabet") == "true")
if err != nil {
s.abortErr(c, err)
return
}
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{})
s.fillSeatDeleted(c.Request.Context(), &dto.Game, map[string]bool{})
s.setUnreadChat(c.Request.Context(), &dto.Game, uid)
c.JSON(http.StatusOK, dto)
}
// ensureVariantAllowed reports whether the caller's profile permits creating a game
// of variant — it must be one of their VariantPreferences. It aborts the request
// with 400 and returns false otherwise. Only the player who creates a game (quick
// match, vs-AI or a friend invitation) is gated this way; an invited friend may
// accept an invitation in any variant.
func (s *Server) ensureVariantAllowed(c *gin.Context, uid uuid.UUID, variant string) bool {
acc, err := s.accounts.GetByID(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return false
}
if !slices.Contains(acc.VariantPreferences, variant) {
abortBadRequest(c, "variant is not in your preferences")
return false
}
return true
}
// enqueueRequest enters per-variant auto-match under a per-turn word rule. VsAI true
// starts an honest-AI game against a robot instead of the open/wait matchmaking path.
type enqueueRequest struct {
Variant string `json:"variant"`
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
VsAI bool `json:"vs_ai"`
}
// handleEnqueue enters the caller into a quick game and returns the game they land in
// immediately. With vs_ai it starts an honest-AI game already seated with a robot;
// otherwise it enters auto-match — a freshly opened game awaiting an opponent, or
// another player's open game they just joined. The client navigates straight in.
func (s *Server) handleEnqueue(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req enqueueRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
variant, err := engine.ParseVariant(req.Variant)
if err != nil {
abortBadRequest(c, "unknown variant")
return
}
if !s.ensureVariantAllowed(c, uid, variant.String()) {
return
}
kind := gamelimits.KindRandom
enter := s.matchmaker.Enqueue
if req.VsAI {
kind = gamelimits.KindVsAI
enter = s.matchmaker.StartVsAI
}
if !s.ensureUnderGameLimit(c, uid, kind) {
return
}
res, err := enter(c.Request.Context(), uid, variant, req.MultipleWordsPerTurn)
if err != nil {
s.abortErr(c, err)
return
}
dto := matchDTOFrom(res)
if dto.Game != nil {
s.fillSeatNames(c.Request.Context(), dto.Game, map[string]string{})
s.fillSeatDeleted(c.Request.Context(), dto.Game, map[string]bool{})
}
c.JSON(http.StatusOK, dto)
}
// chatPostRequest posts a per-game chat message.
type chatPostRequest struct {
Body string `json:"body"`
}
// handleChatPost stores a chat message from the authenticated player. The sender
// IP is taken from the gateway-forwarded X-Forwarded-For header.
func (s *Server) handleChatPost(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
gameID, ok := gameIDParam(c)
if !ok {
abortBadRequest(c, "invalid game id")
return
}
var req chatPostRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
msg, err := s.social.PostMessage(c.Request.Context(), gameID, uid, req.Body, clientIP(c))
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, chatDTOFrom(msg))
}