Finished step '2.2 User Service (Port 8082)'

master
oabrivard 1 month ago
parent 79531ca862
commit 4797cf8c42

@ -15,7 +15,7 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.55.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/sys v0.40.0 // indirect
)
replace knowfoolery/backend/shared => ../../shared

@ -8,11 +8,11 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
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.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

@ -15,7 +15,7 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.55.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/sys v0.40.0 // indirect
)
replace knowfoolery/backend/shared => ../../shared

@ -7,9 +7,9 @@ github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

@ -15,7 +15,7 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.55.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/sys v0.40.0 // indirect
)
replace knowfoolery/backend/shared => ../../shared

@ -7,9 +7,9 @@ github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

@ -15,7 +15,7 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.55.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/sys v0.40.0 // indirect
)
replace knowfoolery/backend/shared => ../../shared

@ -7,9 +7,9 @@ github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

@ -7,6 +7,7 @@ require (
github.com/gofiber/fiber/v3 v3.0.0-beta.3
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.2
github.com/prometheus/client_golang v1.20.5
knowfoolery/backend/shared v0.0.0
)
@ -35,7 +36,6 @@ require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_golang v1.20.5 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect

@ -1,22 +1,123 @@
package main
import (
"context"
"log"
"time"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/adaptor"
appu "knowfoolery/backend/services/user-service/internal/application/user"
uconfig "knowfoolery/backend/services/user-service/internal/infra/config"
uent "knowfoolery/backend/services/user-service/internal/infra/persistence/ent"
httpapi "knowfoolery/backend/services/user-service/internal/interfaces/http"
"knowfoolery/backend/shared/infra/auth/zitadel"
"knowfoolery/backend/shared/infra/observability/logging"
sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics"
"knowfoolery/backend/shared/infra/observability/tracing"
"knowfoolery/backend/shared/infra/utils/serviceboot"
"knowfoolery/backend/shared/infra/utils/validation"
)
func main() {
cfg := serviceboot.Config{
AppName: "Know Foolery - User Service",
cfg := uconfig.FromEnv()
logger := logging.NewLogger(cfg.Logging)
metrics := sharedmetrics.NewMetrics(cfg.Metrics)
tracer, err := tracing.NewTracer(cfg.Tracing)
if err != nil {
logger.Fatal("failed to initialize tracer")
}
defer func() {
_ = tracer.Shutdown(context.Background())
}()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
persistence, err := uent.NewClient(ctx, cfg.Postgres)
if err != nil {
logger.WithError(err).Fatal("failed to initialize postgres client")
}
defer persistence.Close()
repo := uent.NewUserRepository(persistence)
if err := repo.EnsureSchema(ctx); err != nil {
logger.WithError(err).Fatal("failed to ensure schema")
}
service := appu.NewService(repo)
handler := httpapi.NewHandler(
service,
validation.NewValidator(),
logger,
metrics,
cfg.AdminListDefaultLimit,
cfg.AdminListMaxLimit,
)
bootCfg := serviceboot.Config{
AppName: cfg.AppName,
ServiceSlug: "user",
PortEnv: "USER_SERVICE_PORT",
DefaultPort: 8082,
DefaultPort: cfg.Port,
}
app := serviceboot.NewFiberApp(bootCfg)
serviceboot.RegisterHealth(app, bootCfg.ServiceSlug)
registerReadiness(app, persistence)
app.Get("/metrics", adaptor.HTTPHandler(sharedmetrics.Handler()))
app := serviceboot.NewFiberApp(cfg)
serviceboot.RegisterHealth(app, cfg.ServiceSlug)
authMiddleware, adminMiddleware := buildAuthMiddleware(cfg)
httpapi.RegisterRoutes(app, handler, authMiddleware, adminMiddleware)
addr := serviceboot.ListenAddress(cfg.PortEnv, cfg.DefaultPort)
addr := serviceboot.ListenAddress(bootCfg.PortEnv, bootCfg.DefaultPort)
log.Fatal(serviceboot.Run(app, addr))
}
func buildAuthMiddleware(cfg uconfig.Config) (fiber.Handler, fiber.Handler) {
if cfg.ZitadelBaseURL == "" {
return nil, 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,
})
auth := zitadel.JWTMiddleware(zitadel.JWTMiddlewareConfig{
Client: client,
Issuer: cfg.ZitadelIssuer,
Audience: cfg.ZitadelAudience,
RequiredClaims: []string{"sub", "email"},
AdminEndpoints: []string{"/admin"},
})
return auth, nil
}
func registerReadiness(app *fiber.App, persistence *uent.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"}
if err := persistence.Pool.Ping(ctx); err != nil {
checks["postgres"] = "down"
}
status := fiber.StatusOK
if checks["postgres"] != "ok" {
status = fiber.StatusServiceUnavailable
}
return c.Status(status).JSON(fiber.Map{
"status": "ready",
"checks": checks,
})
})
}

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

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

@ -0,0 +1,40 @@
package user
import (
"time"
domain "knowfoolery/backend/services/user-service/internal/domain/user"
)
// RegisterInput is input for register operation.
type RegisterInput struct {
ActorZitadelUserID string
ActorEmail string
DisplayName string
ConsentVersion string
ConsentSource string
}
// UpdateProfileInput is input for profile update.
type UpdateProfileInput struct {
DisplayName string
ConsentVersion string
ConsentSource string
}
// ListUsersInput controls admin list operation.
type ListUsersInput struct {
Page int
PageSize int
Email string
DisplayName string
CreatedAfter *time.Time
CreatedBefore *time.Time
IncludeDeleted bool
}
// ExportBundle is GDPR export payload.
type ExportBundle struct {
User *domain.User `json:"user"`
AuditLogs []domain.AuditLogEntry `json:"audit_logs"`
}

@ -0,0 +1,229 @@
package user
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
domain "knowfoolery/backend/services/user-service/internal/domain/user"
sharederrors "knowfoolery/backend/shared/domain/errors"
sharedtypes "knowfoolery/backend/shared/domain/types"
sharedsecurity "knowfoolery/backend/shared/infra/security"
)
// Service orchestrates user use-cases.
type Service struct {
repo domain.Repository
}
// NewService creates a new user service.
func NewService(repo domain.Repository) *Service {
return &Service{repo: repo}
}
// Register creates or returns an existing user (idempotent behavior).
func (s *Service) Register(ctx context.Context, in RegisterInput) (*domain.User, error) {
zitadelID, email, err := validateIdentity(in)
if err != nil {
return nil, err
}
displayName, consentVersion, consentSource, err := sanitizeRegistrationInput(in)
if err != nil {
return nil, err
}
existing, err := s.findExistingUser(ctx, zitadelID, email)
if err != nil {
return nil, err
}
if existing != nil {
return existing, nil
}
now := time.Now().UTC()
created, err := s.repo.Create(ctx, &domain.User{
ZitadelUserID: zitadelID,
Email: email,
EmailVerified: false,
DisplayName: displayName,
ConsentVersion: consentVersion,
ConsentGivenAt: now,
ConsentSource: consentSource,
})
if err != nil {
return nil, err
}
return created, nil
}
// GetProfile returns a user profile by id.
func (s *Service) GetProfile(ctx context.Context, id string) (*domain.User, error) {
if strings.TrimSpace(id) == "" {
return nil, domain.ErrValidationFailed
}
return s.repo.GetByID(ctx, id)
}
// UpdateProfile updates mutable profile fields.
func (s *Service) UpdateProfile(ctx context.Context, id string, in UpdateProfileInput) (*domain.User, error) {
if strings.TrimSpace(id) == "" {
return nil, domain.ErrValidationFailed
}
displayName := sharedsecurity.SanitizePlayerName(in.DisplayName)
if displayName == "" {
return nil, domain.ErrValidationFailed
}
consentVersion := strings.TrimSpace(in.ConsentVersion)
if consentVersion == "" || len(consentVersion) > 32 {
return nil, domain.ErrValidationFailed
}
consentSource := strings.TrimSpace(in.ConsentSource)
if consentSource == "" {
consentSource = "web"
}
if len(consentSource) > 32 {
return nil, domain.ErrValidationFailed
}
return s.repo.UpdateProfile(ctx, id, displayName, domain.ConsentRecord{
Version: consentVersion,
GivenAt: time.Now().UTC(),
Source: consentSource,
})
}
// VerifyEmail sets email verified flag.
func (s *Service) VerifyEmail(ctx context.Context, id string) (*domain.User, error) {
if strings.TrimSpace(id) == "" {
return nil, domain.ErrValidationFailed
}
return s.repo.MarkEmailVerified(ctx, id)
}
// DeleteUser soft-deletes a user and writes audit.
func (s *Service) DeleteUser(ctx context.Context, id, actorUserID string) error {
if strings.TrimSpace(id) == "" {
return domain.ErrValidationFailed
}
return s.repo.SoftDelete(ctx, id, actorUserID)
}
// AdminListUsers lists users with pagination and filters.
func (s *Service) AdminListUsers(
ctx context.Context,
in ListUsersInput,
) ([]*domain.User, int64, sharedtypes.Pagination, error) {
pagination := sharedtypes.Pagination{Page: in.Page, PageSize: in.PageSize}
pagination.Normalize()
items, total, err := s.repo.List(ctx, pagination, domain.ListFilter{
Email: strings.ToLower(strings.TrimSpace(in.Email)),
DisplayName: strings.TrimSpace(in.DisplayName),
CreatedAfter: in.CreatedAfter,
CreatedBefore: in.CreatedBefore,
IncludeDeleted: in.IncludeDeleted,
})
if err != nil {
return nil, 0, pagination, err
}
return items, total, pagination, nil
}
// AdminExportUser builds GDPR export payload and writes audit log.
func (s *Service) AdminExportUser(ctx context.Context, id, actorUserID string) (*ExportBundle, error) {
if strings.TrimSpace(id) == "" {
return nil, domain.ErrValidationFailed
}
u, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
logs, err := s.repo.AuditLogsByUserID(ctx, id)
if err != nil {
return nil, err
}
meta := map[string]string{"operation": domain.AuditActionGDPRExport}
metaJSON, _ := json.Marshal(meta)
_ = s.repo.WriteAuditLog(ctx, domain.AuditLogEntry{
ActorUserID: actorUserID,
TargetUserID: id,
Action: domain.AuditActionGDPRExport,
MetadataJSON: string(metaJSON),
})
return &ExportBundle{User: u, AuditLogs: logs}, nil
}
func isNotFoundError(err error) bool {
if err == nil {
return false
}
var domainErr *sharederrors.DomainError
if errors.As(err, &domainErr) {
return domainErr.Code == sharederrors.CodeUserNotFound
}
return false
}
func validateIdentity(in RegisterInput) (string, string, error) {
zitadelID := strings.TrimSpace(in.ActorZitadelUserID)
email := strings.ToLower(strings.TrimSpace(in.ActorEmail))
if zitadelID == "" || email == "" {
return "", "", domain.ErrUnauthorized
}
if !sharedsecurity.IsValidEmail(email) {
return "", "", domain.ErrValidationFailed
}
return zitadelID, email, nil
}
func sanitizeRegistrationInput(in RegisterInput) (string, string, string, error) {
displayName := sharedsecurity.SanitizePlayerName(in.DisplayName)
if displayName == "" {
return "", "", "", domain.ErrValidationFailed
}
consentVersion := strings.TrimSpace(in.ConsentVersion)
if consentVersion == "" || len(consentVersion) > 32 {
return "", "", "", domain.ErrValidationFailed
}
consentSource := strings.TrimSpace(in.ConsentSource)
if consentSource == "" {
consentSource = "web"
}
if len(consentSource) > 32 {
return "", "", "", domain.ErrValidationFailed
}
return displayName, consentVersion, consentSource, nil
}
func (s *Service) findExistingUser(
ctx context.Context,
zitadelID string,
email string,
) (*domain.User, error) {
existing, err := s.repo.GetByZitadelUserID(ctx, zitadelID)
if err == nil && existing != nil {
return existing, nil
}
if !isNotFoundError(err) {
return nil, err
}
existing, err = s.repo.GetByEmail(ctx, email)
if err == nil && existing != nil {
return existing, nil
}
if !isNotFoundError(err) {
return nil, err
}
return nil, nil
}

@ -0,0 +1,206 @@
package user
import (
"context"
"testing"
"time"
domain "knowfoolery/backend/services/user-service/internal/domain/user"
sharederrors "knowfoolery/backend/shared/domain/errors"
sharedtypes "knowfoolery/backend/shared/domain/types"
)
// fakeRepo is an in-memory repository stub used by service unit tests.
type fakeRepo struct {
usersByID map[string]*domain.User
usersByEmail map[string]*domain.User
usersByZitadel map[string]*domain.User
audit []domain.AuditLogEntry
}
// newFakeRepo creates a fake repository with empty stores.
func newFakeRepo() *fakeRepo {
return &fakeRepo{
usersByID: map[string]*domain.User{},
usersByEmail: map[string]*domain.User{},
usersByZitadel: map[string]*domain.User{},
}
}
// EnsureSchema is a no-op for the in-memory fake repository.
func (r *fakeRepo) EnsureSchema(ctx context.Context) error { return nil }
// Create stores a user and assigns deterministic timestamps and ID for tests.
func (r *fakeRepo) Create(ctx context.Context, user *domain.User) (*domain.User, error) {
user.ID = "u-1"
now := time.Now().UTC()
user.CreatedAt = now
user.UpdatedAt = now
r.usersByID[user.ID] = user
r.usersByEmail[user.Email] = user
r.usersByZitadel[user.ZitadelUserID] = user
return user, nil
}
// GetByID returns a non-deleted user by ID.
func (r *fakeRepo) GetByID(ctx context.Context, id string) (*domain.User, error) {
if u, ok := r.usersByID[id]; ok && !u.IsDeleted() {
return u, nil
}
return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", nil)
}
// GetByEmail returns a non-deleted user by email.
func (r *fakeRepo) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
if u, ok := r.usersByEmail[email]; ok && !u.IsDeleted() {
return u, nil
}
return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", nil)
}
// GetByZitadelUserID returns a non-deleted user by Zitadel subject.
func (r *fakeRepo) GetByZitadelUserID(ctx context.Context, zid string) (*domain.User, error) {
if u, ok := r.usersByZitadel[zid]; ok && !u.IsDeleted() {
return u, nil
}
return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", nil)
}
// UpdateProfile updates mutable user profile fields in place.
func (r *fakeRepo) UpdateProfile(
ctx context.Context,
id, displayName string,
consent domain.ConsentRecord,
) (*domain.User, error) {
u, err := r.GetByID(ctx, id)
if err != nil {
return nil, err
}
u.DisplayName = displayName
u.ConsentVersion = consent.Version
u.ConsentGivenAt = consent.GivenAt
u.ConsentSource = consent.Source
u.UpdatedAt = time.Now().UTC()
return u, nil
}
// MarkEmailVerified marks a user as verified in the fake store.
func (r *fakeRepo) MarkEmailVerified(ctx context.Context, id string) (*domain.User, error) {
u, err := r.GetByID(ctx, id)
if err != nil {
return nil, err
}
u.EmailVerified = true
u.UpdatedAt = time.Now().UTC()
return u, nil
}
// SoftDelete marks a user as deleted and records an audit entry.
func (r *fakeRepo) SoftDelete(ctx context.Context, id string, actorUserID string) error {
u, err := r.GetByID(ctx, id)
if err != nil {
return err
}
now := time.Now().UTC()
u.DeletedAt = &now
r.audit = append(
r.audit,
domain.AuditLogEntry{
Action: domain.AuditActionGDPRDelete,
TargetUserID: id,
ActorUserID: actorUserID,
},
)
return nil
}
// List returns users while honoring include-deleted filtering.
func (r *fakeRepo) List(
ctx context.Context,
pagination sharedtypes.Pagination,
filter domain.ListFilter,
) ([]*domain.User, int64, error) {
items := make([]*domain.User, 0, len(r.usersByID))
for _, u := range r.usersByID {
if !filter.IncludeDeleted && u.IsDeleted() {
continue
}
items = append(items, u)
}
return items, int64(len(items)), nil
}
// AuditLogsByUserID returns audit entries associated with a target user.
func (r *fakeRepo) AuditLogsByUserID(ctx context.Context, id string) ([]domain.AuditLogEntry, error) {
out := make([]domain.AuditLogEntry, 0)
for _, a := range r.audit {
if a.TargetUserID == id {
out = append(out, a)
}
}
return out, nil
}
// WriteAuditLog appends an audit entry to the fake repository.
func (r *fakeRepo) WriteAuditLog(ctx context.Context, entry domain.AuditLogEntry) error {
r.audit = append(r.audit, entry)
return nil
}
// TestRegisterIdempotent verifies repeated registration returns the same existing user.
func TestRegisterIdempotent(t *testing.T) {
repo := newFakeRepo()
svc := NewService(repo)
first, err := svc.Register(context.Background(), RegisterInput{
ActorZitadelUserID: "zitadel-1",
ActorEmail: "player@example.com",
DisplayName: "Player One",
ConsentVersion: "v1",
ConsentSource: "web",
})
if err != nil {
t.Fatalf("first register failed: %v", err)
}
second, err := svc.Register(context.Background(), RegisterInput{
ActorZitadelUserID: "zitadel-1",
ActorEmail: "player@example.com",
DisplayName: "Player One",
ConsentVersion: "v1",
ConsentSource: "web",
})
if err != nil {
t.Fatalf("second register failed: %v", err)
}
if first.ID != second.ID {
t.Fatalf("expected idempotent register, got %s and %s", first.ID, second.ID)
}
}
// TestDeleteAndExport verifies a deleted user can no longer be fetched.
func TestDeleteAndExport(t *testing.T) {
repo := newFakeRepo()
svc := NewService(repo)
u, err := svc.Register(context.Background(), RegisterInput{
ActorZitadelUserID: "zitadel-1",
ActorEmail: "player@example.com",
DisplayName: "Player One",
ConsentVersion: "v1",
ConsentSource: "web",
})
if err != nil {
t.Fatalf("register failed: %v", err)
}
if err := svc.DeleteUser(context.Background(), u.ID, "admin-1"); err != nil {
t.Fatalf("delete failed: %v", err)
}
_, err = svc.GetProfile(context.Background(), u.ID)
if err == nil {
t.Fatal("expected deleted user to be unavailable")
}
}

@ -0,0 +1,49 @@
package user
import "time"
// User is the user aggregate root.
type User struct {
ID string
ZitadelUserID string
Email string
EmailVerified bool
DisplayName string
ConsentVersion string
ConsentGivenAt time.Time
ConsentSource string
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
}
// IsDeleted reports whether the user has been soft-deleted.
func (u *User) IsDeleted() bool {
return u != nil && u.DeletedAt != nil
}
// ConsentRecord captures user consent details.
type ConsentRecord struct {
Version string
GivenAt time.Time
Source string
}
// AuditLogEntry tracks compliance-relevant actions.
type AuditLogEntry struct {
ID string
ActorUserID string
TargetUserID string
Action string
MetadataJSON string
CreatedAt time.Time
}
const (
// AuditActionGDPRExport indicates a GDPR export operation.
AuditActionGDPRExport = "gdpr_export"
// AuditActionGDPRDelete indicates a GDPR delete operation.
AuditActionGDPRDelete = "gdpr_delete"
)

@ -0,0 +1,16 @@
package user
import sharederrors "knowfoolery/backend/shared/domain/errors"
var (
// ErrUserNotFound indicates user was not found.
ErrUserNotFound = sharederrors.New(sharederrors.CodeUserNotFound, "user not found")
// ErrUserAlreadyExists indicates user already exists.
ErrUserAlreadyExists = sharederrors.New(sharederrors.CodeUserAlreadyExists, "user already exists")
// ErrValidationFailed indicates user data is invalid.
ErrValidationFailed = sharederrors.New(sharederrors.CodeValidationFailed, "validation failed")
// ErrForbidden indicates the caller cannot access the resource.
ErrForbidden = sharederrors.New(sharederrors.CodeForbidden, "forbidden")
// ErrUnauthorized indicates missing user context.
ErrUnauthorized = sharederrors.New(sharederrors.CodeUnauthorized, "unauthorized")
)

@ -0,0 +1,32 @@
package user
import (
"context"
"time"
sharedtypes "knowfoolery/backend/shared/domain/types"
)
// ListFilter controls user listing behavior.
type ListFilter struct {
Email string
DisplayName string
CreatedAfter *time.Time
CreatedBefore *time.Time
IncludeDeleted bool
}
// Repository defines persistence behavior for users.
type Repository interface {
EnsureSchema(ctx context.Context) error
Create(ctx context.Context, user *User) (*User, error)
GetByID(ctx context.Context, id string) (*User, error)
GetByEmail(ctx context.Context, email string) (*User, error)
GetByZitadelUserID(ctx context.Context, zitadelUserID string) (*User, error)
UpdateProfile(ctx context.Context, id string, displayName string, consent ConsentRecord) (*User, error)
MarkEmailVerified(ctx context.Context, id string) (*User, error)
SoftDelete(ctx context.Context, id string, actorUserID string) error
List(ctx context.Context, pagination sharedtypes.Pagination, filter ListFilter) ([]*User, int64, error)
AuditLogsByUserID(ctx context.Context, id string) ([]AuditLogEntry, error)
WriteAuditLog(ctx context.Context, entry AuditLogEntry) error
}

@ -0,0 +1,75 @@
package config
import (
sharedpostgres "knowfoolery/backend/shared/infra/database/postgres"
"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
AdminListDefaultLimit int
AdminListMaxLimit int
Postgres sharedpostgres.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 - User Service"
serviceName := "user-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("USER_SERVICE_PORT", 8082),
Env: env,
LogLevel: logCfg.Level,
AdminListDefaultLimit: envutil.Int("USER_ADMIN_LIST_DEFAULT_LIMIT", 50),
AdminListMaxLimit: envutil.Int("USER_ADMIN_LIST_MAX_LIMIT", 200),
Postgres: sharedpostgres.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,425 @@
package ent
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
domain "knowfoolery/backend/services/user-service/internal/domain/user"
sharederrors "knowfoolery/backend/shared/domain/errors"
sharedtypes "knowfoolery/backend/shared/domain/types"
)
// UserRepository implements user storage on PostgreSQL.
type UserRepository struct {
client *Client
}
// NewUserRepository creates a new user repository.
func NewUserRepository(client *Client) *UserRepository {
return &UserRepository{client: client}
}
// EnsureSchema creates service tables if missing.
func (r *UserRepository) EnsureSchema(ctx context.Context) error {
const ddl = `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY,
zitadel_user_id VARCHAR(128) UNIQUE,
email VARCHAR(320) NOT NULL UNIQUE,
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
display_name VARCHAR(50) NOT NULL,
consent_version VARCHAR(32) NOT NULL,
consent_given_at TIMESTAMPTZ NOT NULL,
consent_source VARCHAR(32) NOT NULL DEFAULT 'web',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL
);
CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users (deleted_at);
CREATE INDEX IF NOT EXISTS idx_users_created_at ON users (created_at DESC);
CREATE TABLE IF NOT EXISTS user_audit_log (
id UUID PRIMARY KEY,
actor_user_id VARCHAR(128),
target_user_id UUID NOT NULL,
action VARCHAR(64) NOT NULL,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_audit_log_target_user_id ON user_audit_log (target_user_id, created_at DESC);
`
_, err := r.client.Pool.Exec(ctx, ddl)
return err
}
// Create inserts a new user.
func (r *UserRepository) Create(ctx context.Context, user *domain.User) (*domain.User, error) {
id := uuid.NewString()
now := time.Now().UTC()
const q = `
INSERT INTO users (
id, zitadel_user_id, email, email_verified, display_name,
consent_version, consent_given_at, consent_source,
created_at, updated_at
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
RETURNING id, zitadel_user_id, email, email_verified, display_name,
consent_version, consent_given_at, consent_source,
created_at, updated_at, deleted_at`
row := r.client.Pool.QueryRow(ctx, q,
id,
nullIfEmpty(user.ZitadelUserID),
user.Email,
user.EmailVerified,
user.DisplayName,
user.ConsentVersion,
user.ConsentGivenAt,
user.ConsentSource,
now,
now,
)
created, err := scanUser(row)
if err != nil {
if isUniqueViolation(err) {
return nil, sharederrors.Wrap(sharederrors.CodeUserAlreadyExists, "user already exists", err)
}
return nil, err
}
return created, nil
}
// GetByID fetches a user by ID and excludes soft-deleted users.
func (r *UserRepository) GetByID(ctx context.Context, id string) (*domain.User, error) {
const q = `
SELECT id, zitadel_user_id, email, email_verified, display_name,
consent_version, consent_given_at, consent_source,
created_at, updated_at, deleted_at
FROM users
WHERE id=$1 AND deleted_at IS NULL`
row := r.client.Pool.QueryRow(ctx, q, id)
u, err := scanUser(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", err)
}
return nil, err
}
return u, nil
}
// GetByEmail fetches a non-deleted user by email.
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
const q = `
SELECT id, zitadel_user_id, email, email_verified, display_name,
consent_version, consent_given_at, consent_source,
created_at, updated_at, deleted_at
FROM users
WHERE email=$1 AND deleted_at IS NULL`
row := r.client.Pool.QueryRow(ctx, q, strings.ToLower(email))
u, err := scanUser(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", err)
}
return nil, err
}
return u, nil
}
// GetByZitadelUserID fetches a non-deleted user by identity id.
func (r *UserRepository) GetByZitadelUserID(ctx context.Context, zitadelUserID string) (*domain.User, error) {
const q = `
SELECT id, zitadel_user_id, email, email_verified, display_name,
consent_version, consent_given_at, consent_source,
created_at, updated_at, deleted_at
FROM users
WHERE zitadel_user_id=$1 AND deleted_at IS NULL`
row := r.client.Pool.QueryRow(ctx, q, zitadelUserID)
u, err := scanUser(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", err)
}
return nil, err
}
return u, nil
}
// UpdateProfile updates mutable profile fields.
func (r *UserRepository) UpdateProfile(
ctx context.Context,
id string,
displayName string,
consent domain.ConsentRecord,
) (*domain.User, error) {
const q = `
UPDATE users
SET display_name=$2,
consent_version=$3,
consent_given_at=$4,
consent_source=$5,
updated_at=NOW()
WHERE id=$1 AND deleted_at IS NULL
RETURNING id, zitadel_user_id, email, email_verified, display_name,
consent_version, consent_given_at, consent_source,
created_at, updated_at, deleted_at`
row := r.client.Pool.QueryRow(ctx, q,
id,
displayName,
consent.Version,
consent.GivenAt,
consent.Source,
)
u, err := scanUser(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", err)
}
return nil, err
}
return u, nil
}
// MarkEmailVerified marks user's email as verified.
func (r *UserRepository) MarkEmailVerified(ctx context.Context, id string) (*domain.User, error) {
const q = `
UPDATE users
SET email_verified=true, updated_at=NOW()
WHERE id=$1 AND deleted_at IS NULL
RETURNING id, zitadel_user_id, email, email_verified, display_name,
consent_version, consent_given_at, consent_source,
created_at, updated_at, deleted_at`
row := r.client.Pool.QueryRow(ctx, q, id)
u, err := scanUser(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", err)
}
return nil, err
}
return u, nil
}
// SoftDelete marks user as deleted and writes an audit entry.
func (r *UserRepository) SoftDelete(ctx context.Context, id string, actorUserID string) error {
tx, err := r.client.Pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
const qDelete = `UPDATE users SET deleted_at=NOW(), updated_at=NOW() WHERE id=$1 AND deleted_at IS NULL`
res, err := tx.Exec(ctx, qDelete, id)
if err != nil {
return err
}
if res.RowsAffected() == 0 {
return sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", nil)
}
auditID := uuid.NewString()
const qAudit = `
INSERT INTO user_audit_log (id, actor_user_id, target_user_id, action, metadata_json, created_at)
VALUES ($1,$2,$3,$4,$5,NOW())`
_, err = tx.Exec(
ctx,
qAudit,
auditID,
nullIfEmpty(actorUserID),
id,
domain.AuditActionGDPRDelete,
`{"operation":"gdpr_delete"}`,
)
if err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return err
}
return nil
}
// List returns paginated users.
func (r *UserRepository) List(
ctx context.Context,
pagination sharedtypes.Pagination,
filter domain.ListFilter,
) ([]*domain.User, int64, error) {
clauses := make([]string, 0)
args := make([]interface{}, 0)
if !filter.IncludeDeleted {
clauses = append(clauses, "deleted_at IS NULL")
}
if filter.Email != "" {
args = append(args, "%"+strings.ToLower(filter.Email)+"%")
clauses = append(clauses, fmt.Sprintf("LOWER(email) LIKE $%d", len(args)))
}
if filter.DisplayName != "" {
args = append(args, "%"+strings.ToLower(filter.DisplayName)+"%")
clauses = append(clauses, fmt.Sprintf("LOWER(display_name) LIKE $%d", len(args)))
}
if filter.CreatedAfter != nil {
args = append(args, *filter.CreatedAfter)
clauses = append(clauses, fmt.Sprintf("created_at >= $%d", len(args)))
}
if filter.CreatedBefore != nil {
args = append(args, *filter.CreatedBefore)
clauses = append(clauses, fmt.Sprintf("created_at <= $%d", len(args)))
}
whereSQL := ""
if len(clauses) > 0 {
whereSQL = " WHERE " + strings.Join(clauses, " AND ")
}
countQuery := "SELECT COUNT(*) FROM users" + whereSQL
var total int64
if err := r.client.Pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
return nil, 0, err
}
limit := pagination.Limit()
offset := pagination.Offset()
args = append(args, limit, offset)
query := `
SELECT id, zitadel_user_id, email, email_verified, display_name,
consent_version, consent_given_at, consent_source,
created_at, updated_at, deleted_at
FROM users` + whereSQL + fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", len(args)-1, len(args))
rows, err := r.client.Pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := make([]*domain.User, 0)
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, 0, err
}
out = append(out, u)
}
return out, total, rows.Err()
}
// AuditLogsByUserID returns audit entries for a user.
func (r *UserRepository) AuditLogsByUserID(ctx context.Context, id string) ([]domain.AuditLogEntry, error) {
const q = `
SELECT id, actor_user_id, target_user_id, action, metadata_json::text, created_at
FROM user_audit_log
WHERE target_user_id=$1
ORDER BY created_at DESC`
rows, err := r.client.Pool.Query(ctx, q, id)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]domain.AuditLogEntry, 0)
for rows.Next() {
var entry domain.AuditLogEntry
var actor sql.NullString
if err := rows.Scan(
&entry.ID,
&actor,
&entry.TargetUserID,
&entry.Action,
&entry.MetadataJSON,
&entry.CreatedAt,
); err != nil {
return nil, err
}
if actor.Valid {
entry.ActorUserID = actor.String
}
out = append(out, entry)
}
return out, rows.Err()
}
// WriteAuditLog writes a custom audit entry.
func (r *UserRepository) WriteAuditLog(ctx context.Context, entry domain.AuditLogEntry) error {
if entry.ID == "" {
entry.ID = uuid.NewString()
}
if entry.Action == "" {
entry.Action = "unknown"
}
if entry.MetadataJSON == "" {
entry.MetadataJSON = "{}"
}
const q = `
INSERT INTO user_audit_log (id, actor_user_id, target_user_id, action, metadata_json, created_at)
VALUES ($1,$2,$3,$4,$5,NOW())`
_, err := r.client.Pool.Exec(ctx, q,
entry.ID,
nullIfEmpty(entry.ActorUserID),
entry.TargetUserID,
entry.Action,
entry.MetadataJSON,
)
return err
}
func nullIfEmpty(v string) interface{} {
if strings.TrimSpace(v) == "" {
return nil
}
return v
}
func scanUser(scanner interface {
Scan(dest ...interface{}) error
}) (*domain.User, error) {
var u domain.User
var zitadelUserID sql.NullString
var deletedAt *time.Time
if err := scanner.Scan(
&u.ID,
&zitadelUserID,
&u.Email,
&u.EmailVerified,
&u.DisplayName,
&u.ConsentVersion,
&u.ConsentGivenAt,
&u.ConsentSource,
&u.CreatedAt,
&u.UpdatedAt,
&deletedAt,
); err != nil {
return nil, err
}
if zitadelUserID.Valid {
u.ZitadelUserID = zitadelUserID.String
}
u.DeletedAt = deletedAt
return &u, nil
}
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "duplicate key")
}

@ -0,0 +1,315 @@
package http
import (
"errors"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v3"
appu "knowfoolery/backend/services/user-service/internal/application/user"
domain "knowfoolery/backend/services/user-service/internal/domain/user"
sharederrors "knowfoolery/backend/shared/domain/errors"
"knowfoolery/backend/shared/infra/auth/zitadel"
"knowfoolery/backend/shared/infra/observability/logging"
sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics"
"knowfoolery/backend/shared/infra/utils/httputil"
"knowfoolery/backend/shared/infra/utils/validation"
)
// Handler implements user service HTTP endpoint handlers.
type Handler struct {
service *appu.Service
validator *validation.Validator
logger *logging.Logger
metrics *sharedmetrics.Metrics
adminDefaultLimit int
adminMaxLimit int
}
// NewHandler creates a new HTTP handler set.
func NewHandler(
service *appu.Service,
validator *validation.Validator,
logger *logging.Logger,
metrics *sharedmetrics.Metrics,
adminDefaultLimit int,
adminMaxLimit int,
) *Handler {
if adminDefaultLimit <= 0 {
adminDefaultLimit = 50
}
if adminMaxLimit <= 0 {
adminMaxLimit = 200
}
return &Handler{
service: service,
validator: validator,
logger: logger,
metrics: metrics,
adminDefaultLimit: adminDefaultLimit,
adminMaxLimit: adminMaxLimit,
}
}
// RegisterUser handles POST /users/register.
func (h *Handler) RegisterUser(c fiber.Ctx) error {
var req RegisterUserRequest
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)
}
claims := authClaimsFromContext(c)
if claims.UserID == "" || claims.Email == "" {
return httputil.SendError(c, domain.ErrUnauthorized)
}
u, err := h.service.Register(c.Context(), appu.RegisterInput{
ActorZitadelUserID: claims.UserID,
ActorEmail: claims.Email,
DisplayName: req.DisplayName,
ConsentVersion: req.ConsentVersion,
ConsentSource: req.ConsentSource,
})
if err != nil {
return h.sendMappedError(c, err)
}
h.recordRequestMetric("POST", "/users/register", fiber.StatusOK)
return httputil.OK(c, toUserResponse(u))
}
// GetUser handles GET /users/:id.
func (h *Handler) GetUser(c fiber.Ctx) error {
id := c.Params("id")
claims := authClaimsFromContext(c)
if !isSelfOrAdmin(claims, id) {
return httputil.SendError(c, domain.ErrForbidden)
}
u, err := h.service.GetProfile(c.Context(), id)
if err != nil {
return h.sendMappedError(c, err)
}
h.recordRequestMetric("GET", "/users/{id}", fiber.StatusOK)
return httputil.OK(c, toUserResponse(u))
}
// UpdateUser handles PUT /users/:id.
func (h *Handler) UpdateUser(c fiber.Ctx) error {
id := c.Params("id")
claims := authClaimsFromContext(c)
if !isSelfOrAdmin(claims, id) {
return httputil.SendError(c, domain.ErrForbidden)
}
var req UpdateUserRequest
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)
}
u, err := h.service.UpdateProfile(c.Context(), id, appu.UpdateProfileInput{
DisplayName: req.DisplayName,
ConsentVersion: req.ConsentVersion,
ConsentSource: req.ConsentSource,
})
if err != nil {
return h.sendMappedError(c, err)
}
h.recordRequestMetric("PUT", "/users/{id}", fiber.StatusOK)
return httputil.OK(c, toUserResponse(u))
}
// DeleteUser handles DELETE /users/:id.
func (h *Handler) DeleteUser(c fiber.Ctx) error {
id := c.Params("id")
claims := authClaimsFromContext(c)
if !isSelfOrAdmin(claims, id) {
return httputil.SendError(c, domain.ErrForbidden)
}
if err := h.service.DeleteUser(c.Context(), id, claims.UserID); err != nil {
return h.sendMappedError(c, err)
}
h.recordRequestMetric("DELETE", "/users/{id}", fiber.StatusNoContent)
return httputil.NoContent(c)
}
// VerifyEmail handles POST /users/verify-email.
func (h *Handler) VerifyEmail(c fiber.Ctx) error {
claims := authClaimsFromContext(c)
if claims.UserID == "" {
return httputil.SendError(c, domain.ErrUnauthorized)
}
u, err := h.service.VerifyEmail(c.Context(), claims.UserID)
if err != nil {
return h.sendMappedError(c, err)
}
h.recordRequestMetric("POST", "/users/verify-email", fiber.StatusOK)
return httputil.OK(c, toUserResponse(u))
}
// AdminListUsers handles GET /admin/users.
func (h *Handler) AdminListUsers(c fiber.Ctx) error {
claims := authClaimsFromContext(c)
if !claims.IsAdmin {
return httputil.SendError(c, domain.ErrForbidden)
}
page := atoiWithDefault(c.Query("page"), 1)
pageSize := atoiWithDefault(c.Query("page_size"), h.adminDefaultLimit)
if pageSize > h.adminMaxLimit {
pageSize = h.adminMaxLimit
}
if pageSize < 1 {
pageSize = h.adminDefaultLimit
}
createdAfter, err := parseTimeQuery(c.Query("created_after"))
if err != nil {
return httputil.SendError(
c,
sharederrors.Wrap(
sharederrors.CodeValidationFailed,
"created_after must be RFC3339",
err,
),
)
}
createdBefore, err := parseTimeQuery(c.Query("created_before"))
if err != nil {
return httputil.SendError(
c,
sharederrors.Wrap(
sharederrors.CodeValidationFailed,
"created_before must be RFC3339",
err,
),
)
}
users, total, normalized, err := h.service.AdminListUsers(c.Context(), appu.ListUsersInput{
Page: page,
PageSize: pageSize,
Email: c.Query("email"),
DisplayName: c.Query("display_name"),
CreatedAfter: createdAfter,
CreatedBefore: createdBefore,
IncludeDeleted: strings.EqualFold(c.Query("include_deleted"), "true"),
})
if err != nil {
return h.sendMappedError(c, err)
}
resp := make([]UserResponse, 0, len(users))
for _, u := range users {
resp = append(resp, toUserResponse(u))
}
h.recordRequestMetric("GET", "/admin/users", fiber.StatusOK)
return httputil.Paginated(c, resp, normalized.Page, normalized.PageSize, total)
}
// AdminExportUser handles POST /admin/users/:id/export.
func (h *Handler) AdminExportUser(c fiber.Ctx) error {
claims := authClaimsFromContext(c)
if !claims.IsAdmin {
return httputil.SendError(c, domain.ErrForbidden)
}
bundle, err := h.service.AdminExportUser(c.Context(), c.Params("id"), claims.UserID)
if err != nil {
return h.sendMappedError(c, err)
}
h.recordRequestMetric("POST", "/admin/users/{id}/export", fiber.StatusOK)
return httputil.OK(c, bundle)
}
func (h *Handler) sendMappedError(c fiber.Ctx, err error) error {
var domainErr *sharederrors.DomainError
if errors.As(err, &domainErr) {
return httputil.SendError(c, domainErr)
}
if h.logger != nil {
h.logger.WithError(err).Error("user-service internal error")
}
return httputil.SendError(c, sharederrors.Wrap(sharederrors.CodeInternal, "internal error", err))
}
func (h *Handler) recordRequestMetric(method, endpoint string, status int) {
if h.metrics == nil {
return
}
h.metrics.HTTPRequestsTotal.WithLabelValues(method, endpoint, strconv.Itoa(status), "user-service").Inc()
}
type authClaims struct {
UserID string
Email string
Roles []string
EmailVerified bool
IsAdmin bool
}
func authClaimsFromContext(c fiber.Ctx) authClaims {
roles := zitadel.GetUserRoles(c)
claims := authClaims{
UserID: zitadel.GetUserID(c),
Email: zitadel.GetUserEmail(c),
Roles: roles,
EmailVerified: claimBool(c.Locals("email_verified")),
}
for _, role := range roles {
if role == "admin" {
claims.IsAdmin = true
break
}
}
return claims
}
func isSelfOrAdmin(claims authClaims, userID string) bool {
if claims.UserID == "" {
return false
}
if claims.IsAdmin {
return true
}
return claims.UserID == userID
}
func claimBool(v interface{}) bool {
if b, ok := v.(bool); ok {
return b
}
return false
}
func atoiWithDefault(v string, d int) int {
if strings.TrimSpace(v) == "" {
return d
}
n, err := strconv.Atoi(v)
if err != nil {
return d
}
return n
}
func parseTimeQuery(v string) (*time.Time, error) {
if strings.TrimSpace(v) == "" {
return nil, nil
}
t, err := time.Parse(time.RFC3339, v)
if err != nil {
return nil, err
}
return &t, nil
}

@ -0,0 +1,20 @@
package http
// RegisterUserRequest is the POST /users/register payload.
type RegisterUserRequest struct {
DisplayName string `json:"display_name" validate:"required,player_name"`
ConsentVersion string `json:"consent_version" validate:"required,min=1,max=32"`
ConsentSource string `json:"consent_source" validate:"omitempty,max=32"`
}
// UpdateUserRequest is the PUT /users/:id payload.
type UpdateUserRequest struct {
DisplayName string `json:"display_name" validate:"required,player_name"`
ConsentVersion string `json:"consent_version" validate:"required,min=1,max=32"`
ConsentSource string `json:"consent_source" validate:"omitempty,max=32"`
}
// VerifyEmailRequest allows request-time verification hints.
type VerifyEmailRequest struct {
Verified bool `json:"verified"`
}

@ -0,0 +1,36 @@
package http
import (
"time"
domain "knowfoolery/backend/services/user-service/internal/domain/user"
)
// UserResponse is API response payload for user data.
type UserResponse struct {
ID string `json:"id"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
DisplayName string `json:"display_name"`
ConsentVersion string `json:"consent_version"`
ConsentGivenAt time.Time `json:"consent_given_at"`
ConsentSource string `json:"consent_source"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
func toUserResponse(u *domain.User) UserResponse {
return UserResponse{
ID: u.ID,
Email: u.Email,
EmailVerified: u.EmailVerified,
DisplayName: u.DisplayName,
ConsentVersion: u.ConsentVersion,
ConsentGivenAt: u.ConsentGivenAt,
ConsentSource: u.ConsentSource,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
DeletedAt: u.DeletedAt,
}
}

@ -0,0 +1,27 @@
package http
import "github.com/gofiber/fiber/v3"
// RegisterRoutes registers user service routes.
func RegisterRoutes(app *fiber.App, h *Handler, authMiddleware fiber.Handler, adminMiddleware fiber.Handler) {
users := app.Group("/users")
if authMiddleware != nil {
users.Use(authMiddleware)
}
users.Post("/register", h.RegisterUser)
users.Get("/:id", h.GetUser)
users.Put("/:id", h.UpdateUser)
users.Delete("/:id", h.DeleteUser)
users.Post("/verify-email", h.VerifyEmail)
admin := app.Group("/admin")
if authMiddleware != nil {
admin.Use(authMiddleware)
}
if adminMiddleware != nil {
admin.Use(adminMiddleware)
}
admin.Get("/users", h.AdminListUsers)
admin.Post("/users/:id/export", h.AdminExportUser)
}

@ -0,0 +1,321 @@
package tests
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"
appu "knowfoolery/backend/services/user-service/internal/application/user"
domain "knowfoolery/backend/services/user-service/internal/domain/user"
httpapi "knowfoolery/backend/services/user-service/internal/interfaces/http"
sharederrors "knowfoolery/backend/shared/domain/errors"
sharedtypes "knowfoolery/backend/shared/domain/types"
sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics"
"knowfoolery/backend/shared/infra/utils/validation"
)
// inMemoryRepo is a lightweight repository double for HTTP integration tests.
type inMemoryRepo struct {
items map[string]*domain.User
audit []domain.AuditLogEntry
}
// newInMemoryRepo creates an empty in-memory repository.
func newInMemoryRepo() *inMemoryRepo {
return &inMemoryRepo{items: map[string]*domain.User{}, audit: make([]domain.AuditLogEntry, 0)}
}
// EnsureSchema is a no-op for the in-memory repository.
func (r *inMemoryRepo) EnsureSchema(ctx context.Context) error { return nil }
// Create inserts a user in memory and fills missing identity/timestamps.
func (r *inMemoryRepo) Create(ctx context.Context, user *domain.User) (*domain.User, error) {
if user.ID == "" {
user.ID = "user-" + strconv.Itoa(len(r.items)+1)
}
now := time.Now().UTC()
if user.CreatedAt.IsZero() {
user.CreatedAt = now
}
user.UpdatedAt = now
r.items[user.ID] = user
return user, nil
}
// GetByID returns a non-deleted user by ID.
func (r *inMemoryRepo) GetByID(ctx context.Context, id string) (*domain.User, error) {
if u, ok := r.items[id]; ok && !u.IsDeleted() {
return u, nil
}
return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", nil)
}
// GetByEmail returns a non-deleted user matching the provided email.
func (r *inMemoryRepo) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
for _, u := range r.items {
if u.Email == email && !u.IsDeleted() {
return u, nil
}
}
return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", nil)
}
// GetByZitadelUserID returns a non-deleted user by identity subject.
func (r *inMemoryRepo) GetByZitadelUserID(ctx context.Context, zid string) (*domain.User, error) {
for _, u := range r.items {
if u.ZitadelUserID == zid && !u.IsDeleted() {
return u, nil
}
}
return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", nil)
}
// UpdateProfile updates mutable profile fields for the selected user.
func (r *inMemoryRepo) UpdateProfile(
ctx context.Context,
id, displayName string,
consent domain.ConsentRecord,
) (*domain.User, error) {
u, err := r.GetByID(ctx, id)
if err != nil {
return nil, err
}
u.DisplayName = displayName
u.ConsentVersion = consent.Version
u.ConsentGivenAt = consent.GivenAt
u.ConsentSource = consent.Source
u.UpdatedAt = time.Now().UTC()
return u, nil
}
// MarkEmailVerified sets email verification on a stored user.
func (r *inMemoryRepo) MarkEmailVerified(ctx context.Context, id string) (*domain.User, error) {
u, err := r.GetByID(ctx, id)
if err != nil {
return nil, err
}
u.EmailVerified = true
u.UpdatedAt = time.Now().UTC()
return u, nil
}
// SoftDelete marks a user as deleted and records a delete audit log.
func (r *inMemoryRepo) SoftDelete(ctx context.Context, id string, actorUserID string) error {
u, err := r.GetByID(ctx, id)
if err != nil {
return err
}
now := time.Now().UTC()
u.DeletedAt = &now
r.audit = append(
r.audit,
domain.AuditLogEntry{
TargetUserID: id,
ActorUserID: actorUserID,
Action: domain.AuditActionGDPRDelete,
CreatedAt: now,
},
)
return nil
}
// List returns users and applies the include-deleted filter.
func (r *inMemoryRepo) List(
ctx context.Context,
pagination sharedtypes.Pagination,
filter domain.ListFilter,
) ([]*domain.User, int64, error) {
out := make([]*domain.User, 0)
for _, u := range r.items {
if !filter.IncludeDeleted && u.IsDeleted() {
continue
}
out = append(out, u)
}
return out, int64(len(out)), nil
}
// AuditLogsByUserID returns audit events for a target user.
func (r *inMemoryRepo) AuditLogsByUserID(ctx context.Context, id string) ([]domain.AuditLogEntry, error) {
out := make([]domain.AuditLogEntry, 0)
for _, a := range r.audit {
if a.TargetUserID == id {
out = append(out, a)
}
}
return out, nil
}
// WriteAuditLog appends an audit event with a synthetic timestamp.
func (r *inMemoryRepo) WriteAuditLog(ctx context.Context, entry domain.AuditLogEntry) error {
entry.CreatedAt = time.Now().UTC()
r.audit = append(r.audit, entry)
return nil
}
// setupApp wires a Fiber test app with auth middleware and in-memory dependencies.
func setupApp(t *testing.T) (*fiber.App, *inMemoryRepo) {
t.Helper()
repo := newInMemoryRepo()
_, _ = repo.Create(context.Background(), &domain.User{
ID: "user-1",
ZitadelUserID: "user-1",
Email: "player@example.com",
DisplayName: "Player One",
ConsentVersion: "v1",
ConsentGivenAt: time.Now().UTC(),
ConsentSource: "web",
})
_, _ = repo.Create(context.Background(), &domain.User{
ID: "user-2",
ZitadelUserID: "user-2",
Email: "other@example.com",
DisplayName: "Player Two",
ConsentVersion: "v1",
ConsentGivenAt: time.Now().UTC(),
ConsentSource: "web",
})
svc := appu.NewService(repo)
metrics := sharedmetrics.NewMetrics(sharedmetrics.Config{
ServiceName: "user-service-test",
Enabled: true,
Registry: prometheus.NewRegistry(),
})
h := httpapi.NewHandler(svc, validation.NewValidator(), nil, metrics, 50, 200)
app := fiber.New()
authMW := func(c fiber.Ctx) error {
auth := c.Get("Authorization")
switch auth {
case "Bearer user-1":
c.Locals("user_id", "user-1")
c.Locals("user_email", "player@example.com")
c.Locals("user_roles", []string{"player"})
return c.Next()
case "Bearer admin":
c.Locals("user_id", "admin-1")
c.Locals("user_email", "admin@example.com")
c.Locals("user_roles", []string{"admin"})
return c.Next()
default:
return c.SendStatus(http.StatusUnauthorized)
}
}
httpapi.RegisterRoutes(app, h, authMW, nil)
app.Get("/metrics", adaptor.HTTPHandler(sharedmetrics.Handler()))
return app, repo
}
// TestRegisterAndCRUD verifies register, read, update, and delete HTTP flows.
func TestRegisterAndCRUD(t *testing.T) {
app, _ := setupApp(t)
payload, _ := json.Marshal(map[string]any{
"display_name": "Player One",
"consent_version": "v1",
"consent_source": "web",
})
req := httptest.NewRequest(http.MethodPost, "/users/register", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer user-1")
resp := mustTest(t, app, req)
assertStatusAndClose(t, resp, http.StatusOK, "register failed")
defer resp.Body.Close()
req = httptest.NewRequest(http.MethodGet, "/users/user-1", nil)
req.Header.Set("Authorization", "Bearer user-1")
resp = mustTest(t, app, req)
assertStatusAndClose(t, resp, http.StatusOK, "get failed")
defer resp.Body.Close()
updatePayload, _ := json.Marshal(map[string]any{
"display_name": "Player One Updated",
"consent_version": "v2",
"consent_source": "web",
})
req = httptest.NewRequest(http.MethodPut, "/users/user-1", bytes.NewReader(updatePayload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer user-1")
resp = mustTest(t, app, req)
assertStatusAndClose(t, resp, http.StatusOK, "update failed")
defer resp.Body.Close()
req = httptest.NewRequest(http.MethodDelete, "/users/user-1", nil)
req.Header.Set("Authorization", "Bearer user-1")
resp = mustTest(t, app, req)
assertStatusAndClose(t, resp, http.StatusNoContent, "delete failed")
defer resp.Body.Close()
}
// TestAuthGuardsAndAdminRoutes verifies auth guards and admin-only route access.
func TestAuthGuardsAndAdminRoutes(t *testing.T) {
app, _ := setupApp(t)
req := httptest.NewRequest(http.MethodGet, "/users/user-1", nil)
resp := mustTest(t, app, req)
assertStatusAndClose(t, resp, http.StatusUnauthorized, "expected unauthorized")
defer resp.Body.Close()
req = httptest.NewRequest(http.MethodGet, "/users/user-2", nil)
req.Header.Set("Authorization", "Bearer user-1")
resp = mustTest(t, app, req)
assertStatusAndClose(t, resp, http.StatusForbidden, "expected forbidden")
defer resp.Body.Close()
req = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
req.Header.Set("Authorization", "Bearer user-1")
resp = mustTest(t, app, req)
assertStatusAndClose(t, resp, http.StatusForbidden, "expected admin forbidden")
defer resp.Body.Close()
req = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
req.Header.Set("Authorization", "Bearer admin")
resp = mustTest(t, app, req)
assertStatusAndClose(t, resp, http.StatusOK, "expected admin list ok")
defer resp.Body.Close()
req = httptest.NewRequest(http.MethodPost, "/admin/users/user-2/export", nil)
req.Header.Set("Authorization", "Bearer admin")
resp = mustTest(t, app, req)
assertStatusAndClose(t, resp, http.StatusOK, "expected export ok")
defer resp.Body.Close()
}
// TestMetricsEndpoint verifies the Prometheus metrics endpoint is exposed.
func TestMetricsEndpoint(t *testing.T) {
app, _ := setupApp(t)
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
resp := mustTest(t, app, req)
assertStatusAndClose(t, resp, http.StatusOK, "metrics failed")
defer resp.Body.Close()
}
// mustTest executes a test request and fails immediately on transport errors.
func mustTest(t *testing.T, app *fiber.App, req *http.Request) *http.Response {
t.Helper()
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test failed: %v", err)
}
return resp
}
// assertStatusAndClose checks the HTTP status and always closes the response body.
func assertStatusAndClose(t *testing.T, resp *http.Response, want int, msg string) {
t.Helper()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != want {
t.Fatalf("%s: status=%d want=%d", msg, resp.StatusCode, want)
}
}

@ -0,0 +1,247 @@
# 2.2 User Service (Port 8082) - Detailed Implementation Plan
## Summary
Implement the User Service as the source of truth for player profile and compliance data (registration, profile updates, email verification status, GDPR export/deletion), aligned with the implementation decisions already used in `backend/services/question-bank-service`.
This service will run on Fiber, persist in PostgreSQL, expose health/readiness/metrics, and use shared packages for auth, validation, error mapping, logging, tracing, and service bootstrapping.
## Decisions Reused from 2.1 Question Bank Service
1. Use the same service composition style as `question-bank-service/cmd/main.go`:
- `internal/infra/config.FromEnv()` for service-specific env parsing.
- Shared logger/metrics/tracer initialization from `backend/shared`.
- Repository initialization + `EnsureSchema(ctx)` at startup.
- `/health`, `/ready`, and `/metrics` endpoints.
- Route registration through `internal/interfaces/http/RegisterRoutes`.
2. Use the same auth approach:
- Public routes can remain open when Zitadel env is missing (local development fallback).
- Admin routes use Zitadel JWT middleware when configured.
3. Use the same persistence style:
- PostgreSQL with `pgxpool` and SQL DDL in repository `EnsureSchema`, matching current pragmatic pattern.
4. Follow the same testing pyramid:
- Domain/application unit tests.
- HTTP integration tests with in-memory doubles.
- Optional DB-backed integration tests gated by environment.
## Objectives
1. Provide player registration and profile management APIs.
2. Keep local profile data synchronized with authenticated identity claims from Zitadel.
3. Support GDPR rights: data export and account deletion.
4. Enforce RBAC on admin endpoints and ownership checks on user-scoped endpoints.
5. Deliver production-ready observability and baseline test coverage.
## API Endpoints
- `POST /users/register`
- `GET /users/:id`
- `PUT /users/:id`
- `DELETE /users/:id`
- `POST /users/verify-email`
- `GET /admin/users`
- `POST /admin/users/:id/export`
## Domain Model
Aggregate:
- `User`
Value objects:
- `Email` (normalized lowercase, RFC-like format validation)
- `DisplayName` (2-50 chars, allowed chars policy aligned with project guidelines)
- `ConsentRecord` (terms/privacy/version + timestamp + source)
Repository contract (`UserRepository`):
- `Create(ctx, user)`
- `GetByID(ctx, id)`
- `GetByEmail(ctx, email)`
- `UpdateProfile(ctx, id, patch)`
- `MarkEmailVerified(ctx, id, verifiedAt)`
- `SoftDelete(ctx, id, deletedAt)`
- `List(ctx, pagination, filters)`
- `ExportBundle(ctx, id)`
## Data Model (PostgreSQL)
Primary table: `users`
- `id UUID PRIMARY KEY`
- `zitadel_user_id VARCHAR(128) UNIQUE NULL`
- `email VARCHAR(320) UNIQUE NOT NULL`
- `email_verified BOOLEAN NOT NULL DEFAULT FALSE`
- `display_name VARCHAR(50) NOT NULL`
- `consent_version VARCHAR(32) NOT NULL`
- `consent_given_at TIMESTAMPTZ NOT NULL`
- `consent_source VARCHAR(32) NOT NULL DEFAULT 'web'`
- `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
- `updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
- `deleted_at TIMESTAMPTZ NULL`
Indexes:
- `UNIQUE(email)`
- `UNIQUE(zitadel_user_id) WHERE zitadel_user_id IS NOT NULL`
- `INDEX(deleted_at)`
- `INDEX(created_at DESC)`
Audit table: `user_audit_log`
- Track admin exports and delete actions for compliance/auditability.
- Fields: `id`, `actor_user_id`, `target_user_id`, `action`, `metadata_json`, `created_at`.
## Authorization and Identity Rules
1. `GET/PUT/DELETE /users/:id`:
- Allowed for the same authenticated user (`sub` mapped to local user) or `admin` role.
2. `POST /users/register`:
- Requires authenticated token.
- If user already exists by `zitadel_user_id` or email, return idempotent success (`200`) with existing profile.
3. `POST /users/verify-email`:
- Requires authenticated token.
- Resolve email verification from trusted source (token claim and/or Zitadel userinfo).
- Never accept arbitrary email-verified flag from client body.
4. Admin routes (`/admin/*`):
- Require admin role + MFA via shared Zitadel middleware behavior.
## Endpoint Behavior
1. `POST /users/register`
- Input: `display_name`, `consent_version`, `consent_source`.
- Identity fields (`email`, `zitadel_user_id`) come from token / Zitadel client, not request body.
- Output: canonical user profile.
2. `GET /users/:id`
- Return sanitized profile (no internal audit metadata).
- `404` for missing or soft-deleted user.
3. `PUT /users/:id`
- Allow updates to `display_name` and consent refresh fields.
- Reject immutable field updates (`email`, `id`, `zitadel_user_id`).
4. `DELETE /users/:id` (GDPR)
- Soft-delete local profile.
- Trigger best-effort anonymization policy for user-facing displays.
- Write compliance audit event.
5. `POST /users/verify-email`
- Refresh verification status from Zitadel and persist local `email_verified=true` with timestamp/audit event.
6. `GET /admin/users`
- Paginated listing with filters: `email`, `display_name`, `created_after`, `created_before`, `include_deleted`.
7. `POST /admin/users/:id/export`
- Produce structured JSON export bundle (profile + consents + audit entries).
- Return payload inline for now (later storage handoff can be added).
## Package and File Layout
Target structure (mirrors 2.1 service organization):
- `backend/services/user-service/cmd/main.go`
- `backend/services/user-service/internal/infra/config/config.go`
- `backend/services/user-service/internal/domain/user/`
- `backend/services/user-service/internal/application/user/`
- `backend/services/user-service/internal/infra/persistence/ent/`
- `backend/services/user-service/internal/interfaces/http/`
- `backend/services/user-service/tests/`
## Implementation Work Breakdown
### Workstream A - Bootstrap and Configuration
1. Add `internal/infra/config/config.go` with:
- `USER_SERVICE_PORT` (default `8082`)
- `USER_ADMIN_LIST_DEFAULT_LIMIT` (default `50`)
- `USER_ADMIN_LIST_MAX_LIMIT` (default `200`)
- shared `POSTGRES_*`, `TRACING_*`, `METRICS_*`, `LOG_LEVEL`, `ZITADEL_*`
2. Update `cmd/main.go` to match question-bank startup pattern:
- logger/metrics/tracer creation
- postgres client + schema ensure
- readiness check endpoint
- metrics endpoint
- route registration with optional admin middleware
### Workstream B - Domain and Application Layer
1. Define domain entity, value objects, domain errors.
2. Define application DTOs for request/response mapping.
3. Implement application service methods:
- `Register`
- `GetProfile`
- `UpdateProfile`
- `DeleteUser`
- `VerifyEmail`
- `AdminListUsers`
- `AdminExportUser`
4. Reuse shared error codes and transport error mapping style from question-bank handlers.
### Workstream C - Persistence Layer
1. Implement `Client` and `UserRepository` under `internal/infra/persistence/ent`.
2. Implement `EnsureSchema(ctx)` for `users` and `user_audit_log` tables/indexes.
3. Add SQL queries for pagination/filtering and soft-delete aware reads.
4. Add mappers between SQL rows and domain entity.
### Workstream D - HTTP Interface
1. Add request/response models with validation tags.
2. Implement handler methods with consistent JSON envelope and status codes.
3. Register routes in `routes.go` with admin group and middleware support.
4. Enforce ownership checks using user id from auth context.
### Workstream E - Compliance and Audit
1. Implement export builder (deterministic JSON schema).
2. Implement delete flow with audit logging and redaction policy hooks.
3. Ensure admin export/delete actions are auditable.
### Workstream F - Testing
1. Unit tests:
- email/display-name/consent validation
- idempotent registration logic
- ownership and admin authorization guard behavior
2. HTTP integration tests (in-memory repo + test middleware):
- register/get/update/delete happy paths
- unauthorized/forbidden cases
- admin list/export auth behavior
- `/metrics` availability
3. Repository integration tests (optional env-gated):
- schema creation
- unique constraints
- pagination/filter correctness
## Error Handling Contract
Use project shared domain errors and consistent mappings:
- `400`: validation failures
- `401`: missing/invalid token
- `403`: non-owner/non-admin access
- `404`: user not found
- `409`: duplicate email or identity conflict
- `500`: unexpected errors
## Observability
1. Structured logs:
- include `service=user-service`, request id, actor id (when available), and route.
- never log raw tokens or PII-heavy payloads.
2. Metrics:
- request count/latency/status via shared HTTP metrics.
- dedicated counters for `user_registration_total`, `user_deletion_total`, `gdpr_export_total`.
3. Tracing:
- ensure startup/shutdown tracer lifecycle identical to question-bank.
- instrument service/repository boundaries for registration/export/delete paths.
## Security and Privacy Controls
1. Input validation and sanitization on all mutable fields.
2. No trust of client-supplied identity attributes when token context exists.
3. Soft-delete as default deletion strategy; avoid hard delete in initial implementation.
4. Minimize data in responses and logs (data minimization principle).
5. Audit every admin export and delete action.
## Delivery Sequence (3-4 Days)
1. Day 1: bootstrap/config + domain/application skeleton + schema DDL.
2. Day 2: repository + register/get/update endpoints + auth/ownership checks.
3. Day 3: delete/verify-email/admin list/export + observability wiring.
4. Day 4: tests, bug fixes, and readiness verification.
## Verification Commands
From `backend/services/user-service`:
```bash
go test ./...
go vet ./...
```
From `backend` (optional full workspace check):
```bash
go test ./...
```
## Definition of Done
1. All endpoints implemented and route-protected per spec.
2. PostgreSQL schema auto-created on startup and validated in tests.
3. Health, readiness, and metrics endpoints functional.
4. AuthN/AuthZ and ownership checks covered by tests.
5. GDPR export/delete flows implemented with audit records.
6. `go test ./...` and `go vet ./...` pass for `user-service`.
Loading…
Cancel
Save