From 79531ca86244a32f17957bd2a1f5ece0e22bb5d6 Mon Sep 17 00:00:00 2001 From: oabrivard Date: Sun, 8 Feb 2026 10:28:39 +0100 Subject: [PATCH] Finished step '2.1 Question Bank Service (Port 8081)' --- backend/go.work.sum | 23 ++ .../question-bank-service/cmd/main.go | 122 ++++++- backend/services/question-bank-service/go.mod | 54 ++- backend/services/question-bank-service/go.sum | 147 +++++++- .../application/question/bulk_import.go | 30 ++ .../internal/application/question/dto.go | 45 +++ .../internal/application/question/service.go | 283 +++++++++++++++ .../application/question/service_test.go | 178 ++++++++++ .../internal/domain/question/answer_match.go | 84 +++++ .../domain/question/answer_match_test.go | 29 ++ .../internal/domain/question/entity.go | 42 +++ .../internal/domain/question/errors.go | 9 + .../internal/domain/question/repository.go | 21 ++ .../infra/cache/random_question_cache.go | 99 ++++++ .../internal/infra/config/config.go | 82 +++++ .../internal/infra/persistence/ent/client.go | 57 ++++ .../infra/persistence/ent/question_repo.go | 323 ++++++++++++++++++ .../infra/persistence/ent/schema/question.go | 36 ++ .../infra/persistence/ent/theme_repo.go | 3 + .../internal/interfaces/http/handler.go | 201 +++++++++++ .../internal/interfaces/http/request.go | 39 +++ .../internal/interfaces/http/response.go | 35 ++ .../internal/interfaces/http/routes.go | 20 ++ .../tests/fixtures/questions.json | 16 + .../tests/integration_http_test.go | 255 ++++++++++++++ .../tests/integration_repo_test.go | 75 ++++ docs/4_work_plan/2.1-question-bank-service.md | 79 +++++ 27 files changed, 2375 insertions(+), 12 deletions(-) create mode 100644 backend/services/question-bank-service/internal/application/question/bulk_import.go create mode 100644 backend/services/question-bank-service/internal/application/question/dto.go create mode 100644 backend/services/question-bank-service/internal/application/question/service.go create mode 100644 backend/services/question-bank-service/internal/application/question/service_test.go create mode 100644 backend/services/question-bank-service/internal/domain/question/answer_match.go create mode 100644 backend/services/question-bank-service/internal/domain/question/answer_match_test.go create mode 100644 backend/services/question-bank-service/internal/domain/question/entity.go create mode 100644 backend/services/question-bank-service/internal/domain/question/errors.go create mode 100644 backend/services/question-bank-service/internal/domain/question/repository.go create mode 100644 backend/services/question-bank-service/internal/infra/cache/random_question_cache.go create mode 100644 backend/services/question-bank-service/internal/infra/config/config.go create mode 100644 backend/services/question-bank-service/internal/infra/persistence/ent/client.go create mode 100644 backend/services/question-bank-service/internal/infra/persistence/ent/question_repo.go create mode 100644 backend/services/question-bank-service/internal/infra/persistence/ent/schema/question.go create mode 100644 backend/services/question-bank-service/internal/infra/persistence/ent/theme_repo.go create mode 100644 backend/services/question-bank-service/internal/interfaces/http/handler.go create mode 100644 backend/services/question-bank-service/internal/interfaces/http/request.go create mode 100644 backend/services/question-bank-service/internal/interfaces/http/response.go create mode 100644 backend/services/question-bank-service/internal/interfaces/http/routes.go create mode 100644 backend/services/question-bank-service/tests/fixtures/questions.json create mode 100644 backend/services/question-bank-service/tests/integration_http_test.go create mode 100644 backend/services/question-bank-service/tests/integration_repo_test.go create mode 100644 docs/4_work_plan/2.1-question-bank-service.md diff --git a/backend/go.work.sum b/backend/go.work.sum index 1ed8589..69a00f8 100644 --- a/backend/go.work.sum +++ b/backend/go.work.sum @@ -1,9 +1,14 @@ +ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9/go.mod h1:Oe1xWPuu5q9LzyrWfbZmEZxFYeu4BHTyzfjeW2aZp/w= cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -15,21 +20,39 @@ github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2T github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4= github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= +github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= diff --git a/backend/services/question-bank-service/cmd/main.go b/backend/services/question-bank-service/cmd/main.go index 59fe662..1580c5b 100644 --- a/backend/services/question-bank-service/cmd/main.go +++ b/backend/services/question-bank-service/cmd/main.go @@ -1,22 +1,132 @@ package main import ( + "context" "log" + "time" + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/adaptor" + + appq "knowfoolery/backend/services/question-bank-service/internal/application/question" + qcache "knowfoolery/backend/services/question-bank-service/internal/infra/cache" + qbconfig "knowfoolery/backend/services/question-bank-service/internal/infra/config" + qent "knowfoolery/backend/services/question-bank-service/internal/infra/persistence/ent" + httpapi "knowfoolery/backend/services/question-bank-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 - Question Bank Service", + cfg := qbconfig.FromEnv() + + logger := logging.NewLogger(cfg.Logging) + metrics := sharedmetrics.NewMetrics(cfg.Metrics) + + tracer, err := tracing.NewTracer(cfg.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 := qent.NewClient(ctx, cfg.Postgres) + if err != nil { + logger.WithError(err).Fatal("failed to initialize postgres client") + } + defer persistence.Close() + + repo := qent.NewQuestionRepository(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(cfg.Redis); redisErr == nil { + redisClient = c + defer func() { _ = redisClient.Close() }() + } else { + logger.WithError(redisErr).Warn("redis unavailable; running with local fallback cache") + } + + randomCache := qcache.NewRandomQuestionCache(redisClient) + service := appq.NewService(repo, randomCache, cfg.CacheTTL, cfg.MaxExclude) + + validator := validation.NewValidator() + handler := httpapi.NewHandler(service, validator, logger, metrics, cfg.BulkMax) + + bootCfg := serviceboot.Config{ + AppName: cfg.AppName, ServiceSlug: "question-bank", PortEnv: "QUESTION_BANK_PORT", - DefaultPort: 8081, + DefaultPort: cfg.Port, } + app := serviceboot.NewFiberApp(bootCfg) + serviceboot.RegisterHealth(app, bootCfg.ServiceSlug) + registerReadiness(app, persistence, redisClient) + app.Get("/metrics", adaptor.HTTPHandler(sharedmetrics.Handler())) - app := serviceboot.NewFiberApp(cfg) - serviceboot.RegisterHealth(app, cfg.ServiceSlug) + adminMiddleware := buildAdminMiddleware(cfg) + httpapi.RegisterRoutes(app, handler, adminMiddleware) - addr := serviceboot.ListenAddress(cfg.PortEnv, cfg.DefaultPort) + addr := serviceboot.ListenAddress(bootCfg.PortEnv, bootCfg.DefaultPort) log.Fatal(serviceboot.Run(app, addr)) } + +func buildAdminMiddleware(cfg qbconfig.Config) fiber.Handler { + if cfg.ZitadelBaseURL == "" { + return nil + } + + client := zitadel.NewClient(zitadel.Config{ + BaseURL: cfg.ZitadelBaseURL, + ClientID: cfg.ZitadelClientID, + ClientSecret: cfg.ZitadelSecret, + Issuer: cfg.ZitadelIssuer, + Audience: cfg.ZitadelAudience, + Timeout: 10 * time.Second, + }) + + return zitadel.JWTMiddleware(zitadel.JWTMiddlewareConfig{ + Client: client, + Issuer: cfg.ZitadelIssuer, + Audience: cfg.ZitadelAudience, + RequiredClaims: []string{"sub"}, + AdminEndpoints: []string{"/admin"}, + }) +} + +func registerReadiness(app *fiber.App, persistence *qent.Client, redisClient *sharedredis.Client) { + app.Get("/ready", func(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(c.Context(), 2*time.Second) + defer cancel() + + checks := map[string]string{"postgres": "ok", "redis": "ok"} + if err := persistence.Pool.Ping(ctx); err != nil { + checks["postgres"] = "down" + } + if redisClient != nil { + if err := redisClient.HealthCheck(ctx); err != nil { + checks["redis"] = "down" + } + } + + status := fiber.StatusOK + if checks["postgres"] != "ok" { + status = fiber.StatusServiceUnavailable + } + return c.Status(status).JSON(fiber.Map{ + "status": "ready", + "checks": checks, + }) + }) +} diff --git a/backend/services/question-bank-service/go.mod b/backend/services/question-bank-service/go.mod index a5d148e..25a323e 100644 --- a/backend/services/question-bank-service/go.mod +++ b/backend/services/question-bank-service/go.mod @@ -2,20 +2,66 @@ module knowfoolery/backend/services/question-bank-service go 1.25.5 -require knowfoolery/backend/shared v0.0.0 +require ( + entgo.io/ent v0.14.5 + 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 + 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_golang v1.20.5 // 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 - golang.org/x/sys v0.29.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/question-bank-service/go.sum b/backend/services/question-bank-service/go.sum index a3d8fb8..d9556f0 100644 --- a/backend/services/question-bank-service/go.sum +++ b/backend/services/question-bank-service/go.sum @@ -1,15 +1,158 @@ +entgo.io/ent v0.14.5 h1:Rj2WOYJtCkWyFo6a+5wB3EfBRP0rnx1fMk6gGA0UUe4= +entgo.io/ent v0.14.5/go.mod h1:zTzLmWtPvGpmSwtkaayM2cm5m819NdM7z7tYPq3vN0U= +github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ= +github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0= +github.com/MicahParks/keyfunc/v3 v3.7.0 h1:pdafUNyq+p3ZlvjJX1HWFP7MA3+cLpDtg69U3kITJGM= +github.com/MicahParks/keyfunc/v3 v3.7.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0= +github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk= +github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= +github.com/alicebob/miniredis/v2 v2.33.0 h1:uvTF0EDeu9RLnUEG27Db5I68ESoIxTiXbNUiji6lZrA= +github.com/alicebob/miniredis/v2 v2.33.0/go.mod h1:MhP4a3EU7aENRi9aO+tHfTBZicLqQevyi/DJpoj6mi0= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8= +github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofiber/fiber/v3 v3.0.0-beta.3 h1:7Q2I+HsIqnIEEDB+9oe7Gadpakh6ZLhXpTYz/L20vrg= +github.com/gofiber/fiber/v3 v3.0.0-beta.3/go.mod h1:kcMur0Dxqk91R7p4vxEpJfDWZ9u5IfvrtQc8Bvv/JmY= github.com/gofiber/utils/v2 v2.0.0-beta.4 h1:1gjbVFFwVwUb9arPcqiB6iEjHBwo7cHsyS41NeIW3co= +github.com/gofiber/utils/v2 v2.0.0-beta.4/go.mod h1:sdRsPU1FXX6YiDGGxd+q2aPJRMzpsxdzCXo9dz+xtOY= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8= +github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/services/question-bank-service/internal/application/question/bulk_import.go b/backend/services/question-bank-service/internal/application/question/bulk_import.go new file mode 100644 index 0000000..0cf4877 --- /dev/null +++ b/backend/services/question-bank-service/internal/application/question/bulk_import.go @@ -0,0 +1,30 @@ +package question + +import ( + "strings" + + domain "knowfoolery/backend/services/question-bank-service/internal/domain/question" + sharedsecurity "knowfoolery/backend/shared/infra/security" +) + +func sanitizeAndValidateItem(item BulkImportItem) (BulkImportItem, error) { + sanitized := BulkImportItem{ + Theme: sharedsecurity.SanitizeTheme(item.Theme), + Text: sharedsecurity.SanitizeQuestionText(item.Text), + Answer: sharedsecurity.SanitizeAnswer(item.Answer), + Hint: sharedsecurity.SanitizeQuestionText(item.Hint), + Difficulty: item.Difficulty, + } + + if sanitized.Theme == "" || sanitized.Text == "" || sanitized.Answer == "" { + return BulkImportItem{}, domain.ErrValidationFailed + } + if !domain.IsValidDifficulty(sanitized.Difficulty) { + return BulkImportItem{}, domain.ErrValidationFailed + } + if strings.TrimSpace(sanitized.Hint) == "" { + sanitized.Hint = "" + } + + return sanitized, nil +} diff --git a/backend/services/question-bank-service/internal/application/question/dto.go b/backend/services/question-bank-service/internal/application/question/dto.go new file mode 100644 index 0000000..088825d --- /dev/null +++ b/backend/services/question-bank-service/internal/application/question/dto.go @@ -0,0 +1,45 @@ +package question + +import domain "knowfoolery/backend/services/question-bank-service/internal/domain/question" + +// RandomQuestionRequest is the random question use-case input. +type RandomQuestionRequest struct { + ExcludeQuestionIDs []string + Theme string + Difficulty domain.Difficulty +} + +// CreateQuestionInput is the create question use-case input. +type CreateQuestionInput struct { + Theme string + Text string + Answer string + Hint string + Difficulty domain.Difficulty +} + +// UpdateQuestionInput is the update question use-case input. +type UpdateQuestionInput struct { + Theme string + Text string + Answer string + Hint string + Difficulty domain.Difficulty + IsActive bool +} + +// BulkImportItem is an item in a bulk import payload. +type BulkImportItem struct { + Theme string `json:"theme"` + Text string `json:"text"` + Answer string `json:"answer"` + Hint string `json:"hint"` + Difficulty domain.Difficulty `json:"difficulty"` +} + +// BulkImportResult is the bulk import use-case output. +type BulkImportResult struct { + CreatedCount int `json:"created_count"` + FailedCount int `json:"failed_count"` + Errors []domain.BulkError `json:"errors,omitempty"` +} diff --git a/backend/services/question-bank-service/internal/application/question/service.go b/backend/services/question-bank-service/internal/application/question/service.go new file mode 100644 index 0000000..10b24a8 --- /dev/null +++ b/backend/services/question-bank-service/internal/application/question/service.go @@ -0,0 +1,283 @@ +package question + +import ( + "context" + cryptorand "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "sort" + "strings" + "time" + + domain "knowfoolery/backend/services/question-bank-service/internal/domain/question" + sharedsecurity "knowfoolery/backend/shared/infra/security" +) + +// AnswerValidationResult is the output for answer validation. +type AnswerValidationResult struct { + Matched bool + Score float64 + Threshold float64 +} + +// Cache describes random question cache behavior. +type Cache interface { + Get(ctx context.Context, key string) (*domain.Question, bool) + Set(ctx context.Context, key string, q *domain.Question, ttl time.Duration) + Invalidate(ctx context.Context) +} + +// Service orchestrates question use-cases. +type Service struct { + repo domain.Repository + cache Cache + randomCacheTTL time.Duration + randomMaxExclusions int +} + +// NewService creates a new question service. +func NewService(repo domain.Repository, cache Cache, randomCacheTTL time.Duration, randomMaxExclusions int) *Service { + if randomCacheTTL <= 0 { + randomCacheTTL = 5 * time.Minute + } + if randomMaxExclusions <= 0 { + randomMaxExclusions = 200 + } + return &Service{ + repo: repo, + cache: cache, + randomCacheTTL: randomCacheTTL, + randomMaxExclusions: randomMaxExclusions, + } +} + +// GetRandomQuestion returns a random active question based on optional filters. +func (s *Service) GetRandomQuestion(ctx context.Context, req RandomQuestionRequest) (*domain.Question, error) { + exclusions := normalizeExclusions(req.ExcludeQuestionIDs, s.randomMaxExclusions) + filter := domain.RandomFilter{ + ExcludeQuestionIDs: exclusions, + Theme: sharedsecurity.SanitizeTheme(req.Theme), + Difficulty: req.Difficulty, + } + + if filter.Difficulty != "" && !domain.IsValidDifficulty(filter.Difficulty) { + return nil, domain.ErrValidationFailed + } + + cacheKey := randomQuestionCacheKey(filter) + if s.cache != nil { + if cached, ok := s.cache.Get(ctx, cacheKey); ok { + return cached, nil + } + } + + count, err := s.repo.CountRandomCandidates(ctx, filter) + if err != nil { + return nil, err + } + if count == 0 { + return nil, domain.ErrNoQuestionsAvailable + } + + offset := 0 + if count > 1 { + n, err := cryptorand.Int(cryptorand.Reader, big.NewInt(int64(count))) + if err == nil { + offset = int(n.Int64()) + } + } + + q, err := s.repo.RandomByOffset(ctx, filter, offset) + if err != nil { + return nil, err + } + + if s.cache != nil { + s.cache.Set(ctx, cacheKey, q, s.randomCacheTTL) + } + + return q, nil +} + +// GetQuestionByID returns a question by identifier. +func (s *Service) GetQuestionByID(ctx context.Context, id string) (*domain.Question, error) { + q, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + return q, nil +} + +// ValidateAnswerByQuestionID validates a provided answer against a stored question answer. +func (s *Service) ValidateAnswerByQuestionID(ctx context.Context, id string, + provided string) (*AnswerValidationResult, error) { + provided = sharedsecurity.SanitizeAnswer(provided) + if provided == "" { + return nil, domain.ErrValidationFailed + } + + q, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + matched, score := domain.IsAnswerMatch(q.Answer, provided) + return &AnswerValidationResult{ + Matched: matched, + Score: score, + Threshold: 0.85, + }, nil +} + +// CreateQuestion creates a new question. +func (s *Service) CreateQuestion(ctx context.Context, in CreateQuestionInput) (*domain.Question, error) { + q, err := s.toQuestion(in.Theme, in.Text, in.Answer, in.Hint, in.Difficulty) + if err != nil { + return nil, err + } + + created, err := s.repo.Create(ctx, q) + if err != nil { + return nil, err + } + s.invalidateRandomCache(ctx) + return created, nil +} + +// UpdateQuestion updates an existing question. +func (s *Service) UpdateQuestion(ctx context.Context, id string, in UpdateQuestionInput) (*domain.Question, error) { + q, err := s.toQuestion(in.Theme, in.Text, in.Answer, in.Hint, in.Difficulty) + if err != nil { + return nil, err + } + q.IsActive = in.IsActive + + updated, err := s.repo.Update(ctx, id, q) + if err != nil { + return nil, err + } + s.invalidateRandomCache(ctx) + return updated, nil +} + +// DeleteQuestion soft-deletes a question. +func (s *Service) DeleteQuestion(ctx context.Context, id string) error { + if err := s.repo.SoftDelete(ctx, id); err != nil { + return err + } + s.invalidateRandomCache(ctx) + return nil +} + +// ListThemes returns all active themes. +func (s *Service) ListThemes(ctx context.Context) ([]string, error) { + return s.repo.ListThemes(ctx) +} + +// BulkImport imports questions in batch with partial failure reporting. +func (s *Service) BulkImport(ctx context.Context, items []BulkImportItem, maxItems int) (*BulkImportResult, error) { + if maxItems > 0 && len(items) > maxItems { + return nil, domain.ErrValidationFailed + } + + questions := make([]*domain.Question, 0, len(items)) + errList := make([]domain.BulkError, 0) + + for i, item := range items { + sanitized, err := sanitizeAndValidateItem(item) + if err != nil { + errList = append(errList, domain.BulkError{Index: i, Reason: err.Error()}) + continue + } + + q, err := s.toQuestion(sanitized.Theme, sanitized.Text, sanitized.Answer, sanitized.Hint, sanitized.Difficulty) + if err != nil { + errList = append(errList, domain.BulkError{Index: i, Reason: err.Error()}) + continue + } + questions = append(questions, q) + } + + createdCount := 0 + if len(questions) > 0 { + created, bulkErrs, err := s.repo.BulkCreate(ctx, questions) + if err != nil { + return nil, err + } + createdCount = created + errList = append(errList, bulkErrs...) + } + + s.invalidateRandomCache(ctx) + return &BulkImportResult{ + CreatedCount: createdCount, + FailedCount: len(items) - createdCount, + Errors: errList, + }, nil +} + +func (s *Service) toQuestion(theme, text, answer, hint string, difficulty domain.Difficulty) (*domain.Question, error) { + theme = sharedsecurity.SanitizeTheme(theme) + text = sharedsecurity.SanitizeQuestionText(text) + answer = sharedsecurity.SanitizeAnswer(answer) + hint = sharedsecurity.SanitizeQuestionText(hint) + + if !domain.IsValidDifficulty(difficulty) { + return nil, domain.ErrValidationFailed + } + if theme == "" || text == "" || answer == "" { + return nil, domain.ErrValidationFailed + } + + return &domain.Question{ + Theme: theme, + Text: text, + Answer: answer, + Hint: hint, + Difficulty: difficulty, + IsActive: true, + }, nil +} + +func (s *Service) invalidateRandomCache(ctx context.Context) { + if s.cache != nil { + s.cache.Invalidate(ctx) + } +} + +func normalizeExclusions(in []string, max int) []string { + if len(in) == 0 { + return nil + } + unique := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + for _, id := range in { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if _, exists := unique[id]; exists { + continue + } + unique[id] = struct{}{} + out = append(out, id) + if len(out) >= max { + break + } + } + sort.Strings(out) + return out +} + +func randomQuestionCacheKey(filter domain.RandomFilter) string { + payload, _ := json.Marshal(map[string]interface{}{ + "theme": strings.ToLower(strings.TrimSpace(filter.Theme)), + "difficulty": filter.Difficulty, + "exclude_ids": filter.ExcludeQuestionIDs, + }) + h := sha256.Sum256(payload) + return fmt.Sprintf("qb:random:v1:%s", hex.EncodeToString(h[:])) +} diff --git a/backend/services/question-bank-service/internal/application/question/service_test.go b/backend/services/question-bank-service/internal/application/question/service_test.go new file mode 100644 index 0000000..20340d0 --- /dev/null +++ b/backend/services/question-bank-service/internal/application/question/service_test.go @@ -0,0 +1,178 @@ +package question + +// Tests for question application service behavior using fake repository and cache adapters. + +import ( + "context" + "errors" + "testing" + "time" + + domain "knowfoolery/backend/services/question-bank-service/internal/domain/question" +) + +// fakeRepo is an in-memory repository double for service tests. +type fakeRepo struct { + items []*domain.Question +} + +// GetByID returns a fake question by ID. +func (f *fakeRepo) GetByID(ctx context.Context, id string) (*domain.Question, error) { + for _, q := range f.items { + if q.ID == id { + return q, nil + } + } + return nil, domain.ErrQuestionNotFound +} + +// Create appends a fake question and assigns a deterministic ID. +func (f *fakeRepo) Create(ctx context.Context, q *domain.Question) (*domain.Question, error) { + q.ID = "id-1" + f.items = append(f.items, q) + return q, nil +} + +// Update returns the provided fake question as updated. +func (f *fakeRepo) Update(ctx context.Context, id string, q *domain.Question) (*domain.Question, error) { + return q, nil +} + +// SoftDelete is a no-op fake delete implementation. +func (f *fakeRepo) SoftDelete(ctx context.Context, id string) error { return nil } + +// ListThemes returns a fixed fake theme set. +func (f *fakeRepo) ListThemes(ctx context.Context) ([]string, error) { + return []string{"Science"}, nil +} + +// CountRandomCandidates returns fake candidate count from in-memory state. +func (f *fakeRepo) CountRandomCandidates(ctx context.Context, filter domain.RandomFilter) (int, error) { + if len(f.items) == 0 { + return 0, nil + } + return len(f.items), nil +} + +// RandomByOffset returns a fake candidate by offset. +func (f *fakeRepo) RandomByOffset( + ctx context.Context, + filter domain.RandomFilter, + offset int, +) (*domain.Question, error) { + if len(f.items) == 0 { + return nil, domain.ErrNoQuestionsAvailable + } + if offset >= len(f.items) { + offset = 0 + } + return f.items[offset], nil +} + +// BulkCreate appends all incoming questions and reports all as created. +func (f *fakeRepo) BulkCreate( + ctx context.Context, + questions []*domain.Question, +) (int, []domain.BulkError, error) { + f.items = append(f.items, questions...) + return len(questions), nil, nil +} + +// fakeCache is a simple in-memory cache double. +type fakeCache struct { + store map[string]*domain.Question +} + +// Get returns a cached fake question when present. +func (f *fakeCache) Get(ctx context.Context, key string) (*domain.Question, bool) { + q, ok := f.store[key] + return q, ok +} + +// Set stores a fake question in cache. +func (f *fakeCache) Set(ctx context.Context, key string, q *domain.Question, ttl time.Duration) { + if f.store == nil { + f.store = map[string]*domain.Question{} + } + f.store[key] = q +} + +// Invalidate is a no-op for tests. +func (f *fakeCache) Invalidate(ctx context.Context) {} + +// TestGetRandomQuestion_NoQuestions returns ErrNoQuestionsAvailable when no active candidates exist. +func TestGetRandomQuestion_NoQuestions(t *testing.T) { + svc := NewService(&fakeRepo{}, &fakeCache{}, time.Minute, 200) + _, err := svc.GetRandomQuestion(context.Background(), RandomQuestionRequest{}) + if !errors.Is(err, domain.ErrNoQuestionsAvailable) { + t.Fatalf("expected no questions error, got %v", err) + } +} + +// TestGetRandomQuestion_WithCache returns a random question successfully with cache enabled. +func TestGetRandomQuestion_WithCache(t *testing.T) { + repo := &fakeRepo{items: []*domain.Question{ + { + ID: "q1", + Theme: "Science", + Text: "Q", + Answer: "A", + Difficulty: domain.DifficultyMedium, + IsActive: true, + }, + }} + cache := &fakeCache{store: map[string]*domain.Question{}} + svc := NewService(repo, cache, time.Minute, 200) + + q, err := svc.GetRandomQuestion(context.Background(), RandomQuestionRequest{}) + if err != nil { + t.Fatal(err) + } + if q.ID == "" { + t.Fatal("expected id") + } +} + +// TestValidateAnswerByQuestionID returns a matched result and expected threshold for equivalent answers. +func TestValidateAnswerByQuestionID(t *testing.T) { + repo := &fakeRepo{items: []*domain.Question{ + { + ID: "q1", + Theme: "Science", + Text: "Q", + Answer: "paris", + Difficulty: domain.DifficultyEasy, + IsActive: true, + }, + }} + svc := NewService(repo, &fakeCache{}, time.Minute, 200) + + res, err := svc.ValidateAnswerByQuestionID(context.Background(), "q1", "Paris") + if err != nil { + t.Fatal(err) + } + if !res.Matched { + t.Fatalf("expected match, got false score=%f", res.Score) + } + if res.Threshold != 0.85 { + t.Fatalf("unexpected threshold %f", res.Threshold) + } +} + +// TestValidateAnswerByQuestionID_ValidationError returns validation error for empty provided answers. +func TestValidateAnswerByQuestionID_ValidationError(t *testing.T) { + repo := &fakeRepo{items: []*domain.Question{ + { + ID: "q1", + Answer: "paris", + Difficulty: domain.DifficultyEasy, + IsActive: true, + }, + }} + svc := NewService(repo, &fakeCache{}, time.Minute, 200) + + _, err := svc.ValidateAnswerByQuestionID(context.Background(), "q1", "") + if !errors.Is(err, domain.ErrValidationFailed) { + t.Fatalf("expected validation error, got %v", err) + } +} diff --git a/backend/services/question-bank-service/internal/domain/question/answer_match.go b/backend/services/question-bank-service/internal/domain/question/answer_match.go new file mode 100644 index 0000000..43046dc --- /dev/null +++ b/backend/services/question-bank-service/internal/domain/question/answer_match.go @@ -0,0 +1,84 @@ +package question + +import ( + "math" + "regexp" + "strings" +) + +const minSimilarity = 0.85 + +var normalizeRegex = regexp.MustCompile(`[^a-z0-9\s]+`) + +// IsAnswerMatch compares an expected and provided answer using a similarity threshold. +func IsAnswerMatch(expected, provided string) (matched bool, score float64) { + a := normalizeForMatch(expected) + b := normalizeForMatch(provided) + + if a == "" && b == "" { + return true, 1 + } + if a == "" || b == "" { + return false, 0 + } + + d := levenshtein(a, b) + maxLen := math.Max(float64(len([]rune(a))), float64(len([]rune(b)))) + if maxLen == 0 { + return true, 1 + } + + score = 1 - float64(d)/maxLen + return score >= minSimilarity, score +} + +func normalizeForMatch(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + s = strings.Join(strings.Fields(s), " ") + s = normalizeRegex.ReplaceAllString(s, "") + s = strings.Join(strings.Fields(s), " ") + return s +} + +func levenshtein(a, b string) int { + ra := []rune(a) + rb := []rune(b) + + da := make([][]int, len(ra)+1) + for i := range da { + da[i] = make([]int, len(rb)+1) + } + + for i := 0; i <= len(ra); i++ { + da[i][0] = i + } + for j := 0; j <= len(rb); j++ { + da[0][j] = j + } + + for i := 1; i <= len(ra); i++ { + for j := 1; j <= len(rb); j++ { + cost := 0 + if ra[i-1] != rb[j-1] { + cost = 1 + } + da[i][j] = min3( + da[i-1][j]+1, + da[i][j-1]+1, + da[i-1][j-1]+cost, + ) + } + } + + return da[len(ra)][len(rb)] +} + +func min3(a, b, c int) int { + if a <= b && a <= c { + return a + } + if b <= c { + return b + } + return c +} diff --git a/backend/services/question-bank-service/internal/domain/question/answer_match_test.go b/backend/services/question-bank-service/internal/domain/question/answer_match_test.go new file mode 100644 index 0000000..2c0406d --- /dev/null +++ b/backend/services/question-bank-service/internal/domain/question/answer_match_test.go @@ -0,0 +1,29 @@ +package question + +// Tests for domain-level fuzzy answer matching threshold behavior and edge cases. + +import "testing" + +// TestIsAnswerMatch validates match decisions for exact, near, and below-threshold answers. +func TestIsAnswerMatch(t *testing.T) { + tests := []struct { + name string + expected string + provided string + wantMatch bool + }{ + {name: "exact", expected: "Paris", provided: "Paris", wantMatch: true}, + {name: "near_match", expected: "Leonardo da Vinci", provided: "Leonardo da vinci", wantMatch: true}, + {name: "below_threshold", expected: "Jupiter", provided: "Mars", wantMatch: false}, + {name: "empty", expected: "", provided: "", wantMatch: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, _ := IsAnswerMatch(tt.expected, tt.provided) + if got != tt.wantMatch { + t.Fatalf("got %v want %v", got, tt.wantMatch) + } + }) + } +} diff --git a/backend/services/question-bank-service/internal/domain/question/entity.go b/backend/services/question-bank-service/internal/domain/question/entity.go new file mode 100644 index 0000000..e75b8cb --- /dev/null +++ b/backend/services/question-bank-service/internal/domain/question/entity.go @@ -0,0 +1,42 @@ +package question + +import "time" + +// Difficulty represents the question difficulty level. +type Difficulty string + +const ( + DifficultyEasy Difficulty = "easy" + DifficultyMedium Difficulty = "medium" + DifficultyHard Difficulty = "hard" +) + +// Question represents a question aggregate root. +type Question struct { + ID string + Theme string + Text string + Answer string + Hint string + Difficulty Difficulty + IsActive bool + CreatedAt time.Time + UpdatedAt time.Time +} + +// RandomFilter defines supported filters for random question retrieval. +type RandomFilter struct { + ExcludeQuestionIDs []string + Theme string + Difficulty Difficulty +} + +// IsValidDifficulty checks whether difficulty is supported. +func IsValidDifficulty(d Difficulty) bool { + switch d { + case DifficultyEasy, DifficultyMedium, DifficultyHard: + return true + default: + return false + } +} diff --git a/backend/services/question-bank-service/internal/domain/question/errors.go b/backend/services/question-bank-service/internal/domain/question/errors.go new file mode 100644 index 0000000..1fb1b6a --- /dev/null +++ b/backend/services/question-bank-service/internal/domain/question/errors.go @@ -0,0 +1,9 @@ +package question + +import sharederrors "knowfoolery/backend/shared/domain/errors" + +var ( + ErrQuestionNotFound = sharederrors.New(sharederrors.CodeQuestionNotFound, "question not found") + ErrNoQuestionsAvailable = sharederrors.New(sharederrors.CodeNoQuestionsAvailable, "no questions available") + ErrValidationFailed = sharederrors.New(sharederrors.CodeValidationFailed, "validation failed") +) diff --git a/backend/services/question-bank-service/internal/domain/question/repository.go b/backend/services/question-bank-service/internal/domain/question/repository.go new file mode 100644 index 0000000..57ff98c --- /dev/null +++ b/backend/services/question-bank-service/internal/domain/question/repository.go @@ -0,0 +1,21 @@ +package question + +import "context" + +// Repository defines storage operations for questions. +type Repository interface { + GetByID(ctx context.Context, id string) (*Question, error) + Create(ctx context.Context, q *Question) (*Question, error) + Update(ctx context.Context, id string, q *Question) (*Question, error) + SoftDelete(ctx context.Context, id string) error + ListThemes(ctx context.Context) ([]string, error) + CountRandomCandidates(ctx context.Context, filter RandomFilter) (int, error) + RandomByOffset(ctx context.Context, filter RandomFilter, offset int) (*Question, error) + BulkCreate(ctx context.Context, questions []*Question) (created int, errs []BulkError, err error) +} + +// BulkError captures a bulk import row-level failure. +type BulkError struct { + Index int + Reason string +} diff --git a/backend/services/question-bank-service/internal/infra/cache/random_question_cache.go b/backend/services/question-bank-service/internal/infra/cache/random_question_cache.go new file mode 100644 index 0000000..ae5c123 --- /dev/null +++ b/backend/services/question-bank-service/internal/infra/cache/random_question_cache.go @@ -0,0 +1,99 @@ +package cache + +import ( + "context" + "encoding/json" + "sync" + "sync/atomic" + "time" + + domain "knowfoolery/backend/services/question-bank-service/internal/domain/question" + sharedredis "knowfoolery/backend/shared/infra/database/redis" +) + +// RandomQuestionCache provides cache behavior for random question retrieval. +type RandomQuestionCache struct { + client *sharedredis.Client + + version atomic.Uint64 + localMu sync.RWMutex + local map[string]cacheEntry +} + +type cacheEntry struct { + question domain.Question + expiresAt time.Time +} + +// NewRandomQuestionCache creates a new cache adapter. +func NewRandomQuestionCache(client *sharedredis.Client) *RandomQuestionCache { + c := &RandomQuestionCache{ + client: client, + local: make(map[string]cacheEntry), + } + c.version.Store(1) + return c +} + +// Get returns a cached question if present. +func (c *RandomQuestionCache) Get(ctx context.Context, key string) (*domain.Question, bool) { + nsKey := c.namespacedKey(key) + + if c.client != nil { + value, err := c.client.Get(ctx, nsKey) + if err == nil { + var q domain.Question + if json.Unmarshal([]byte(value), &q) == nil { + return &q, true + } + } + } + + c.localMu.RLock() + entry, ok := c.local[nsKey] + c.localMu.RUnlock() + if !ok || time.Now().After(entry.expiresAt) { + return nil, false + } + + q := entry.question + return &q, true +} + +// Set stores a question in cache. +func (c *RandomQuestionCache) Set(ctx context.Context, key string, q *domain.Question, ttl time.Duration) { + nsKey := c.namespacedKey(key) + payload, err := json.Marshal(q) + if err == nil && c.client != nil { + _ = c.client.Set(ctx, nsKey, string(payload), ttl) + } + + c.localMu.Lock() + c.local[nsKey] = cacheEntry{question: *q, expiresAt: time.Now().Add(ttl)} + c.localMu.Unlock() +} + +// Invalidate bumps cache namespace version to invalidate current keys. +func (c *RandomQuestionCache) Invalidate(ctx context.Context) { + _ = ctx + c.version.Add(1) +} + +func (c *RandomQuestionCache) namespacedKey(key string) string { + return "v" + itoa(c.version.Load()) + ":" + key +} + +func itoa(i uint64) string { + if i == 0 { + return "0" + } + + buf := [20]byte{} + pos := len(buf) + for i > 0 { + pos-- + buf[pos] = byte('0' + i%10) + i /= 10 + } + return string(buf[pos:]) +} diff --git a/backend/services/question-bank-service/internal/infra/config/config.go b/backend/services/question-bank-service/internal/infra/config/config.go new file mode 100644 index 0000000..afa5a91 --- /dev/null +++ b/backend/services/question-bank-service/internal/infra/config/config.go @@ -0,0 +1,82 @@ +package config + +import ( + "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 service configuration. +type Config struct { + AppName string + + Port int + Env string + LogLevel string + CacheTTL time.Duration + MaxExclude int + BulkMax int + + 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 service config from environment variables. +func FromEnv() Config { + env := envutil.String("ENVIRONMENT", "development") + appName := "Know Foolery - Question Bank Service" + serviceName := "question-bank-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: appName, + + Port: envutil.Int("QUESTION_BANK_PORT", 8081), + Env: env, + LogLevel: logCfg.Level, + CacheTTL: envutil.Duration("QUESTION_CACHE_TTL", 5*time.Minute), + MaxExclude: envutil.Int("QUESTION_RANDOM_MAX_EXCLUSIONS", 200), + BulkMax: envutil.Int("QUESTION_BULK_MAX_ITEMS", 5000), + + 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", ""), + } +} diff --git a/backend/services/question-bank-service/internal/infra/persistence/ent/client.go b/backend/services/question-bank-service/internal/infra/persistence/ent/client.go new file mode 100644 index 0000000..4fbfabf --- /dev/null +++ b/backend/services/question-bank-service/internal/infra/persistence/ent/client.go @@ -0,0 +1,57 @@ +package ent + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + + sharedpostgres "knowfoolery/backend/shared/infra/database/postgres" +) + +// Client wraps the pgx pool used by repository implementations. +type Client struct { + Pool *pgxpool.Pool +} + +// NewClient creates a persistence client and verifies connectivity. +func NewClient(ctx context.Context, cfg sharedpostgres.Config) (*Client, error) { + poolCfg, err := pgxpool.ParseConfig(cfg.URL()) + if err != nil { + return nil, fmt.Errorf("parse postgres config: %w", err) + } + poolCfg.MaxConns = clampIntToInt32(cfg.MaxOpenConns) + poolCfg.MinConns = clampIntToInt32(cfg.MaxIdleConns) + poolCfg.MaxConnLifetime = cfg.ConnMaxLifetime + poolCfg.MaxConnIdleTime = cfg.ConnMaxIdleTime + + pool, err := pgxpool.NewWithConfig(ctx, poolCfg) + if err != nil { + return nil, fmt.Errorf("create postgres pool: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping postgres: %w", err) + } + + return &Client{Pool: pool}, nil +} + +// Close closes the underlying pool. +func (c *Client) Close() { + if c != nil && c.Pool != nil { + c.Pool.Close() + } +} + +func clampIntToInt32(v int) int32 { + const maxInt32 = int(^uint32(0) >> 1) + if v <= 0 { + return 0 + } + if v > maxInt32 { + return int32(maxInt32) + } + return int32(v) +} diff --git a/backend/services/question-bank-service/internal/infra/persistence/ent/question_repo.go b/backend/services/question-bank-service/internal/infra/persistence/ent/question_repo.go new file mode 100644 index 0000000..3b51623 --- /dev/null +++ b/backend/services/question-bank-service/internal/infra/persistence/ent/question_repo.go @@ -0,0 +1,323 @@ +package ent + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + + domain "knowfoolery/backend/services/question-bank-service/internal/domain/question" + sharederrors "knowfoolery/backend/shared/domain/errors" +) + +// QuestionRepository implements question storage on PostgreSQL. +type QuestionRepository struct { + client *Client +} + +// NewQuestionRepository creates a new question repository. +func NewQuestionRepository(client *Client) *QuestionRepository { + return &QuestionRepository{client: client} +} + +// EnsureSchema creates the service table if missing. +func (r *QuestionRepository) EnsureSchema(ctx context.Context) error { + const ddl = ` +CREATE TABLE IF NOT EXISTS questions ( + id UUID PRIMARY KEY, + theme VARCHAR(100) NOT NULL, + text TEXT NOT NULL, + answer VARCHAR(500) NOT NULL, + hint TEXT, + difficulty VARCHAR(10) NOT NULL DEFAULT 'medium', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_questions_theme_active ON questions (theme, is_active); +CREATE INDEX IF NOT EXISTS idx_questions_difficulty_active ON questions (difficulty, is_active); +` + _, err := r.client.Pool.Exec(ctx, ddl) + return err +} + +func (r *QuestionRepository) GetByID(ctx context.Context, id string) (*domain.Question, error) { + const q = ` +SELECT id, theme, text, answer, hint, difficulty, is_active, created_at, updated_at +FROM questions +WHERE id=$1` + row := r.client.Pool.QueryRow(ctx, q, id) + question, err := scanQuestion(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, sharederrors.Wrap( + sharederrors.CodeQuestionNotFound, + "question not found", + err, + ) + } + return nil, err + } + return question, nil +} + +func (r *QuestionRepository) Create(ctx context.Context, qn *domain.Question) (*domain.Question, error) { + id := uuid.NewString() + now := time.Now().UTC() + if qn.Difficulty == "" { + qn.Difficulty = domain.DifficultyMedium + } + const q = ` +INSERT INTO questions (id, theme, text, answer, hint, difficulty, is_active, created_at, updated_at) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) +RETURNING id, theme, text, answer, hint, difficulty, is_active, created_at, updated_at` + row := r.client.Pool.QueryRow(ctx, q, + id, + qn.Theme, + qn.Text, + qn.Answer, + qn.Hint, + string(qn.Difficulty), + true, + now, + now, + ) + created, err := scanQuestion(row) + if err != nil { + return nil, err + } + return created, nil +} + +func (r *QuestionRepository) Update( + ctx context.Context, + id string, + qn *domain.Question, +) (*domain.Question, error) { + const q = ` +UPDATE questions +SET theme=$2, text=$3, answer=$4, hint=$5, difficulty=$6, is_active=$7, updated_at=NOW() +WHERE id=$1 +RETURNING id, theme, text, answer, hint, difficulty, is_active, created_at, updated_at` + row := r.client.Pool.QueryRow(ctx, q, + id, + qn.Theme, + qn.Text, + qn.Answer, + qn.Hint, + string(qn.Difficulty), + qn.IsActive, + ) + updated, err := scanQuestion(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, sharederrors.Wrap( + sharederrors.CodeQuestionNotFound, + "question not found", + err, + ) + } + return nil, err + } + return updated, nil +} + +func (r *QuestionRepository) SoftDelete(ctx context.Context, id string) error { + const q = `UPDATE questions SET is_active=false, updated_at=NOW() WHERE id=$1` + res, err := r.client.Pool.Exec(ctx, q, id) + if err != nil { + return err + } + if res.RowsAffected() == 0 { + return sharederrors.Wrap( + sharederrors.CodeQuestionNotFound, + "question not found", + nil, + ) + } + return nil +} + +func (r *QuestionRepository) ListThemes(ctx context.Context) ([]string, error) { + const q = `SELECT DISTINCT theme FROM questions WHERE is_active=true ORDER BY theme ASC` + rows, err := r.client.Pool.Query(ctx, q) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]string, 0) + for rows.Next() { + var theme string + if err := rows.Scan(&theme); err != nil { + return nil, err + } + out = append(out, theme) + } + return out, rows.Err() +} + +func (r *QuestionRepository) CountRandomCandidates( + ctx context.Context, + filter domain.RandomFilter, +) (int, error) { + where, args := buildRandomWhere(filter) + query := `SELECT COUNT(*) FROM questions WHERE is_active=true` + where + row := r.client.Pool.QueryRow(ctx, query, args...) + var count int + if err := row.Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +func (r *QuestionRepository) RandomByOffset( + ctx context.Context, + filter domain.RandomFilter, + offset int, +) (*domain.Question, error) { + where, args := buildRandomWhere(filter) + args = append(args, offset) + query := ` +SELECT id, theme, text, answer, hint, difficulty, is_active, created_at, updated_at +FROM questions +WHERE is_active=true` + where + ` ORDER BY id LIMIT 1 OFFSET $` + fmt.Sprintf("%d", len(args)) + + row := r.client.Pool.QueryRow(ctx, query, args...) + question, err := scanQuestion(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, sharederrors.Wrap( + sharederrors.CodeNoQuestionsAvailable, + "no questions available", + err, + ) + } + return nil, err + } + return question, nil +} + +func (r *QuestionRepository) BulkCreate( + ctx context.Context, + questions []*domain.Question, +) (int, []domain.BulkError, error) { + tx, err := r.client.Pool.Begin(ctx) + if err != nil { + return 0, nil, err + } + defer func() { + _ = tx.Rollback(ctx) + }() + + created := 0 + errs := make([]domain.BulkError, 0) + const q = ` +INSERT INTO questions (id, theme, text, answer, hint, difficulty, is_active, created_at, updated_at) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)` + + for i, item := range questions { + id := uuid.NewString() + now := time.Now().UTC() + _, execErr := tx.Exec(ctx, q, + id, + item.Theme, + item.Text, + item.Answer, + item.Hint, + string(item.Difficulty), + true, + now, + now, + ) + if execErr != nil { + errs = append( + errs, + domain.BulkError{Index: i, Reason: execErr.Error()}, + ) + continue + } + created++ + } + + if err := tx.Commit(ctx); err != nil { + return 0, nil, err + } + return created, errs, nil +} + +func buildRandomWhere(filter domain.RandomFilter) (string, []interface{}) { + clauses := make([]string, 0) + args := make([]interface{}, 0) + + if filter.Theme != "" { + args = append(args, filter.Theme) + clauses = append(clauses, fmt.Sprintf("theme=$%d", len(args))) + } + if filter.Difficulty != "" { + args = append(args, string(filter.Difficulty)) + clauses = append(clauses, fmt.Sprintf("difficulty=$%d", len(args))) + } + if len(filter.ExcludeQuestionIDs) > 0 { + excludeClause := make([]string, 0, len(filter.ExcludeQuestionIDs)) + for _, id := range filter.ExcludeQuestionIDs { + args = append(args, id) + excludeClause = append(excludeClause, fmt.Sprintf("$%d", len(args))) + } + clauses = append(clauses, "id NOT IN ("+strings.Join(excludeClause, ",")+")") + } + + if len(clauses) == 0 { + return "", args + } + return " AND " + strings.Join(clauses, " AND "), args +} + +type scanner interface { + Scan(dest ...interface{}) error +} + +func scanQuestion(row scanner) (*domain.Question, error) { + var ( + id string + theme string + text string + answer string + hint *string + difficulty string + isActive bool + createdAt time.Time + updatedAt time.Time + ) + if err := row.Scan( + &id, + &theme, + &text, + &answer, + &hint, + &difficulty, + &isActive, + &createdAt, + &updatedAt, + ); err != nil { + return nil, err + } + q := &domain.Question{ + ID: id, + Theme: theme, + Text: text, + Answer: answer, + Difficulty: domain.Difficulty(difficulty), + IsActive: isActive, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } + if hint != nil { + q.Hint = *hint + } + return q, nil +} diff --git a/backend/services/question-bank-service/internal/infra/persistence/ent/schema/question.go b/backend/services/question-bank-service/internal/infra/persistence/ent/schema/question.go new file mode 100644 index 0000000..55f6d9e --- /dev/null +++ b/backend/services/question-bank-service/internal/infra/persistence/ent/schema/question.go @@ -0,0 +1,36 @@ +package schema + +import ( + "time" + + "entgo.io/ent" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/index" +) + +// Question holds the schema definition for the Question entity. +type Question struct { + ent.Schema +} + +// Fields of the Question. +func (Question) Fields() []ent.Field { + return []ent.Field{ + field.String("theme").NotEmpty().MaxLen(100), + field.Text("text").NotEmpty().MaxLen(1000), + field.String("answer").NotEmpty().MaxLen(500), + field.Text("hint").Optional().MaxLen(500), + field.Enum("difficulty").Values("easy", "medium", "hard").Default("medium"), + field.Bool("is_active").Default(true), + field.Time("created_at").Default(time.Now).Immutable(), + field.Time("updated_at").Default(time.Now).UpdateDefault(time.Now), + } +} + +// Indexes of the Question. +func (Question) Indexes() []ent.Index { + return []ent.Index{ + index.Fields("theme", "is_active"), + index.Fields("difficulty", "is_active"), + } +} diff --git a/backend/services/question-bank-service/internal/infra/persistence/ent/theme_repo.go b/backend/services/question-bank-service/internal/infra/persistence/ent/theme_repo.go new file mode 100644 index 0000000..1fddb3d --- /dev/null +++ b/backend/services/question-bank-service/internal/infra/persistence/ent/theme_repo.go @@ -0,0 +1,3 @@ +package ent + +// Theme-specific queries are currently implemented in QuestionRepository.ListThemes. diff --git a/backend/services/question-bank-service/internal/interfaces/http/handler.go b/backend/services/question-bank-service/internal/interfaces/http/handler.go new file mode 100644 index 0000000..1487db2 --- /dev/null +++ b/backend/services/question-bank-service/internal/interfaces/http/handler.go @@ -0,0 +1,201 @@ +package http + +import ( + "errors" + "strconv" + + "github.com/gofiber/fiber/v3" + + appq "knowfoolery/backend/services/question-bank-service/internal/application/question" + domain "knowfoolery/backend/services/question-bank-service/internal/domain/question" + sharederrors "knowfoolery/backend/shared/domain/errors" + "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 HTTP endpoint handlers. +type Handler struct { + service *appq.Service + validator *validation.Validator + logger *logging.Logger + metrics *sharedmetrics.Metrics + bulkMaxItems int +} + +// NewHandler creates a new HTTP handler set. +func NewHandler(service *appq.Service, validator *validation.Validator, logger *logging.Logger, + metrics *sharedmetrics.Metrics, bulkMaxItems int) *Handler { + return &Handler{service: service, validator: validator, logger: logger, metrics: metrics, bulkMaxItems: bulkMaxItems} +} + +// PostRandomQuestion handles POST /questions/random. +func (h *Handler) PostRandomQuestion(c fiber.Ctx) error { + var req RandomQuestionRequest + if err := c.Bind().Body(&req); err != nil { + return httputil.SendError(c, sharederrors.Wrap(sharederrors.CodeInvalidInput, "invalid request body", err)) + } + + if req.Difficulty != "" && !domain.IsValidDifficulty(req.Difficulty) { + return httputil.SendError(c, sharederrors.Wrap(sharederrors.CodeValidationFailed, "invalid difficulty", nil)) + } + + result, err := h.service.GetRandomQuestion(c.Context(), appq.RandomQuestionRequest{ + ExcludeQuestionIDs: req.ExcludeQuestionIDs, + Theme: req.Theme, + Difficulty: req.Difficulty, + }) + if err != nil { + return h.sendMappedError(c, err) + } + + h.recordRequestMetric("POST", "/questions/random", fiber.StatusOK) + if h.logger != nil { + h.logger.Info("question selected") + } + return httputil.OK(c, toQuestionResponse(result, false)) +} + +// GetQuestionByID handles GET /questions/:id. +func (h *Handler) GetQuestionByID(c fiber.Ctx) error { + id := c.Params("id") + result, err := h.service.GetQuestionByID(c.Context(), id) + if err != nil { + return h.sendMappedError(c, err) + } + + h.recordRequestMetric("GET", "/questions/{id}", fiber.StatusOK) + return httputil.OK(c, toQuestionResponse(result, false)) +} + +// PostValidateAnswer handles POST /questions/:id/validate-answer. +func (h *Handler) PostValidateAnswer(c fiber.Ctx) error { + var req ValidateAnswerRequest + 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) + } + + result, err := h.service.ValidateAnswerByQuestionID(c.Context(), c.Params("id"), req.Answer) + if err != nil { + return h.sendMappedError(c, err) + } + + h.recordRequestMetric("POST", "/questions/{id}/validate-answer", fiber.StatusOK) + return httputil.OK(c, fiber.Map{ + "matched": result.Matched, + "score": result.Score, + "threshold": result.Threshold, + }) +} + +// AdminCreateQuestion handles POST /admin/questions. +func (h *Handler) AdminCreateQuestion(c fiber.Ctx) error { + var req CreateQuestionRequest + 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) + } + + result, err := h.service.CreateQuestion(c.Context(), appq.CreateQuestionInput(req)) + if err != nil { + return h.sendMappedError(c, err) + } + + h.recordRequestMetric("POST", "/admin/questions", fiber.StatusCreated) + return c.Status(fiber.StatusCreated).JSON(httputil.NewResponse(toQuestionResponse(result, true))) +} + +// AdminUpdateQuestion handles PUT /admin/questions/:id. +func (h *Handler) AdminUpdateQuestion(c fiber.Ctx) error { + var req UpdateQuestionRequest + 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) + } + + result, err := h.service.UpdateQuestion(c.Context(), c.Params("id"), appq.UpdateQuestionInput(req)) + if err != nil { + return h.sendMappedError(c, err) + } + + h.recordRequestMetric("PUT", "/admin/questions/{id}", fiber.StatusOK) + return httputil.OK(c, toQuestionResponse(result, true)) +} + +// AdminDeleteQuestion handles DELETE /admin/questions/:id. +func (h *Handler) AdminDeleteQuestion(c fiber.Ctx) error { + if err := h.service.DeleteQuestion(c.Context(), c.Params("id")); err != nil { + return h.sendMappedError(c, err) + } + + h.recordRequestMetric("DELETE", "/admin/questions/{id}", fiber.StatusNoContent) + return httputil.NoContent(c) +} + +// AdminListThemes handles GET /admin/themes. +func (h *Handler) AdminListThemes(c fiber.Ctx) error { + themes, err := h.service.ListThemes(c.Context()) + if err != nil { + return h.sendMappedError(c, err) + } + + h.recordRequestMetric("GET", "/admin/themes", fiber.StatusOK) + return httputil.OK(c, fiber.Map{"themes": themes}) +} + +// AdminBulkImport handles POST /admin/questions/bulk. +func (h *Handler) AdminBulkImport(c fiber.Ctx) error { + var req BulkImportRequest + if err := c.Bind().Body(&req); err != nil { + return httputil.SendError(c, sharederrors.Wrap(sharederrors.CodeInvalidInput, "invalid request body", err)) + } + if len(req.Questions) == 0 { + return httputil.SendError(c, sharederrors.Wrap(sharederrors.CodeValidationFailed, "questions is required", nil)) + } + + items := make([]appq.BulkImportItem, 0, len(req.Questions)) + for _, q := range req.Questions { + items = append(items, appq.BulkImportItem{ + Theme: q.Theme, + Text: q.Text, + Answer: q.Answer, + Hint: q.Hint, + Difficulty: q.Difficulty, + }) + } + + result, err := h.service.BulkImport(c.Context(), items, h.bulkMaxItems) + if err != nil { + return h.sendMappedError(c, err) + } + + h.recordRequestMetric("POST", "/admin/questions/bulk", fiber.StatusOK) + return httputil.OK(c, result) +} + +func (h *Handler) sendMappedError(c fiber.Ctx, err error) error { + var domainErr *sharederrors.DomainError + if errors.As(err, &domainErr) { + return httputil.SendError(c, domainErr) + } + 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), h.metricsConfigService()).Inc() +} + +func (h *Handler) metricsConfigService() string { + return "question-bank-service" +} diff --git a/backend/services/question-bank-service/internal/interfaces/http/request.go b/backend/services/question-bank-service/internal/interfaces/http/request.go new file mode 100644 index 0000000..8172f92 --- /dev/null +++ b/backend/services/question-bank-service/internal/interfaces/http/request.go @@ -0,0 +1,39 @@ +package http + +import domain "knowfoolery/backend/services/question-bank-service/internal/domain/question" + +// RandomQuestionRequest is the POST /questions/random request payload. +type RandomQuestionRequest struct { + ExcludeQuestionIDs []string `json:"exclude_question_ids"` + Theme string `json:"theme"` + Difficulty domain.Difficulty `json:"difficulty"` +} + +// CreateQuestionRequest is the admin create question payload. +type CreateQuestionRequest struct { + Theme string `json:"theme" validate:"required,min=2,max=100"` + Text string `json:"text" validate:"required,min=5,max=1000"` + Answer string `json:"answer" validate:"required,min=1,max=500"` + Hint string `json:"hint" validate:"omitempty,max=500"` + Difficulty domain.Difficulty `json:"difficulty" validate:"required,oneof=easy medium hard"` +} + +// UpdateQuestionRequest is the admin update question payload. +type UpdateQuestionRequest struct { + Theme string `json:"theme" validate:"required,min=2,max=100"` + Text string `json:"text" validate:"required,min=5,max=1000"` + Answer string `json:"answer" validate:"required,min=1,max=500"` + Hint string `json:"hint" validate:"omitempty,max=500"` + Difficulty domain.Difficulty `json:"difficulty" validate:"required,oneof=easy medium hard"` + IsActive bool `json:"is_active"` +} + +// BulkImportRequest is the admin bulk import payload. +type BulkImportRequest struct { + Questions []CreateQuestionRequest `json:"questions" validate:"required,min=1"` +} + +// ValidateAnswerRequest is the POST /questions/:id/validate-answer request payload. +type ValidateAnswerRequest struct { + Answer string `json:"answer" validate:"required,min=1,max=500"` +} diff --git a/backend/services/question-bank-service/internal/interfaces/http/response.go b/backend/services/question-bank-service/internal/interfaces/http/response.go new file mode 100644 index 0000000..fa06b9a --- /dev/null +++ b/backend/services/question-bank-service/internal/interfaces/http/response.go @@ -0,0 +1,35 @@ +package http + +import ( + "time" + + domain "knowfoolery/backend/services/question-bank-service/internal/domain/question" +) + +// QuestionResponse is a public API response payload for question data. +type QuestionResponse struct { + ID string `json:"id"` + Theme string `json:"theme"` + Text string `json:"text"` + Hint string `json:"hint,omitempty"` + Difficulty domain.Difficulty `json:"difficulty"` + IsActive bool `json:"is_active,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +func toQuestionResponse(q *domain.Question, admin bool) QuestionResponse { + resp := QuestionResponse{ + ID: q.ID, + Theme: q.Theme, + Text: q.Text, + Hint: q.Hint, + Difficulty: q.Difficulty, + } + if admin { + resp.IsActive = q.IsActive + resp.CreatedAt = &q.CreatedAt + resp.UpdatedAt = &q.UpdatedAt + } + return resp +} diff --git a/backend/services/question-bank-service/internal/interfaces/http/routes.go b/backend/services/question-bank-service/internal/interfaces/http/routes.go new file mode 100644 index 0000000..d2bca05 --- /dev/null +++ b/backend/services/question-bank-service/internal/interfaces/http/routes.go @@ -0,0 +1,20 @@ +package http + +import "github.com/gofiber/fiber/v3" + +// RegisterRoutes registers question bank routes. +func RegisterRoutes(app *fiber.App, h *Handler, adminMiddleware fiber.Handler) { + app.Post("/questions/random", h.PostRandomQuestion) + app.Get("/questions/:id", h.GetQuestionByID) + app.Post("/questions/:id/validate-answer", h.PostValidateAnswer) + + admin := app.Group("/admin") + if adminMiddleware != nil { + admin.Use(adminMiddleware) + } + admin.Post("/questions", h.AdminCreateQuestion) + admin.Put("/questions/:id", h.AdminUpdateQuestion) + admin.Delete("/questions/:id", h.AdminDeleteQuestion) + admin.Get("/themes", h.AdminListThemes) + admin.Post("/questions/bulk", h.AdminBulkImport) +} diff --git a/backend/services/question-bank-service/tests/fixtures/questions.json b/backend/services/question-bank-service/tests/fixtures/questions.json new file mode 100644 index 0000000..9ce5d3f --- /dev/null +++ b/backend/services/question-bank-service/tests/fixtures/questions.json @@ -0,0 +1,16 @@ +[ + { + "theme": "Science", + "text": "What is the chemical symbol for water?", + "answer": "H2O", + "hint": "Two hydrogen and one oxygen", + "difficulty": "easy" + }, + { + "theme": "Geography", + "text": "What is the capital of France?", + "answer": "Paris", + "hint": "City of Light", + "difficulty": "easy" + } +] diff --git a/backend/services/question-bank-service/tests/integration_http_test.go b/backend/services/question-bank-service/tests/integration_http_test.go new file mode 100644 index 0000000..37cacb9 --- /dev/null +++ b/backend/services/question-bank-service/tests/integration_http_test.go @@ -0,0 +1,255 @@ +package tests + +// Integration-style HTTP tests for question-bank routes, admin guard behavior, and metrics exposure. + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/adaptor" + "github.com/prometheus/client_golang/prometheus" + + appq "knowfoolery/backend/services/question-bank-service/internal/application/question" + domain "knowfoolery/backend/services/question-bank-service/internal/domain/question" + httpapi "knowfoolery/backend/services/question-bank-service/internal/interfaces/http" + sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics" + "knowfoolery/backend/shared/infra/utils/validation" +) + +// inMemoryRepo is an in-memory repository used for HTTP integration tests. +type inMemoryRepo struct { + items map[string]*domain.Question +} + +func newInMemoryRepo() *inMemoryRepo { + return &inMemoryRepo{items: map[string]*domain.Question{}} +} + +func (r *inMemoryRepo) GetByID(ctx context.Context, id string) (*domain.Question, error) { + if q, ok := r.items[id]; ok { + return q, nil + } + return nil, domain.ErrQuestionNotFound +} +func (r *inMemoryRepo) Create(ctx context.Context, q *domain.Question) (*domain.Question, error) { + q.ID = "q-created" + q.IsActive = true + r.items[q.ID] = q + return q, nil +} +func (r *inMemoryRepo) Update(ctx context.Context, id string, q *domain.Question) (*domain.Question, error) { + if _, ok := r.items[id]; !ok { + return nil, domain.ErrQuestionNotFound + } + q.ID = id + r.items[id] = q + return q, nil +} +func (r *inMemoryRepo) SoftDelete(ctx context.Context, id string) error { + if q, ok := r.items[id]; ok { + q.IsActive = false + return nil + } + return domain.ErrQuestionNotFound +} +func (r *inMemoryRepo) ListThemes(ctx context.Context) ([]string, error) { + return []string{"Science"}, nil +} +func (r *inMemoryRepo) CountRandomCandidates(ctx context.Context, filter domain.RandomFilter) (int, error) { + count := 0 + for _, q := range r.items { + if !q.IsActive { + continue + } + count++ + } + return count, nil +} +func (r *inMemoryRepo) RandomByOffset(ctx context.Context, + filter domain.RandomFilter, offset int) (*domain.Question, error) { + for _, q := range r.items { + if q.IsActive { + return q, nil + } + } + return nil, domain.ErrNoQuestionsAvailable +} +func (r *inMemoryRepo) BulkCreate(ctx context.Context, questions []*domain.Question) (int, []domain.BulkError, error) { + for i, q := range questions { + id := "bulk-" + strconv.Itoa(i) + q.ID = id + q.IsActive = true + r.items[id] = q + } + return len(questions), nil, nil +} + +// noOpCache disables caching behavior in HTTP integration tests. +type noOpCache struct{} + +func (c *noOpCache) Get(ctx context.Context, key string) (*domain.Question, bool) { + return nil, false +} + +func (c *noOpCache) Set(ctx context.Context, key string, q *domain.Question, ttl time.Duration) {} + +func (c *noOpCache) Invalidate(ctx context.Context) {} + +// setupApp wires a test Fiber app with in-memory dependencies and admin middleware. +func setupApp(t *testing.T) *fiber.App { + t.Helper() + repo := newInMemoryRepo() + repo.items["q1"] = &domain.Question{ + ID: "q1", + Theme: "Science", + Text: "What planet is largest?", + Answer: "jupiter", + Difficulty: domain.DifficultyMedium, + IsActive: true, + } + svc := appq.NewService(repo, &noOpCache{}, time.Minute, 200) + metrics := sharedmetrics.NewMetrics(sharedmetrics.Config{ + ServiceName: "question-bank-service-test", + Enabled: true, + Registry: prometheus.NewRegistry(), + }) + h := httpapi.NewHandler(svc, validation.NewValidator(), nil, metrics, 5000) + + app := fiber.New() + adminMW := func(c fiber.Ctx) error { + if c.Get("Authorization") == "Bearer admin" { + return c.Next() + } + return c.SendStatus(http.StatusUnauthorized) + } + httpapi.RegisterRoutes(app, h, adminMW) + app.Get("/metrics", adaptor.HTTPHandler(sharedmetrics.Handler())) + return app +} + +// TestHTTPRandomAndGet validates public random and get-by-id endpoints. +func TestHTTPRandomAndGet(t *testing.T) { + app := setupApp(t) + + payload, _ := json.Marshal(map[string]any{}) + req := httptest.NewRequest( + http.MethodPost, + "/questions/random", + bytes.NewReader(payload), + ) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil || resp.StatusCode != http.StatusOK { + t.Fatalf("random failed: err=%v status=%d", err, resp.StatusCode) + } + defer resp.Body.Close() + req = httptest.NewRequest(http.MethodGet, "/questions/q1", nil) + resp, err = app.Test(req) + if err != nil || resp.StatusCode != http.StatusOK { + t.Fatalf("get failed: err=%v status=%d", err, resp.StatusCode) + } + defer resp.Body.Close() +} + +// TestHTTPAdminAuthAndMetrics validates admin auth guard and metrics endpoint availability. +func TestHTTPAdminAuthAndMetrics(t *testing.T) { + app := setupApp(t) + + createPayload, _ := json.Marshal(map[string]any{ + "theme": "Science", + "text": "What is H2O?", + "answer": "water", + "hint": "liquid", + "difficulty": "easy", + }) + + req := httptest.NewRequest( + http.MethodPost, + "/admin/questions", + bytes.NewReader(createPayload), + ) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil || resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected unauthorized: err=%v status=%d", err, resp.StatusCode) + } + defer resp.Body.Close() + req = httptest.NewRequest( + http.MethodPost, + "/admin/questions", + bytes.NewReader(createPayload), + ) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer admin") + resp, err = app.Test(req) + if err != nil || resp.StatusCode != http.StatusCreated { + t.Fatalf("expected created: err=%v status=%d", err, resp.StatusCode) + } + defer resp.Body.Close() + req = httptest.NewRequest(http.MethodGet, "/metrics", nil) + resp, err = app.Test(req) + if err != nil || resp.StatusCode != http.StatusOK { + t.Fatalf("metrics failed: err=%v status=%d", err, resp.StatusCode) + } + defer resp.Body.Close() +} + +// TestHTTPValidateAnswer covers matched/unmatched, missing question, and invalid answer responses. +func TestHTTPValidateAnswer(t *testing.T) { + app := setupApp(t) + + matchedPayload, _ := json.Marshal(map[string]any{"answer": "Jupiter"}) + req := httptest.NewRequest( + http.MethodPost, + "/questions/q1/validate-answer", + bytes.NewReader(matchedPayload), + ) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil || resp.StatusCode != http.StatusOK { + t.Fatalf("expected matched 200: err=%v status=%d", err, resp.StatusCode) + } + defer resp.Body.Close() + unmatchedPayload, _ := json.Marshal(map[string]any{"answer": "Mars"}) + req = httptest.NewRequest( + http.MethodPost, + "/questions/q1/validate-answer", + bytes.NewReader(unmatchedPayload), + ) + req.Header.Set("Content-Type", "application/json") + resp, err = app.Test(req) + if err != nil || resp.StatusCode != http.StatusOK { + t.Fatalf("expected unmatched 200: err=%v status=%d", err, resp.StatusCode) + } + defer resp.Body.Close() + req = httptest.NewRequest( + http.MethodPost, + "/questions/missing/validate-answer", + bytes.NewReader(matchedPayload), + ) + req.Header.Set("Content-Type", "application/json") + resp, err = app.Test(req) + if err != nil || resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 for missing question: err=%v status=%d", err, resp.StatusCode) + } + defer resp.Body.Close() + badPayload, _ := json.Marshal(map[string]any{"answer": ""}) + req = httptest.NewRequest( + http.MethodPost, + "/questions/q1/validate-answer", + bytes.NewReader(badPayload), + ) + req.Header.Set("Content-Type", "application/json") + resp, err = app.Test(req) + if err != nil || resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for empty answer: err=%v status=%d", err, resp.StatusCode) + } + defer resp.Body.Close() +} diff --git a/backend/services/question-bank-service/tests/integration_repo_test.go b/backend/services/question-bank-service/tests/integration_repo_test.go new file mode 100644 index 0000000..743ae6d --- /dev/null +++ b/backend/services/question-bank-service/tests/integration_repo_test.go @@ -0,0 +1,75 @@ +package tests + +// Optional DB-backed repository integration test, enabled only when TEST_POSTGRES_URL is set. + +import ( + "context" + "net/url" + "os" + "strconv" + "strings" + "testing" + "time" + + domain "knowfoolery/backend/services/question-bank-service/internal/domain/question" + qent "knowfoolery/backend/services/question-bank-service/internal/infra/persistence/ent" + sharedpostgres "knowfoolery/backend/shared/infra/database/postgres" +) + +// TestRepositoryLifecycle creates and fetches a question against a real Postgres instance when enabled. +func TestRepositoryLifecycle(t *testing.T) { + dsn := os.Getenv("TEST_POSTGRES_URL") + if dsn == "" { + t.Skip("TEST_POSTGRES_URL not set") + } + + cfg := sharedpostgres.DefaultConfig() + parsed, err := url.Parse(dsn) + if err != nil { + t.Fatalf("parse TEST_POSTGRES_URL: %v", err) + } + cfg.Host = parsed.Hostname() + if p, convErr := strconv.Atoi(parsed.Port()); convErr == nil { + cfg.Port = p + } + if parsed.User != nil { + cfg.User = parsed.User.Username() + pw, _ := parsed.User.Password() + cfg.Password = pw + } + cfg.Database = strings.TrimPrefix(parsed.Path, "/") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + client, err := qent.NewClient(ctx, cfg) + if err != nil { + t.Fatalf("new client: %v", err) + } + defer client.Close() + + repo := qent.NewQuestionRepository(client) + if err := repo.EnsureSchema(ctx); err != nil { + t.Fatalf("ensure schema: %v", err) + } + + created, err := repo.Create(ctx, &domain.Question{ + Theme: "Science", + Text: "What is H2O?", + Answer: "water", + Hint: "liquid", + Difficulty: domain.DifficultyEasy, + IsActive: true, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + got, err := repo.GetByID(ctx, created.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if got.ID != created.ID { + t.Fatalf("id mismatch") + } +} diff --git a/docs/4_work_plan/2.1-question-bank-service.md b/docs/4_work_plan/2.1-question-bank-service.md new file mode 100644 index 0000000..4a022a9 --- /dev/null +++ b/docs/4_work_plan/2.1-question-bank-service.md @@ -0,0 +1,79 @@ +# 2.1 Question Bank Service (Port 8081) - Detailed Implementation Plan + +## Summary +Implement the Question Bank Service as the source of truth for quiz questions and themes, with public random/get endpoints and admin CRUD/bulk workflows. Runtime stack: Fiber HTTP, PostgreSQL persistence, Redis-backed random-question cache, and shared packages for auth/validation/errors/observability. + +## Objectives +1. Provide `/questions/random` and `/questions/{id}` for gameplay. +2. Provide admin endpoints for create/update/delete/list themes/bulk import. +3. Implement 85% fuzzy answer matching helper for downstream game-session usage. +4. Add observability, health/readiness, and test coverage. + +## API Endpoints +- `POST /questions/random` +- `GET /questions/{id}` +- `POST /admin/questions` +- `PUT /admin/questions/{id}` +- `DELETE /admin/questions/{id}` +- `GET /admin/themes` +- `POST /admin/questions/bulk` + +## Data Model +Question fields: +- `id` (UUID) +- `theme` (max 100) +- `text` (max 1000) +- `answer` (max 500) +- `hint` (optional, max 500) +- `difficulty` (`easy|medium|hard`, default `medium`) +- `is_active` (default `true`) +- `created_at`, `updated_at` + +Indexes: +- `(theme, is_active)` +- `(difficulty, is_active)` + +## Core Logic +1. Random selection with exclusion list and optional theme/difficulty filters. +2. Cache key format: `qb:random:v1:{sha256(filters+sorted_exclusions)}`. +3. Cache TTL: `5m`. +4. Cache invalidation on admin writes via namespace version bump. +5. Fuzzy matching utility: +- normalize strings (trim/lower/collapse spaces/remove punctuation) +- compute Levenshtein similarity +- match when score `>= 0.85` + +## Security +1. Public endpoints are open for internal service usage. +2. Admin endpoints protected by Zitadel JWT middleware. +3. Admin authorization requires `admin` role + MFA. + +## Observability +1. Shared logger initialized with service metadata. +2. Shared metrics registered and `/metrics` exposed. +3. Shared tracer initialization and shutdown. +4. `/health` and `/ready` endpoints available. + +## Configuration +- `QUESTION_BANK_PORT` (default `8081`) +- `QUESTION_CACHE_TTL` (default `5m`) +- `QUESTION_RANDOM_MAX_EXCLUSIONS` (default `200`) +- `QUESTION_BULK_MAX_ITEMS` (default `5000`) +- shared `POSTGRES_*`, `REDIS_*`, `TRACING_*`, `METRICS_*`, `LOG_LEVEL`, `ZITADEL_*` + +## Testing Strategy +1. Unit tests: +- fuzzy matching threshold behavior +- random selection no-result and cache paths +2. HTTP integration tests: +- random/get success +- admin auth guard behavior +- metrics endpoint +3. Repository integration test scaffold (optional, DB-backed when env is set) + +## Verification +From `backend/services/question-bank-service`: +```bash +go test ./... +go vet ./... +```