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 }