package server import ( "errors" "net/http" "strconv" "galaxy/backend/internal/server/handlers" "galaxy/backend/internal/server/httperr" "galaxy/backend/internal/server/middleware/basicauth" "galaxy/backend/internal/user" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" ) // AdminUsersHandlers groups the admin-side user-management handlers // under `/api/v1/admin/users/*`. The current implementation ships real implementations // backed by `*user.Service`; tests that supply a nil service fall back // to the Stage-3 placeholder body so the contract test continues to // validate the OpenAPI envelope without booting a database. type AdminUsersHandlers struct { svc *user.Service logger *zap.Logger } // NewAdminUsersHandlers constructs the handler set. svc may be nil — in // that case every handler returns 501 not_implemented, matching the // pre-Stage-5.2 placeholder. logger may also be nil; zap.NewNop is used // in that case. func NewAdminUsersHandlers(svc *user.Service, logger *zap.Logger) *AdminUsersHandlers { if logger == nil { logger = zap.NewNop() } return &AdminUsersHandlers{svc: svc, logger: logger.Named("http.admin.users")} } // List handles GET /api/v1/admin/users. func (h *AdminUsersHandlers) List() gin.HandlerFunc { if h.svc == nil { return handlers.NotImplemented("adminUsersList") } return func(c *gin.Context) { page := parsePositiveQueryInt(c.Query("page"), 1) pageSize := parsePositiveQueryInt(c.Query("page_size"), 50) ctx := c.Request.Context() result, err := h.svc.ListAccounts(ctx, page, pageSize) if err != nil { respondAccountError(c, h.logger, "admin users list", ctx, err) return } c.JSON(http.StatusOK, accountListToWire(result)) } } // Get handles GET /api/v1/admin/users/{user_id}. func (h *AdminUsersHandlers) Get() gin.HandlerFunc { if h.svc == nil { return handlers.NotImplemented("adminUsersGet") } return func(c *gin.Context) { userID, ok := parseUserIDParam(c) if !ok { return } ctx := c.Request.Context() account, err := h.svc.GetAccount(ctx, userID) if err != nil { respondAccountError(c, h.logger, "admin users get", ctx, err) return } c.JSON(http.StatusOK, accountResponseToWire(account)) } } // AddSanction handles POST /api/v1/admin/users/{user_id}/sanctions. func (h *AdminUsersHandlers) AddSanction() gin.HandlerFunc { if h.svc == nil { return handlers.NotImplemented("adminUsersAddSanction") } return func(c *gin.Context) { userID, ok := parseUserIDParam(c) if !ok { return } var req adminUserSanctionRequestWire if err := c.ShouldBindJSON(&req); err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON") return } expiresAt, err := parseTimePtr(req.ExpiresAt) if err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "expires_at must be RFC 3339") return } ctx := c.Request.Context() account, err := h.svc.ApplySanction(ctx, user.ApplySanctionInput{ UserID: userID, SanctionCode: req.SanctionCode, Scope: req.Scope, ReasonCode: req.ReasonCode, Actor: wireToActorRef(req.Actor, c), ExpiresAt: expiresAt, }) if err != nil { respondAccountError(c, h.logger, "admin users add sanction", ctx, err) return } c.JSON(http.StatusOK, accountResponseToWire(account)) } } // AddLimit handles POST /api/v1/admin/users/{user_id}/limits. func (h *AdminUsersHandlers) AddLimit() gin.HandlerFunc { if h.svc == nil { return handlers.NotImplemented("adminUsersAddLimit") } return func(c *gin.Context) { userID, ok := parseUserIDParam(c) if !ok { return } var req adminUserLimitRequestWire if err := c.ShouldBindJSON(&req); err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON") return } expiresAt, err := parseTimePtr(req.ExpiresAt) if err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "expires_at must be RFC 3339") return } ctx := c.Request.Context() account, err := h.svc.ApplyLimit(ctx, user.ApplyLimitInput{ UserID: userID, LimitCode: req.LimitCode, Value: req.Value, ReasonCode: req.ReasonCode, Actor: wireToActorRef(req.Actor, c), ExpiresAt: expiresAt, }) if err != nil { respondAccountError(c, h.logger, "admin users add limit", ctx, err) return } c.JSON(http.StatusOK, accountResponseToWire(account)) } } // AddEntitlement handles POST /api/v1/admin/users/{user_id}/entitlements. func (h *AdminUsersHandlers) AddEntitlement() gin.HandlerFunc { if h.svc == nil { return handlers.NotImplemented("adminUsersAddEntitlement") } return func(c *gin.Context) { userID, ok := parseUserIDParam(c) if !ok { return } var req adminUserEntitlementRequestWire if err := c.ShouldBindJSON(&req); err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON") return } startsAt, err := parseTimePtr(req.StartsAt) if err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "starts_at must be RFC 3339") return } endsAt, err := parseTimePtr(req.EndsAt) if err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "ends_at must be RFC 3339") return } ctx := c.Request.Context() account, err := h.svc.ApplyEntitlement(ctx, user.ApplyEntitlementInput{ UserID: userID, Tier: req.Tier, Source: req.Source, Actor: wireToActorRef(req.Actor, c), ReasonCode: req.ReasonCode, StartsAt: startsAt, EndsAt: endsAt, }) if err != nil { respondAccountError(c, h.logger, "admin users add entitlement", ctx, err) return } c.JSON(http.StatusOK, accountResponseToWire(account)) } } // SoftDelete handles POST /api/v1/admin/users/{user_id}/soft-delete. func (h *AdminUsersHandlers) SoftDelete() gin.HandlerFunc { if h.svc == nil { return handlers.NotImplemented("adminUsersSoftDelete") } return func(c *gin.Context) { userID, ok := parseUserIDParam(c) if !ok { return } ctx := c.Request.Context() username, _ := basicauth.UsernameFromContext(ctx) actor := user.ActorRef{Type: "admin", ID: username} if err := h.svc.SoftDelete(ctx, userID, actor); err != nil { if errors.Is(err, user.ErrAccountNotFound) { httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "account not found") return } // Cascade errors do not mask the canonical state — the // account is soft-deleted in Postgres. Surface 204 with // the error logged so caller UI proceeds. h.logger.Warn("admin users soft-delete cascade returned error", zap.Error(err)) } c.Status(http.StatusNoContent) } } // parseUserIDParam reads `user_id` from the path. On invalid input it // writes the standard 400 envelope and returns (uuid.Nil, false). func parseUserIDParam(c *gin.Context) (uuid.UUID, bool) { parsed, err := uuid.Parse(c.Param("user_id")) if err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user_id must be a valid UUID") return uuid.Nil, false } return parsed, true } // parsePositiveQueryInt parses a non-negative integer query parameter. // Empty / non-numeric values fall back to fallback. func parsePositiveQueryInt(raw string, fallback int) int { if raw == "" { return fallback } parsed, err := strconv.Atoi(raw) if err != nil || parsed <= 0 { return fallback } return parsed } // wireToActorRef converts the wire-level ActorRef into the user-domain // type. The basic-auth context plumbing supplies a fallback id when the // client omits one, so admin actions always carry the operator // identity. func wireToActorRef(actor *actorRefWire, c *gin.Context) user.ActorRef { if actor == nil { username, _ := basicauth.UsernameFromContext(c.Request.Context()) return user.ActorRef{Type: "admin", ID: username} } out := user.ActorRef{Type: actor.Type, ID: actor.ID} if out.ID == "" { if username, ok := basicauth.UsernameFromContext(c.Request.Context()); ok { out.ID = username } } return out } // accountListToWire renders the AccountPage into the AdminUserList // schema declared in openapi.yaml. func accountListToWire(page user.AccountPage) adminUserListWire { out := adminUserListWire{ Items: make([]accountWire, 0, len(page.Items)), Page: page.Page, PageSize: page.PageSize, Total: page.Total, } for _, a := range page.Items { out.Items = append(out.Items, accountToWire(a)) } return out } // adminUserSanctionRequestWire mirrors `AdminUserSanctionRequest`. type adminUserSanctionRequestWire struct { SanctionCode string `json:"sanction_code"` Scope string `json:"scope"` ReasonCode string `json:"reason_code"` Actor *actorRefWire `json:"actor"` ExpiresAt *string `json:"expires_at,omitempty"` } // adminUserLimitRequestWire mirrors `AdminUserLimitRequest`. type adminUserLimitRequestWire struct { LimitCode string `json:"limit_code"` Value int32 `json:"value"` ReasonCode string `json:"reason_code"` Actor *actorRefWire `json:"actor"` ExpiresAt *string `json:"expires_at,omitempty"` } // adminUserEntitlementRequestWire mirrors `AdminUserEntitlementRequest`. type adminUserEntitlementRequestWire struct { Tier string `json:"tier"` Source string `json:"source"` Actor *actorRefWire `json:"actor"` ReasonCode string `json:"reason_code,omitempty"` StartsAt *string `json:"starts_at,omitempty"` EndsAt *string `json:"ends_at,omitempty"` } // adminUserListWire mirrors `AdminUserList`. type adminUserListWire struct { Items []accountWire `json:"items"` Page int `json:"page"` PageSize int `json:"page_size"` Total int `json:"total"` }