package http import ( "errors" "strconv" "strings" "time" "github.com/gofiber/fiber/v3" app "knowfoolery/backend/services/leaderboard-service/internal/application/leaderboard" domain "knowfoolery/backend/services/leaderboard-service/internal/domain/leaderboard" sharederrors "knowfoolery/backend/shared/domain/errors" sharedtypes "knowfoolery/backend/shared/domain/types" "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 leaderboard HTTP endpoints. type Handler struct { service *app.Service validator *validation.Validator logger *logging.Logger metrics *sharedmetrics.Metrics updateRequireAuth bool defaultPageSize int maxPageSize int } // NewHandler creates handler set. func NewHandler( service *app.Service, validator *validation.Validator, logger *logging.Logger, metrics *sharedmetrics.Metrics, updateRequireAuth bool, defaultPageSize int, maxPageSize int, ) *Handler { if defaultPageSize <= 0 { defaultPageSize = 20 } if maxPageSize <= 0 { maxPageSize = 100 } return &Handler{ service: service, validator: validator, logger: logger, metrics: metrics, updateRequireAuth: updateRequireAuth, defaultPageSize: defaultPageSize, maxPageSize: maxPageSize, } } // GetTop10 handles GET /leaderboard/top10. func (h *Handler) GetTop10(c fiber.Ctx) error { result, err := h.service.GetTop10(c.Context(), app.GetTopInput{ CompletionType: strings.TrimSpace(c.Query("completion_type")), Window: domain.Window(strings.TrimSpace(c.Query("window", string(domain.WindowAll)))), }) if err != nil { return h.sendMappedError(c, err) } h.recordRequestMetric("GET", "/leaderboard/top10", fiber.StatusOK) return httputil.OK(c, result) } // GetPlayerRanking handles GET /leaderboard/players/:id. func (h *Handler) GetPlayerRanking(c fiber.Ctx) error { playerID := strings.TrimSpace(c.Params("id")) claims := authClaimsFromContext(c) if !claims.IsAdmin && claims.UserID != playerID { return httputil.SendError(c, domain.ErrForbidden) } page := atoiWithDefault(c.Query("page"), 1) pageSize := atoiWithDefault(c.Query("page_size"), h.defaultPageSize) if pageSize > h.maxPageSize { pageSize = h.maxPageSize } if pageSize < 1 { pageSize = h.defaultPageSize } result, err := h.service.GetPlayerRanking(c.Context(), app.GetPlayerRankingInput{ PlayerID: playerID, Pagination: sharedtypes.Pagination{ Page: page, PageSize: pageSize, }, }) if err != nil { return h.sendMappedError(c, err) } h.recordRequestMetric("GET", "/leaderboard/players/{id}", fiber.StatusOK) return httputil.OK(c, result) } // GetStats handles GET /leaderboard/stats. func (h *Handler) GetStats(c fiber.Ctx) error { stats, err := h.service.GetGlobalStats(c.Context(), app.GetStatsInput{ CompletionType: strings.TrimSpace(c.Query("completion_type")), Window: domain.Window(strings.TrimSpace(c.Query("window", string(domain.WindowAll)))), }) if err != nil { return h.sendMappedError(c, err) } h.recordRequestMetric("GET", "/leaderboard/stats", fiber.StatusOK) return httputil.OK(c, stats) } // Update handles POST /leaderboard/update. func (h *Handler) Update(c fiber.Ctx) error { if h.updateRequireAuth { claims := authClaimsFromContext(c) if claims.UserID == "" || (!claims.IsAdmin && !claims.IsService) { return httputil.SendError(c, sharederrors.New(sharederrors.CodeForbidden, "forbidden")) } } var req UpdateLeaderboardRequest 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) } completedAt, err := time.Parse(time.RFC3339, req.CompletedAt) if err != nil { return httputil.SendError( c, sharederrors.Wrap(sharederrors.CodeValidationFailed, "completed_at must be RFC3339", err), ) } entry, err := h.service.UpdateScore(c.Context(), app.UpdateScoreInput{ SessionID: req.SessionID, PlayerID: req.PlayerID, PlayerName: req.PlayerName, TotalScore: req.TotalScore, QuestionsAsked: req.QuestionsAsked, QuestionsCorrect: req.QuestionsCorrect, HintsUsed: req.HintsUsed, DurationSeconds: req.DurationSeconds, CompletedAt: completedAt, CompletionType: req.CompletionType, }) if err != nil { return h.sendMappedError(c, err) } h.recordRequestMetric("POST", "/leaderboard/update", fiber.StatusOK) return httputil.OK(c, entry) } 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("leaderboard-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), "leaderboard-service", ).Inc() } type authClaims struct { UserID string IsAdmin bool IsService bool } func authClaimsFromContext(c fiber.Ctx) authClaims { roles := zitadel.GetUserRoles(c) claims := authClaims{UserID: zitadel.GetUserID(c)} for _, role := range roles { if role == "admin" { claims.IsAdmin = true } if role == "service" { claims.IsService = true } } return claims } func atoiWithDefault(v string, d int) int { if strings.TrimSpace(v) == "" { return d } n, err := strconv.Atoi(v) if err != nil { return d } return n }