From 80003d19ca3db4d5d02aa3606724664084345398 Mon Sep 17 00:00:00 2001 From: oabrivard Date: Mon, 9 Feb 2026 00:13:58 +0100 Subject: [PATCH] Implemented '2.4 Leaderboard Service (Port 8083)' --- .../services/leaderboard-service/cmd/main.go | 114 ++++- backend/services/leaderboard-service/go.mod | 51 +- backend/services/leaderboard-service/go.sum | 55 +++ .../internal/application/leaderboard/dto.go | 60 +++ .../application/leaderboard/service.go | 243 ++++++++++ .../application/leaderboard/service_test.go | 227 +++++++++ .../internal/domain/leaderboard/entity.go | 69 +++ .../internal/domain/leaderboard/errors.go | 12 + .../internal/domain/leaderboard/repository.go | 28 ++ .../internal/infra/config/config.go | 95 ++++ .../internal/infra/persistence/ent/client.go | 15 + .../infra/persistence/ent/leaderboard_repo.go | 438 ++++++++++++++++++ .../internal/infra/state/redis_state.go | 47 ++ .../internal/interfaces/http/handler.go | 209 +++++++++ .../internal/interfaces/http/request.go | 15 + .../internal/interfaces/http/routes.go | 17 + .../tests/integration_http_test.go | 204 ++++++++ docs/4_work_plan/2.4-leaderboard-service.md | 389 ++++++++++++++++ 18 files changed, 2279 insertions(+), 9 deletions(-) create mode 100644 backend/services/leaderboard-service/internal/application/leaderboard/dto.go create mode 100644 backend/services/leaderboard-service/internal/application/leaderboard/service.go create mode 100644 backend/services/leaderboard-service/internal/application/leaderboard/service_test.go create mode 100644 backend/services/leaderboard-service/internal/domain/leaderboard/entity.go create mode 100644 backend/services/leaderboard-service/internal/domain/leaderboard/errors.go create mode 100644 backend/services/leaderboard-service/internal/domain/leaderboard/repository.go create mode 100644 backend/services/leaderboard-service/internal/infra/config/config.go create mode 100644 backend/services/leaderboard-service/internal/infra/persistence/ent/client.go create mode 100644 backend/services/leaderboard-service/internal/infra/persistence/ent/leaderboard_repo.go create mode 100644 backend/services/leaderboard-service/internal/infra/state/redis_state.go create mode 100644 backend/services/leaderboard-service/internal/interfaces/http/handler.go create mode 100644 backend/services/leaderboard-service/internal/interfaces/http/request.go create mode 100644 backend/services/leaderboard-service/internal/interfaces/http/routes.go create mode 100644 backend/services/leaderboard-service/tests/integration_http_test.go create mode 100644 docs/4_work_plan/2.4-leaderboard-service.md diff --git a/backend/services/leaderboard-service/cmd/main.go b/backend/services/leaderboard-service/cmd/main.go index 93d023e..03f905e 100644 --- a/backend/services/leaderboard-service/cmd/main.go +++ b/backend/services/leaderboard-service/cmd/main.go @@ -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, + }) +} diff --git a/backend/services/leaderboard-service/go.mod b/backend/services/leaderboard-service/go.mod index 7fa8a80..e472f4f 100644 --- a/backend/services/leaderboard-service/go.mod +++ b/backend/services/leaderboard-service/go.mod @@ -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 diff --git a/backend/services/leaderboard-service/go.sum b/backend/services/leaderboard-service/go.sum index 97bf2d6..64dc880 100644 --- a/backend/services/leaderboard-service/go.sum +++ b/backend/services/leaderboard-service/go.sum @@ -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= diff --git a/backend/services/leaderboard-service/internal/application/leaderboard/dto.go b/backend/services/leaderboard-service/internal/application/leaderboard/dto.go new file mode 100644 index 0000000..25f02b7 --- /dev/null +++ b/backend/services/leaderboard-service/internal/application/leaderboard/dto.go @@ -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"` +} diff --git a/backend/services/leaderboard-service/internal/application/leaderboard/service.go b/backend/services/leaderboard-service/internal/application/leaderboard/service.go new file mode 100644 index 0000000..5c2d435 --- /dev/null +++ b/backend/services/leaderboard-service/internal/application/leaderboard/service.go @@ -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 + } +} diff --git a/backend/services/leaderboard-service/internal/application/leaderboard/service_test.go b/backend/services/leaderboard-service/internal/application/leaderboard/service_test.go new file mode 100644 index 0000000..3fb9c88 --- /dev/null +++ b/backend/services/leaderboard-service/internal/application/leaderboard/service_test.go @@ -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)) + } +} diff --git a/backend/services/leaderboard-service/internal/domain/leaderboard/entity.go b/backend/services/leaderboard-service/internal/domain/leaderboard/entity.go new file mode 100644 index 0000000..0d6bd86 --- /dev/null +++ b/backend/services/leaderboard-service/internal/domain/leaderboard/entity.go @@ -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" +) diff --git a/backend/services/leaderboard-service/internal/domain/leaderboard/errors.go b/backend/services/leaderboard-service/internal/domain/leaderboard/errors.go new file mode 100644 index 0000000..c79864d --- /dev/null +++ b/backend/services/leaderboard-service/internal/domain/leaderboard/errors.go @@ -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") +) diff --git a/backend/services/leaderboard-service/internal/domain/leaderboard/repository.go b/backend/services/leaderboard-service/internal/domain/leaderboard/repository.go new file mode 100644 index 0000000..406f258 --- /dev/null +++ b/backend/services/leaderboard-service/internal/domain/leaderboard/repository.go @@ -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) +} diff --git a/backend/services/leaderboard-service/internal/infra/config/config.go b/backend/services/leaderboard-service/internal/infra/config/config.go new file mode 100644 index 0000000..03060c5 --- /dev/null +++ b/backend/services/leaderboard-service/internal/infra/config/config.go @@ -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 +} diff --git a/backend/services/leaderboard-service/internal/infra/persistence/ent/client.go b/backend/services/leaderboard-service/internal/infra/persistence/ent/client.go new file mode 100644 index 0000000..d6889e6 --- /dev/null +++ b/backend/services/leaderboard-service/internal/infra/persistence/ent/client.go @@ -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) +} diff --git a/backend/services/leaderboard-service/internal/infra/persistence/ent/leaderboard_repo.go b/backend/services/leaderboard-service/internal/infra/persistence/ent/leaderboard_repo.go new file mode 100644 index 0000000..2fb8ccd --- /dev/null +++ b/backend/services/leaderboard-service/internal/infra/persistence/ent/leaderboard_repo.go @@ -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) diff --git a/backend/services/leaderboard-service/internal/infra/state/redis_state.go b/backend/services/leaderboard-service/internal/infra/state/redis_state.go new file mode 100644 index 0000000..70380b5 --- /dev/null +++ b/backend/services/leaderboard-service/internal/infra/state/redis_state.go @@ -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...) +} diff --git a/backend/services/leaderboard-service/internal/interfaces/http/handler.go b/backend/services/leaderboard-service/internal/interfaces/http/handler.go new file mode 100644 index 0000000..5a4060a --- /dev/null +++ b/backend/services/leaderboard-service/internal/interfaces/http/handler.go @@ -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 +} diff --git a/backend/services/leaderboard-service/internal/interfaces/http/request.go b/backend/services/leaderboard-service/internal/interfaces/http/request.go new file mode 100644 index 0000000..658a144 --- /dev/null +++ b/backend/services/leaderboard-service/internal/interfaces/http/request.go @@ -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"` +} diff --git a/backend/services/leaderboard-service/internal/interfaces/http/routes.go b/backend/services/leaderboard-service/internal/interfaces/http/routes.go new file mode 100644 index 0000000..e8b64dc --- /dev/null +++ b/backend/services/leaderboard-service/internal/interfaces/http/routes.go @@ -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) +} diff --git a/backend/services/leaderboard-service/tests/integration_http_test.go b/backend/services/leaderboard-service/tests/integration_http_test.go new file mode 100644 index 0000000..a090f16 --- /dev/null +++ b/backend/services/leaderboard-service/tests/integration_http_test.go @@ -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") +} diff --git a/docs/4_work_plan/2.4-leaderboard-service.md b/docs/4_work_plan/2.4-leaderboard-service.md new file mode 100644 index 0000000..9bbcb9a --- /dev/null +++ b/docs/4_work_plan/2.4-leaderboard-service.md @@ -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.