package server import ( "context" "errors" "net/http" "time" "galaxy/backend/internal/lobby" "galaxy/backend/internal/server/httperr" "galaxy/backend/internal/telemetry" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" ) // respondLobbyError maps lobby-package sentinel errors to the standard // JSON error envelope. Unknown errors land on a 500. func respondLobbyError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) { switch { case errors.Is(err, lobby.ErrInvalidInput): httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error()) case errors.Is(err, lobby.ErrNotFound): httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "resource was not found") case errors.Is(err, lobby.ErrForbidden): httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, err.Error()) case errors.Is(err, lobby.ErrConflict), errors.Is(err, lobby.ErrInvalidStatus), errors.Is(err, lobby.ErrRaceNameTaken), errors.Is(err, lobby.ErrEntitlementExceeded), errors.Is(err, lobby.ErrPendingExpired): httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error()) default: logger.Error(op+" failed", append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))..., ) httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error") } } // parseGameIDParam reads `game_id` from the path. Writes 400 envelope on // invalid input and returns false in that case. func parseGameIDParam(c *gin.Context) (uuid.UUID, bool) { parsed, err := uuid.Parse(c.Param("game_id")) if err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "game_id must be a valid UUID") return uuid.Nil, false } return parsed, true } func parseApplicationIDParam(c *gin.Context) (uuid.UUID, bool) { parsed, err := uuid.Parse(c.Param("application_id")) if err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "application_id must be a valid UUID") return uuid.Nil, false } return parsed, true } func parseInviteIDParam(c *gin.Context) (uuid.UUID, bool) { parsed, err := uuid.Parse(c.Param("invite_id")) if err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "invite_id must be a valid UUID") return uuid.Nil, false } return parsed, true } func parseMembershipIDParam(c *gin.Context) (uuid.UUID, bool) { parsed, err := uuid.Parse(c.Param("membership_id")) if err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "membership_id must be a valid UUID") return uuid.Nil, false } return parsed, true } // gameSummaryWire mirrors `GameSummary` from openapi.yaml. type gameSummaryWire struct { GameID string `json:"game_id"` GameName string `json:"game_name"` GameType string `json:"game_type"` Status string `json:"status"` OwnerUserID *string `json:"owner_user_id,omitempty"` MinPlayers int32 `json:"min_players"` MaxPlayers int32 `json:"max_players"` EnrollmentEndsAt string `json:"enrollment_ends_at"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } // lobbyGameDetailWire mirrors `LobbyGameDetail` from openapi.yaml. type lobbyGameDetailWire struct { gameSummaryWire Visibility string `json:"visibility"` Description string `json:"description,omitempty"` TurnSchedule string `json:"turn_schedule"` TargetEngineVersion string `json:"target_engine_version"` StartGapHours int32 `json:"start_gap_hours"` StartGapPlayers int32 `json:"start_gap_players"` CurrentTurn int32 `json:"current_turn"` RuntimeStatus string `json:"runtime_status"` EngineHealth string `json:"engine_health,omitempty"` StartedAt *string `json:"started_at,omitempty"` FinishedAt *string `json:"finished_at,omitempty"` } func gameSummaryToWire(g lobby.GameRecord) gameSummaryWire { out := gameSummaryWire{ GameID: g.GameID.String(), GameName: g.GameName, GameType: g.Visibility, Status: g.Status, MinPlayers: g.MinPlayers, MaxPlayers: g.MaxPlayers, EnrollmentEndsAt: g.EnrollmentEndsAt.UTC().Format(timestampLayout), CreatedAt: g.CreatedAt.UTC().Format(timestampLayout), UpdatedAt: g.UpdatedAt.UTC().Format(timestampLayout), } if g.OwnerUserID != nil { s := g.OwnerUserID.String() out.OwnerUserID = &s } return out } func lobbyGameDetailToWire(g lobby.GameRecord) lobbyGameDetailWire { out := lobbyGameDetailWire{ gameSummaryWire: gameSummaryToWire(g), Visibility: g.Visibility, Description: g.Description, TurnSchedule: g.TurnSchedule, TargetEngineVersion: g.TargetEngineVersion, StartGapHours: g.StartGapHours, StartGapPlayers: g.StartGapPlayers, CurrentTurn: g.RuntimeSnapshot.CurrentTurn, RuntimeStatus: g.RuntimeSnapshot.RuntimeStatus, EngineHealth: g.RuntimeSnapshot.EngineHealth, } if g.StartedAt != nil { s := g.StartedAt.UTC().Format(timestampLayout) out.StartedAt = &s } if g.FinishedAt != nil { s := g.FinishedAt.UTC().Format(timestampLayout) out.FinishedAt = &s } return out } // lobbyGameStateChangeWire mirrors `LobbyGameStateChange`. type lobbyGameStateChangeWire struct { GameID string `json:"game_id"` Status string `json:"status"` RuntimeStatus string `json:"runtime_status,omitempty"` } func lobbyGameStateChangeToWire(g lobby.GameRecord) lobbyGameStateChangeWire { return lobbyGameStateChangeWire{ GameID: g.GameID.String(), Status: g.Status, RuntimeStatus: g.RuntimeSnapshot.RuntimeStatus, } } // lobbyApplicationDetailWire mirrors `LobbyApplicationDetail`. type lobbyApplicationDetailWire struct { ApplicationID string `json:"application_id"` GameID string `json:"game_id"` ApplicantUserID string `json:"applicant_user_id"` RaceName string `json:"race_name"` Status string `json:"status"` CreatedAt string `json:"created_at"` DecidedAt *string `json:"decided_at,omitempty"` } func lobbyApplicationDetailToWire(a lobby.Application) lobbyApplicationDetailWire { out := lobbyApplicationDetailWire{ ApplicationID: a.ApplicationID.String(), GameID: a.GameID.String(), ApplicantUserID: a.ApplicantUserID.String(), RaceName: a.RaceName, Status: a.Status, CreatedAt: a.CreatedAt.UTC().Format(timestampLayout), } if a.DecidedAt != nil { s := a.DecidedAt.UTC().Format(timestampLayout) out.DecidedAt = &s } return out } // lobbyInviteDetailWire mirrors `LobbyInviteDetail`. type lobbyInviteDetailWire struct { InviteID string `json:"invite_id"` GameID string `json:"game_id"` InviterUserID string `json:"inviter_user_id"` InvitedUserID *string `json:"invited_user_id,omitempty"` Code *string `json:"code,omitempty"` RaceName string `json:"race_name"` Status string `json:"status"` CreatedAt string `json:"created_at"` ExpiresAt string `json:"expires_at"` DecidedAt *string `json:"decided_at,omitempty"` } func lobbyInviteDetailToWire(i lobby.Invite) lobbyInviteDetailWire { out := lobbyInviteDetailWire{ InviteID: i.InviteID.String(), GameID: i.GameID.String(), InviterUserID: i.InviterUserID.String(), RaceName: i.RaceName, Status: i.Status, CreatedAt: i.CreatedAt.UTC().Format(timestampLayout), ExpiresAt: i.ExpiresAt.UTC().Format(timestampLayout), } if i.InvitedUserID != nil { s := i.InvitedUserID.String() out.InvitedUserID = &s } if i.Code != "" { c := i.Code out.Code = &c } if i.DecidedAt != nil { s := i.DecidedAt.UTC().Format(timestampLayout) out.DecidedAt = &s } return out } // lobbyMembershipDetailWire mirrors `LobbyMembershipDetail`. type lobbyMembershipDetailWire struct { MembershipID string `json:"membership_id"` GameID string `json:"game_id"` UserID string `json:"user_id"` RaceName string `json:"race_name"` CanonicalKey string `json:"canonical_key"` Status string `json:"status"` JoinedAt string `json:"joined_at"` RemovedAt *string `json:"removed_at,omitempty"` } func lobbyMembershipDetailToWire(m lobby.Membership) lobbyMembershipDetailWire { out := lobbyMembershipDetailWire{ MembershipID: m.MembershipID.String(), GameID: m.GameID.String(), UserID: m.UserID.String(), RaceName: m.RaceName, CanonicalKey: m.CanonicalKey, Status: m.Status, JoinedAt: m.JoinedAt.UTC().Format(timestampLayout), } if m.RemovedAt != nil { s := m.RemovedAt.UTC().Format(timestampLayout) out.RemovedAt = &s } return out } // raceNameDetailWire mirrors `RaceNameDetail`. type raceNameDetailWire struct { Name string `json:"name"` Canonical string `json:"canonical"` Status string `json:"status"` OwnerUserID string `json:"owner_user_id"` GameID *string `json:"game_id,omitempty"` SourceGameID *string `json:"source_game_id,omitempty"` ReservedAt *string `json:"reserved_at,omitempty"` ExpiresAt *string `json:"expires_at,omitempty"` RegisteredAt *string `json:"registered_at,omitempty"` } func raceNameDetailToWire(e lobby.RaceNameEntry) raceNameDetailWire { out := raceNameDetailWire{ Name: e.Name, Canonical: string(e.Canonical), Status: e.Status, OwnerUserID: e.OwnerUserID.String(), } if e.GameID != (uuid.UUID{}) { s := e.GameID.String() out.GameID = &s } if e.SourceGameID != nil { s := e.SourceGameID.String() out.SourceGameID = &s } if e.ReservedAt != nil { s := e.ReservedAt.UTC().Format(timestampLayout) out.ReservedAt = &s } if e.ExpiresAt != nil { s := e.ExpiresAt.UTC().Format(timestampLayout) out.ExpiresAt = &s } if e.RegisteredAt != nil { s := e.RegisteredAt.UTC().Format(timestampLayout) out.RegisteredAt = &s } return out } // parseTimePtrField parses a wire timestamp pointer into a time.Time // pointer. Empty / nil input yields nil. Invalid timestamps return an // error. func parseTimePtrField(raw *string, field string) (*time.Time, error) { if raw == nil || *raw == "" { return nil, nil } t, err := time.Parse(time.RFC3339Nano, *raw) if err != nil { return nil, errors.New(field + " must be RFC 3339") } return &t, nil }