6b6baf5710
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Lobby: group the my-games list into your-turn / opponent-turn / finished (empty sections hidden), ordered by last activity (your-turn oldest-first, the other two newest-first), as a compact line-separated list. gameDTO and FB GameView gain last_activity_unix (turn start while active, finish time once finished); a pure lib/lobbysort.ts holds the grouping/ordering. Friends: the in-game 'add to friends' item is now server-derived via a new GET /user/friends/outgoing (+ friends.outgoing op), returning addressees with a pending OR declined request (both read as 'request sent'), so it is correct across reloads; it shows a disabled '✓ in friends' once accepted. It live-updates when the opponent answers: RespondFriendRequest now publishes friend_added (accept) / friend_declined (new notify sub-kind, decline) to the original requester, whose open game re-derives its friend state. Tests: lobbysort unit test; gateway outgoing + last_activity transcode tests; backend integration ListOutgoingRequests + respond-publishes-to-requester; e2e updated for the new lobby section labels + a non-friend active opponent. Docs: ARCHITECTURE notify catalog, FUNCTIONAL(+ru) lobby/friends, PLAN.
277 lines
7.8 KiB
Go
277 lines
7.8 KiB
Go
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"`
|
|
}
|
|
|
|
// outgoingListDTO is the addressees the caller has already requested (a live pending
|
|
// request or one the addressee declined) and therefore cannot re-request.
|
|
type outgoingListDTO 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)})
|
|
}
|
|
|
|
// handleOutgoingRequests returns the addressees the caller has already requested
|
|
// (pending or declined) and cannot re-request.
|
|
func (s *Server) handleOutgoingRequests(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
ids, err := s.social.ListOutgoingRequests(c.Request.Context(), uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, outgoingListDTO{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)})
|
|
}
|