feat(v0.11.3): F421-F424 admin platform handler and routes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
senke 2026-03-10 18:19:45 +01:00
parent 0a055db479
commit f68405a52e
4 changed files with 515 additions and 0 deletions

View file

@ -343,6 +343,9 @@ func (r *APIRouter) Setup(router *gin.Engine) error {
// v0.11.2: Advanced Moderation (F411-F420)
r.setupModerationRoutes(v1)
// v0.11.3: Admin Platform Management (F421-F435)
r.setupAdminPlatformRoutes(v1)
}
return nil

View file

@ -0,0 +1,41 @@
package api
import (
admincore "veza-backend-api/internal/core/admin"
"veza-backend-api/internal/services"
"github.com/gin-gonic/gin"
)
// setupAdminPlatformRoutes registers admin platform management routes (v0.11.3 F421-F435)
func (r *APIRouter) setupAdminPlatformRoutes(router *gin.RouterGroup) {
platformService := services.NewAdminPlatformService(r.db.GormDB, r.logger)
platformHandler := admincore.NewPlatformAdminHandler(platformService, r.logger)
admin := router.Group("/admin/platform")
{
if r.config.AuthMiddleware != nil {
admin.Use(r.config.AuthMiddleware.RequireAuth())
admin.Use(r.config.AuthMiddleware.RequireAdmin())
}
// F421: Platform metrics
admin.GET("/metrics", platformHandler.GetPlatformMetrics)
// F422: User management
admin.GET("/users", platformHandler.SearchUsers)
admin.GET("/users/:userId", platformHandler.GetUserDetail)
admin.PUT("/users/:userId/role", platformHandler.UpdateUserRole)
admin.POST("/users/:userId/suspend", platformHandler.SuspendUser)
admin.POST("/users/:userId/unsuspend", platformHandler.UnsuspendUser)
// F423: Content management
admin.GET("/content", platformHandler.SearchContent)
admin.POST("/content/:id/hide", platformHandler.HideContent)
admin.POST("/content/:id/restore", platformHandler.RestoreContent)
// F424: Payment management
admin.GET("/payments", platformHandler.GetPaymentOverview)
admin.POST("/orders/:id/refund", platformHandler.RefundOrder)
}
}

View file

@ -0,0 +1,339 @@
package admin
import (
"net/http"
"strconv"
"strings"
apperrors "veza-backend-api/internal/errors"
"veza-backend-api/internal/handlers"
"veza-backend-api/internal/services"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// PlatformAdminHandler handles admin platform HTTP requests (F421-F435)
type PlatformAdminHandler struct {
service *services.AdminPlatformService
logger *zap.Logger
}
// NewPlatformAdminHandler creates a new platform admin handler
func NewPlatformAdminHandler(service *services.AdminPlatformService, logger *zap.Logger) *PlatformAdminHandler {
if logger == nil {
logger = zap.NewNop()
}
return &PlatformAdminHandler{service: service, logger: logger}
}
// getAdminID extracts admin user ID from context
func (h *PlatformAdminHandler) getAdminID(c *gin.Context) (uuid.UUID, bool) {
userID, ok := handlers.GetUserIDUUID(c)
if !ok || userID == uuid.Nil {
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeUnauthorized, "authentication required"))
return uuid.Nil, false
}
return userID, true
}
// --- F421: Platform Dashboard ---
// GetPlatformMetrics handles GET /api/v1/admin/platform/metrics (F421)
func (h *PlatformAdminHandler) GetPlatformMetrics(c *gin.Context) {
metrics, err := h.service.GetPlatformMetrics(c.Request.Context())
if err != nil {
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get metrics", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{
"metrics": metrics,
})
}
// --- F422: User Management ---
// SearchUsers handles GET /api/v1/admin/platform/users (F422)
func (h *PlatformAdminHandler) SearchUsers(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
var isBanned *bool
if b := c.Query("is_banned"); b != "" {
v := b == "true"
isBanned = &v
}
params := services.AdminUserSearchParams{
Query: c.Query("q"),
Role: c.Query("role"),
IsBanned: isBanned,
Limit: limit,
Offset: offset,
SortBy: c.DefaultQuery("sort_by", "created_at"),
}
users, total, err := h.service.SearchUsers(c.Request.Context(), params)
if err != nil {
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to search users", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{
"users": users,
"pagination": gin.H{
"total": total,
"limit": limit,
"offset": offset,
},
})
}
// GetUserDetail handles GET /api/v1/admin/platform/users/:userId (F422)
func (h *PlatformAdminHandler) GetUserDetail(c *gin.Context) {
userIDStr := c.Param("userId")
userID, err := uuid.Parse(userIDStr)
if err != nil {
handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid user ID"))
return
}
user, err := h.service.GetUserDetail(c.Request.Context(), userID)
if err != nil {
if strings.Contains(err.Error(), "not found") {
handlers.RespondWithAppError(c, apperrors.NewNotFoundError("user"))
return
}
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get user", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{
"user": user,
})
}
// UpdateUserRole handles PUT /api/v1/admin/platform/users/:userId/role (F422)
func (h *PlatformAdminHandler) UpdateUserRole(c *gin.Context) {
userIDStr := c.Param("userId")
userID, err := uuid.Parse(userIDStr)
if err != nil {
handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid user ID"))
return
}
var req struct {
Role string `json:"role" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
handlers.RespondWithAppError(c, apperrors.NewValidationError("role is required"))
return
}
if err := h.service.UpdateUserRole(c.Request.Context(), userID, req.Role); err != nil {
if strings.Contains(err.Error(), "invalid role") {
handlers.RespondWithAppError(c, apperrors.NewValidationError(err.Error()))
return
}
if strings.Contains(err.Error(), "not found") {
handlers.RespondWithAppError(c, apperrors.NewNotFoundError("user"))
return
}
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to update role", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{"status": "role_updated"})
}
// SuspendUser handles POST /api/v1/admin/platform/users/:userId/suspend (F422)
func (h *PlatformAdminHandler) SuspendUser(c *gin.Context) {
adminID, ok := h.getAdminID(c)
if !ok {
return
}
userIDStr := c.Param("userId")
userID, err := uuid.Parse(userIDStr)
if err != nil {
handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid user ID"))
return
}
var req struct {
Reason string `json:"reason" binding:"required"`
DurationDays *int `json:"duration_days"`
}
if err := c.ShouldBindJSON(&req); err != nil {
handlers.RespondWithAppError(c, apperrors.NewValidationError("reason is required"))
return
}
if err := h.service.SuspendUser(c.Request.Context(), userID, adminID, req.Reason, req.DurationDays); err != nil {
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to suspend user", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{"status": "suspended"})
}
// UnsuspendUser handles POST /api/v1/admin/platform/users/:userId/unsuspend (F422)
func (h *PlatformAdminHandler) UnsuspendUser(c *gin.Context) {
adminID, ok := h.getAdminID(c)
if !ok {
return
}
userIDStr := c.Param("userId")
userID, err := uuid.Parse(userIDStr)
if err != nil {
handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid user ID"))
return
}
if err := h.service.UnsuspendUser(c.Request.Context(), userID, adminID); err != nil {
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to unsuspend user", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{"status": "unsuspended"})
}
// --- F423: Content Management ---
// SearchContent handles GET /api/v1/admin/platform/content (F423)
func (h *PlatformAdminHandler) SearchContent(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
items, total, err := h.service.SearchContent(c.Request.Context(), c.DefaultQuery("type", "track"), c.Query("q"), limit, offset)
if err != nil {
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to search content", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{
"content": items,
"pagination": gin.H{
"total": total,
"limit": limit,
"offset": offset,
},
})
}
// HideContent handles POST /api/v1/admin/platform/content/:id/hide (F423)
func (h *PlatformAdminHandler) HideContent(c *gin.Context) {
adminID, ok := h.getAdminID(c)
if !ok {
return
}
contentIDStr := c.Param("id")
contentID, err := uuid.Parse(contentIDStr)
if err != nil {
handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid content ID"))
return
}
var req struct {
ContentType string `json:"content_type" binding:"required"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
handlers.RespondWithAppError(c, apperrors.NewValidationError("content_type is required"))
return
}
if err := h.service.HideContent(c.Request.Context(), adminID, req.ContentType, contentID, req.Reason); err != nil {
if strings.Contains(err.Error(), "invalid content type") {
handlers.RespondWithAppError(c, apperrors.NewValidationError(err.Error()))
return
}
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to hide content", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{"status": "hidden"})
}
// RestoreContent handles POST /api/v1/admin/platform/content/:id/restore (F423)
func (h *PlatformAdminHandler) RestoreContent(c *gin.Context) {
adminID, ok := h.getAdminID(c)
if !ok {
return
}
contentIDStr := c.Param("id")
contentID, err := uuid.Parse(contentIDStr)
if err != nil {
handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid content ID"))
return
}
var req struct {
ContentType string `json:"content_type" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
handlers.RespondWithAppError(c, apperrors.NewValidationError("content_type is required"))
return
}
if err := h.service.RestoreContent(c.Request.Context(), adminID, req.ContentType, contentID); err != nil {
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to restore content", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{"status": "restored"})
}
// --- F424: Payment Management ---
// GetPaymentOverview handles GET /api/v1/admin/platform/payments (F424)
func (h *PlatformAdminHandler) GetPaymentOverview(c *gin.Context) {
overview, err := h.service.GetPaymentOverview(c.Request.Context())
if err != nil {
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get payment overview", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{
"payments": overview,
})
}
// RefundOrder handles POST /api/v1/admin/platform/orders/:id/refund (F424)
func (h *PlatformAdminHandler) RefundOrder(c *gin.Context) {
adminID, ok := h.getAdminID(c)
if !ok {
return
}
orderIDStr := c.Param("id")
orderID, err := uuid.Parse(orderIDStr)
if err != nil {
handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid order ID"))
return
}
var req struct {
Reason string `json:"reason" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
handlers.RespondWithAppError(c, apperrors.NewValidationError("reason is required"))
return
}
if err := h.service.RefundOrder(c.Request.Context(), adminID, orderID, req.Reason); err != nil {
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "not eligible") {
handlers.RespondWithAppError(c, apperrors.NewValidationError(err.Error()))
return
}
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to refund order", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{"status": "refunded"})
}

View file

@ -0,0 +1,132 @@
package admin
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func TestGetPlatformMetrics_NoAuth(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewPlatformAdminHandler(nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/admin/platform/users/invalid/suspend", strings.NewReader(`{"reason":"test"}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Params = gin.Params{{Key: "userId", Value: "not-a-uuid"}}
// No user_id in context
handler.SuspendUser(c)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestGetUserDetail_InvalidID(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewPlatformAdminHandler(nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/admin/platform/users/invalid", nil)
c.Params = gin.Params{{Key: "userId", Value: "not-a-uuid"}}
handler.GetUserDetail(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestSuspendUser_InvalidUserID(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewPlatformAdminHandler(nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/admin/platform/users/invalid/suspend",
strings.NewReader(`{"reason":"test"}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Params = gin.Params{{Key: "userId", Value: "not-a-uuid"}}
c.Set("user_id", uuid.MustParse("00000000-0000-0000-0000-000000000001"))
handler.SuspendUser(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestUpdateUserRole_InvalidBody(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewPlatformAdminHandler(nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPut, "/admin/platform/users/00000000-0000-0000-0000-000000000001/role",
strings.NewReader(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Params = gin.Params{{Key: "userId", Value: "00000000-0000-0000-0000-000000000001"}}
handler.UpdateUserRole(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestHideContent_InvalidContentID(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewPlatformAdminHandler(nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/admin/platform/content/bad-id/hide",
strings.NewReader(`{"content_type":"track","reason":"test"}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Params = gin.Params{{Key: "id", Value: "not-a-uuid"}}
c.Set("user_id", uuid.MustParse("00000000-0000-0000-0000-000000000001"))
handler.HideContent(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestRefundOrder_InvalidOrderID(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewPlatformAdminHandler(nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/admin/platform/orders/bad/refund",
strings.NewReader(`{"reason":"duplicate payment"}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Params = gin.Params{{Key: "id", Value: "not-a-uuid"}}
c.Set("user_id", uuid.MustParse("00000000-0000-0000-0000-000000000001"))
handler.RefundOrder(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestRefundOrder_MissingReason(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewPlatformAdminHandler(nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/admin/platform/orders/00000000-0000-0000-0000-000000000001/refund",
strings.NewReader(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Params = gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-000000000001"}}
c.Set("user_id", uuid.MustParse("00000000-0000-0000-0000-000000000001"))
handler.RefundOrder(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}