Implemented '2.4 Leaderboard Service (Port 8083)'

master
oabrivard 1 month ago
parent 3cc74867a7
commit 80003d19ca

@ -1,22 +1,124 @@
package main
import (
"context"
"log"
"time"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/adaptor"
applb "knowfoolery/backend/services/leaderboard-service/internal/application/leaderboard"
lbconfig "knowfoolery/backend/services/leaderboard-service/internal/infra/config"
lbent "knowfoolery/backend/services/leaderboard-service/internal/infra/persistence/ent"
lbstate "knowfoolery/backend/services/leaderboard-service/internal/infra/state"
httpapi "knowfoolery/backend/services/leaderboard-service/internal/interfaces/http"
"knowfoolery/backend/shared/infra/auth/zitadel"
sharedredis "knowfoolery/backend/shared/infra/database/redis"
"knowfoolery/backend/shared/infra/observability/logging"
sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics"
"knowfoolery/backend/shared/infra/observability/tracing"
"knowfoolery/backend/shared/infra/utils/serviceboot"
"knowfoolery/backend/shared/infra/utils/validation"
)
func main() {
cfg := serviceboot.Config{
AppName: "Know Foolery - Leaderboard Service",
serviceCfg := lbconfig.FromEnv()
logger := logging.NewLogger(serviceCfg.Logging)
metrics := sharedmetrics.NewMetrics(serviceCfg.Metrics)
tracer, err := tracing.NewTracer(serviceCfg.Tracing)
if err != nil {
logger.Fatal("failed to initialize tracer")
}
defer func() { _ = tracer.Shutdown(context.Background()) }()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
persistence, err := lbent.NewClient(ctx, serviceCfg.Postgres)
if err != nil {
logger.WithError(err).Fatal("failed to initialize postgres client")
}
defer persistence.Close()
repo := lbent.NewLeaderboardRepository(persistence)
if err := repo.EnsureSchema(ctx); err != nil {
logger.WithError(err).Fatal("failed to ensure schema")
}
var redisClient *sharedredis.Client
if c, redisErr := sharedredis.NewClient(serviceCfg.Redis); redisErr == nil {
redisClient = c
defer func() { _ = redisClient.Close() }()
} else {
logger.WithError(redisErr).Warn("redis unavailable; running without cache")
}
state := lbstate.NewStore(redisClient)
service := applb.NewService(repo, state, applb.Config{
TopLimit: serviceCfg.TopLimit,
PlayerHistoryDefault: serviceCfg.PlayerHistoryDefault,
PlayerHistoryMax: serviceCfg.PlayerHistoryMax,
CacheTTL: serviceCfg.CacheTTL,
UpdateRequireAuth: serviceCfg.UpdateRequireAuth,
})
handler := httpapi.NewHandler(
service,
validation.NewValidator(),
logger,
metrics,
serviceCfg.UpdateRequireAuth,
serviceCfg.PlayerHistoryDefault,
serviceCfg.PlayerHistoryMax,
)
bootCfg := serviceboot.Config{
AppName: serviceCfg.AppName,
ServiceSlug: "leaderboard",
PortEnv: "LEADERBOARD_PORT",
DefaultPort: 8083,
DefaultPort: serviceCfg.Port,
}
app := serviceboot.NewFiberApp(bootCfg)
serviceboot.RegisterHealth(app, bootCfg.ServiceSlug)
serviceboot.RegisterReadiness(
app,
2*time.Second,
serviceboot.ReadyCheck{
Name: "postgres",
Required: true,
Probe: persistence.Pool.Ping,
},
serviceboot.ReadyCheck{
Name: "redis",
Required: false,
Probe: func(ctx context.Context) error {
if redisClient == nil {
return nil
}
return redisClient.HealthCheck(ctx)
},
},
)
app.Get("/metrics", adaptor.HTTPHandler(sharedmetrics.Handler()))
app := serviceboot.NewFiberApp(cfg)
serviceboot.RegisterHealth(app, cfg.ServiceSlug)
authMiddleware := buildAuthMiddleware(serviceCfg)
httpapi.RegisterRoutes(app, handler, authMiddleware)
addr := serviceboot.ListenAddress(cfg.PortEnv, cfg.DefaultPort)
addr := serviceboot.ListenAddress(bootCfg.PortEnv, bootCfg.DefaultPort)
log.Fatal(serviceboot.Run(app, addr))
}
func buildAuthMiddleware(cfg lbconfig.Config) fiber.Handler {
return zitadel.BuildJWTMiddleware(zitadel.MiddlewareFactoryConfig{
BaseURL: cfg.ZitadelBaseURL,
ClientID: cfg.ZitadelClientID,
ClientSecret: cfg.ZitadelSecret,
Issuer: cfg.ZitadelIssuer,
Audience: cfg.ZitadelAudience,
RequiredClaims: []string{
"sub",
},
Timeout: 10 * time.Second,
})
}

@ -2,20 +2,65 @@ module knowfoolery/backend/services/leaderboard-service
go 1.25.5
require knowfoolery/backend/shared v0.0.0
require (
github.com/gofiber/fiber/v3 v3.0.0-beta.3
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.2
github.com/prometheus/client_golang v1.20.5
knowfoolery/backend/shared v0.0.0
)
require (
github.com/MicahParks/jwkset v0.11.0 // indirect
github.com/MicahParks/keyfunc/v3 v3.7.0 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/gofiber/fiber/v3 v3.0.0-beta.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.25.0 // indirect
github.com/gofiber/utils/v2 v2.0.0-beta.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/redis/go-redis/v9 v9.7.0 // indirect
github.com/rs/zerolog v1.33.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.55.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.9.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
replace knowfoolery/backend/shared => ../../shared

@ -1,15 +1,70 @@
github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ=
github.com/MicahParks/keyfunc/v3 v3.7.0 h1:pdafUNyq+p3ZlvjJX1HWFP7MA3+cLpDtg69U3kITJGM=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
github.com/alicebob/miniredis/v2 v2.33.0 h1:uvTF0EDeu9RLnUEG27Db5I68ESoIxTiXbNUiji6lZrA=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
github.com/gofiber/fiber/v3 v3.0.0-beta.3 h1:7Q2I+HsIqnIEEDB+9oe7Gadpakh6ZLhXpTYz/L20vrg=
github.com/gofiber/utils/v2 v2.0.0-beta.4 h1:1gjbVFFwVwUb9arPcqiB6iEjHBwo7cHsyS41NeIW3co=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

@ -0,0 +1,60 @@
package leaderboard
import (
"time"
domain "knowfoolery/backend/services/leaderboard-service/internal/domain/leaderboard"
sharedtypes "knowfoolery/backend/shared/domain/types"
)
// UpdateScoreInput is the internal score ingestion payload.
type UpdateScoreInput struct {
SessionID string
PlayerID string
PlayerName string
TotalScore int
QuestionsAsked int
QuestionsCorrect int
HintsUsed int
DurationSeconds int
CompletedAt time.Time
CompletionType string
}
// GetTopInput captures top leaderboard query options.
type GetTopInput struct {
CompletionType string
Window domain.Window
}
// GetPlayerRankingInput captures player ranking query.
type GetPlayerRankingInput struct {
PlayerID string
Pagination sharedtypes.Pagination
}
// GetStatsInput captures global stats query options.
type GetStatsInput struct {
CompletionType string
Window domain.Window
}
// RankedEntry is one ranked leaderboard line.
type RankedEntry struct {
Rank int `json:"rank"`
Entry domain.LeaderboardEntry `json:"entry"`
}
// Top10Result returns top entries.
type Top10Result struct {
Items []RankedEntry `json:"items"`
}
// PlayerRankingResult returns player rank and history.
type PlayerRankingResult struct {
Player domain.PlayerStats `json:"player"`
Rank int64 `json:"rank"`
History []domain.LeaderboardEntry `json:"history"`
Pagination sharedtypes.Pagination `json:"pagination"`
Total int64 `json:"total"`
}

@ -0,0 +1,243 @@
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
}
}

@ -0,0 +1,227 @@
package leaderboard
import (
"context"
"errors"
"testing"
"time"
domain "knowfoolery/backend/services/leaderboard-service/internal/domain/leaderboard"
sharedtypes "knowfoolery/backend/shared/domain/types"
)
type fakeRepo struct {
entries []*domain.LeaderboardEntry
stats map[string]*domain.PlayerStats
}
func newFakeRepo() *fakeRepo {
return &fakeRepo{
entries: make([]*domain.LeaderboardEntry, 0),
stats: map[string]*domain.PlayerStats{},
}
}
func (r *fakeRepo) EnsureSchema(ctx context.Context) error { return nil }
func (r *fakeRepo) IngestEntry(
ctx context.Context,
entry *domain.LeaderboardEntry,
) (*domain.LeaderboardEntry, bool, error) {
for _, e := range r.entries {
if e.SessionID == entry.SessionID {
return e, true, nil
}
}
cp := *entry
cp.ID = "id-" + entry.SessionID
cp.CreatedAt = time.Now().UTC()
r.entries = append(r.entries, &cp)
stats := r.stats[entry.PlayerID]
if stats == nil {
best := entry.DurationSeconds
stats = &domain.PlayerStats{
PlayerID: entry.PlayerID,
PlayerName: entry.PlayerName,
GamesPlayed: 1,
GamesCompleted: 0,
TotalScore: int64(entry.Score),
BestScore: entry.Score,
AvgScore: float64(entry.Score),
AvgSuccessRate: entry.SuccessRate,
TotalQuestions: int64(entry.QuestionsAsked),
TotalCorrect: int64(entry.QuestionsCorrect),
BestDurationSec: &best,
}
if entry.CompletionType == domain.CompletionCompleted {
stats.GamesCompleted = 1
}
r.stats[entry.PlayerID] = stats
return &cp, false, nil
}
stats.GamesPlayed++
stats.TotalScore += int64(entry.Score)
stats.TotalQuestions += int64(entry.QuestionsAsked)
stats.TotalCorrect += int64(entry.QuestionsCorrect)
stats.AvgScore = float64(stats.TotalScore) / float64(stats.GamesPlayed)
if stats.TotalQuestions > 0 {
stats.AvgSuccessRate = float64(stats.TotalCorrect) * 100 / float64(stats.TotalQuestions)
}
if entry.Score > stats.BestScore {
stats.BestScore = entry.Score
best := entry.DurationSeconds
stats.BestDurationSec = &best
}
return &cp, false, nil
}
func (r *fakeRepo) ListTop(ctx context.Context, filter domain.TopFilter, limit int) ([]*domain.LeaderboardEntry, error) {
if len(r.entries) < limit {
limit = len(r.entries)
}
out := make([]*domain.LeaderboardEntry, 0, limit)
for i := 0; i < limit; i++ {
out = append(out, r.entries[i])
}
return out, nil
}
func (r *fakeRepo) GetPlayerStats(ctx context.Context, playerID string) (*domain.PlayerStats, error) {
stats := r.stats[playerID]
if stats == nil {
return nil, domain.ErrPlayerNotFound
}
cp := *stats
return &cp, nil
}
func (r *fakeRepo) GetPlayerRank(ctx context.Context, playerID string) (int64, error) {
if _, ok := r.stats[playerID]; !ok {
return 0, domain.ErrPlayerNotFound
}
return 1, nil
}
func (r *fakeRepo) ListPlayerHistory(
ctx context.Context,
playerID string,
pagination sharedtypes.Pagination,
) ([]*domain.LeaderboardEntry, int64, error) {
out := make([]*domain.LeaderboardEntry, 0)
for _, entry := range r.entries {
if entry.PlayerID == playerID {
out = append(out, entry)
}
}
return out, int64(len(out)), nil
}
func (r *fakeRepo) GetGlobalStats(ctx context.Context, filter domain.TopFilter) (*domain.GlobalStats, error) {
return &domain.GlobalStats{TotalGames: int64(len(r.entries)), UpdatedAt: time.Now().UTC()}, nil
}
type fakeState struct {
data map[string]string
}
func newFakeState() *fakeState {
return &fakeState{data: map[string]string{}}
}
func (s *fakeState) Get(ctx context.Context, key string) (string, bool) {
v, ok := s.data[key]
return v, ok
}
func (s *fakeState) Set(ctx context.Context, key, value string, ttl time.Duration) error {
s.data[key] = value
return nil
}
func (s *fakeState) Delete(ctx context.Context, keys ...string) error {
for _, key := range keys {
delete(s.data, key)
}
return nil
}
func TestUpdateScoreIdempotent(t *testing.T) {
repo := newFakeRepo()
state := newFakeState()
svc := NewService(repo, state, Config{})
in := UpdateScoreInput{
SessionID: "s1",
PlayerID: "u1",
PlayerName: "Alice",
TotalScore: 8,
QuestionsAsked: 10,
QuestionsCorrect: 7,
HintsUsed: 1,
DurationSeconds: 100,
CompletedAt: time.Now().UTC(),
CompletionType: "completed",
}
one, err := svc.UpdateScore(context.Background(), in)
if err != nil {
t.Fatalf("UpdateScore first failed: %v", err)
}
two, err := svc.UpdateScore(context.Background(), in)
if err != nil {
t.Fatalf("UpdateScore second failed: %v", err)
}
if one.ID != two.ID {
t.Fatalf("idempotency failed: %s != %s", one.ID, two.ID)
}
}
func TestUpdateScoreValidatesInput(t *testing.T) {
svc := NewService(newFakeRepo(), newFakeState(), Config{})
_, err := svc.UpdateScore(context.Background(), UpdateScoreInput{
SessionID: "s1",
PlayerID: "u1",
PlayerName: "A",
TotalScore: 1,
QuestionsAsked: 1,
QuestionsCorrect: 2,
DurationSeconds: 1,
CompletedAt: time.Now().UTC(),
CompletionType: "completed",
})
if !errors.Is(err, domain.ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput, got %v", err)
}
}
func TestGetPlayerRanking(t *testing.T) {
repo := newFakeRepo()
state := newFakeState()
svc := NewService(repo, state, Config{})
_, _ = svc.UpdateScore(context.Background(), UpdateScoreInput{
SessionID: "s1",
PlayerID: "u1",
PlayerName: "Alice",
TotalScore: 3,
QuestionsAsked: 4,
QuestionsCorrect: 2,
DurationSeconds: 50,
CompletedAt: time.Now().UTC(),
CompletionType: "completed",
})
result, err := svc.GetPlayerRanking(context.Background(), GetPlayerRankingInput{
PlayerID: "u1",
Pagination: sharedtypes.Pagination{
Page: 1,
PageSize: 10,
},
})
if err != nil {
t.Fatalf("GetPlayerRanking failed: %v", err)
}
if result.Rank != 1 {
t.Fatalf("rank=%d want=1", result.Rank)
}
if len(result.History) != 1 {
t.Fatalf("history len=%d want=1", len(result.History))
}
}

@ -0,0 +1,69 @@
package leaderboard
import "time"
// CompletionType represents how a game session ended.
type CompletionType string
const (
CompletionCompleted CompletionType = "completed"
CompletionTimedOut CompletionType = "timed_out"
CompletionAbandoned CompletionType = "abandoned"
)
// LeaderboardEntry stores one finalized game result.
type LeaderboardEntry struct {
ID string
SessionID string
PlayerID string
PlayerName string
Score int
QuestionsAsked int
QuestionsCorrect int
HintsUsed int
DurationSeconds int
SuccessRate float64
CompletionType CompletionType
CompletedAt time.Time
CreatedAt time.Time
}
// PlayerStats stores aggregated player-level ranking data.
type PlayerStats struct {
PlayerID string
PlayerName string
GamesPlayed int
GamesCompleted int
TotalScore int64
BestScore int
AvgScore float64
AvgSuccessRate float64
TotalQuestions int64
TotalCorrect int64
BestDurationSec *int
LastPlayedAt *time.Time
UpdatedAt time.Time
}
// GlobalStats stores global leaderboard aggregates.
type GlobalStats struct {
TotalGames int64
TotalPlayers int64
AvgScore float64
AvgSuccessRate float64
MaxScore int
ScoreP50 float64
ScoreP90 float64
ScoreP99 float64
UpdatedAt time.Time
}
// Window is a top/stats time filter.
type Window string
const (
WindowAll Window = "all"
Window24h Window = "24h"
Window7d Window = "7d"
Window30d Window = "30d"
)

@ -0,0 +1,12 @@
package leaderboard
import sharederrors "knowfoolery/backend/shared/domain/errors"
var (
// ErrInvalidInput indicates invalid leaderboard request fields.
ErrInvalidInput = sharederrors.New(sharederrors.CodeValidationFailed, "invalid leaderboard input")
// ErrForbidden indicates caller is not authorized for requested operation.
ErrForbidden = sharederrors.New(sharederrors.CodeForbidden, "forbidden")
// ErrPlayerNotFound indicates missing player ranking data.
ErrPlayerNotFound = sharederrors.New(sharederrors.CodeNotFound, "player not found")
)

@ -0,0 +1,28 @@
package leaderboard
import (
"context"
sharedtypes "knowfoolery/backend/shared/domain/types"
)
// TopFilter narrows leaderboard list/stat queries.
type TopFilter struct {
CompletionType string
Window Window
}
// Repository defines leaderboard persistence behavior.
type Repository interface {
EnsureSchema(ctx context.Context) error
IngestEntry(ctx context.Context, entry *LeaderboardEntry) (*LeaderboardEntry, bool, error)
ListTop(ctx context.Context, filter TopFilter, limit int) ([]*LeaderboardEntry, error)
GetPlayerStats(ctx context.Context, playerID string) (*PlayerStats, error)
GetPlayerRank(ctx context.Context, playerID string) (int64, error)
ListPlayerHistory(
ctx context.Context,
playerID string,
pagination sharedtypes.Pagination,
) ([]*LeaderboardEntry, int64, error)
GetGlobalStats(ctx context.Context, filter TopFilter) (*GlobalStats, error)
}

@ -0,0 +1,95 @@
package config
import (
"strconv"
"time"
sharedpostgres "knowfoolery/backend/shared/infra/database/postgres"
sharedredis "knowfoolery/backend/shared/infra/database/redis"
"knowfoolery/backend/shared/infra/observability/logging"
"knowfoolery/backend/shared/infra/observability/metrics"
"knowfoolery/backend/shared/infra/observability/tracing"
"knowfoolery/backend/shared/infra/utils/envutil"
)
// Config holds runtime configuration for leaderboard-service.
type Config struct {
AppName string
Port int
TopLimit int
PlayerHistoryDefault int
PlayerHistoryMax int
CacheTTL time.Duration
UpdateRequireAuth bool
UpstreamHTTPTimeout time.Duration
Postgres sharedpostgres.Config
Redis sharedredis.Config
Tracing tracing.Config
Metrics metrics.Config
Logging logging.Config
ZitadelBaseURL string
ZitadelIssuer string
ZitadelAudience string
ZitadelClientID string
ZitadelSecret string
}
// FromEnv builds config from env vars.
func FromEnv() Config {
env := envutil.String("ENVIRONMENT", "development")
serviceName := "leaderboard-service"
logCfg := logging.DefaultConfig()
logCfg.ServiceName = serviceName
logCfg.Environment = env
logCfg.Level = envutil.String("LOG_LEVEL", logCfg.Level)
traceCfg := tracing.ConfigFromEnv()
if traceCfg.ServiceName == "knowfoolery" {
traceCfg.ServiceName = serviceName
}
traceCfg.Environment = env
metricsCfg := metrics.ConfigFromEnv()
if metricsCfg.ServiceName == "knowfoolery" {
metricsCfg.ServiceName = serviceName
}
return Config{
AppName: "Know Foolery - Leaderboard Service",
Port: envutil.Int("LEADERBOARD_PORT", 8083),
TopLimit: envutil.Int("LEADERBOARD_TOP_LIMIT", 10),
PlayerHistoryDefault: envutil.Int("LEADERBOARD_PLAYER_HISTORY_DEFAULT_LIMIT", 20),
PlayerHistoryMax: envutil.Int("LEADERBOARD_PLAYER_HISTORY_MAX_LIMIT", 100),
CacheTTL: envutil.Duration("LEADERBOARD_CACHE_TTL", 60*time.Second),
UpdateRequireAuth: parseBool("LEADERBOARD_UPDATE_REQUIRE_AUTH", true),
UpstreamHTTPTimeout: envutil.Duration("UPSTREAM_HTTP_TIMEOUT", 3*time.Second),
Postgres: sharedpostgres.ConfigFromEnv(),
Redis: sharedredis.ConfigFromEnv(),
Tracing: traceCfg,
Metrics: metricsCfg,
Logging: logCfg,
ZitadelBaseURL: envutil.String("ZITADEL_URL", ""),
ZitadelIssuer: envutil.String("ZITADEL_ISSUER", ""),
ZitadelAudience: envutil.String("ZITADEL_AUDIENCE", ""),
ZitadelClientID: envutil.String("ZITADEL_CLIENT_ID", ""),
ZitadelSecret: envutil.String("ZITADEL_CLIENT_SECRET", ""),
}
}
func parseBool(key string, fallback bool) bool {
v := envutil.String(key, "")
if v == "" {
return fallback
}
parsed, err := strconv.ParseBool(v)
if err != nil {
return fallback
}
return parsed
}

@ -0,0 +1,15 @@
package ent
import (
"context"
sharedpostgres "knowfoolery/backend/shared/infra/database/postgres"
)
// Client aliases shared postgres client.
type Client = sharedpostgres.Client
// NewClient creates postgres pooled client.
func NewClient(ctx context.Context, cfg sharedpostgres.Config) (*Client, error) {
return sharedpostgres.NewClient(ctx, cfg)
}

@ -0,0 +1,438 @@
package ent
import (
"context"
"errors"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
domain "knowfoolery/backend/services/leaderboard-service/internal/domain/leaderboard"
sharedtypes "knowfoolery/backend/shared/domain/types"
)
// LeaderboardRepository implements leaderboard persistence.
type LeaderboardRepository struct {
client *Client
}
// NewLeaderboardRepository creates leaderboard repository.
func NewLeaderboardRepository(client *Client) *LeaderboardRepository {
return &LeaderboardRepository{client: client}
}
// EnsureSchema creates required tables and indexes.
func (r *LeaderboardRepository) EnsureSchema(ctx context.Context) error {
const ddl = `
CREATE TABLE IF NOT EXISTS leaderboard_entries (
id UUID PRIMARY KEY,
session_id VARCHAR(64) NOT NULL UNIQUE,
player_id VARCHAR(128) NOT NULL,
player_name VARCHAR(50) NOT NULL,
score INT NOT NULL,
questions_asked INT NOT NULL,
questions_correct INT NOT NULL,
hints_used INT NOT NULL DEFAULT 0,
duration_seconds INT NOT NULL,
success_rate NUMERIC(5,2) NOT NULL,
completion_type VARCHAR(16) NOT NULL,
completed_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_lb_entries_rank_order
ON leaderboard_entries (score DESC, duration_seconds ASC, completed_at ASC);
CREATE INDEX IF NOT EXISTS idx_lb_entries_player_time
ON leaderboard_entries (player_id, completed_at DESC);
CREATE INDEX IF NOT EXISTS idx_lb_entries_completion_time
ON leaderboard_entries (completion_type, completed_at DESC);
CREATE INDEX IF NOT EXISTS idx_lb_entries_created
ON leaderboard_entries (created_at DESC);
CREATE TABLE IF NOT EXISTS leaderboard_player_stats (
player_id VARCHAR(128) PRIMARY KEY,
player_name VARCHAR(50) NOT NULL,
games_played INT NOT NULL DEFAULT 0,
games_completed INT NOT NULL DEFAULT 0,
total_score BIGINT NOT NULL DEFAULT 0,
best_score INT NOT NULL DEFAULT 0,
avg_score NUMERIC(10,2) NOT NULL DEFAULT 0,
avg_success_rate NUMERIC(5,2) NOT NULL DEFAULT 0,
total_questions BIGINT NOT NULL DEFAULT 0,
total_correct BIGINT NOT NULL DEFAULT 0,
best_duration_seconds INT NULL,
last_played_at TIMESTAMPTZ NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_lb_player_rank_order
ON leaderboard_player_stats (best_score DESC, best_duration_seconds ASC, last_played_at ASC);
CREATE INDEX IF NOT EXISTS idx_lb_player_last_played
ON leaderboard_player_stats (last_played_at DESC);
`
_, err := r.client.Pool.Exec(ctx, ddl)
return err
}
// IngestEntry inserts one new entry and upserts player stats atomically.
func (r *LeaderboardRepository) IngestEntry(
ctx context.Context,
entry *domain.LeaderboardEntry,
) (*domain.LeaderboardEntry, bool, error) {
tx, err := r.client.Pool.Begin(ctx)
if err != nil {
return nil, false, err
}
defer func() { _ = tx.Rollback(ctx) }()
existing, getErr := r.getEntryBySessionIDTx(ctx, tx, entry.SessionID)
if getErr == nil {
if commitErr := tx.Commit(ctx); commitErr != nil {
return nil, false, commitErr
}
return existing, true, nil
}
if !errors.Is(getErr, pgx.ErrNoRows) {
return nil, false, getErr
}
inserted, err := r.insertEntryTx(ctx, tx, entry)
if err != nil {
return nil, false, err
}
if err := r.upsertPlayerStatsTx(ctx, tx, inserted); err != nil {
return nil, false, err
}
if err := tx.Commit(ctx); err != nil {
return nil, false, err
}
return inserted, false, nil
}
// ListTop lists top entries for given filter.
func (r *LeaderboardRepository) ListTop(
ctx context.Context,
filter domain.TopFilter,
limit int,
) ([]*domain.LeaderboardEntry, error) {
if limit <= 0 {
limit = 10
}
where, args := buildFilterWhere(filter)
args = append(args, limit)
q := `
SELECT id, session_id, player_id, player_name, score, questions_asked, questions_correct, hints_used,
success_rate, duration_seconds, completion_type, completed_at, created_at
FROM leaderboard_entries
` + where + `
ORDER BY score DESC, duration_seconds ASC, completed_at ASC
LIMIT $` + strconvI(len(args))
rows, err := r.client.Pool.Query(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]*domain.LeaderboardEntry, 0)
for rows.Next() {
item, scanErr := scanEntry(rows)
if scanErr != nil {
return nil, scanErr
}
items = append(items, item)
}
return items, rows.Err()
}
// GetPlayerStats returns one player's aggregate stats.
func (r *LeaderboardRepository) GetPlayerStats(ctx context.Context, playerID string) (*domain.PlayerStats, error) {
const q = `
SELECT player_id, player_name, games_played, games_completed, total_score, best_score, avg_score,
avg_success_rate, total_questions, total_correct, best_duration_seconds, last_played_at, updated_at
FROM leaderboard_player_stats
WHERE player_id=$1`
row := r.client.Pool.QueryRow(ctx, q, playerID)
stats, err := scanPlayerStats(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrPlayerNotFound
}
return nil, err
}
return stats, nil
}
// GetPlayerRank computes current rank for one player.
func (r *LeaderboardRepository) GetPlayerRank(ctx context.Context, playerID string) (int64, error) {
const q = `
WITH ranked AS (
SELECT player_id,
RANK() OVER (
ORDER BY best_score DESC, best_duration_seconds ASC NULLS LAST, last_played_at ASC NULLS LAST
) AS rank_value
FROM leaderboard_player_stats
)
SELECT rank_value FROM ranked WHERE player_id=$1`
row := r.client.Pool.QueryRow(ctx, q, playerID)
var rank int64
if err := row.Scan(&rank); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, domain.ErrPlayerNotFound
}
return 0, err
}
return rank, nil
}
// ListPlayerHistory returns paginated history for one player.
func (r *LeaderboardRepository) ListPlayerHistory(
ctx context.Context,
playerID string,
pagination sharedtypes.Pagination,
) ([]*domain.LeaderboardEntry, int64, error) {
pagination.Normalize()
const qCount = `SELECT COUNT(1) FROM leaderboard_entries WHERE player_id=$1`
var total int64
if err := r.client.Pool.QueryRow(ctx, qCount, playerID).Scan(&total); err != nil {
return nil, 0, err
}
const q = `
SELECT id, session_id, player_id, player_name, score, questions_asked, questions_correct, hints_used,
success_rate, duration_seconds, completion_type, completed_at, created_at
FROM leaderboard_entries
WHERE player_id=$1
ORDER BY completed_at DESC
LIMIT $2 OFFSET $3`
rows, err := r.client.Pool.Query(ctx, q, playerID, pagination.Limit(), pagination.Offset())
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]*domain.LeaderboardEntry, 0)
for rows.Next() {
item, scanErr := scanEntry(rows)
if scanErr != nil {
return nil, 0, scanErr
}
items = append(items, item)
}
return items, total, rows.Err()
}
// GetGlobalStats computes global leaderboard statistics.
func (r *LeaderboardRepository) GetGlobalStats(ctx context.Context, filter domain.TopFilter) (*domain.GlobalStats, error) {
where, args := buildFilterWhere(filter)
q := `
SELECT
COUNT(1) AS total_games,
COUNT(DISTINCT player_id) AS total_players,
COALESCE(AVG(score), 0) AS avg_score,
COALESCE(AVG(success_rate), 0) AS avg_success_rate,
COALESCE(MAX(score), 0) AS max_score,
COALESCE(percentile_cont(0.5) WITHIN GROUP (ORDER BY score), 0) AS score_p50,
COALESCE(percentile_cont(0.9) WITHIN GROUP (ORDER BY score), 0) AS score_p90,
COALESCE(percentile_cont(0.99) WITHIN GROUP (ORDER BY score), 0) AS score_p99
FROM leaderboard_entries
` + where
row := r.client.Pool.QueryRow(ctx, q, args...)
stats := &domain.GlobalStats{UpdatedAt: time.Now().UTC()}
if err := row.Scan(
&stats.TotalGames,
&stats.TotalPlayers,
&stats.AvgScore,
&stats.AvgSuccessRate,
&stats.MaxScore,
&stats.ScoreP50,
&stats.ScoreP90,
&stats.ScoreP99,
); err != nil {
return nil, err
}
return stats, nil
}
func (r *LeaderboardRepository) getEntryBySessionIDTx(
ctx context.Context,
tx pgx.Tx,
sessionID string,
) (*domain.LeaderboardEntry, error) {
const q = `
SELECT id, session_id, player_id, player_name, score, questions_asked, questions_correct, hints_used,
success_rate, duration_seconds, completion_type, completed_at, created_at
FROM leaderboard_entries WHERE session_id=$1`
return scanEntry(tx.QueryRow(ctx, q, sessionID))
}
func (r *LeaderboardRepository) insertEntryTx(
ctx context.Context,
tx pgx.Tx,
entry *domain.LeaderboardEntry,
) (*domain.LeaderboardEntry, error) {
const q = `
INSERT INTO leaderboard_entries (
id, session_id, player_id, player_name, score, questions_asked, questions_correct, hints_used,
success_rate, duration_seconds, completion_type, completed_at, created_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW())
RETURNING id, session_id, player_id, player_name, score, questions_asked, questions_correct, hints_used,
success_rate, duration_seconds, completion_type, completed_at, created_at`
row := tx.QueryRow(ctx, q,
uuid.NewString(),
entry.SessionID,
entry.PlayerID,
entry.PlayerName,
entry.Score,
entry.QuestionsAsked,
entry.QuestionsCorrect,
entry.HintsUsed,
entry.SuccessRate,
entry.DurationSeconds,
string(entry.CompletionType),
entry.CompletedAt,
)
return scanEntry(row)
}
func (r *LeaderboardRepository) upsertPlayerStatsTx(
ctx context.Context,
tx pgx.Tx,
entry *domain.LeaderboardEntry,
) error {
const q = `
INSERT INTO leaderboard_player_stats (
player_id, player_name, games_played, games_completed, total_score, best_score, avg_score,
avg_success_rate, total_questions, total_correct, best_duration_seconds, last_played_at, updated_at
) VALUES (
$1, $2, 1, CASE WHEN $3='completed' THEN 1 ELSE 0 END, $4, $4, $4::numeric, $5, $6, $7, $8, $9, NOW()
)
ON CONFLICT (player_id)
DO UPDATE SET
player_name = EXCLUDED.player_name,
games_played = leaderboard_player_stats.games_played + 1,
games_completed = leaderboard_player_stats.games_completed
+ CASE WHEN EXCLUDED.games_completed > 0 THEN 1 ELSE 0 END,
total_score = leaderboard_player_stats.total_score + EXCLUDED.total_score,
best_score = GREATEST(leaderboard_player_stats.best_score, EXCLUDED.best_score),
avg_score = (
(leaderboard_player_stats.total_score + EXCLUDED.total_score)::numeric
/ (leaderboard_player_stats.games_played + 1)::numeric
),
total_questions = leaderboard_player_stats.total_questions + EXCLUDED.total_questions,
total_correct = leaderboard_player_stats.total_correct + EXCLUDED.total_correct,
avg_success_rate = CASE
WHEN (leaderboard_player_stats.total_questions + EXCLUDED.total_questions) = 0 THEN 0
ELSE (
(leaderboard_player_stats.total_correct + EXCLUDED.total_correct)::numeric * 100
/ (leaderboard_player_stats.total_questions + EXCLUDED.total_questions)::numeric
)
END,
best_duration_seconds = CASE
WHEN EXCLUDED.best_score > leaderboard_player_stats.best_score THEN EXCLUDED.best_duration_seconds
WHEN EXCLUDED.best_score = leaderboard_player_stats.best_score THEN LEAST(
COALESCE(leaderboard_player_stats.best_duration_seconds, EXCLUDED.best_duration_seconds),
EXCLUDED.best_duration_seconds
)
ELSE leaderboard_player_stats.best_duration_seconds
END,
last_played_at = GREATEST(COALESCE(leaderboard_player_stats.last_played_at, EXCLUDED.last_played_at), EXCLUDED.last_played_at),
updated_at = NOW()`
_, err := tx.Exec(ctx, q,
entry.PlayerID,
entry.PlayerName,
string(entry.CompletionType),
entry.Score,
entry.SuccessRate,
entry.QuestionsAsked,
entry.QuestionsCorrect,
entry.DurationSeconds,
entry.CompletedAt,
)
return err
}
func buildFilterWhere(filter domain.TopFilter) (string, []any) {
parts := make([]string, 0, 2)
args := make([]any, 0, 2)
if strings.TrimSpace(filter.CompletionType) != "" {
args = append(args, filter.CompletionType)
parts = append(parts, "completion_type=$"+strconvI(len(args)))
}
switch filter.Window {
case domain.Window24h:
args = append(args, time.Now().UTC().Add(-24*time.Hour))
parts = append(parts, "completed_at>=$"+strconvI(len(args)))
case domain.Window7d:
args = append(args, time.Now().UTC().Add(-7*24*time.Hour))
parts = append(parts, "completed_at>=$"+strconvI(len(args)))
case domain.Window30d:
args = append(args, time.Now().UTC().Add(-30*24*time.Hour))
parts = append(parts, "completed_at>=$"+strconvI(len(args)))
}
if len(parts) == 0 {
return "", args
}
return "WHERE " + strings.Join(parts, " AND "), args
}
func scanEntry(scanner interface {
Scan(dest ...interface{}) error
}) (*domain.LeaderboardEntry, error) {
var entry domain.LeaderboardEntry
var completionType string
if err := scanner.Scan(
&entry.ID,
&entry.SessionID,
&entry.PlayerID,
&entry.PlayerName,
&entry.Score,
&entry.QuestionsAsked,
&entry.QuestionsCorrect,
&entry.HintsUsed,
&entry.SuccessRate,
&entry.DurationSeconds,
&completionType,
&entry.CompletedAt,
&entry.CreatedAt,
); err != nil {
return nil, err
}
entry.CompletionType = domain.CompletionType(completionType)
return &entry, nil
}
func scanPlayerStats(scanner interface {
Scan(dest ...interface{}) error
}) (*domain.PlayerStats, error) {
var stats domain.PlayerStats
if err := scanner.Scan(
&stats.PlayerID,
&stats.PlayerName,
&stats.GamesPlayed,
&stats.GamesCompleted,
&stats.TotalScore,
&stats.BestScore,
&stats.AvgScore,
&stats.AvgSuccessRate,
&stats.TotalQuestions,
&stats.TotalCorrect,
&stats.BestDurationSec,
&stats.LastPlayedAt,
&stats.UpdatedAt,
); err != nil {
return nil, err
}
return &stats, nil
}
func strconvI(n int) string {
return strconv.FormatInt(int64(n), 10)
}
var _ domain.Repository = (*LeaderboardRepository)(nil)

@ -0,0 +1,47 @@
package state
import (
"context"
"strings"
"time"
sharedredis "knowfoolery/backend/shared/infra/database/redis"
)
// Store provides redis-backed cache operations.
type Store struct {
redis *sharedredis.Client
}
// NewStore creates cache store.
func NewStore(redisClient *sharedredis.Client) *Store {
return &Store{redis: redisClient}
}
// Get retrieves a cached string value.
func (s *Store) Get(ctx context.Context, key string) (string, bool) {
if s.redis == nil {
return "", false
}
value, err := s.redis.Get(ctx, key)
if err != nil || strings.TrimSpace(value) == "" {
return "", false
}
return value, true
}
// Set writes a cache value with TTL.
func (s *Store) Set(ctx context.Context, key, value string, ttl time.Duration) error {
if s.redis == nil {
return nil
}
return s.redis.Set(ctx, key, value, ttl)
}
// Delete removes cache keys.
func (s *Store) Delete(ctx context.Context, keys ...string) error {
if s.redis == nil || len(keys) == 0 {
return nil
}
return s.redis.Delete(ctx, keys...)
}

@ -0,0 +1,209 @@
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
}

@ -0,0 +1,15 @@
package http
// UpdateLeaderboardRequest is POST /leaderboard/update payload.
type UpdateLeaderboardRequest struct {
SessionID string `json:"session_id" validate:"required,min=1,max=64"`
PlayerID string `json:"player_id" validate:"required,min=1,max=128"`
PlayerName string `json:"player_name" validate:"required,player_name"`
TotalScore int `json:"total_score" validate:"min=0"`
QuestionsAsked int `json:"questions_asked" validate:"min=0"`
QuestionsCorrect int `json:"questions_correct" validate:"min=0"`
HintsUsed int `json:"hints_used" validate:"min=0"`
DurationSeconds int `json:"duration_seconds" validate:"min=0"`
CompletedAt string `json:"completed_at" validate:"required"`
CompletionType string `json:"completion_type" validate:"required,oneof=completed timed_out abandoned"`
}

@ -0,0 +1,17 @@
package http
import "github.com/gofiber/fiber/v3"
// RegisterRoutes registers leaderboard routes.
func RegisterRoutes(app *fiber.App, h *Handler, authMiddleware fiber.Handler) {
lb := app.Group("/leaderboard")
lb.Get("/top10", h.GetTop10)
lb.Get("/stats", h.GetStats)
protected := app.Group("/leaderboard")
if authMiddleware != nil {
protected.Use(authMiddleware)
}
protected.Get("/players/:id", h.GetPlayerRanking)
protected.Post("/update", h.Update)
}

@ -0,0 +1,204 @@
package tests
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/adaptor"
"github.com/prometheus/client_golang/prometheus"
applb "knowfoolery/backend/services/leaderboard-service/internal/application/leaderboard"
domain "knowfoolery/backend/services/leaderboard-service/internal/domain/leaderboard"
httpapi "knowfoolery/backend/services/leaderboard-service/internal/interfaces/http"
sharedtypes "knowfoolery/backend/shared/domain/types"
sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics"
"knowfoolery/backend/shared/infra/utils/validation"
sharedhttpx "knowfoolery/backend/shared/testutil/httpx"
)
type inMemoryRepo struct {
entries []*domain.LeaderboardEntry
stats map[string]*domain.PlayerStats
}
func newInMemoryRepo() *inMemoryRepo {
return &inMemoryRepo{
entries: make([]*domain.LeaderboardEntry, 0),
stats: map[string]*domain.PlayerStats{},
}
}
func (r *inMemoryRepo) EnsureSchema(ctx context.Context) error { return nil }
func (r *inMemoryRepo) IngestEntry(
ctx context.Context,
entry *domain.LeaderboardEntry,
) (*domain.LeaderboardEntry, bool, error) {
for _, e := range r.entries {
if e.SessionID == entry.SessionID {
return e, true, nil
}
}
cp := *entry
cp.ID = "id-" + entry.SessionID
cp.CreatedAt = time.Now().UTC()
r.entries = append(r.entries, &cp)
stats := r.stats[entry.PlayerID]
if stats == nil {
best := entry.DurationSeconds
stats = &domain.PlayerStats{
PlayerID: entry.PlayerID,
PlayerName: entry.PlayerName,
GamesPlayed: 1,
GamesCompleted: 1,
TotalScore: int64(entry.Score),
BestScore: entry.Score,
AvgScore: float64(entry.Score),
AvgSuccessRate: entry.SuccessRate,
TotalQuestions: int64(entry.QuestionsAsked),
TotalCorrect: int64(entry.QuestionsCorrect),
BestDurationSec: &best,
}
r.stats[entry.PlayerID] = stats
}
return &cp, false, nil
}
func (r *inMemoryRepo) ListTop(ctx context.Context, filter domain.TopFilter, limit int) ([]*domain.LeaderboardEntry, error) {
if len(r.entries) < limit {
limit = len(r.entries)
}
out := make([]*domain.LeaderboardEntry, 0, limit)
for i := 0; i < limit; i++ {
out = append(out, r.entries[i])
}
return out, nil
}
func (r *inMemoryRepo) GetPlayerStats(ctx context.Context, playerID string) (*domain.PlayerStats, error) {
stats := r.stats[playerID]
if stats == nil {
return nil, domain.ErrPlayerNotFound
}
cp := *stats
return &cp, nil
}
func (r *inMemoryRepo) GetPlayerRank(ctx context.Context, playerID string) (int64, error) {
if _, ok := r.stats[playerID]; !ok {
return 0, domain.ErrPlayerNotFound
}
return 1, nil
}
func (r *inMemoryRepo) ListPlayerHistory(
ctx context.Context,
playerID string,
pagination sharedtypes.Pagination,
) ([]*domain.LeaderboardEntry, int64, error) {
out := make([]*domain.LeaderboardEntry, 0)
for _, e := range r.entries {
if e.PlayerID == playerID {
out = append(out, e)
}
}
return out, int64(len(out)), nil
}
func (r *inMemoryRepo) GetGlobalStats(ctx context.Context, filter domain.TopFilter) (*domain.GlobalStats, error) {
return &domain.GlobalStats{
TotalGames: int64(len(r.entries)),
TotalPlayers: int64(len(r.stats)),
UpdatedAt: time.Now().UTC(),
}, nil
}
type fakeState struct{}
func (s *fakeState) Get(ctx context.Context, key string) (string, bool) { return "", false }
func (s *fakeState) Set(ctx context.Context, key, value string, ttl time.Duration) error {
return nil
}
func (s *fakeState) Delete(ctx context.Context, keys ...string) error { return nil }
func setupApp(t *testing.T) *fiber.App {
t.Helper()
repo := newInMemoryRepo()
svc := applb.NewService(repo, &fakeState{}, applb.Config{UpdateRequireAuth: true})
metrics := sharedmetrics.NewMetrics(sharedmetrics.Config{
ServiceName: "leaderboard-service-test",
Enabled: true,
Registry: prometheus.NewRegistry(),
})
h := httpapi.NewHandler(svc, validation.NewValidator(), nil, metrics, true, 20, 100)
app := fiber.New()
auth := func(c fiber.Ctx) error {
switch c.Get("Authorization") {
case "Bearer player":
c.Locals("user_id", "user-1")
c.Locals("user_roles", []string{"player"})
return c.Next()
case "Bearer service":
c.Locals("user_id", "svc")
c.Locals("user_roles", []string{"service"})
return c.Next()
default:
return c.SendStatus(http.StatusUnauthorized)
}
}
httpapi.RegisterRoutes(app, h, auth)
app.Get("/metrics", adaptor.HTTPHandler(sharedmetrics.Handler()))
return app
}
func TestUpdateAndTop10(t *testing.T) {
app := setupApp(t)
payload, _ := json.Marshal(map[string]any{
"session_id": "s1",
"player_id": "user-1",
"player_name": "Alice",
"total_score": 10,
"questions_asked": 12,
"questions_correct": 9,
"hints_used": 1,
"duration_seconds": 200,
"completed_at": time.Now().UTC().Format(time.RFC3339),
"completion_type": "completed",
})
req := httptest.NewRequest(http.MethodPost, "/leaderboard/update", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer service")
resp := sharedhttpx.MustTest(t, app, req)
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "update failed")
req = httptest.NewRequest(http.MethodGet, "/leaderboard/top10", nil)
resp = sharedhttpx.MustTest(t, app, req)
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "top10 failed")
}
func TestPlayerAuthAndStats(t *testing.T) {
app := setupApp(t)
req := httptest.NewRequest(http.MethodGet, "/leaderboard/players/user-1", nil)
req.Header.Set("Authorization", "Bearer player")
resp := sharedhttpx.MustTest(t, app, req)
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusNotFound, "expected not found before update")
req = httptest.NewRequest(http.MethodGet, "/leaderboard/players/user-2", nil)
req.Header.Set("Authorization", "Bearer player")
resp = sharedhttpx.MustTest(t, app, req)
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusForbidden, "expected forbidden for other player")
req = httptest.NewRequest(http.MethodGet, "/leaderboard/stats", nil)
resp = sharedhttpx.MustTest(t, app, req)
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "stats failed")
}
func TestMetricsEndpoint(t *testing.T) {
app := setupApp(t)
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
resp := sharedhttpx.MustTest(t, app, req)
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "metrics failed")
}

@ -0,0 +1,389 @@
# 2.4 Leaderboard Service (Port 8083) - Detailed Implementation Plan
## Summary
Implement the Leaderboard Service as the read-optimized ranking and statistics service for game outcomes, consistent with implementation decisions used in `2.1` (Question Bank), `2.2` (User), and `2.3` (Game Session).
Runtime stack and conventions:
- Fiber HTTP service with shared bootstrap and observability.
- PostgreSQL persistence with `EnsureSchema(ctx)` startup DDL.
- Redis for read-optimized ranking cache and top-10 acceleration.
- Shared `backend/shared` packages for auth, errors, validation, logging, tracing, metrics, and readiness.
Scope boundary:
- Modify only `backend/services/leaderboard-service/**`.
- Do not modify `backend/services/*` other than leaderboard-service.
- Do not modify `backend/shared/**`.
## Decisions Reused from 2.1, 2.2, and 2.3
1. Service composition pattern:
- `internal/infra/config.FromEnv()`
- logger/metrics/tracer initialization in `cmd/main.go`
- repository initialization + `EnsureSchema(ctx)` at startup
- `/health`, `/ready`, `/metrics` registration
- route registration via `internal/interfaces/http/routes.go`
2. Persistence and state pattern:
- PostgreSQL as source of truth.
- Redis as optional performance layer, non-fatal when unavailable.
- service remains functional on PostgreSQL when Redis is down.
3. Error and transport pattern:
- domain/application errors mapped via shared `httputil.SendError`.
- standard response envelope style (`success`, `data`) used by existing services.
4. Inter-service integration approach:
- HTTP adapters with application interfaces so transport can evolve later without domain changes.
- explicit DTOs for upstream/downstream contracts.
5. Test pyramid:
- unit tests for ranking/statistics logic.
- HTTP integration tests with in-memory doubles/fakes.
- optional DB-backed integration tests gated by environment.
## Objectives
1. Provide public leaderboard query endpoints:
- top 10 scores
- player ranking and history
- global statistics
2. Provide internal score ingestion endpoint for completed sessions.
3. Ensure deterministic ranking:
- sort by score descending, then completion duration ascending, then completed_at ascending.
4. Maintain historical score records for analytics and auditability.
5. Deliver production-ready observability, readiness checks, and test coverage.
## API Endpoints
- `GET /leaderboard/top10`
- `GET /leaderboard/players/:id`
- `GET /leaderboard/stats`
- `POST /leaderboard/update` (internal command endpoint)
## Auth and Access Rules
1. Query endpoints:
- `GET /leaderboard/top10`, `GET /leaderboard/stats` are public read endpoints.
- `GET /leaderboard/players/:id` requires auth; user can read own detailed history.
- Admin role may read any player history.
2. Update endpoint:
- `POST /leaderboard/update` is internal-only and requires service/admin auth middleware.
- Reject anonymous or non-privileged callers.
## Inter-Service Contracts
### Game Session dependency
Purpose: ingest canonical final session outcomes.
Contract for `POST /leaderboard/update` request:
- `session_id` (string, required)
- `player_id` (string, required)
- `player_name` (string, required)
- `total_score` (int, required, >= 0)
- `questions_asked` (int, required, >= 0)
- `questions_correct` (int, required, >= 0)
- `hints_used` (int, required, >= 0)
- `duration_seconds` (int, required, >= 0)
- `completed_at` (RFC3339 timestamp, required)
- `completion_type` (`completed|timed_out|abandoned`, required)
Idempotency decision:
- deduplicate on `session_id` (unique).
- repeated update for same `session_id` returns success with existing persisted record.
### User Service dependency
Purpose: optional profile hydration fallback for player display fields.
Decision:
- leaderboard update request is authoritative for `player_name` to avoid hard runtime coupling.
- optional future enrichment can call `GET /users/:id`, but is not required for step `2.4`.
## Domain Model
Aggregates:
- `LeaderboardEntry` (one per completed session)
- `PlayerRankingSnapshot` (derived read model)
Value objects:
- `Rank` (positive integer)
- `SuccessRate` (0..100 percentage)
- `CompletionType`
Domain services:
- `RankingService` (ordering and tie-breaks)
- `StatisticsService` (global aggregates)
Core invariants:
1. Each `session_id` can be ingested once.
2. Score cannot be negative.
3. Questions counts cannot be negative.
4. `questions_correct <= questions_asked`.
5. Rank ordering rule is deterministic:
- score desc
- duration asc
- completed_at asc
6. Top10 response always returns at most 10 entries.
## Data Model (PostgreSQL)
### `leaderboard_entries`
- `id UUID PRIMARY KEY`
- `session_id VARCHAR(64) NOT NULL UNIQUE`
- `player_id VARCHAR(128) NOT NULL`
- `player_name VARCHAR(50) NOT NULL`
- `score INT NOT NULL`
- `questions_asked INT NOT NULL`
- `questions_correct INT NOT NULL`
- `hints_used INT NOT NULL DEFAULT 0`
- `duration_seconds INT NOT NULL`
- `success_rate NUMERIC(5,2) NOT NULL`
- `completion_type VARCHAR(16) NOT NULL`
- `completed_at TIMESTAMPTZ NOT NULL`
- `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
Indexes:
- `(score DESC, duration_seconds ASC, completed_at ASC)`
- `(player_id, completed_at DESC)`
- `(completion_type, completed_at DESC)`
- `(created_at DESC)`
### `leaderboard_player_stats`
Pre-aggregated player read model for fast rank/profile reads.
- `player_id VARCHAR(128) PRIMARY KEY`
- `player_name VARCHAR(50) NOT NULL`
- `games_played INT NOT NULL DEFAULT 0`
- `games_completed INT NOT NULL DEFAULT 0`
- `total_score BIGINT NOT NULL DEFAULT 0`
- `best_score INT NOT NULL DEFAULT 0`
- `avg_score NUMERIC(10,2) NOT NULL DEFAULT 0`
- `avg_success_rate NUMERIC(5,2) NOT NULL DEFAULT 0`
- `total_questions BIGINT NOT NULL DEFAULT 0`
- `total_correct BIGINT NOT NULL DEFAULT 0`
- `best_duration_seconds INT NULL`
- `last_played_at TIMESTAMPTZ NULL`
- `updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
Indexes:
- `(best_score DESC, best_duration_seconds ASC, last_played_at ASC)`
- `(last_played_at DESC)`
Note:
- all write updates to `leaderboard_player_stats` happen transactionally with `leaderboard_entries` insert in the same repository method.
## Redis Usage
Key prefix: `lb:`
Keys:
- `lb:top10:v1` -> serialized top10 payload (TTL cache)
- `lb:rank:{player_id}` -> cached player rank snapshot
- `lb:stats:global:v1` -> cached global stats payload
- `lb:zset:scores` -> sorted set score index (optional acceleration, non-authoritative)
Rules:
1. PostgreSQL remains source of truth.
2. On successful update ingestion:
- invalidate `lb:top10:v1`
- invalidate `lb:stats:global:v1`
- invalidate `lb:rank:{player_id}`
- best-effort Redis operations; failures logged and counted, not fatal.
3. If Redis is unavailable:
- query endpoints compute from PostgreSQL and still return success.
- readiness check marks Redis as down but optional.
## Endpoint Behavior (Decision Complete)
### `POST /leaderboard/update`
Input: final session outcome payload (see contract above).
Flow:
1. authenticate internal caller.
2. validate input fields and invariants.
3. start transaction.
4. check existing by `session_id`.
5. if existing:
- return existing normalized entry response (`200`, idempotent success).
6. insert into `leaderboard_entries`.
7. upsert/refresh `leaderboard_player_stats` aggregate.
8. commit transaction.
9. invalidate related Redis caches.
10. emit structured log + metrics counter.
Output:
- persisted leaderboard entry summary including computed `success_rate`.
### `GET /leaderboard/top10`
Query params:
- optional `completion_type` filter (`completed|timed_out|abandoned`)
- optional `window` (`24h|7d|30d|all`, default `all`)
Flow:
1. attempt Redis cache hit for matching key variant.
2. on miss, query PostgreSQL ordered by ranking rule.
3. compute rank values (1..N), cap at 10.
4. cache result with short TTL.
Output:
- ordered top list with rank, player_id, player_name, score, questions_asked, success_rate, duration_seconds, completed_at.
### `GET /leaderboard/players/:id`
Auth:
- self or admin.
Query params:
- `page` (default 1)
- `page_size` (default 20, max 100)
Flow:
1. auth and ownership/admin check.
2. fetch player aggregate from `leaderboard_player_stats`.
3. fetch paginated history from `leaderboard_entries`.
4. compute current global rank from ordering criteria against all players using `best_score` then tie-breakers.
Output:
- player summary:
- current_rank, games_played, best_score, avg_score, avg_success_rate, total_score
- paginated history list.
### `GET /leaderboard/stats`
Flow:
1. attempt Redis cache hit.
2. on miss, aggregate from PostgreSQL.
Returned stats:
- `total_games`
- `total_players`
- `avg_score`
- `avg_success_rate`
- `max_score`
- `score_p50`, `score_p90`, `score_p99`
- `updated_at`
## Package Layout
- `backend/services/leaderboard-service/cmd/main.go`
- `backend/services/leaderboard-service/internal/infra/config/config.go`
- `backend/services/leaderboard-service/internal/domain/leaderboard/`
- `backend/services/leaderboard-service/internal/application/leaderboard/`
- `backend/services/leaderboard-service/internal/infra/persistence/ent/`
- `backend/services/leaderboard-service/internal/infra/state/`
- `backend/services/leaderboard-service/internal/interfaces/http/`
- `backend/services/leaderboard-service/tests/`
## Configuration
Service-specific:
- `LEADERBOARD_PORT` (default `8083`)
- `LEADERBOARD_TOP_LIMIT` (default `10`)
- `LEADERBOARD_PLAYER_HISTORY_DEFAULT_LIMIT` (default `20`)
- `LEADERBOARD_PLAYER_HISTORY_MAX_LIMIT` (default `100`)
- `LEADERBOARD_CACHE_TTL` (default `60s`)
- `LEADERBOARD_UPDATE_REQUIRE_AUTH` (default `true`)
Optional integration:
- `GAME_SESSION_BASE_URL` (default `http://localhost:8080`) for future backfill tooling
- `UPSTREAM_HTTP_TIMEOUT` (default `3s`)
Shared:
- `POSTGRES_*`, `REDIS_*`, `TRACING_*`, `METRICS_*`, `LOG_LEVEL`, `ZITADEL_*`
## Implementation Work Breakdown
### Workstream A - Bootstrap, config, wiring
1. Add `internal/infra/config/config.go` env parsing.
2. Wire logger/metrics/tracer in `cmd/main.go`.
3. Initialize postgres + redis clients.
4. Initialize repository and `EnsureSchema(ctx)`.
5. Register `/health`, `/ready`, `/metrics`.
6. Build auth middleware and register routes.
### Workstream B - Domain and application
1. Define domain entities, value objects, and domain errors.
2. Implement ranking and statistics calculation services.
3. Implement application use-cases:
- `UpdateScore`
- `GetTop10`
- `GetPlayerRanking`
- `GetGlobalStats`
### Workstream C - Persistence
1. Implement repository interfaces and SQL-backed repository.
2. Add `EnsureSchema(ctx)` DDL for both tables and indexes.
3. Implement transactional ingestion:
- insert entry
- upsert player stats
4. Implement top10/history/stats query methods.
### Workstream D - HTTP interface
1. Add request/response DTOs with validation tags.
2. Implement handlers with shared error mapping.
3. Apply ownership/admin checks for player history endpoint.
4. Keep response envelope consistent with existing services.
### Workstream E - Cache and read optimization
1. Add Redis cache adapter for top10/stats/rank snapshots.
2. Implement cache keys, invalidation, and graceful fallback.
3. Add counters for cache hit/miss and invalidation failures.
### Workstream F - Testing
1. Unit tests:
- ranking tie-break correctness
- success-rate calculation
- stats aggregate math
- update idempotency behavior
2. HTTP integration tests:
- update + top10 happy path
- duplicate update idempotency
- player endpoint auth guards
- stats endpoint and metrics availability
3. Optional DB-backed tests (env-gated):
- schema creation
- transactional consistency
- unique `session_id` constraint
## Error Handling Contract
- `400`: invalid input or invariant violation
- `401`: missing/invalid auth for protected endpoints
- `403`: ownership/admin violation
- `404`: player not found (for player ranking endpoint)
- `409`: conflicting update (non-idempotent invalid duplicate payload scenario)
- `500`: unexpected internal failures
## Observability
1. Structured logs:
- include `session_id`, `player_id`, endpoint, and operation outcome.
- avoid PII-heavy payload logging.
2. Metrics:
- `leaderboard_updates_total{status}`
- `leaderboard_top10_requests_total{cache}`
- `leaderboard_player_requests_total{status}`
- `leaderboard_stats_requests_total{cache}`
- `leaderboard_update_latency_seconds`
3. Tracing:
- endpoint -> application -> repository/cache spans.
- include attributes for cache hit/miss and DB query class.
## Delivery Sequence (3-4 Days)
1. Day 1: bootstrap/config + schema + repository scaffolding.
2. Day 2: `POST /leaderboard/update` transactional ingestion + cache invalidation.
3. Day 3: query endpoints (`top10`, `players/:id`, `stats`) + auth checks.
4. Day 4: tests, observability hardening, bugfix buffer.
## Verification Commands
From `backend/services/leaderboard-service`:
```bash
go test ./...
go vet ./...
```
Optional workspace-level check from `backend`:
```bash
go test ./...
```
## Definition of Done
1. All four endpoints implemented and route protection rules enforced.
2. Ranking order and tie-break behavior matches functional requirement.
3. Update ingestion is idempotent by `session_id`.
4. `/health`, `/ready`, `/metrics` functional with meaningful checks.
5. Redis cache fallback behavior works when Redis is unavailable.
6. `go test ./...` and `go vet ./...` pass for leaderboard-service.
7. No code changes outside `backend/services/leaderboard-service/**`.
## Assumptions and Defaults
1. Inter-service transport remains HTTP in phase 2.
2. Leaderboard consistency can be eventual within seconds.
3. `POST /leaderboard/update` is called by internal trusted workflow after session termination.
4. No changes are made in other services or `backend/shared` during this step.
Loading…
Cancel
Save