Files
scrabble-game/backend/internal/server/handlers.go
T
Ilia Denisov 3590df28db
Tests · Go / test (push) Successful in 7s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 12s
Tests · UI / test (pull_request) Failing after 5m9s
Stage 9: Telegram integration (connector side-service, Mini App, out-of-app push)
New platform/telegram connector (own container, bot token only there):
- go-telegram/bot long-poll loop: /start deep-links + Mini App launch button.
- gRPC API pkg/proto/telegram/v1 (Telegram service): ValidateInitData, Notify
  (renders a localized message + deep-link button), SendToUser/SendToGameChannel
  (admin, wired in Stage 10). Generic methods are platform-agnostic (external_id).
- Bot API base override for Telegram's test environment; Dockerfile + compose
  (VPN sidecar, no public ingress); README.

Gateway:
- initData validation relocated from the gateway into the connector; the gateway
  calls ValidateInitData over gRPC (GATEWAY_CONNECTOR_ADDR), drops the bot token,
  and deletes internal/auth.
- Out-of-app push: runPushPump routes events whose recipient has no live in-app
  stream to connector.Notify, gated by /internal/push-target + the in-app-only
  flag (race-free de-dup); HasSubscribers added to the push hub.

Backend:
- Migration 00007 accounts.notifications_in_app_only (default true) + jetgen.
- ProvisionTelegram seeds a new account's language/display name from the launch
  fields; IdentityExternalID reverse lookup; /internal/push-target handler.

UI:
- Telegram Mini App launch: detect initData, apply themeParams, authTelegram,
  route the deep-link start_param (g/i/f); /telegram/ guard redirects outside
  Telegram. Vite relative base + telegram-web-app.js. In-app-only profile toggle;
  share-to-Telegram link for a friend code. Vitest + Playwright coverage.

Wire/docs/CI: fbs Profile/UpdateProfileRequest gain notifications_in_app_only
(Go + TS); go.work uses ./platform/telegram; go-unit.yaml covers it; PLAN,
ARCHITECTURE, FUNCTIONAL (+ru), UI_DESIGN, READMEs updated.
2026-06-04 01:48:03 +02:00

214 lines
8.9 KiB
Go

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)
}
s.admin.GET("/ping", s.handleAdminPing)
}
// 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"
}
}