package server import ( "errors" "net/http" "strings" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" "scrabble/backend/internal/account" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" "scrabble/backend/internal/lobby" "scrabble/backend/internal/session" "scrabble/backend/internal/social" ) // registerRoutes wires the Stage 6 REST handlers onto the /api/v1 groups. The // internal group is gateway-only (the gateway authenticates and forwards); the // user group requires X-User-ID; the admin group is reached through the gateway's // Basic-Auth proxy. This is the representative vertical slice — further domain // operations follow the same pattern (PLAN.md Stage 6). func (s *Server) registerRoutes() { if s.sessions != nil && s.accounts != nil { in := s.internal in.POST("/sessions/telegram", s.handleTelegramAuth) in.POST("/sessions/guest", s.handleGuestAuth) in.POST("/sessions/email/request", s.handleEmailRequest) in.POST("/sessions/email/login", s.handleEmailLogin) in.POST("/sessions/resolve", s.handleResolveSession) in.POST("/sessions/revoke", s.handleRevokeSession) // Out-of-app push routing for the platform side-service (Stage 9): the // gateway resolves a recipient's Telegram chat + language + in-app-only flag // before delivering an out-of-app notification. in.POST("/push-target", s.handlePushTarget) } 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) u.POST("/games/:id/play", s.handleSubmitPlay) u.GET("/games/:id/state", s.handleGameState) u.POST("/games/:id/pass", s.handlePass) u.POST("/games/:id/exchange", s.handleExchange) u.POST("/games/:id/resign", s.handleResign) u.POST("/games/:id/hint", s.handleHint) u.POST("/games/:id/evaluate", s.handleEvaluate) 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) } } // userID returns the authenticated account id stored by RequireUserID. The user // group always runs that middleware, so absence is a programming error. func userID(c *gin.Context) (uuid.UUID, bool) { return UserIDFromContext(c.Request.Context()) } // gameIDParam parses the :id path parameter as a game UUID. func gameIDParam(c *gin.Context) (uuid.UUID, bool) { id, err := uuid.Parse(c.Param("id")) if err != nil { return uuid.UUID{}, false } return id, true } // clientIP returns the originating client IP the gateway forwarded in // X-Forwarded-For (the first hop), falling back to the direct peer. func clientIP(c *gin.Context) string { if xff := c.GetHeader("X-Forwarded-For"); xff != "" { if i := strings.IndexByte(xff, ','); i >= 0 { return strings.TrimSpace(xff[:i]) } return strings.TrimSpace(xff) } return c.ClientIP() } // abortBadRequest rejects a malformed request body or parameter. func abortBadRequest(c *gin.Context, msg string) { c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "bad_request", Message: msg}}) } // abortErr maps a domain error to its HTTP status and a stable code. Server-side // (5xx) errors are logged with the real cause and reported generically. func (s *Server) abortErr(c *gin.Context, err error) { status, code := statusForError(err) msg := err.Error() if status >= http.StatusInternalServerError { s.log.Error("request failed", zap.String("path", c.FullPath()), zap.Error(err)) msg = "internal error" } c.AbortWithStatusJSON(status, errorResponse{Error: errorBody{Code: code, Message: msg}}) } // statusForError maps a known domain sentinel to an HTTP status and code, // defaulting to 500/internal for anything unrecognised. func statusForError(err error) (int, string) { switch { case errors.Is(err, game.ErrNotFound), errors.Is(err, account.ErrNotFound): return http.StatusNotFound, "not_found" case errors.Is(err, game.ErrNotAPlayer), errors.Is(err, social.ErrNotParticipant): return http.StatusForbidden, "not_a_player" case errors.Is(err, game.ErrNotYourTurn), errors.Is(err, social.ErrNudgeOnOwnTurn): 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): // No legal move for the rack — distinct from a budget/disabled hint so the UI // can say "no options" (and the service spends nothing in this case). return http.StatusConflict, "no_hint_available" case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft): return http.StatusConflict, "hint_unavailable" case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver): return http.StatusUnprocessableEntity, "illegal_play" case errors.Is(err, account.ErrEmailTaken): return http.StatusConflict, "email_taken" case errors.Is(err, account.ErrInvalidEmail): return http.StatusBadRequest, "invalid_email" case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired), errors.Is(err, account.ErrNoPendingCode), errors.Is(err, account.ErrTooManyAttempts): return http.StatusUnauthorized, "code_invalid" case errors.Is(err, session.ErrNotFound): return http.StatusUnauthorized, "session_invalid" case errors.Is(err, social.ErrChatBlocked), errors.Is(err, social.ErrMessageTooLong), 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" } }