package handler import ( "errors" "net/http" "strconv" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/yuxingu/digital-psychology/apps/api/internal/middleware" "github.com/yuxingu/digital-psychology/apps/api/internal/repository" "github.com/yuxingu/digital-psychology/apps/api/internal/service/admin" "github.com/yuxingu/digital-psychology/apps/api/internal/service/analytics" "github.com/yuxingu/digital-psychology/apps/api/pkg/response" ) // AdminHandler serves /api/v1/admin/* (no DeviceAuth). type AdminHandler struct { Svc *admin.Service Analytics *analytics.Service } // Register mounts public login + authed admin routes. func (h *AdminHandler) Register(api *gin.RouterGroup) { g := api.Group("/admin") g.POST("/auth/login", h.Login) authed := g.Group("") authed.Use(middleware.AdminAuth(h.Svc)) authed.POST("/auth/logout", h.Logout) authed.GET("/me", h.Me) authed.GET("/stats", middleware.RequireAdminPermission(h.Svc, admin.PermUsersRead), h.Stats) authed.GET("/users", middleware.RequireAdminPermission(h.Svc, admin.PermUsersRead), h.ListUsers) authed.GET("/users/:id", middleware.RequireAdminPermission(h.Svc, admin.PermUsersRead), h.GetUser) authed.POST("/users/:id/membership/grant", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipGrant), h.GrantMembership) authed.POST("/users/:id/ask-quota/grant", middleware.RequireAdminPermission(h.Svc, admin.PermAskQuotaGrant), h.GrantAskQuota) authed.GET("/orders", middleware.RequireAdminPermission(h.Svc, admin.PermOrdersRead), h.ListOrders) authed.GET("/membership/plan-prices", h.ListPlanPrices) authed.PUT("/membership/plan-prices", h.PutPlanPrices) authed.GET("/audit-logs", middleware.RequireAdminPermission(h.Svc, admin.PermAuditRead), h.ListAudit) authed.GET("/analytics/overview", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsOverview) authed.GET("/analytics/pages", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsPages) authed.GET("/analytics/exits", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsExits) authed.GET("/analytics/clicks", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsClicks) authed.GET("/analytics/funnel", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsFunnel) h.registerContent(authed) h.registerSystem(authed) h.registerRBAC(authed) h.registerLifecycle(authed) h.registerMembershipPlans(authed) h.registerRedemption(authed) h.registerInsight(authed) h.registerAskOps(authed) h.registerQualityFeedback(authed) h.registerEntitlement(authed) h.registerContentSafety(authed) h.registerAIConfig(authed) h.registerCrisis(authed) h.registerCMS(authed) h.registerCMSPublications(authed) h.registerKnowledgeChunks(authed) h.registerToolDefinitions(authed) h.registerBlockPolicies(authed) h.registerModerationCases(authed) h.registerCrisisEvents(authed) h.registerInterventionOutcomes(authed) h.registerHandoffCases(authed) h.registerPrivacyRequests(authed) h.registerStarConfigs(authed) h.registerRhythmConfigs(authed) h.registerImageCardDecks(authed) h.registerReportTemplates(authed) h.registerFunnelDefinitions(authed) h.registerExploreScales(authed) } func (h *AdminHandler) Login(c *gin.Context) { var body struct { Username string `json:"username"` Password string `json:"password"` } if err := c.ShouldBindJSON(&body); err != nil || body.Username == "" || body.Password == "" { response.Fail(c, http.StatusBadRequest, 40001, "username and password required") return } res, err := h.Svc.Login(c.Request.Context(), body.Username, body.Password) if err != nil { if errors.Is(err, admin.ErrBadCredentials) { response.Fail(c, http.StatusUnauthorized, 40103, "invalid credentials") return } response.Fail(c, http.StatusInternalServerError, 50010, "login failed") return } response.OK(c, res) } func (h *AdminHandler) Logout(c *gin.Context) { token := middleware.BearerToken(c.GetHeader("Authorization")) _ = h.Svc.Logout(c.Request.Context(), token) response.OK(c, gin.H{"ok": true}) } func (h *AdminHandler) Me(c *gin.Context) { adminID, ok := middleware.AdminIDFromContext(c) if !ok { response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required") return } me, err := h.Svc.Me(c.Request.Context(), adminID) if err != nil { response.Fail(c, http.StatusInternalServerError, 50011, "me failed") return } response.OK(c, me) } func (h *AdminHandler) Stats(c *gin.Context) { stats, err := h.Svc.DashboardStats(c.Request.Context()) if err != nil { response.Fail(c, http.StatusInternalServerError, 50017, "stats failed") return } response.OK(c, stats) } func (h *AdminHandler) ListUsers(c *gin.Context) { limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) items, err := h.Svc.ListUsers(c.Request.Context(), c.Query("q"), limit, offset) if err != nil { response.Fail(c, http.StatusInternalServerError, 50012, "list users failed") return } response.OK(c, gin.H{"items": items}) } func (h *AdminHandler) GetUser(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { response.Fail(c, http.StatusBadRequest, 40002, "invalid user id") return } detail, err := h.Svc.GetUser(c.Request.Context(), id) if err != nil { if errors.Is(err, admin.ErrUserNotFound) { response.Fail(c, http.StatusNotFound, 40401, "user not found") return } response.Fail(c, http.StatusInternalServerError, 50013, "get user failed") return } response.OK(c, detail) } func (h *AdminHandler) GrantMembership(c *gin.Context) { adminID, ok := middleware.AdminIDFromContext(c) if !ok { response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required") return } userID, err := uuid.Parse(c.Param("id")) if err != nil { response.Fail(c, http.StatusBadRequest, 40002, "invalid user id") return } var body admin.GrantInput if err := c.ShouldBindJSON(&body); err != nil || body.Plan == "" { response.Fail(c, http.StatusBadRequest, 40003, "plan required") return } if err := h.Svc.GrantMembership(c.Request.Context(), adminID, userID, body.Plan); err != nil { if errors.Is(err, admin.ErrForbidden) { response.Fail(c, http.StatusForbidden, 40301, "forbidden") return } if errors.Is(err, admin.ErrInvalidPlan) { response.Fail(c, http.StatusBadRequest, 40004, "invalid plan") return } if errors.Is(err, admin.ErrUserNotFound) { response.Fail(c, http.StatusNotFound, 40401, "user not found") return } response.Fail(c, http.StatusInternalServerError, 50014, "grant failed") return } response.OK(c, gin.H{"ok": true}) } func (h *AdminHandler) GrantAskQuota(c *gin.Context) { adminID, ok := middleware.AdminIDFromContext(c) if !ok { response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required") return } userID, err := uuid.Parse(c.Param("id")) if err != nil { response.Fail(c, http.StatusBadRequest, 40002, "invalid user id") return } var body admin.GrantAskQuotaInput if err := c.ShouldBindJSON(&body); err != nil { response.Fail(c, http.StatusBadRequest, 40005, "delta required") return } left, err := h.Svc.GrantAskQuota(c.Request.Context(), adminID, userID, body.Delta) if err != nil { if errors.Is(err, admin.ErrForbidden) { response.Fail(c, http.StatusForbidden, 40301, "forbidden") return } if errors.Is(err, admin.ErrInvalidAskDelta) { response.Fail(c, http.StatusBadRequest, 40006, "invalid delta") return } if errors.Is(err, admin.ErrUserNotFound) { response.Fail(c, http.StatusNotFound, 40401, "user not found") return } response.Fail(c, http.StatusInternalServerError, 50018, "grant ask quota failed") return } response.OK(c, gin.H{"ok": true, "ask_paid_quota_left": left}) } func (h *AdminHandler) ListOrders(c *gin.Context) { limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) f := admin.OrderFilterFromQuery( c.Query("user_id"), c.Query("status"), c.Query("kind"), c.Query("from"), c.Query("to"), limit, offset, ) items, err := h.Svc.ListOrders(c.Request.Context(), f) if err != nil { response.Fail(c, http.StatusInternalServerError, 50015, "list orders failed") return } response.OK(c, gin.H{"items": items}) } func (h *AdminHandler) ListPlanPrices(c *gin.Context) { items, err := h.Svc.ListPlanPrices(c.Request.Context()) if err != nil { response.Fail(c, http.StatusInternalServerError, 50017, "list plan prices failed") return } response.OK(c, gin.H{"items": items}) } func (h *AdminHandler) PutPlanPrices(c *gin.Context) { adminID, ok := middleware.AdminIDFromContext(c) if !ok { response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required") return } var body struct { Items []struct { Plan string `json:"plan"` DisplayCents int `json:"display_cents"` } `json:"items"` } if err := c.ShouldBindJSON(&body); err != nil || len(body.Items) == 0 { response.Fail(c, http.StatusBadRequest, 40040, "items required") return } prices := make([]repository.PlanPrice, 0, len(body.Items)) for _, it := range body.Items { prices = append(prices, repository.PlanPrice{Plan: it.Plan, DisplayCents: it.DisplayCents}) } if err := h.Svc.UpsertPlanPrices(c.Request.Context(), adminID, prices); err != nil { if errors.Is(err, admin.ErrForbidden) { response.Fail(c, http.StatusForbidden, 40301, "forbidden") return } response.Fail(c, http.StatusBadRequest, 40041, "upsert plan prices failed") return } response.OK(c, gin.H{"ok": true}) } func (h *AdminHandler) ListAudit(c *gin.Context) { limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) items, err := h.Svc.ListAuditLogs(c.Request.Context(), limit, offset) if err != nil { response.Fail(c, http.StatusInternalServerError, 50016, "list audit failed") return } response.OK(c, gin.H{"items": items}) } func (h *AdminHandler) AnalyticsOverview(c *gin.Context) { from, to, err := analytics.ParseDayRange(c.Query("from"), c.Query("to")) if err != nil { response.Fail(c, http.StatusBadRequest, 40030, "invalid from/to") return } if !h.requireAnalytics(c) { return } data, err := h.Analytics.Overview(c.Request.Context(), from, to) if err != nil { response.Fail(c, http.StatusInternalServerError, 50022, "overview failed") return } response.OK(c, data) } func (h *AdminHandler) AnalyticsPages(c *gin.Context) { from, to, err := analytics.ParseDayRange(c.Query("from"), c.Query("to")) if err != nil { response.Fail(c, http.StatusBadRequest, 40030, "invalid from/to") return } if !h.requireAnalytics(c) { return } items, err := h.Analytics.Pages(c.Request.Context(), from, to) if err != nil { response.Fail(c, http.StatusInternalServerError, 50023, "pages failed") return } response.OK(c, gin.H{"items": items}) } func (h *AdminHandler) AnalyticsExits(c *gin.Context) { from, to, err := analytics.ParseDayRange(c.Query("from"), c.Query("to")) if err != nil { response.Fail(c, http.StatusBadRequest, 40030, "invalid from/to") return } if !h.requireAnalytics(c) { return } items, err := h.Analytics.Exits(c.Request.Context(), from, to) if err != nil { response.Fail(c, http.StatusInternalServerError, 50024, "exits failed") return } response.OK(c, gin.H{"items": items}) } func (h *AdminHandler) AnalyticsClicks(c *gin.Context) { from, to, err := analytics.ParseDayRange(c.Query("from"), c.Query("to")) if err != nil { response.Fail(c, http.StatusBadRequest, 40030, "invalid from/to") return } if !h.requireAnalytics(c) { return } items, err := h.Analytics.Clicks(c.Request.Context(), from, to) if err != nil { response.Fail(c, http.StatusInternalServerError, 50025, "clicks failed") return } response.OK(c, gin.H{"items": items}) } func (h *AdminHandler) AnalyticsFunnel(c *gin.Context) { from, to, err := analytics.ParseDayRange(c.Query("from"), c.Query("to")) if err != nil { response.Fail(c, http.StatusBadRequest, 40030, "invalid from/to") return } if !h.requireAnalytics(c) { return } steps, err := h.Analytics.Funnel(c.Request.Context(), from, to) if err != nil { response.Fail(c, http.StatusInternalServerError, 50026, "funnel failed") return } response.OK(c, gin.H{"steps": steps}) } func (h *AdminHandler) requireAnalytics(c *gin.Context) bool { if h.Analytics == nil { response.Fail(c, http.StatusInternalServerError, 50021, "analytics unavailable") return false } return true }