314 lines
9.5 KiB
Go
314 lines
9.5 KiB
Go
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"`
|
|
}
|