Stage 8: UI social/account/history surfaces
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 13s
Tests · UI / test (push) Successful in 16s

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.
This commit is contained in:
Ilia Denisov
2026-06-03 19:47:40 +02:00
parent 539e24fba1
commit d733ce3119
114 changed files with 8210 additions and 149 deletions
+9 -1
View File
@@ -32,12 +32,15 @@ type resolveResponse struct {
UserID string `json:"user_id"`
}
// profileResponse is the authenticated account's own profile.
// profileResponse is the authenticated account's own profile. AwayStart and AwayEnd
// are the daily away window's "HH:MM" local-time bounds (in TimeZone).
type profileResponse struct {
UserID string `json:"user_id"`
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"`
HintBalance int `json:"hint_balance"`
BlockChat bool `json:"block_chat"`
BlockFriendRequests bool `json:"block_friend_requests"`
@@ -149,6 +152,8 @@ func profileResponseFor(acc account.Account) profileResponse {
DisplayName: acc.DisplayName,
PreferredLanguage: acc.PreferredLanguage,
TimeZone: acc.TimeZone,
AwayStart: acc.AwayStart.Format(awayTimeLayout),
AwayEnd: acc.AwayEnd.Format(awayTimeLayout),
HintBalance: acc.HintBalance,
BlockChat: acc.BlockChat,
BlockFriendRequests: acc.BlockFriendRequests,
@@ -156,6 +161,9 @@ func profileResponseFor(acc account.Account) profileResponse {
}
}
// awayTimeLayout is the "HH:MM" wire form of the daily away-window bounds.
const awayTimeLayout = "15:04"
// gameDTOFromGame projects a game.Game into its DTO.
func gameDTOFromGame(g game.Game) gameDTO {
seats := make([]seatDTO, 0, len(g.Seats))
+18
View File
@@ -96,6 +96,24 @@ func TestGameDTOFromGame(t *testing.T) {
}
}
func TestProfileResponseForAwayWindow(t *testing.T) {
acc := account.Account{
ID: uuid.New(),
DisplayName: "Kaya",
PreferredLanguage: "ru",
TimeZone: "Europe/Moscow",
AwayStart: time.Date(0, 1, 1, 0, 0, 0, 0, time.UTC),
AwayEnd: time.Date(0, 1, 1, 7, 30, 0, 0, time.UTC),
}
dto := profileResponseFor(acc)
if dto.AwayStart != "00:00" || dto.AwayEnd != "07:30" {
t.Fatalf("away window = (%q, %q), want (00:00, 07:30)", dto.AwayStart, dto.AwayEnd)
}
if dto.PreferredLanguage != "ru" || dto.TimeZone != "Europe/Moscow" {
t.Fatalf("profile dto mismatch: %+v", dto)
}
}
func TestMoveRecordDTOFrom(t *testing.T) {
rec := engine.MoveRecord{
Player: 1,
+61
View File
@@ -35,6 +35,12 @@ func (s *Server) registerRoutes() {
u := s.user
if s.accounts != nil {
u.GET("/profile", s.handleProfile)
u.PUT("/profile", s.handleUpdateProfile)
u.GET("/stats", s.handleStats)
}
if s.emails != nil {
u.POST("/email/request", s.handleEmailBindRequest)
u.POST("/email/confirm", s.handleEmailBindConfirm)
}
if s.games != nil {
u.GET("/games", s.handleListGames)
@@ -48,15 +54,34 @@ func (s *Server) registerRoutes() {
u.GET("/games/:id/check_word", s.handleCheckWord)
u.POST("/games/:id/complaint", s.handleComplaint)
u.GET("/games/:id/history", s.handleHistory)
u.GET("/games/:id/gcg", s.handleExportGCG)
}
if s.matchmaker != nil {
u.POST("/lobby/enqueue", s.handleEnqueue)
u.GET("/lobby/poll", s.handlePoll)
}
if s.invitations != nil {
u.GET("/invitations", s.handleListInvitations)
u.POST("/invitations", s.handleCreateInvitation)
u.POST("/invitations/:id/accept", s.handleAcceptInvitation)
u.POST("/invitations/:id/decline", s.handleDeclineInvitation)
u.DELETE("/invitations/:id", s.handleCancelInvitation)
}
if s.social != nil {
u.POST("/games/:id/chat", s.handleChatPost)
u.GET("/games/:id/chat", s.handleChatList)
u.POST("/games/:id/nudge", s.handleNudge)
u.GET("/friends", s.handleListFriends)
u.GET("/friends/incoming", s.handleIncomingRequests)
u.POST("/friends/request", s.handleFriendRequest)
u.POST("/friends/respond", s.handleFriendRespond)
u.POST("/friends/cancel", s.handleFriendCancel)
u.DELETE("/friends/:id", s.handleUnfriend)
u.POST("/friends/code", s.handleIssueFriendCode)
u.POST("/friends/code/redeem", s.handleRedeemFriendCode)
u.GET("/blocks", s.handleListBlocks)
u.POST("/blocks", s.handleBlock)
u.DELETE("/blocks/:id", s.handleUnblock)
}
s.admin.GET("/ping", s.handleAdminPing)
}
@@ -117,8 +142,30 @@ func statusForError(err error) (int, string) {
return http.StatusConflict, "not_your_turn"
case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive):
return http.StatusConflict, "game_finished"
case errors.Is(err, game.ErrGameActive):
return http.StatusConflict, "game_active"
case errors.Is(err, account.ErrInvalidProfile):
return http.StatusBadRequest, "invalid_profile"
case errors.Is(err, account.ErrAlreadyConfirmed):
return http.StatusConflict, "already_confirmed"
case errors.Is(err, lobby.ErrAlreadyQueued):
return http.StatusConflict, "already_queued"
case errors.Is(err, lobby.ErrInvalidInvitation):
return http.StatusBadRequest, "invalid_invitation"
case errors.Is(err, lobby.ErrInvitationBlocked):
return http.StatusForbidden, "invitation_blocked"
case errors.Is(err, lobby.ErrInvitationNotFound):
return http.StatusNotFound, "invitation_not_found"
case errors.Is(err, lobby.ErrInvitationNotPending):
return http.StatusConflict, "invitation_not_pending"
case errors.Is(err, lobby.ErrInvitationExpired):
return http.StatusConflict, "invitation_expired"
case errors.Is(err, lobby.ErrNotInvited):
return http.StatusForbidden, "not_invited"
case errors.Is(err, lobby.ErrAlreadyResponded):
return http.StatusConflict, "already_responded"
case errors.Is(err, lobby.ErrNotInviter):
return http.StatusForbidden, "not_inviter"
case errors.Is(err, game.ErrInvalidConfig):
return http.StatusBadRequest, "invalid_config"
case errors.Is(err, game.ErrNoHintAvailable):
@@ -142,6 +189,20 @@ func statusForError(err error) (int, string) {
errors.Is(err, social.ErrEmptyMessage), errors.Is(err, social.ErrForbiddenContent),
errors.Is(err, social.ErrNudgeTooSoon):
return http.StatusUnprocessableEntity, "chat_rejected"
case errors.Is(err, social.ErrSelfRelation):
return http.StatusBadRequest, "self_relation"
case errors.Is(err, social.ErrRequestExists):
return http.StatusConflict, "request_exists"
case errors.Is(err, social.ErrRequestBlocked):
return http.StatusForbidden, "request_blocked"
case errors.Is(err, social.ErrRequestNotFound):
return http.StatusNotFound, "request_not_found"
case errors.Is(err, social.ErrNoSharedGame):
return http.StatusForbidden, "no_shared_game"
case errors.Is(err, social.ErrRequestDeclined):
return http.StatusConflict, "request_declined"
case errors.Is(err, social.ErrFriendCodeInvalid):
return http.StatusUnprocessableEntity, "friend_code_invalid"
default:
return http.StatusInternalServerError, "internal"
}
+157
View File
@@ -0,0 +1,157 @@
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))
}
@@ -0,0 +1,76 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// The /api/v1/user/blocks/* handlers wire the per-user block list (Stage 8). A block
// is mutual in effect (the social checks apply it both ways) and severs any
// friendship between the pair. They reuse the friend handlers' targetIDRequest and
// account-ref resolution.
// blockListDTO is the accounts the caller has blocked.
type blockListDTO struct {
Blocked []accountRefDTO `json:"blocked"`
}
// handleBlock blocks the body-supplied account.
func (s *Server) handleBlock(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req targetIDRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
target, ok := parseUUIDField(req.AccountID)
if !ok {
abortBadRequest(c, "invalid account id")
return
}
if err := s.social.Block(c.Request.Context(), uid, target); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// handleUnblock removes the caller's block on the :id account.
func (s *Server) handleUnblock(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
target, err := uuid.Parse(c.Param("id"))
if err != nil {
abortBadRequest(c, "invalid account id")
return
}
if err := s.social.Unblock(c.Request.Context(), uid, target); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// handleListBlocks returns the accounts the caller has blocked.
func (s *Server) handleListBlocks(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
ids, err := s.social.ListBlocks(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, blockListDTO{Blocked: s.accountRefs(c.Request.Context(), ids)})
}
+254
View File
@@ -0,0 +1,254 @@
package server
import (
"context"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// The /api/v1/user/friends/* handlers wire the social friend graph (Stage 8): the
// befriend-an-opponent request flow, the one-time friend-code path, and the
// friends/incoming lists. They follow handlers_user.go: X-User-ID identity, a domain
// call, a JSON DTO. Account ids are projected to {id, display_name} refs resolved
// from the account store, mirroring fillSeatNames.
// accountRefDTO is a referenced account with its display name resolved for the UI.
type accountRefDTO struct {
AccountID string `json:"account_id"`
DisplayName string `json:"display_name"`
}
// friendListDTO is the caller's accepted friends.
type friendListDTO struct {
Friends []accountRefDTO `json:"friends"`
}
// incomingListDTO is the friend requests awaiting the caller's response.
type incomingListDTO struct {
Requests []accountRefDTO `json:"requests"`
}
// friendCodeDTO is a freshly issued one-time friend code (returned once).
type friendCodeDTO struct {
Code string `json:"code"`
ExpiresAtUnix int64 `json:"expires_at_unix"`
}
// redeemResultDTO reports the new friend gained by redeeming a code.
type redeemResultDTO struct {
Friend accountRefDTO `json:"friend"`
}
// targetIDRequest carries a single counterpart account id.
type targetIDRequest struct {
AccountID string `json:"account_id"`
}
// friendRespondRequest accepts or declines a pending request from a requester.
type friendRespondRequest struct {
RequesterID string `json:"requester_id"`
Accept bool `json:"accept"`
}
// redeemCodeRequest carries a friend code to redeem.
type redeemCodeRequest struct {
Code string `json:"code"`
}
// namedRef resolves a single account id into its display-name ref, caching the
// lookup in memo so a caller can share it across many refs in one response.
func (s *Server) namedRef(ctx context.Context, id uuid.UUID, memo map[string]string) accountRefDTO {
key := id.String()
name, ok := memo[key]
if !ok {
if acc, err := s.accounts.GetByID(ctx, id); err == nil {
name = acc.DisplayName
}
memo[key] = name
}
return accountRefDTO{AccountID: key, DisplayName: name}
}
// accountRefs resolves a list of account ids into display-name refs, memoising
// lookups within the call.
func (s *Server) accountRefs(ctx context.Context, ids []uuid.UUID) []accountRefDTO {
memo := map[string]string{}
out := make([]accountRefDTO, 0, len(ids))
for _, id := range ids {
out = append(out, s.namedRef(ctx, id, memo))
}
return out
}
// accountRef resolves a single account id into its display-name ref.
func (s *Server) accountRef(ctx context.Context, id uuid.UUID) accountRefDTO {
return s.namedRef(ctx, id, map[string]string{})
}
// parseUUIDField parses a body-supplied account id, trimming whitespace.
func parseUUIDField(s string) (uuid.UUID, bool) {
id, err := uuid.Parse(strings.TrimSpace(s))
if err != nil {
return uuid.UUID{}, false
}
return id, true
}
// handleFriendRequest sends a friend request to an opponent the caller has played.
func (s *Server) handleFriendRequest(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req targetIDRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
target, ok := parseUUIDField(req.AccountID)
if !ok {
abortBadRequest(c, "invalid account id")
return
}
if err := s.social.SendFriendRequest(c.Request.Context(), uid, target); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// handleFriendRespond accepts or declines a pending incoming request.
func (s *Server) handleFriendRespond(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req friendRespondRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
requester, ok := parseUUIDField(req.RequesterID)
if !ok {
abortBadRequest(c, "invalid requester id")
return
}
if err := s.social.RespondFriendRequest(c.Request.Context(), uid, requester, req.Accept); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// handleFriendCancel withdraws the caller's own pending request.
func (s *Server) handleFriendCancel(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req targetIDRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
target, ok := parseUUIDField(req.AccountID)
if !ok {
abortBadRequest(c, "invalid account id")
return
}
if err := s.social.CancelFriendRequest(c.Request.Context(), uid, target); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// handleUnfriend removes a friendship with the :id account.
func (s *Server) handleUnfriend(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
other, err := uuid.Parse(c.Param("id"))
if err != nil {
abortBadRequest(c, "invalid account id")
return
}
if err := s.social.Unfriend(c.Request.Context(), uid, other); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// handleListFriends returns the caller's accepted friends.
func (s *Server) handleListFriends(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
ids, err := s.social.ListFriends(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, friendListDTO{Friends: s.accountRefs(c.Request.Context(), ids)})
}
// handleIncomingRequests returns the friend requests awaiting the caller.
func (s *Server) handleIncomingRequests(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
ids, err := s.social.ListIncomingRequests(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, incomingListDTO{Requests: s.accountRefs(c.Request.Context(), ids)})
}
// handleIssueFriendCode issues a one-time add-a-friend code for the caller.
func (s *Server) handleIssueFriendCode(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
code, err := s.social.IssueFriendCode(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, friendCodeDTO{Code: code.Code, ExpiresAtUnix: code.ExpiresAt.Unix()})
}
// handleRedeemFriendCode redeems a friend code, befriending its issuer.
func (s *Server) handleRedeemFriendCode(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req redeemCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
issuer, err := s.social.RedeemFriendCode(c.Request.Context(), uid, strings.TrimSpace(req.Code))
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, redeemResultDTO{Friend: s.accountRef(c.Request.Context(), issuer)})
}
+26
View File
@@ -243,6 +243,32 @@ func (s *Server) handleHistory(c *gin.Context) {
c.JSON(http.StatusOK, historyDTO{GameID: gameID.String(), Moves: moves})
}
// gcgDTO is a game's GCG export: a suggested filename plus the GCG text.
type gcgDTO struct {
GameID string `json:"game_id"`
Filename string `json:"filename"`
Content string `json:"content"`
}
// handleExportGCG returns a finished game's GCG transcript for download/share. The
// service refuses an active game (ErrGameActive) to avoid leaking the live journal.
func (s *Server) handleExportGCG(c *gin.Context) {
_, gameID, ok := s.userGame(c)
if !ok {
return
}
gcg, err := s.games.ExportGCG(c.Request.Context(), gameID)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, gcgDTO{
GameID: gameID.String(),
Filename: "game-" + gameID.String() + ".gcg",
Content: gcg,
})
}
// handleListGames returns the caller's active and finished games for the lobby.
func (s *Server) handleListGames(c *gin.Context) {
uid, ok := userID(c)
@@ -0,0 +1,205 @@
package server
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/lobby"
)
// The /api/v1/user/invitations/* handlers wire friend-game invitations (Stage 8):
// create a 2-4 player invitation, accept/decline as an invitee, cancel as the
// inviter, and list the open invitations touching the caller. Display names for the
// inviter and invitees are resolved from the account store.
// invitationInviteeDTO is one invitee's seat and response with their name resolved.
type invitationInviteeDTO struct {
AccountID string `json:"account_id"`
DisplayName string `json:"display_name"`
Seat int `json:"seat"`
Response string `json:"response"`
}
// invitationDTO is a friend-game invitation with its settings and invitees.
type invitationDTO struct {
ID string `json:"id"`
Inviter accountRefDTO `json:"inviter"`
Invitees []invitationInviteeDTO `json:"invitees"`
Variant string `json:"variant"`
TurnTimeoutSecs int `json:"turn_timeout_secs"`
HintsAllowed bool `json:"hints_allowed"`
HintsPerPlayer int `json:"hints_per_player"`
DropoutTiles string `json:"dropout_tiles"`
Status string `json:"status"`
GameID string `json:"game_id,omitempty"`
ExpiresAtUnix int64 `json:"expires_at_unix"`
}
// invitationListDTO is the caller's open invitations.
type invitationListDTO struct {
Invitations []invitationDTO `json:"invitations"`
}
// createInvitationRequest proposes a friend game to the named invitees.
type createInvitationRequest struct {
InviteeIDs []string `json:"invitee_ids"`
Variant string `json:"variant"`
TurnTimeoutSecs int `json:"turn_timeout_secs"`
HintsAllowed bool `json:"hints_allowed"`
HintsPerPlayer int `json:"hints_per_player"`
DropoutTiles string `json:"dropout_tiles"`
}
// invitationDTOFrom projects a lobby invitation, resolving names through memo.
func (s *Server) invitationDTOFrom(ctx context.Context, inv lobby.Invitation, memo map[string]string) invitationDTO {
dto := invitationDTO{
ID: inv.ID.String(),
Inviter: s.namedRef(ctx, inv.InviterID, memo),
Invitees: make([]invitationInviteeDTO, 0, len(inv.Invitees)),
Variant: inv.Settings.Variant.String(),
TurnTimeoutSecs: int(inv.Settings.TurnTimeout.Seconds()),
HintsAllowed: inv.Settings.HintsAllowed,
HintsPerPlayer: inv.Settings.HintsPerPlayer,
DropoutTiles: inv.Settings.DropoutTiles.String(),
Status: inv.Status,
ExpiresAtUnix: inv.ExpiresAt.Unix(),
}
if inv.GameID != nil {
dto.GameID = inv.GameID.String()
}
for _, iv := range inv.Invitees {
ref := s.namedRef(ctx, iv.AccountID, memo)
dto.Invitees = append(dto.Invitees, invitationInviteeDTO{
AccountID: ref.AccountID,
DisplayName: ref.DisplayName,
Seat: iv.Seat,
Response: iv.Response,
})
}
return dto
}
// handleCreateInvitation records a new friend-game invitation from the caller.
func (s *Server) handleCreateInvitation(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req createInvitationRequest
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
}
settings := lobby.InvitationSettings{
Variant: variant,
HintsAllowed: req.HintsAllowed,
HintsPerPlayer: req.HintsPerPlayer,
}
if req.TurnTimeoutSecs > 0 {
settings.TurnTimeout = time.Duration(req.TurnTimeoutSecs) * time.Second
}
if req.DropoutTiles != "" {
dropout, err := engine.ParseDropoutTiles(req.DropoutTiles)
if err != nil {
abortBadRequest(c, "unknown dropout_tiles")
return
}
settings.DropoutTiles = dropout
}
inviteeIDs := make([]uuid.UUID, 0, len(req.InviteeIDs))
for _, raw := range req.InviteeIDs {
id, ok := parseUUIDField(raw)
if !ok {
abortBadRequest(c, "invalid invitee id")
return
}
inviteeIDs = append(inviteeIDs, id)
}
inv, err := s.invitations.CreateInvitation(c.Request.Context(), uid, inviteeIDs, settings)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, s.invitationDTOFrom(c.Request.Context(), inv, map[string]string{}))
}
// handleAcceptInvitation records the caller's acceptance, starting the game when it
// completes the set.
func (s *Server) handleAcceptInvitation(c *gin.Context) {
s.respondInvitation(c, true)
}
// handleDeclineInvitation records the caller's decline, cancelling the invitation.
func (s *Server) handleDeclineInvitation(c *gin.Context) {
s.respondInvitation(c, false)
}
// respondInvitation applies the caller's accept/decline to the :id invitation.
func (s *Server) respondInvitation(c *gin.Context, accept bool) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
abortBadRequest(c, "invalid invitation id")
return
}
inv, err := s.invitations.RespondInvitation(c.Request.Context(), id, uid, accept)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, s.invitationDTOFrom(c.Request.Context(), inv, map[string]string{}))
}
// handleCancelInvitation withdraws the caller's own pending invitation.
func (s *Server) handleCancelInvitation(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
abortBadRequest(c, "invalid invitation id")
return
}
if err := s.invitations.CancelInvitation(c.Request.Context(), id, uid); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// handleListInvitations returns the open invitations touching the caller.
func (s *Server) handleListInvitations(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
invs, err := s.invitations.ListInvitations(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
memo := map[string]string{}
out := make([]invitationDTO, 0, len(invs))
for _, inv := range invs {
out = append(out, s.invitationDTOFrom(c.Request.Context(), inv, memo))
}
c.JSON(http.StatusOK, invitationListDTO{Invitations: out})
}