From 4797cf8c42aae1ff9d8bff775908b292b6589236 Mon Sep 17 00:00:00 2001 From: oabrivard Date: Sun, 8 Feb 2026 21:49:33 +0100 Subject: [PATCH] Finished step '2.2 User Service (Port 8082)' --- backend/services/admin-service/go.mod | 2 +- backend/services/admin-service/go.sum | 4 +- backend/services/game-session-service/go.mod | 2 +- backend/services/game-session-service/go.sum | 4 +- backend/services/gateway-service/go.mod | 2 +- backend/services/gateway-service/go.sum | 4 +- backend/services/leaderboard-service/go.mod | 2 +- backend/services/leaderboard-service/go.sum | 4 +- backend/services/question-bank-service/go.mod | 2 +- backend/services/user-service/cmd/main.go | 113 ++++- backend/services/user-service/go.mod | 51 ++- backend/services/user-service/go.sum | 52 ++- .../internal/application/user/dto.go | 40 ++ .../internal/application/user/service.go | 229 ++++++++++ .../internal/application/user/service_test.go | 206 +++++++++ .../internal/domain/user/entity.go | 49 ++ .../internal/domain/user/errors.go | 16 + .../internal/domain/user/repository.go | 32 ++ .../internal/infra/config/config.go | 75 ++++ .../internal/infra/persistence/ent/client.go | 57 +++ .../infra/persistence/ent/user_repo.go | 425 ++++++++++++++++++ .../internal/interfaces/http/handler.go | 315 +++++++++++++ .../internal/interfaces/http/request.go | 20 + .../internal/interfaces/http/response.go | 36 ++ .../internal/interfaces/http/routes.go | 27 ++ .../tests/integration_http_test.go | 321 +++++++++++++ docs/4_work_plan/2.2-user-service.md | 247 ++++++++++ 27 files changed, 2312 insertions(+), 25 deletions(-) create mode 100644 backend/services/user-service/internal/application/user/dto.go create mode 100644 backend/services/user-service/internal/application/user/service.go create mode 100644 backend/services/user-service/internal/application/user/service_test.go create mode 100644 backend/services/user-service/internal/domain/user/entity.go create mode 100644 backend/services/user-service/internal/domain/user/errors.go create mode 100644 backend/services/user-service/internal/domain/user/repository.go create mode 100644 backend/services/user-service/internal/infra/config/config.go create mode 100644 backend/services/user-service/internal/infra/persistence/ent/client.go create mode 100644 backend/services/user-service/internal/infra/persistence/ent/user_repo.go create mode 100644 backend/services/user-service/internal/interfaces/http/handler.go create mode 100644 backend/services/user-service/internal/interfaces/http/request.go create mode 100644 backend/services/user-service/internal/interfaces/http/response.go create mode 100644 backend/services/user-service/internal/interfaces/http/routes.go create mode 100644 backend/services/user-service/tests/integration_http_test.go create mode 100644 docs/4_work_plan/2.2-user-service.md diff --git a/backend/services/admin-service/go.mod b/backend/services/admin-service/go.mod index d09080f..9c8fc86 100644 --- a/backend/services/admin-service/go.mod +++ b/backend/services/admin-service/go.mod @@ -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 diff --git a/backend/services/admin-service/go.sum b/backend/services/admin-service/go.sum index d6f473f..5763b68 100644 --- a/backend/services/admin-service/go.sum +++ b/backend/services/admin-service/go.sum @@ -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= diff --git a/backend/services/game-session-service/go.mod b/backend/services/game-session-service/go.mod index 8e4a53f..8f16233 100644 --- a/backend/services/game-session-service/go.mod +++ b/backend/services/game-session-service/go.mod @@ -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 diff --git a/backend/services/game-session-service/go.sum b/backend/services/game-session-service/go.sum index a3d8fb8..97bf2d6 100644 --- a/backend/services/game-session-service/go.sum +++ b/backend/services/game-session-service/go.sum @@ -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= diff --git a/backend/services/gateway-service/go.mod b/backend/services/gateway-service/go.mod index d6ad19d..57a8da7 100644 --- a/backend/services/gateway-service/go.mod +++ b/backend/services/gateway-service/go.mod @@ -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 diff --git a/backend/services/gateway-service/go.sum b/backend/services/gateway-service/go.sum index a3d8fb8..97bf2d6 100644 --- a/backend/services/gateway-service/go.sum +++ b/backend/services/gateway-service/go.sum @@ -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= diff --git a/backend/services/leaderboard-service/go.mod b/backend/services/leaderboard-service/go.mod index 50de001..7fa8a80 100644 --- a/backend/services/leaderboard-service/go.mod +++ b/backend/services/leaderboard-service/go.mod @@ -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 diff --git a/backend/services/leaderboard-service/go.sum b/backend/services/leaderboard-service/go.sum index a3d8fb8..97bf2d6 100644 --- a/backend/services/leaderboard-service/go.sum +++ b/backend/services/leaderboard-service/go.sum @@ -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= diff --git a/backend/services/question-bank-service/go.mod b/backend/services/question-bank-service/go.mod index 25a323e..47d4ef1 100644 --- a/backend/services/question-bank-service/go.mod +++ b/backend/services/question-bank-service/go.mod @@ -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 diff --git a/backend/services/user-service/cmd/main.go b/backend/services/user-service/cmd/main.go index 0f36ac5..dd7bb8f 100644 --- a/backend/services/user-service/cmd/main.go +++ b/backend/services/user-service/cmd/main.go @@ -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, + }) + }) +} diff --git a/backend/services/user-service/go.mod b/backend/services/user-service/go.mod index 547e442..c940c23 100644 --- a/backend/services/user-service/go.mod +++ b/backend/services/user-service/go.mod @@ -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 diff --git a/backend/services/user-service/go.sum b/backend/services/user-service/go.sum index a3d8fb8..f30a52c 100644 --- a/backend/services/user-service/go.sum +++ b/backend/services/user-service/go.sum @@ -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= diff --git a/backend/services/user-service/internal/application/user/dto.go b/backend/services/user-service/internal/application/user/dto.go new file mode 100644 index 0000000..3e9b2a6 --- /dev/null +++ b/backend/services/user-service/internal/application/user/dto.go @@ -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"` +} diff --git a/backend/services/user-service/internal/application/user/service.go b/backend/services/user-service/internal/application/user/service.go new file mode 100644 index 0000000..af9076c --- /dev/null +++ b/backend/services/user-service/internal/application/user/service.go @@ -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 +} diff --git a/backend/services/user-service/internal/application/user/service_test.go b/backend/services/user-service/internal/application/user/service_test.go new file mode 100644 index 0000000..6249fce --- /dev/null +++ b/backend/services/user-service/internal/application/user/service_test.go @@ -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") + } +} diff --git a/backend/services/user-service/internal/domain/user/entity.go b/backend/services/user-service/internal/domain/user/entity.go new file mode 100644 index 0000000..600fe83 --- /dev/null +++ b/backend/services/user-service/internal/domain/user/entity.go @@ -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" +) diff --git a/backend/services/user-service/internal/domain/user/errors.go b/backend/services/user-service/internal/domain/user/errors.go new file mode 100644 index 0000000..d589593 --- /dev/null +++ b/backend/services/user-service/internal/domain/user/errors.go @@ -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") +) diff --git a/backend/services/user-service/internal/domain/user/repository.go b/backend/services/user-service/internal/domain/user/repository.go new file mode 100644 index 0000000..6149302 --- /dev/null +++ b/backend/services/user-service/internal/domain/user/repository.go @@ -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 +} diff --git a/backend/services/user-service/internal/infra/config/config.go b/backend/services/user-service/internal/infra/config/config.go new file mode 100644 index 0000000..1bd1518 --- /dev/null +++ b/backend/services/user-service/internal/infra/config/config.go @@ -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", ""), + } +} diff --git a/backend/services/user-service/internal/infra/persistence/ent/client.go b/backend/services/user-service/internal/infra/persistence/ent/client.go new file mode 100644 index 0000000..4fbfabf --- /dev/null +++ b/backend/services/user-service/internal/infra/persistence/ent/client.go @@ -0,0 +1,57 @@ +package ent + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + + sharedpostgres "knowfoolery/backend/shared/infra/database/postgres" +) + +// Client wraps the pgx pool used by repository implementations. +type Client struct { + Pool *pgxpool.Pool +} + +// NewClient creates a persistence client and verifies connectivity. +func NewClient(ctx context.Context, cfg sharedpostgres.Config) (*Client, error) { + poolCfg, err := pgxpool.ParseConfig(cfg.URL()) + if err != nil { + return nil, fmt.Errorf("parse postgres config: %w", err) + } + poolCfg.MaxConns = clampIntToInt32(cfg.MaxOpenConns) + poolCfg.MinConns = clampIntToInt32(cfg.MaxIdleConns) + poolCfg.MaxConnLifetime = cfg.ConnMaxLifetime + poolCfg.MaxConnIdleTime = cfg.ConnMaxIdleTime + + pool, err := pgxpool.NewWithConfig(ctx, poolCfg) + if err != nil { + return nil, fmt.Errorf("create postgres pool: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping postgres: %w", err) + } + + return &Client{Pool: pool}, nil +} + +// Close closes the underlying pool. +func (c *Client) Close() { + if c != nil && c.Pool != nil { + c.Pool.Close() + } +} + +func clampIntToInt32(v int) int32 { + const maxInt32 = int(^uint32(0) >> 1) + if v <= 0 { + return 0 + } + if v > maxInt32 { + return int32(maxInt32) + } + return int32(v) +} diff --git a/backend/services/user-service/internal/infra/persistence/ent/user_repo.go b/backend/services/user-service/internal/infra/persistence/ent/user_repo.go new file mode 100644 index 0000000..1f0c5de --- /dev/null +++ b/backend/services/user-service/internal/infra/persistence/ent/user_repo.go @@ -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") +} diff --git a/backend/services/user-service/internal/interfaces/http/handler.go b/backend/services/user-service/internal/interfaces/http/handler.go new file mode 100644 index 0000000..3dec3b3 --- /dev/null +++ b/backend/services/user-service/internal/interfaces/http/handler.go @@ -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 +} diff --git a/backend/services/user-service/internal/interfaces/http/request.go b/backend/services/user-service/internal/interfaces/http/request.go new file mode 100644 index 0000000..c9f67aa --- /dev/null +++ b/backend/services/user-service/internal/interfaces/http/request.go @@ -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"` +} diff --git a/backend/services/user-service/internal/interfaces/http/response.go b/backend/services/user-service/internal/interfaces/http/response.go new file mode 100644 index 0000000..1ce8ec2 --- /dev/null +++ b/backend/services/user-service/internal/interfaces/http/response.go @@ -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, + } +} diff --git a/backend/services/user-service/internal/interfaces/http/routes.go b/backend/services/user-service/internal/interfaces/http/routes.go new file mode 100644 index 0000000..2310a0e --- /dev/null +++ b/backend/services/user-service/internal/interfaces/http/routes.go @@ -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) +} diff --git a/backend/services/user-service/tests/integration_http_test.go b/backend/services/user-service/tests/integration_http_test.go new file mode 100644 index 0000000..a9a74c5 --- /dev/null +++ b/backend/services/user-service/tests/integration_http_test.go @@ -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) + } +} diff --git a/docs/4_work_plan/2.2-user-service.md b/docs/4_work_plan/2.2-user-service.md new file mode 100644 index 0000000..ca61a4e --- /dev/null +++ b/docs/4_work_plan/2.2-user-service.md @@ -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`.