Stage 8: UI social/account/history surfaces
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:
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user