Finished step '2.2 User Service (Port 8082)'
parent
79531ca862
commit
4797cf8c42
@ -1,22 +1,123 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log"
|
"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/serviceboot"
|
||||||
|
"knowfoolery/backend/shared/infra/utils/validation"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg := serviceboot.Config{
|
cfg := uconfig.FromEnv()
|
||||||
AppName: "Know Foolery - User Service",
|
|
||||||
|
logger := logging.NewLogger(cfg.Logging)
|
||||||
|
metrics := sharedmetrics.NewMetrics(cfg.Metrics)
|
||||||
|
|
||||||
|
tracer, err := tracing.NewTracer(cfg.Tracing)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal("failed to initialize tracer")
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = tracer.Shutdown(context.Background())
|
||||||
|
}()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
persistence, err := 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",
|
ServiceSlug: "user",
|
||||||
PortEnv: "USER_SERVICE_PORT",
|
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)
|
authMiddleware, adminMiddleware := buildAuthMiddleware(cfg)
|
||||||
serviceboot.RegisterHealth(app, cfg.ServiceSlug)
|
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))
|
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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -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/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/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/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/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/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/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-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/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/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8=
|
github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8=
|
||||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
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=
|
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…
Reference in New Issue