Finished step '2.1 Question Bank Service (Port 8081)'

master
oabrivard 1 month ago
parent a5c04308d9
commit 79531ca862

@ -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= cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= 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/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/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= 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/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/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/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 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-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-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-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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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.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= 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= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=

@ -1,22 +1,132 @@
package main package main
import ( import (
"context"
"log" "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/serviceboot"
"knowfoolery/backend/shared/infra/utils/validation"
) )
func main() { func main() {
cfg := serviceboot.Config{ cfg := qbconfig.FromEnv()
AppName: "Know Foolery - Question Bank Service",
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", ServiceSlug: "question-bank",
PortEnv: "QUESTION_BANK_PORT", 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) adminMiddleware := buildAdminMiddleware(cfg)
serviceboot.RegisterHealth(app, cfg.ServiceSlug) 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)) 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,
})
})
}

@ -2,20 +2,66 @@ module knowfoolery/backend/services/question-bank-service
go 1.25.5 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 ( 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/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/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/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-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.55.0 // indirect github.com/valyala/fasthttp v1.55.0 // indirect
github.com/valyala/tcplisten v1.0.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 replace knowfoolery/backend/shared => ../../shared

@ -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 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 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 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 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 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 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 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 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/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 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 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= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

@ -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
}

@ -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"`
}

@ -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[:]))
}

@ -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)
}
}

@ -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
}

@ -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)
}
})
}
}

@ -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
}
}

@ -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")
)

@ -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
}

@ -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:])
}

@ -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", ""),
}
}

@ -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)
}

@ -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
}

@ -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"),
}
}

@ -0,0 +1,3 @@
package ent
// Theme-specific queries are currently implemented in QuestionRepository.ListThemes.

@ -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"
}

@ -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"`
}

@ -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
}

@ -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)
}

@ -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"
}
]

@ -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()
}

@ -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")
}
}

@ -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 ./...
```
Loading…
Cancel
Save