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)}) }