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