Finished step '2.1 Question Bank Service (Port 8081)'
parent
a5c04308d9
commit
79531ca862
@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -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…
Reference in New Issue