Files
galaxy-game/backend/internal/server/handlers_admin_users.go
2026-05-06 10:14:55 +03:00

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"`
}