package leaderboard import ( "context" "encoding/json" "strconv" "strings" "time" domain "knowfoolery/backend/services/leaderboard-service/internal/domain/leaderboard" sharedsecurity "knowfoolery/backend/shared/infra/security" ) // StateStore defines cache operations used by leaderboard service. type StateStore interface { Get(ctx context.Context, key string) (string, bool) Set(ctx context.Context, key, value string, ttl time.Duration) error Delete(ctx context.Context, keys ...string) error } // Config controls leaderboard behavior. type Config struct { TopLimit int PlayerHistoryDefault int PlayerHistoryMax int CacheTTL time.Duration UpdateRequireAuth bool } // Service orchestrates leaderboard use-cases. type Service struct { repo domain.Repository state StateStore cfg Config } // NewService creates a leaderboard service. func NewService(repo domain.Repository, state StateStore, cfg Config) *Service { if cfg.TopLimit <= 0 { cfg.TopLimit = 10 } if cfg.PlayerHistoryDefault <= 0 { cfg.PlayerHistoryDefault = 20 } if cfg.PlayerHistoryMax <= 0 { cfg.PlayerHistoryMax = 100 } if cfg.CacheTTL <= 0 { cfg.CacheTTL = 60 * time.Second } return &Service{repo: repo, state: state, cfg: cfg} } // UpdateScore ingests a final session result. func (s *Service) UpdateScore(ctx context.Context, in UpdateScoreInput) (*domain.LeaderboardEntry, error) { entry, err := s.validateUpdateInput(in) if err != nil { return nil, err } ingested, _, err := s.repo.IngestEntry(ctx, entry) if err != nil { return nil, err } _ = s.state.Delete( ctx, "lb:top10:v1", "lb:stats:global:v1", "lb:rank:"+ingested.PlayerID, ) return ingested, nil } // GetTop10 returns top leaderboard entries. func (s *Service) GetTop10(ctx context.Context, in GetTopInput) (*Top10Result, error) { filter := domain.TopFilter{ CompletionType: strings.TrimSpace(in.CompletionType), Window: normalizeWindow(in.Window), } cacheKey := "lb:top10:v1:" + filter.CompletionType + ":" + string(filter.Window) if payload, ok := s.state.Get(ctx, cacheKey); ok { var result Top10Result if err := json.Unmarshal([]byte(payload), &result); err == nil { return &result, nil } } items, err := s.repo.ListTop(ctx, filter, s.cfg.TopLimit) if err != nil { return nil, err } result := &Top10Result{Items: make([]RankedEntry, 0, len(items))} for i, item := range items { result.Items = append(result.Items, RankedEntry{ Rank: i + 1, Entry: *item, }) } if payload, err := json.Marshal(result); err == nil { _ = s.state.Set(ctx, cacheKey, string(payload), s.cfg.CacheTTL) } return result, nil } // GetPlayerRanking returns player rank, stats, and paginated history. func (s *Service) GetPlayerRanking(ctx context.Context, in GetPlayerRankingInput) (*PlayerRankingResult, error) { playerID := strings.TrimSpace(in.PlayerID) if playerID == "" { return nil, domain.ErrInvalidInput } p := in.Pagination if p.Page <= 0 { p.Page = 1 } if p.PageSize <= 0 { p.PageSize = s.cfg.PlayerHistoryDefault } if p.PageSize > s.cfg.PlayerHistoryMax { p.PageSize = s.cfg.PlayerHistoryMax } player, err := s.repo.GetPlayerStats(ctx, playerID) if err != nil { return nil, err } var rank int64 rankKey := "lb:rank:" + playerID if cached, ok := s.state.Get(ctx, rankKey); ok { if parsed, parseErr := strconv.ParseInt(cached, 10, 64); parseErr == nil { rank = parsed } } if rank == 0 { rank, err = s.repo.GetPlayerRank(ctx, playerID) if err != nil { return nil, err } _ = s.state.Set(ctx, rankKey, strconv.FormatInt(rank, 10), s.cfg.CacheTTL) } historyItems, total, err := s.repo.ListPlayerHistory(ctx, playerID, p) if err != nil { return nil, err } history := make([]domain.LeaderboardEntry, 0, len(historyItems)) for _, item := range historyItems { history = append(history, *item) } return &PlayerRankingResult{ Player: *player, Rank: rank, History: history, Pagination: p, Total: total, }, nil } // GetGlobalStats returns aggregate leaderboard statistics. func (s *Service) GetGlobalStats(ctx context.Context, in GetStatsInput) (*domain.GlobalStats, error) { filter := domain.TopFilter{ CompletionType: strings.TrimSpace(in.CompletionType), Window: normalizeWindow(in.Window), } cacheKey := "lb:stats:global:v1:" + filter.CompletionType + ":" + string(filter.Window) if payload, ok := s.state.Get(ctx, cacheKey); ok { var result domain.GlobalStats if err := json.Unmarshal([]byte(payload), &result); err == nil { return &result, nil } } stats, err := s.repo.GetGlobalStats(ctx, filter) if err != nil { return nil, err } if payload, err := json.Marshal(stats); err == nil { _ = s.state.Set(ctx, cacheKey, string(payload), s.cfg.CacheTTL) } return stats, nil } func (s *Service) validateUpdateInput(in UpdateScoreInput) (*domain.LeaderboardEntry, error) { sessionID := strings.TrimSpace(in.SessionID) playerID := strings.TrimSpace(in.PlayerID) if sessionID == "" || playerID == "" { return nil, domain.ErrInvalidInput } if in.TotalScore < 0 || in.QuestionsAsked < 0 || in.QuestionsCorrect < 0 || in.DurationSeconds < 0 { return nil, domain.ErrInvalidInput } if in.QuestionsCorrect > in.QuestionsAsked { return nil, domain.ErrInvalidInput } completionType := strings.TrimSpace(in.CompletionType) if completionType == "" { return nil, domain.ErrInvalidInput } if completionType != string(domain.CompletionCompleted) && completionType != string(domain.CompletionTimedOut) && completionType != string(domain.CompletionAbandoned) { return nil, domain.ErrInvalidInput } completedAt := in.CompletedAt.UTC() if completedAt.IsZero() { completedAt = time.Now().UTC() } rate := 0.0 if in.QuestionsAsked > 0 { rate = float64(in.QuestionsCorrect) * 100.0 / float64(in.QuestionsAsked) } playerName := sharedsecurity.SanitizePlayerName(in.PlayerName) if playerName == "" { playerName = "Player" } return &domain.LeaderboardEntry{ SessionID: sessionID, PlayerID: playerID, PlayerName: playerName, Score: in.TotalScore, QuestionsAsked: in.QuestionsAsked, QuestionsCorrect: in.QuestionsCorrect, HintsUsed: in.HintsUsed, DurationSeconds: in.DurationSeconds, SuccessRate: rate, CompletionType: domain.CompletionType(completionType), CompletedAt: completedAt, }, nil } func normalizeWindow(w domain.Window) domain.Window { switch w { case domain.Window24h, domain.Window7d, domain.Window30d, domain.WindowAll: return w default: return domain.WindowAll } }