package server import ( "context" "net/http" "time" "galaxy/backend/internal/lobby" "galaxy/backend/internal/server/handlers" "galaxy/backend/internal/server/httperr" "galaxy/backend/internal/server/middleware/userid" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" ) // UserLobbyGamesHandlers groups the handlers under // `/api/v1/user/lobby/games/*`. The current implementation ships real implementations // backed by `*lobby.Service`; tests that supply a nil service fall back // to the Stage-3 placeholder body so the contract test continues to // validate the OpenAPI envelope without booting a database. type UserLobbyGamesHandlers struct { svc *lobby.Service logger *zap.Logger } // NewUserLobbyGamesHandlers constructs the handler set. svc may be nil // — in that case every handler returns 501 not_implemented. func NewUserLobbyGamesHandlers(svc *lobby.Service, logger *zap.Logger) *UserLobbyGamesHandlers { if logger == nil { logger = zap.NewNop() } return &UserLobbyGamesHandlers{svc: svc, logger: logger.Named("http.user.lobby.games")} } func (h *UserLobbyGamesHandlers) callerUserID(c *gin.Context) (uuid.UUID, bool) { userID, ok := userid.FromContext(c.Request.Context()) if !ok { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required") return uuid.Nil, false } return userID, true } // List handles GET /api/v1/user/lobby/games. func (h *UserLobbyGamesHandlers) List() gin.HandlerFunc { if h.svc == nil { return handlers.NotImplemented("userLobbyGamesList") } return func(c *gin.Context) { page := parsePositiveQueryInt(c.Query("page"), 1) pageSize := parsePositiveQueryInt(c.Query("page_size"), 50) ctx := c.Request.Context() result, err := h.svc.ListPublicGames(ctx, page, pageSize) if err != nil { respondLobbyError(c, h.logger, "user lobby games list", ctx, err) return } c.JSON(http.StatusOK, gameSummaryPageToWire(result)) } } // Create handles POST /api/v1/user/lobby/games. func (h *UserLobbyGamesHandlers) Create() gin.HandlerFunc { if h.svc == nil { return handlers.NotImplemented("userLobbyGamesCreate") } return func(c *gin.Context) { userID, ok := h.callerUserID(c) if !ok { return } var req lobbyGameCreateRequestWire if err := c.ShouldBindJSON(&req); err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON") return } if req.Visibility != lobby.VisibilityPrivate { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user-facing /lobby/games only creates private games; admins use /api/v1/admin/games for public") return } enrollmentEndsAt, err := time.Parse(time.RFC3339Nano, req.EnrollmentEndsAt) if err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "enrollment_ends_at must be RFC 3339") return } ctx := c.Request.Context() owner := userID game, err := h.svc.CreateGame(ctx, lobby.CreateGameInput{ OwnerUserID: &owner, Visibility: req.Visibility, GameName: req.GameName, Description: req.Description, MinPlayers: req.MinPlayers, MaxPlayers: req.MaxPlayers, StartGapHours: req.StartGapHours, StartGapPlayers: req.StartGapPlayers, EnrollmentEndsAt: enrollmentEndsAt, TurnSchedule: req.TurnSchedule, TargetEngineVersion: req.TargetEngineVersion, }) if err != nil { respondLobbyError(c, h.logger, "user lobby games create", ctx, err) return } c.JSON(http.StatusCreated, lobbyGameDetailToWire(game)) } } // Get handles GET /api/v1/user/lobby/games/{game_id}. func (h *UserLobbyGamesHandlers) Get() gin.HandlerFunc { if h.svc == nil { return handlers.NotImplemented("userLobbyGamesGet") } return func(c *gin.Context) { gameID, ok := parseGameIDParam(c) if !ok { return } ctx := c.Request.Context() game, err := h.svc.GetGame(ctx, gameID) if err != nil { respondLobbyError(c, h.logger, "user lobby games get", ctx, err) return } c.JSON(http.StatusOK, lobbyGameDetailToWire(game)) } } // Update handles PATCH /api/v1/user/lobby/games/{game_id}. func (h *UserLobbyGamesHandlers) Update() gin.HandlerFunc { if h.svc == nil { return handlers.NotImplemented("userLobbyGamesUpdate") } return func(c *gin.Context) { userID, ok := h.callerUserID(c) if !ok { return } gameID, ok := parseGameIDParam(c) if !ok { return } var req lobbyGameUpdateRequestWire if err := c.ShouldBindJSON(&req); err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON") return } ends, err := parseTimePtrField(req.EnrollmentEndsAt, "enrollment_ends_at") if err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error()) return } ctx := c.Request.Context() caller := userID updated, err := h.svc.UpdateGame(ctx, &caller, false, gameID, lobby.UpdateGameInput{ GameName: req.GameName, Description: req.Description, EnrollmentEndsAt: ends, TurnSchedule: req.TurnSchedule, TargetEngineVersion: req.TargetEngineVersion, MinPlayers: req.MinPlayers, MaxPlayers: req.MaxPlayers, StartGapHours: req.StartGapHours, StartGapPlayers: req.StartGapPlayers, }) if err != nil { respondLobbyError(c, h.logger, "user lobby games update", ctx, err) return } c.JSON(http.StatusOK, lobbyGameDetailToWire(updated)) } } // transitionHandler is the shared shape for owner-driven state-machine // endpoints. fn captures the lobby Service method to invoke. func (h *UserLobbyGamesHandlers) transitionHandler(opName string, successStatus int, fn func(context.Context, *lobby.Service, *uuid.UUID, uuid.UUID) (lobby.GameRecord, error)) gin.HandlerFunc { if h.svc == nil { return handlers.NotImplemented(opName) } return func(c *gin.Context) { userID, ok := h.callerUserID(c) if !ok { return } gameID, ok := parseGameIDParam(c) if !ok { return } ctx := c.Request.Context() caller := userID updated, err := fn(ctx, h.svc, &caller, gameID) if err != nil { respondLobbyError(c, h.logger, "user lobby games "+opName, ctx, err) return } c.JSON(successStatus, lobbyGameStateChangeToWire(updated)) } } // OpenEnrollment handles POST /api/v1/user/lobby/games/{game_id}/open-enrollment. func (h *UserLobbyGamesHandlers) OpenEnrollment() gin.HandlerFunc { return h.transitionHandler("openEnrollment", http.StatusOK, func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) { return svc.OpenEnrollment(ctx, caller, false, gameID) }) } // ReadyToStart handles POST /api/v1/user/lobby/games/{game_id}/ready-to-start. func (h *UserLobbyGamesHandlers) ReadyToStart() gin.HandlerFunc { return h.transitionHandler("readyToStart", http.StatusOK, func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) { return svc.ReadyToStart(ctx, caller, false, gameID) }) } // Start handles POST /api/v1/user/lobby/games/{game_id}/start. func (h *UserLobbyGamesHandlers) Start() gin.HandlerFunc { return h.transitionHandler("start", http.StatusAccepted, func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) { return svc.Start(ctx, caller, false, gameID) }) } // Pause handles POST /api/v1/user/lobby/games/{game_id}/pause. func (h *UserLobbyGamesHandlers) Pause() gin.HandlerFunc { return h.transitionHandler("pause", http.StatusOK, func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) { return svc.Pause(ctx, caller, false, gameID) }) } // Resume handles POST /api/v1/user/lobby/games/{game_id}/resume. func (h *UserLobbyGamesHandlers) Resume() gin.HandlerFunc { return h.transitionHandler("resume", http.StatusOK, func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) { return svc.Resume(ctx, caller, false, gameID) }) } // Cancel handles POST /api/v1/user/lobby/games/{game_id}/cancel. func (h *UserLobbyGamesHandlers) Cancel() gin.HandlerFunc { return h.transitionHandler("cancel", http.StatusOK, func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) { return svc.Cancel(ctx, caller, false, gameID) }) } // RetryStart handles POST /api/v1/user/lobby/games/{game_id}/retry-start. func (h *UserLobbyGamesHandlers) RetryStart() gin.HandlerFunc { return h.transitionHandler("retryStart", http.StatusAccepted, func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) { return svc.RetryStart(ctx, caller, false, gameID) }) } // lobbyGameCreateRequestWire mirrors `LobbyGameCreateRequest`. type lobbyGameCreateRequestWire struct { GameName string `json:"game_name"` Visibility string `json:"visibility"` Description string `json:"description"` MinPlayers int32 `json:"min_players"` MaxPlayers int32 `json:"max_players"` StartGapHours int32 `json:"start_gap_hours"` StartGapPlayers int32 `json:"start_gap_players"` EnrollmentEndsAt string `json:"enrollment_ends_at"` TurnSchedule string `json:"turn_schedule"` TargetEngineVersion string `json:"target_engine_version"` } // lobbyGameUpdateRequestWire mirrors `LobbyGameUpdateRequest`. Optional // fields are pointers so the handler can distinguish "not supplied" // from "empty string". type lobbyGameUpdateRequestWire struct { GameName *string `json:"game_name,omitempty"` Description *string `json:"description,omitempty"` EnrollmentEndsAt *string `json:"enrollment_ends_at,omitempty"` TurnSchedule *string `json:"turn_schedule,omitempty"` TargetEngineVersion *string `json:"target_engine_version,omitempty"` MinPlayers *int32 `json:"min_players,omitempty"` MaxPlayers *int32 `json:"max_players,omitempty"` StartGapHours *int32 `json:"start_gap_hours,omitempty"` StartGapPlayers *int32 `json:"start_gap_players,omitempty"` } // gameSummaryPageWire mirrors `GameSummaryPage`. type gameSummaryPageWire struct { Items []gameSummaryWire `json:"items"` Page int `json:"page"` PageSize int `json:"page_size"` Total int `json:"total"` } func gameSummaryPageToWire(page lobby.GamePage) gameSummaryPageWire { out := gameSummaryPageWire{ Items: make([]gameSummaryWire, 0, len(page.Items)), Page: page.Page, PageSize: page.PageSize, Total: page.Total, } for _, g := range page.Items { out.Items = append(out.Items, gameSummaryToWire(g)) } return out }