diff --git a/veza-backend-api/internal/api/router.go b/veza-backend-api/internal/api/router.go index 8fb3da60f..1969eedad 100644 --- a/veza-backend-api/internal/api/router.go +++ b/veza-backend-api/internal/api/router.go @@ -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 diff --git a/veza-backend-api/internal/api/routes_admin_platform.go b/veza-backend-api/internal/api/routes_admin_platform.go new file mode 100644 index 000000000..e444894dc --- /dev/null +++ b/veza-backend-api/internal/api/routes_admin_platform.go @@ -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) + } +} diff --git a/veza-backend-api/internal/core/admin/handler.go b/veza-backend-api/internal/core/admin/handler.go new file mode 100644 index 000000000..ca2a22918 --- /dev/null +++ b/veza-backend-api/internal/core/admin/handler.go @@ -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"}) +} diff --git a/veza-backend-api/internal/core/admin/handler_test.go b/veza-backend-api/internal/core/admin/handler_test.go new file mode 100644 index 000000000..43bb98c6c --- /dev/null +++ b/veza-backend-api/internal/core/admin/handler_test.go @@ -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) + } +}