Files
scrabble-game/backend/internal/server/handlers_friends.go
Ilia Denisov 8881214213 R6(a): de-stage code, docs, READMEs; split stage6_test
Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN)
references from comments, doc-comments, service READMEs, the current-state docs
(ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the
.fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage
history.

- Rename the only stage-named identifiers: registerStage8 -> registerSocialOps,
  registerStage11 -> registerLinkOps (gateway transcode).
- Split stage6_test.go: TestEmailLoginFlow -> email_test.go,
  TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go.
- Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged
  .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments).

go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
2026-06-10 16:56:03 +02:00

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