package http import ( "errors" "strconv" "strings" "github.com/gofiber/fiber/v3" app "knowfoolery/backend/services/game-session-service/internal/application/session" domain "knowfoolery/backend/services/game-session-service/internal/domain/session" sharederrors "knowfoolery/backend/shared/domain/errors" "knowfoolery/backend/shared/infra/auth/zitadel" "knowfoolery/backend/shared/infra/observability/logging" sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics" "knowfoolery/backend/shared/infra/utils/httputil" "knowfoolery/backend/shared/infra/utils/validation" ) // Handler implements game-session service HTTP endpoint handlers. type Handler struct { service *app.Service validator *validation.Validator logger *logging.Logger metrics *sharedmetrics.Metrics } // NewHandler creates a new handler set. func NewHandler( service *app.Service, validator *validation.Validator, logger *logging.Logger, metrics *sharedmetrics.Metrics, ) *Handler { return &Handler{ service: service, validator: validator, logger: logger, metrics: metrics, } } // StartSession handles POST /sessions/start. func (h *Handler) StartSession(c fiber.Ctx) error { claims := authClaimsFromContext(c) if claims.UserID == "" { return httputil.SendError(c, sharederrors.New(sharederrors.CodeUnauthorized, "unauthorized")) } var req StartSessionRequest if err := c.Bind().Body(&req); err != nil { return httputil.SendError(c, sharederrors.Wrap(sharederrors.CodeInvalidInput, "invalid request body", err)) } if err := h.validator.Validate(req); err != nil { return httputil.SendError(c, err) } res, err := h.service.StartSession(c.Context(), app.StartSessionInput{ PlayerID: claims.UserID, BearerToken: bearerToken(c), PreferredTheme: req.PreferredTheme, Difficulty: req.Difficulty, }) if err != nil { return h.sendMappedError(c, err) } h.recordRequestMetric("POST", "/sessions/start", fiber.StatusOK) return httputil.OK(c, res) } // EndSession handles POST /sessions/end. func (h *Handler) EndSession(c fiber.Ctx) error { claims := authClaimsFromContext(c) if claims.UserID == "" { return httputil.SendError(c, sharederrors.New(sharederrors.CodeUnauthorized, "unauthorized")) } var req EndSessionRequest if err := c.Bind().Body(&req); err != nil { return httputil.SendError(c, sharederrors.Wrap(sharederrors.CodeInvalidInput, "invalid request body", err)) } if err := h.validator.Validate(req); err != nil { return httputil.SendError(c, err) } summary, err := h.authorizeSession(c, req.SessionID, claims) if err != nil { return h.sendMappedError(c, err) } if summary.ID == "" { return httputil.SendError(c, domain.ErrSessionNotFound) } res, err := h.service.EndSession(c.Context(), app.EndSessionInput{ SessionID: req.SessionID, Reason: req.Reason, }) if err != nil { return h.sendMappedError(c, err) } h.recordRequestMetric("POST", "/sessions/end", fiber.StatusOK) return httputil.OK(c, res) } // SubmitAnswer handles POST /sessions/:id/answer. func (h *Handler) SubmitAnswer(c fiber.Ctx) error { claims := authClaimsFromContext(c) if claims.UserID == "" { return httputil.SendError(c, sharederrors.New(sharederrors.CodeUnauthorized, "unauthorized")) } sessionID := c.Params("id") if _, err := h.authorizeSession(c, sessionID, claims); err != nil { return h.sendMappedError(c, err) } var req SubmitAnswerRequest if err := c.Bind().Body(&req); err != nil { return httputil.SendError(c, sharederrors.Wrap(sharederrors.CodeInvalidInput, "invalid request body", err)) } if err := h.validator.Validate(req); err != nil { return httputil.SendError(c, err) } res, err := h.service.SubmitAnswer(c.Context(), app.SubmitAnswerInput{ SessionID: sessionID, Answer: req.Answer, }) if err != nil { return h.sendMappedError(c, err) } h.recordRequestMetric("POST", "/sessions/{id}/answer", fiber.StatusOK) return httputil.OK(c, res) } // RequestHint handles POST /sessions/:id/hint. func (h *Handler) RequestHint(c fiber.Ctx) error { claims := authClaimsFromContext(c) if claims.UserID == "" { return httputil.SendError(c, sharederrors.New(sharederrors.CodeUnauthorized, "unauthorized")) } sessionID := c.Params("id") if _, err := h.authorizeSession(c, sessionID, claims); err != nil { return h.sendMappedError(c, err) } res, err := h.service.RequestHint(c.Context(), app.RequestHintInput{SessionID: sessionID}) if err != nil { return h.sendMappedError(c, err) } h.recordRequestMetric("POST", "/sessions/{id}/hint", fiber.StatusOK) return httputil.OK(c, res) } // GetSession handles GET /sessions/:id. func (h *Handler) GetSession(c fiber.Ctx) error { claims := authClaimsFromContext(c) if claims.UserID == "" { return httputil.SendError(c, sharederrors.New(sharederrors.CodeUnauthorized, "unauthorized")) } sessionID := c.Params("id") summary, err := h.authorizeSession(c, sessionID, claims) if err != nil { return h.sendMappedError(c, err) } h.recordRequestMetric("GET", "/sessions/{id}", fiber.StatusOK) return httputil.OK(c, summary) } // GetCurrentQuestion handles GET /sessions/:id/question. func (h *Handler) GetCurrentQuestion(c fiber.Ctx) error { claims := authClaimsFromContext(c) if claims.UserID == "" { return httputil.SendError(c, sharederrors.New(sharederrors.CodeUnauthorized, "unauthorized")) } sessionID := c.Params("id") summary, err := h.authorizeSession(c, sessionID, claims) if err != nil { return h.sendMappedError(c, err) } question, err := h.service.GetCurrentQuestion(c.Context(), sessionID) if err != nil { return h.sendMappedError(c, err) } resp := fiber.Map{ "question": question, "attempts_used": summary.CurrentAttempts, "attempts_remaining": 3 - summary.CurrentAttempts, "hint_used": summary.CurrentHintUsed, } if resp["attempts_remaining"].(int) < 0 { resp["attempts_remaining"] = 0 } h.recordRequestMetric("GET", "/sessions/{id}/question", fiber.StatusOK) return httputil.OK(c, resp) } func (h *Handler) authorizeSession(c fiber.Ctx, sessionID string, claims authClaims) (*app.SessionSummary, error) { summary, err := h.service.GetSession(c.Context(), sessionID) if err != nil { return nil, err } if claims.IsAdmin || summary.PlayerID == claims.UserID { return summary, nil } return nil, domain.ErrForbidden } func (h *Handler) sendMappedError(c fiber.Ctx, err error) error { var domainErr *sharederrors.DomainError if errors.As(err, &domainErr) { return httputil.SendError(c, domainErr) } if h.logger != nil { h.logger.WithError(err).Error("game-session-service internal error") } return httputil.SendError(c, sharederrors.Wrap(sharederrors.CodeInternal, "internal error", err)) } func (h *Handler) recordRequestMetric(method, endpoint string, status int) { if h.metrics == nil { return } h.metrics.HTTPRequestsTotal.WithLabelValues( method, endpoint, strconv.Itoa(status), "game-session-service", ).Inc() } type authClaims struct { UserID string Roles []string IsAdmin bool } func authClaimsFromContext(c fiber.Ctx) authClaims { roles := zitadel.GetUserRoles(c) claims := authClaims{ UserID: zitadel.GetUserID(c), Roles: roles, } for _, role := range roles { if role == "admin" { claims.IsAdmin = true break } } return claims } func bearerToken(c fiber.Ctx) string { authHeader := strings.TrimSpace(c.Get("Authorization")) if authHeader == "" { return "" } return strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) }