merge: 合入本地 Ops 扩展与 origin/main(ECR-009–016)
保留远程用户侧 ECR-009–016 与本地 Ops 目录/RBAC/CMS/危机等能力;文档标注分叉期间 ECR 编号冲突。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -30,22 +30,49 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) {
|
||||
authed.Use(middleware.AdminAuth(h.Svc))
|
||||
authed.POST("/auth/logout", h.Logout)
|
||||
authed.GET("/me", h.Me)
|
||||
authed.GET("/stats", h.Stats)
|
||||
authed.GET("/users", h.ListUsers)
|
||||
authed.GET("/users/:id", h.GetUser)
|
||||
authed.POST("/users/:id/membership/grant", h.GrantMembership)
|
||||
authed.POST("/users/:id/ask-quota/grant", h.GrantAskQuota)
|
||||
authed.GET("/orders", h.ListOrders)
|
||||
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", h.ListAudit)
|
||||
authed.GET("/analytics/overview", h.AnalyticsOverview)
|
||||
authed.GET("/analytics/pages", h.AnalyticsPages)
|
||||
authed.GET("/analytics/exits", h.AnalyticsExits)
|
||||
authed.GET("/analytics/clicks", h.AnalyticsClicks)
|
||||
authed.GET("/analytics/funnel", h.AnalyticsFunnel)
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerAIConfig(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/ai")
|
||||
g.GET("/system-prompts", middleware.RequireAdminPermission(h.Svc, admin.PermAIConfigRead), h.ListSystemPrompts)
|
||||
g.GET("/system-prompts/:id", middleware.RequireAdminPermission(h.Svc, admin.PermAIConfigRead), h.GetSystemPrompt)
|
||||
g.GET("/knowledge-sources", middleware.RequireAdminPermission(h.Svc, admin.PermAIConfigRead), h.ListKnowledgeSources)
|
||||
g.GET("/knowledge-sources/:id", middleware.RequireAdminPermission(h.Svc, admin.PermAIConfigRead), h.GetKnowledgeSource)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListSystemPrompts(c *gin.Context) {
|
||||
items, err := h.Svc.ListSystemPrompts(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50027, "list system prompts failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetSystemPrompt(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetSystemPrompt(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrSystemPromptNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40404, "system prompt not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50028, "get system prompt failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListKnowledgeSources(c *gin.Context) {
|
||||
items, err := h.Svc.ListKnowledgeSources(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50029, "list knowledge sources failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetKnowledgeSource(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetKnowledgeSource(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrKnowledgeSourceNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40405, "knowledge source not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50030, "get knowledge source failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerAskOps(authed *gin.RouterGroup) {
|
||||
authed.GET("/ask/threads", middleware.RequireAdminPermission(h.Svc, admin.PermAskRead), h.ListAskThreads)
|
||||
authed.GET("/ask/threads/:id", middleware.RequireAdminPermission(h.Svc, admin.PermAskRead), h.GetAskThread)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListAskThreads(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
var userID *uuid.UUID
|
||||
if q := c.Query("user_id"); q != "" {
|
||||
id, err := uuid.Parse(q)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid user_id")
|
||||
return
|
||||
}
|
||||
userID = &id
|
||||
}
|
||||
items, err := h.Svc.ListAskSessions(c.Request.Context(), userID, limit, offset)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50019, "list ask threads failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetAskThread(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid thread id")
|
||||
return
|
||||
}
|
||||
detail, err := h.Svc.GetAskSessionDetail(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrAskThreadNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40402, "ask thread not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50020, "get ask thread failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, detail)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerBlockPolicies(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/content-safety")
|
||||
g.GET("/block-policies", middleware.RequireAdminPermission(h.Svc, admin.PermContentSafetyRead), h.ListBlockPolicies)
|
||||
g.GET("/block-policies/:id", middleware.RequireAdminPermission(h.Svc, admin.PermContentSafetyRead), h.GetBlockPolicy)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListBlockPolicies(c *gin.Context) {
|
||||
items, err := h.Svc.ListBlockPolicies(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list block-policy failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetBlockPolicy(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetBlockPolicy(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrBlockPolicyNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "block-policy not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get block-policy failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerCMS(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/cms")
|
||||
g.GET("/banners", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.ListBanners)
|
||||
g.GET("/banners/:id", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.GetBanner)
|
||||
g.GET("/feed-slots", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.ListFeedSlots)
|
||||
g.GET("/feed-slots/:id", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.GetFeedSlot)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListBanners(c *gin.Context) {
|
||||
items, err := h.Svc.ListBanners(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50040, "list banners failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetBanner(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetBanner(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrBannerNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40410, "banner not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50041, "get banner failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListFeedSlots(c *gin.Context) {
|
||||
items, err := h.Svc.ListFeedSlots(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50042, "list feed slots failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetFeedSlot(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetFeedSlot(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrFeedSlotNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40411, "feed slot not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50043, "get feed slot failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -14,10 +14,10 @@ import (
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerContent(authed *gin.RouterGroup) {
|
||||
authed.GET("/home/tools", h.ListHomeTools)
|
||||
authed.PUT("/home/tools", h.ReplaceHomeTools)
|
||||
authed.GET("/scales", h.ListScales)
|
||||
authed.PATCH("/scales/:id", h.PatchScale)
|
||||
authed.GET("/home/tools", middleware.RequireAdminPermission(h.Svc, admin.PermContentWrite), h.ListHomeTools)
|
||||
authed.PUT("/home/tools", middleware.RequireAdminPermission(h.Svc, admin.PermContentWrite), h.ReplaceHomeTools)
|
||||
authed.GET("/scales", middleware.RequireAdminPermission(h.Svc, admin.PermContentWrite), h.ListScales)
|
||||
authed.PATCH("/scales/:id", middleware.RequireAdminPermission(h.Svc, admin.PermContentWrite), h.PatchScale)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListHomeTools(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerContentSafety(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/content-safety")
|
||||
g.GET("/filter-rules", middleware.RequireAdminPermission(h.Svc, admin.PermContentSafetyRead), h.ListFilterRules)
|
||||
g.GET("/filter-rules/:id", middleware.RequireAdminPermission(h.Svc, admin.PermContentSafetyRead), h.GetFilterRule)
|
||||
g.POST("/evaluate", middleware.RequireAdminPermission(h.Svc, admin.PermContentSafetyRead), h.EvaluateContent)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListFilterRules(c *gin.Context) {
|
||||
items, err := h.Svc.ListFilterRules(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50022, "list filter rules failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetFilterRule(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetFilterRule(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrFilterRuleNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40403, "filter rule not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50023, "get filter rule failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) EvaluateContent(c *gin.Context) {
|
||||
var body struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid body")
|
||||
return
|
||||
}
|
||||
matches, err := h.Svc.EvaluateContent(c.Request.Context(), body.Text)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50024, "evaluate failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"matches": matches})
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerCrisis(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/crisis")
|
||||
g.GET("/policies", middleware.RequireAdminPermission(h.Svc, admin.PermCrisisRead), h.ListCrisisPolicies)
|
||||
g.GET("/policies/:id", middleware.RequireAdminPermission(h.Svc, admin.PermCrisisRead), h.GetCrisisPolicy)
|
||||
g.POST("/evaluate", middleware.RequireAdminPermission(h.Svc, admin.PermCrisisRead), h.EvaluateCrisis)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListCrisisPolicies(c *gin.Context) {
|
||||
items, err := h.Svc.ListCrisisPolicies(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50029, "list crisis policies failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetCrisisPolicy(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetCrisisPolicy(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrCrisisPolicyNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40405, "crisis policy not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50030, "get crisis policy failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) EvaluateCrisis(c *gin.Context) {
|
||||
var body struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid body")
|
||||
return
|
||||
}
|
||||
matches, err := h.Svc.EvaluateCrisis(c.Request.Context(), body.Text)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50031, "evaluate failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"matches": matches})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerCrisisEvents(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/crisis")
|
||||
g.GET("/events", middleware.RequireAdminPermission(h.Svc, admin.PermCrisisRead), h.ListCrisisEvents)
|
||||
g.GET("/events/:id", middleware.RequireAdminPermission(h.Svc, admin.PermCrisisRead), h.GetCrisisEvent)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListCrisisEvents(c *gin.Context) {
|
||||
items, err := h.Svc.ListCrisisEvents(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list crisis-event failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetCrisisEvent(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetCrisisEvent(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrCrisisEventNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "crisis-event not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get crisis-event failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerEntitlement(authed *gin.RouterGroup) {
|
||||
authed.GET("/users/:id/entitlements", middleware.RequireAdminPermission(h.Svc, admin.PermUsersRead), h.GetUserEntitlements)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetUserEntitlements(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid user id")
|
||||
return
|
||||
}
|
||||
ent, err := h.Svc.GetUserEntitlement(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrUserNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40401, "user not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50021, "get entitlements failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, ent)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerExploreScales(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/explore")
|
||||
g.GET("/scales", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.ListExploreScales)
|
||||
g.GET("/scales/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.GetExploreScale)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListExploreScales(c *gin.Context) {
|
||||
items, err := h.Svc.ListScalesAdmin(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50060, "list explore scales failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetExploreScale(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetScaleAdmin(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrScaleNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40430, "scale not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50061, "get explore scale failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerFunnelDefinitions(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/analytics")
|
||||
g.GET("/funnel-definitions", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.ListFunnelDefinitions)
|
||||
g.GET("/funnel-definitions/:id", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.GetFunnelDefinition)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListFunnelDefinitions(c *gin.Context) {
|
||||
items, err := h.Svc.ListFunnelDefinitions(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list funnel-definition failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetFunnelDefinition(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetFunnelDefinition(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrFunnelDefinitionNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "funnel-definition not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get funnel-definition failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerHandoffCases(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/ask")
|
||||
g.GET("/handoffs", middleware.RequireAdminPermission(h.Svc, admin.PermAskRead), h.ListHandoffCases)
|
||||
g.GET("/handoffs/:id", middleware.RequireAdminPermission(h.Svc, admin.PermAskRead), h.GetHandoffCase)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListHandoffCases(c *gin.Context) {
|
||||
items, err := h.Svc.ListHandoffCases(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list handoff-case failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetHandoffCase(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetHandoffCase(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrHandoffCaseNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "handoff-case not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get handoff-case failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerImageCardDecks(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/explore")
|
||||
g.GET("/image-card-decks", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.ListImageCardDecks)
|
||||
g.GET("/image-card-decks/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.GetImageCardDeck)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListImageCardDecks(c *gin.Context) {
|
||||
items, err := h.Svc.ListImageCardDecks(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list image-card-deck failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetImageCardDeck(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetImageCardDeck(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrImageCardDeckNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "image-card-deck not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get image-card-deck failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerInsight(authed *gin.RouterGroup) {
|
||||
authed.GET("/users/:id/insight", middleware.RequireAdminPermission(h.Svc, admin.PermUsersRead), h.GetUserInsight)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetUserInsight(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid user id")
|
||||
return
|
||||
}
|
||||
insight, err := h.Svc.GetUserInsight(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrUserNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40401, "user not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50018, "get insight failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, insight)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerInterventionOutcomes(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/crisis")
|
||||
g.GET("/interventions", middleware.RequireAdminPermission(h.Svc, admin.PermCrisisRead), h.ListInterventionOutcomes)
|
||||
g.GET("/interventions/:id", middleware.RequireAdminPermission(h.Svc, admin.PermCrisisRead), h.GetInterventionOutcome)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListInterventionOutcomes(c *gin.Context) {
|
||||
items, err := h.Svc.ListInterventionOutcomes(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list intervention-outcome failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetInterventionOutcome(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetInterventionOutcome(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrInterventionOutcomeNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "intervention-outcome not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get intervention-outcome failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerKnowledgeChunks(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/ai")
|
||||
g.GET("/knowledge-chunks", middleware.RequireAdminPermission(h.Svc, admin.PermAIConfigRead), h.ListKnowledgeChunks)
|
||||
g.GET("/knowledge-chunks/:id", middleware.RequireAdminPermission(h.Svc, admin.PermAIConfigRead), h.GetKnowledgeChunk)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListKnowledgeChunks(c *gin.Context) {
|
||||
items, err := h.Svc.ListKnowledgeChunks(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list knowledge-chunk failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetKnowledgeChunk(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetKnowledgeChunk(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrKnowledgeChunkNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "knowledge-chunk not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get knowledge-chunk failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerLifecycle(authed *gin.RouterGroup) {
|
||||
authed.POST("/users/:id/status", middleware.RequireAdminPermission(h.Svc, admin.PermUsersStatusWrite), h.PostUserStatus)
|
||||
authed.GET("/users/:id/status-transitions", middleware.RequireAdminPermission(h.Svc, admin.PermUsersRead), h.ListUserStatusTransitions)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) PostUserStatus(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
|
||||
return
|
||||
}
|
||||
userID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Status == "" {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "status required")
|
||||
return
|
||||
}
|
||||
err = h.Svc.TransitionUserStatus(c.Request.Context(), adminID, userID, body.Status, body.Reason)
|
||||
if errors.Is(err, admin.ErrUserNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40400, "user not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrReasonRequired) || errors.Is(err, admin.ErrInvalidStatusEdge) {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
detail, err := h.Svc.GetUser(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, detail)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListUserStatusTransitions(c *gin.Context) {
|
||||
userID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid id")
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
items, err := h.Svc.ListStatusTransitions(c.Request.Context(), userID, limit)
|
||||
if errors.Is(err, admin.ErrUserNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40400, "user not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerMembershipPlans(authed *gin.RouterGroup) {
|
||||
authed.GET("/membership-plans", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipPlansRead), h.ListMembershipPlans)
|
||||
authed.GET("/membership-plans/:code", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipPlansRead), h.GetMembershipPlan)
|
||||
authed.PUT("/membership-plans/:code", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipPlansWrite), h.PutMembershipPlan)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListMembershipPlans(c *gin.Context) {
|
||||
items, err := h.Svc.ListMembershipPlans(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetMembershipPlan(c *gin.Context) {
|
||||
plan, err := h.Svc.GetMembershipPlan(c.Request.Context(), c.Param("code"))
|
||||
if errors.Is(err, admin.ErrPlanNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40400, "plan not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, plan)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) PutMembershipPlan(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Title string `json:"title"`
|
||||
DurationDays int `json:"duration_days"`
|
||||
AmountCents int `json:"amount_cents"`
|
||||
Active *bool `json:"active"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid body")
|
||||
return
|
||||
}
|
||||
active := true
|
||||
if body.Active != nil {
|
||||
active = *body.Active
|
||||
}
|
||||
plan, err := h.Svc.UpdateMembershipPlan(
|
||||
c.Request.Context(), adminID, c.Param("code"), body.Title, body.DurationDays, body.AmountCents, active,
|
||||
)
|
||||
if errors.Is(err, admin.ErrPlanNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40400, "plan not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrInvalidPlanU) {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, plan)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerModerationCases(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/content-safety")
|
||||
g.GET("/cases", middleware.RequireAdminPermission(h.Svc, admin.PermContentSafetyRead), h.ListModerationCases)
|
||||
g.GET("/cases/:id", middleware.RequireAdminPermission(h.Svc, admin.PermContentSafetyRead), h.GetModerationCase)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListModerationCases(c *gin.Context) {
|
||||
items, err := h.Svc.ListModerationCases(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list moderation-case failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetModerationCase(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetModerationCase(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrModerationCaseNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "moderation-case not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get moderation-case failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerPrivacyRequests(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/privacy")
|
||||
g.GET("/requests", middleware.RequireAdminPermission(h.Svc, admin.PermPrivacyRead), h.ListPrivacyRequests)
|
||||
g.GET("/requests/:id", middleware.RequireAdminPermission(h.Svc, admin.PermPrivacyRead), h.GetPrivacyRequest)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListPrivacyRequests(c *gin.Context) {
|
||||
items, err := h.Svc.ListPrivacyRequests(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list privacy-request failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetPrivacyRequest(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetPrivacyRequest(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrPrivacyRequestNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "privacy-request not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get privacy-request failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerQualityFeedback(authed *gin.RouterGroup) {
|
||||
authed.GET("/ask/feedback", middleware.RequireAdminPermission(h.Svc, admin.PermAskRead), h.ListAskFeedback)
|
||||
authed.POST("/ask/threads/:id/feedback", middleware.RequireAdminPermission(h.Svc, admin.PermAskFeedbackWrite), h.CreateAskFeedback)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListAskFeedback(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
items, err := h.Svc.ListQualityFeedback(c.Request.Context(), limit, offset)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50025, "list feedback failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CreateAskFeedback(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
|
||||
return
|
||||
}
|
||||
threadID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid thread id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Rating int `json:"rating"`
|
||||
Tag string `json:"tag"`
|
||||
Note string `json:"note"`
|
||||
MessageID *string `json:"message_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid body")
|
||||
return
|
||||
}
|
||||
var msgID *uuid.UUID
|
||||
if body.MessageID != nil && *body.MessageID != "" {
|
||||
id, err := uuid.Parse(*body.MessageID)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid message_id")
|
||||
return
|
||||
}
|
||||
msgID = &id
|
||||
}
|
||||
row, err := h.Svc.CreateQualityFeedback(c.Request.Context(), adminID, threadID, msgID, body.Rating, body.Tag, body.Note)
|
||||
if errors.Is(err, admin.ErrBadFeedbackRating) || errors.Is(err, admin.ErrBadFeedbackTag) || errors.Is(err, admin.ErrFeedbackNoteLong) {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrAskThreadNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40402, "ask thread not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50026, "create feedback failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerRBAC(authed *gin.RouterGroup) {
|
||||
authed.GET("/roles", middleware.RequireAdminPermission(h.Svc, admin.PermRolesRead), h.ListRoles)
|
||||
authed.GET("/roles/:id", middleware.RequireAdminPermission(h.Svc, admin.PermRolesRead), h.GetRole)
|
||||
authed.PUT("/roles/:id/permissions", middleware.RequireAdminPermission(h.Svc, admin.PermRolesWrite), h.PutRolePermissions)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListRoles(c *gin.Context) {
|
||||
items, err := h.Svc.ListRoles(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetRole(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid id")
|
||||
return
|
||||
}
|
||||
role, err := h.Svc.GetRole(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrRoleNotFound) || role == nil {
|
||||
response.Fail(c, http.StatusNotFound, 40400, "role not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, role)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) PutRolePermissions(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
|
||||
return
|
||||
}
|
||||
roleID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid body")
|
||||
return
|
||||
}
|
||||
if body.Permissions == nil {
|
||||
body.Permissions = []string{}
|
||||
}
|
||||
err = h.Svc.ReplaceRolePermissions(c.Request.Context(), adminID, roleID, body.Permissions)
|
||||
if errors.Is(err, admin.ErrRoleNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40400, "role not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrInvalidPerm) {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
role, err := h.Svc.GetRole(c.Request.Context(), roleID)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, role)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerRedemption(authed *gin.RouterGroup) {
|
||||
authed.POST("/redemption-batches", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipCodesWrite), h.CreateRedemptionBatch)
|
||||
authed.GET("/redemption-batches", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipCodesRead), h.ListRedemptionBatches)
|
||||
authed.GET("/redemption-batches/:id/codes", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipCodesRead), h.ListRedemptionCodes)
|
||||
authed.POST("/redemption-codes/:id/disable", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipCodesWrite), h.DisableRedemptionCode)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CreateRedemptionBatch(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Label string `json:"label"`
|
||||
PlanCode string `json:"plan_code"`
|
||||
Quantity int `json:"quantity"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid body")
|
||||
return
|
||||
}
|
||||
batch, codes, err := h.Svc.CreateRedemptionBatch(c.Request.Context(), adminID, body.Label, body.PlanCode, body.Quantity)
|
||||
if errors.Is(err, admin.ErrBadBatchQty) || errors.Is(err, admin.ErrPlanNotFound) || errors.Is(err, admin.ErrInvalidPlanU) {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"batch": batch, "codes": codes})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListRedemptionBatches(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
items, err := h.Svc.ListRedemptionBatches(c.Request.Context(), limit)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListRedemptionCodes(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid id")
|
||||
return
|
||||
}
|
||||
items, err := h.Svc.ListRedemptionCodes(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrBatchNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40400, "batch not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) DisableRedemptionCode(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid id")
|
||||
return
|
||||
}
|
||||
err = h.Svc.DisableRedemptionCode(c.Request.Context(), adminID, id)
|
||||
if errors.Is(err, admin.ErrCodeDisable) {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerReportTemplates(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/growth")
|
||||
g.GET("/report-templates", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthRead), h.ListReportTemplates)
|
||||
g.GET("/report-templates/:id", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthRead), h.GetReportTemplate)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListReportTemplates(c *gin.Context) {
|
||||
items, err := h.Svc.ListReportTemplates(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list report-template failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetReportTemplate(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetReportTemplate(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrReportTemplateNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "report-template not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get report-template failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerRhythmConfigs(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/explore")
|
||||
g.GET("/rhythm-configs", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.ListRhythmConfigs)
|
||||
g.GET("/rhythm-configs/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.GetRhythmConfig)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListRhythmConfigs(c *gin.Context) {
|
||||
items, err := h.Svc.ListRhythmConfigs(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list rhythm-config failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetRhythmConfig(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetRhythmConfig(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrRhythmConfigNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "rhythm-config not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get rhythm-config failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerCMSPublications(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/cms")
|
||||
g.GET("/publications", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.ListScheduledPublications)
|
||||
g.GET("/publications/:id", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.GetScheduledPublication)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListScheduledPublications(c *gin.Context) {
|
||||
items, err := h.Svc.ListScheduledPublications(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list scheduled-publication failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetScheduledPublication(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetScheduledPublication(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrScheduledPublicationNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "scheduled-publication not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get scheduled-publication failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerStarConfigs(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/explore")
|
||||
g.GET("/star-configs", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.ListStarConfigs)
|
||||
g.GET("/star-configs/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.GetStarConfig)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListStarConfigs(c *gin.Context) {
|
||||
items, err := h.Svc.ListStarConfigs(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list star-config failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetStarConfig(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetStarConfig(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrStarConfigNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "star-config not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get star-config failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerToolDefinitions(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/ai")
|
||||
g.GET("/tools", middleware.RequireAdminPermission(h.Svc, admin.PermAIConfigRead), h.ListToolDefinitions)
|
||||
g.GET("/tools/:id", middleware.RequireAdminPermission(h.Svc, admin.PermAIConfigRead), h.GetToolDefinition)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListToolDefinitions(c *gin.Context) {
|
||||
items, err := h.Svc.ListToolDefinitions(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list tool-definition failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetToolDefinition(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetToolDefinition(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrToolDefinitionNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "tool-definition not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get tool-definition failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -28,6 +28,7 @@ func (h *AskHandler) Register(rg *gin.RouterGroup) {
|
||||
rg.DELETE("/ask/threads/:id", h.ClearThread)
|
||||
rg.GET("/ask/threads/:id/messages", h.ListMessages)
|
||||
rg.POST("/ask/threads/:id/messages", h.SendMessage)
|
||||
h.registerFeedback(rg)
|
||||
}
|
||||
|
||||
// GetQuota handles GET /ask/quota.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
asksvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/ask"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AskHandler) registerFeedback(rg *gin.RouterGroup) {
|
||||
rg.POST("/ask/threads/:id/feedback", h.SubmitFeedback)
|
||||
}
|
||||
|
||||
// SubmitFeedback handles POST /ask/threads/:id/feedback.
|
||||
func (h *AskHandler) SubmitFeedback(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||
return
|
||||
}
|
||||
threadID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid thread id")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Rating int `json:"rating"`
|
||||
Tag string `json:"tag"`
|
||||
Note string `json:"note"`
|
||||
MessageID *string `json:"message_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
|
||||
return
|
||||
}
|
||||
var msgID *uuid.UUID
|
||||
if req.MessageID != nil && *req.MessageID != "" {
|
||||
id, err := uuid.Parse(*req.MessageID)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid message_id")
|
||||
return
|
||||
}
|
||||
msgID = &id
|
||||
}
|
||||
row, err := h.Svc.SubmitFeedback(c.Request.Context(), userID, threadID, asksvc.SubmitFeedbackInput{
|
||||
MessageID: msgID, Rating: req.Rating, Tag: req.Tag, Note: req.Note,
|
||||
})
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "rating") || strings.Contains(msg, "tag") || strings.Contains(msg, "note") {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, msg)
|
||||
return
|
||||
}
|
||||
if strings.Contains(msg, "thread not found") {
|
||||
response.Fail(c, http.StatusNotFound, 40410, msg)
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, msg)
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -51,6 +52,10 @@ func (h *AuthHandler) RegisterAccount(c *gin.Context) {
|
||||
if failTextCompliance(c, err) {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, auth.ErrAccountRestricted) {
|
||||
response.Fail(c, http.StatusUnauthorized, 40113, err.Error())
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusBadRequest, 40110, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -72,6 +77,10 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
deviceKey := c.GetHeader(middleware.DeviceKeyHeader)
|
||||
res, err := h.Svc.Login(c.Request.Context(), userID, deviceKey, body.Phone, body.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrAccountRestricted) {
|
||||
response.Fail(c, http.StatusUnauthorized, 40113, err.Error())
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusBadRequest, 40111, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ func (h *ReportHandler) Register(rg *gin.RouterGroup) {
|
||||
rg.GET("/reports/latest", h.GetLatest)
|
||||
rg.GET("/reports/:id", h.Get)
|
||||
rg.GET("/membership/me", h.GetMembership)
|
||||
rg.POST("/membership/redeem", h.RedeemCode)
|
||||
rg.POST("/orders", h.CreateOrder)
|
||||
rg.POST("/orders/:id/pay-mock", h.PayMock)
|
||||
}
|
||||
@@ -257,6 +258,37 @@ func (h *ReportHandler) GetMembership(c *gin.Context) {
|
||||
response.OK(c, me)
|
||||
}
|
||||
|
||||
// RedeemCode handles POST /membership/redeem.
|
||||
func (h *ReportHandler) RedeemCode(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||
return
|
||||
}
|
||||
msvc, ok := h.requireMembership(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Code == "" {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "code required")
|
||||
return
|
||||
}
|
||||
plan, err := msvc.Redeem(c.Request.Context(), userID, body.Code)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
|
||||
return
|
||||
}
|
||||
me, err := msvc.Get(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"plan": plan, "membership": me})
|
||||
}
|
||||
|
||||
// CreateOrder handles POST /orders.
|
||||
func (h *ReportHandler) CreateOrder(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestAccountLifecycle(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
superTok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
// AC-S-03 / AC-S-04: no admin session
|
||||
_, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+uuid.New().String()+"/status",
|
||||
map[string]string{"status": "banned", "reason": "x"}, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 POST status, got %d", code)
|
||||
}
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users/"+uuid.New().String()+"/status-transitions", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 GET transitions, got %d", code)
|
||||
}
|
||||
|
||||
key := mustRegister(t, r)
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, superTok)
|
||||
if code != 200 {
|
||||
t.Fatalf("list users: %d", code)
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
if len(list.Items) == 0 {
|
||||
t.Fatal("need user")
|
||||
}
|
||||
userID := list.Items[0].ID
|
||||
|
||||
// AC-F-01 ban
|
||||
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/status",
|
||||
map[string]string{"status": "banned", "reason": "abuse"}, superTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("ban failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
var detail struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &detail)
|
||||
if detail.Status != "banned" {
|
||||
t.Fatalf("expected banned, got %s", detail.Status)
|
||||
}
|
||||
|
||||
// AC-F-03 same status
|
||||
_, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/status",
|
||||
map[string]string{"status": "banned", "reason": "again"}, superTok)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 same status, got %d", code)
|
||||
}
|
||||
|
||||
// AC-S-02 C-end reject
|
||||
if code := deviceGET(t, r, "/api/v1/auth/me", key, testBearer); code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 banned bearer, got %d", code)
|
||||
}
|
||||
|
||||
// AC-F-04 + AC-P-01 + AC-O
|
||||
start := time.Now()
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users/"+userID+"/status-transitions?limit=50", nil, superTok)
|
||||
if code != 200 || time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("transitions failed/slow http=%d dur=%v", code, time.Since(start))
|
||||
}
|
||||
var tr struct {
|
||||
Items []struct {
|
||||
FromStatus string `json:"from_status"`
|
||||
ToStatus string `json:"to_status"`
|
||||
Reason string `json:"reason"`
|
||||
AdminID string `json:"admin_id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &tr)
|
||||
if len(tr.Items) == 0 || tr.Items[0].ToStatus != "banned" || tr.Items[0].Reason != "abuse" || tr.Items[0].AdminID == "" {
|
||||
t.Fatalf("unexpected transitions %#v", tr.Items)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/audit-logs", nil, superTok)
|
||||
if code != 200 {
|
||||
t.Fatalf("audit %d", code)
|
||||
}
|
||||
var audit struct {
|
||||
Items []struct {
|
||||
Action string `json:"action"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &audit)
|
||||
found := false
|
||||
for _, it := range audit.Items {
|
||||
if it.Action == "users.status.transition" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("missing users.status.transition audit")
|
||||
}
|
||||
|
||||
// AC-F-02 restore
|
||||
_, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/status",
|
||||
map[string]string{"status": "active", "reason": "appeal"}, superTok)
|
||||
if code != 200 {
|
||||
t.Fatalf("restore failed %d", code)
|
||||
}
|
||||
if code := deviceGET(t, r, "/api/v1/auth/me", key, testBearer); code != 200 {
|
||||
t.Fatalf("expected me ok after unban, got %d", code)
|
||||
}
|
||||
|
||||
// AC-S-01 limited admin without status.write
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`, limitedRoleID, "lc_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = pool.Exec(ctx, `
|
||||
INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
limitedUser := fmt.Sprintf("lc_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limitedUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limitedUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limitedTok := adminLogin(t, r, limitedUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/status",
|
||||
map[string]string{"status": "suspended", "reason": "nope"}, limitedTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 status.write, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func deviceGET(t *testing.T, r http.Handler, path, deviceKey, bearer string) int {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, bytes.NewReader(nil))
|
||||
if deviceKey != "" {
|
||||
req.Header.Set("X-Device-Key", deviceKey)
|
||||
}
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w.Code
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/config"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/db"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/httpserver"
|
||||
)
|
||||
|
||||
func setupAPIPool(t *testing.T) (*gin.Engine, *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
cfg := config.Load()
|
||||
cfg.Admin.BootstrapUsername = "admin"
|
||||
cfg.Admin.BootstrapPassword = "change-me"
|
||||
pool, err := db.Connect(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (run npm run deps:up): %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
|
||||
migDir := filepath.Join("..", "..", "migrations")
|
||||
if err := db.Migrate(ctx, pool, migDir); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return httpserver.NewRouter(pool, cfg), pool
|
||||
}
|
||||
|
||||
func adminLogin(t *testing.T, r http.Handler, user, pass string) string {
|
||||
t.Helper()
|
||||
env, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/auth/login", map[string]string{
|
||||
"username": user, "password": pass,
|
||||
}, "")
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("login %s failed http=%d code=%d msg=%s", user, code, env.Code, env.Message)
|
||||
}
|
||||
var login struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &login); err != nil || login.Token == "" {
|
||||
t.Fatalf("login token missing: %v %s", err, env.Data)
|
||||
}
|
||||
return login.Token
|
||||
}
|
||||
|
||||
func TestAdminRBAC(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
superTok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
// AC-S-03: no admin session → 401
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/roles", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 without admin token, got http=%d", code)
|
||||
}
|
||||
|
||||
// AC-F-03: /me includes permissions
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/me", nil, superTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("me failed http=%d code=%d", code, env.Code)
|
||||
}
|
||||
var me struct {
|
||||
Permissions []string `json:"permissions"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &me); err != nil || len(me.Permissions) == 0 {
|
||||
t.Fatalf("expected permissions on me: %v %s", err, env.Data)
|
||||
}
|
||||
if me.Role != "super_admin" {
|
||||
t.Fatalf("expected super_admin role, got %q", me.Role)
|
||||
}
|
||||
|
||||
// AC-F-01: list roles
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/roles", nil, superTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list roles failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
var roles struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &roles); err != nil || len(roles.Items) == 0 {
|
||||
t.Fatalf("expected roles: %v %s", err, env.Data)
|
||||
}
|
||||
var superRoleID string
|
||||
for _, it := range roles.Items {
|
||||
if it.Name == "super_admin" {
|
||||
superRoleID = it.ID
|
||||
}
|
||||
}
|
||||
if superRoleID == "" {
|
||||
t.Fatal("super_admin role missing")
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/roles/"+superRoleID, nil, superTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("get role failed http=%d code=%d", code, env.Code)
|
||||
}
|
||||
|
||||
// Seed limited role + account for deny ACs
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO admin_roles(id, name, system) VALUES ($1, $2, false)
|
||||
ON CONFLICT (name) DO NOTHING`, limitedRoleID, "rbac_limited_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatalf("insert role: %v", err)
|
||||
}
|
||||
// resolve actual id if conflict
|
||||
var roleName string
|
||||
err = pool.QueryRow(ctx, `SELECT id, name FROM admin_roles WHERE id=$1`, limitedRoleID).Scan(&limitedRoleID, &roleName)
|
||||
if err != nil {
|
||||
t.Fatalf("load limited role: %v", err)
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limitedUser := fmt.Sprintf("limited_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash, role_id)
|
||||
VALUES ($1, $2, $3)`, limitedUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert limited admin: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limitedUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
|
||||
limitedTok := adminLogin(t, r, limitedUser, "limited-pass")
|
||||
|
||||
// AC-S-01: no roles.write → 403
|
||||
env, code = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/roles/"+limitedRoleID.String()+"/permissions",
|
||||
map[string]any{"permissions": []string{"admin.users.read"}}, limitedTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 roles.write, got http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
|
||||
// AC-S-02: no membership.grant → 403
|
||||
_ = mustRegister(t, r)
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, superTok)
|
||||
if code != 200 {
|
||||
t.Fatalf("list users: %d", code)
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
if len(list.Items) == 0 {
|
||||
t.Fatal("need a user for grant deny")
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+list.Items[0].ID+"/membership/grant",
|
||||
map[string]string{"plan": "month"}, limitedTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 membership.grant, got http=%d code=%d", code, env.Code)
|
||||
}
|
||||
|
||||
// AC-F-02 + AC-O-01: super replaces permissions
|
||||
want := []string{"admin.users.read", "admin.roles.read"}
|
||||
env, code = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/roles/"+limitedRoleID.String()+"/permissions",
|
||||
map[string]any{"permissions": want}, superTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("put permissions failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/roles/"+limitedRoleID.String(), nil, superTok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get after put: %d", code)
|
||||
}
|
||||
var roleDetail struct {
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &roleDetail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(roleDetail.Permissions) != 2 {
|
||||
t.Fatalf("expected 2 perms, got %#v", roleDetail.Permissions)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/audit-logs", nil, superTok)
|
||||
if code != 200 {
|
||||
t.Fatalf("audit: %d", code)
|
||||
}
|
||||
var audit struct {
|
||||
Items []struct {
|
||||
Action string `json:"action"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &audit)
|
||||
foundUpdate, foundDeny := false, false
|
||||
for _, it := range audit.Items {
|
||||
if it.Action == "roles.permissions.update" {
|
||||
foundUpdate = true
|
||||
}
|
||||
if it.Action == "permission.denied" {
|
||||
foundDeny = true
|
||||
}
|
||||
}
|
||||
if !foundUpdate {
|
||||
t.Fatal("expected roles.permissions.update audit")
|
||||
}
|
||||
if !foundDeny {
|
||||
t.Fatal("expected permission.denied audit")
|
||||
}
|
||||
|
||||
// AC-S-04: system role cannot be deleted (FK + system seed; no Delete API)
|
||||
tag, err := pool.Exec(ctx, `DELETE FROM admin_roles WHERE name='super_admin'`)
|
||||
if err == nil && tag.RowsAffected() > 0 {
|
||||
t.Fatal("expected delete super_admin to fail or affect 0 rows")
|
||||
}
|
||||
|
||||
// AC-P-01: list roles under 500ms locally
|
||||
start := time.Now()
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/roles", nil, superTok)
|
||||
if code != 200 || time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("AC-P-01 list roles slow or failed: http=%d dur=%v", code, time.Since(start))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestAICoreSystemPrompts(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/system-prompts", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "ai_lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("ailim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/system-prompts", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/system-prompts", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Body string `json:"body"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
var askID string
|
||||
for _, it := range list.Items {
|
||||
if it.Code == "ask_default" {
|
||||
askID = it.ID
|
||||
if it.Body == "" {
|
||||
t.Fatal("ask_default body empty in list")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if askID == "" {
|
||||
t.Fatalf("missing ask_default: %#v", list.Items)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/system-prompts/"+askID, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
var detail struct {
|
||||
Body string `json:"body"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &detail)
|
||||
if detail.Code != "ask_default" || detail.Body == "" {
|
||||
t.Fatalf("bad detail %#v", detail)
|
||||
}
|
||||
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/system-prompts/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestAskOperations(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/threads", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO admin_roles(id, name, system) VALUES ($1, $2, false)`,
|
||||
limitedRoleID, "ask_lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatalf("insert role: %v", err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limitedUser := fmt.Sprintf("asklim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash, role_id)
|
||||
VALUES ($1,$2,$3)`, limitedUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limitedUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
|
||||
limTok := adminLogin(t, r, limitedUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/threads", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 without ask.read, got %d", code)
|
||||
}
|
||||
|
||||
key := mustRegister(t, r)
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||
"relation": "self", "birth_date": "1992-06-01", "display_name": "问",
|
||||
}, key)
|
||||
profileID := decodeData[map[string]any](t, env.Data)["id"].(string)
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads", map[string]any{
|
||||
"profile_id": profileID, "scene": "self",
|
||||
}, key)
|
||||
threadID := decodeData[map[string]any](t, env.Data)["id"].(string)
|
||||
|
||||
_, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads/"+threadID+"/messages", map[string]any{
|
||||
"content": "运营可读吗",
|
||||
}, key)
|
||||
|
||||
start := time.Now()
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/threads", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow: %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
MessageCount int `json:"message_count"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
found := false
|
||||
for _, it := range list.Items {
|
||||
if it.ID == threadID && it.MessageCount >= 1 {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected thread %s in list: %#v", threadID, list.Items)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/threads/"+threadID, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("detail http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
var detail struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &detail)
|
||||
if len(detail.Messages) < 2 {
|
||||
t.Fatalf("expected user+assistant, got %#v", detail.Messages)
|
||||
}
|
||||
_ = key
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestContentSafetyBlockPolicies(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/block-policies", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("lim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/block-policies", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/block-policies", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
var id string
|
||||
for _, it := range list.Items {
|
||||
if it.Code == "block_spam_link" {
|
||||
id = it.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatalf("missing block_spam_link: %#v", list.Items)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/block-policies/"+id, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/block-policies/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestOpsCMSBanners(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/banners", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "cms_lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("cmslim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/banners", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/banners", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
var id string
|
||||
for _, it := range list.Items {
|
||||
if it.Code == "home_promo" {
|
||||
id = it.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatalf("missing home_promo: %#v", list.Items)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/banners/"+id, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
var detail struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &detail)
|
||||
if detail.Code != "home_promo" {
|
||||
t.Fatalf("bad detail %#v", detail)
|
||||
}
|
||||
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/banners/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestOpsCMSFeedSlots(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/feed-slots", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "fs_lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("fslim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/feed-slots", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/feed-slots", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
var id string
|
||||
for _, it := range list.Items {
|
||||
if it.Code == "home_feed_main" {
|
||||
id = it.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatalf("missing home_feed_main: %#v", list.Items)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/feed-slots/"+id, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
var detail struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &detail)
|
||||
if detail.Code != "home_feed_main" {
|
||||
t.Fatalf("bad detail %#v", detail)
|
||||
}
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/feed-slots/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestContentSafetyFilterRules(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/filter-rules", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "cs_lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("cslim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/filter-rules", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/filter-rules", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
System bool `json:"system"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
if len(list.Items) < 1 {
|
||||
t.Fatal("expected seeded filter rules")
|
||||
}
|
||||
var firstID string
|
||||
for _, it := range list.Items {
|
||||
if it.System {
|
||||
firstID = it.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if firstID == "" {
|
||||
firstID = list.Items[0].ID
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/filter-rules/"+firstID, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/filter-rules/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/content-safety/evaluate",
|
||||
map[string]string{"text": "真的不想活了怎么办"}, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("evaluate %d msg=%s", code, env.Message)
|
||||
}
|
||||
var ev struct {
|
||||
Matches []struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"matches"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &ev)
|
||||
if len(ev.Matches) < 1 {
|
||||
t.Fatalf("expected match, got %#v", ev)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestCrisisCareEvents(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/events", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("lim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/events", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/events", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
var id string
|
||||
for _, it := range list.Items {
|
||||
if it.Code == "demo_crisis_event" {
|
||||
id = it.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatalf("missing demo_crisis_event: %#v", list.Items)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/events/"+id, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/events/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestCrisisCarePolicies(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/policies", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "cr_lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("crlim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/policies", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/policies", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
System bool `json:"system"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
if len(list.Items) < 1 {
|
||||
t.Fatal("expected seeded crisis policies")
|
||||
}
|
||||
firstID := list.Items[0].ID
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/policies/"+firstID, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/policies/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/crisis/evaluate",
|
||||
map[string]string{"text": "我真的不想活了"}, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("evaluate %d msg=%s", code, env.Message)
|
||||
}
|
||||
var ev struct {
|
||||
Matches []struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"matches"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &ev)
|
||||
if len(ev.Matches) < 1 {
|
||||
t.Fatalf("expected match, got %#v", ev)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUserEntitlements(t *testing.T) {
|
||||
r, _ := setupAPIPool(t)
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users/"+fakeUUID()+"/entitlements", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
testBearer = ""
|
||||
phone := fmt.Sprintf("1%010d", time.Now().UnixNano()%10_000_000_000)
|
||||
nick := "ent_" + phone[7:]
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/auth/register", map[string]any{
|
||||
"phone": phone, "password": "secret12", "nickname": nick,
|
||||
}, "")
|
||||
sess := decodeData[map[string]any](t, env.Data)
|
||||
testBearer = sess["token"].(string)
|
||||
t.Cleanup(func() { testBearer = "" })
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||
"relation": "self", "birth_date": "1990-01-01", "display_name": "权",
|
||||
}, key)
|
||||
profileID := decodeData[map[string]any](t, env.Data)["id"].(string)
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/reports/portrait", map[string]any{
|
||||
"profile_id": profileID,
|
||||
}, key)
|
||||
reportID := decodeData[map[string]any](t, env.Data)["id"].(string)
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{
|
||||
"kind": "deep_access", "report_id": reportID,
|
||||
}, key)
|
||||
orderID := decodeData[map[string]any](t, env.Data)["order_id"].(string)
|
||||
_, key = doJSON(t, r, http.MethodPost, "/api/v1/orders/"+orderID+"/pay-mock", nil, key)
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users?q="+url.QueryEscape(nick), nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("list users %d", code)
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
userID := list.Items[0].ID
|
||||
|
||||
start := time.Now()
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users/"+userID+"/entitlements", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("entitlements http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("too slow %v", time.Since(start))
|
||||
}
|
||||
var before struct {
|
||||
Flags struct {
|
||||
ViaMem bool `json:"report_detail_via_membership"`
|
||||
Count int `json:"deep_access_count"`
|
||||
} `json:"flags"`
|
||||
Deep []any `json:"deep_accesses"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &before)
|
||||
if before.Flags.Count < 1 || len(before.Deep) < 1 {
|
||||
t.Fatalf("expected deep_access: %#v", before)
|
||||
}
|
||||
if before.Flags.ViaMem {
|
||||
t.Fatal("expected membership inactive before grant")
|
||||
}
|
||||
|
||||
_, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/membership/grant",
|
||||
map[string]string{"plan": "month"}, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("grant %d", code)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users/"+userID+"/entitlements", nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("after grant %d", code)
|
||||
}
|
||||
var after struct {
|
||||
Flags struct {
|
||||
ViaMem bool `json:"report_detail_via_membership"`
|
||||
} `json:"flags"`
|
||||
Membership struct {
|
||||
Active bool `json:"active"`
|
||||
} `json:"membership"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &after)
|
||||
if !after.Flags.ViaMem || !after.Membership.Active {
|
||||
t.Fatalf("expected active membership entitlement: %#v", after)
|
||||
}
|
||||
_ = key
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestExploreScaleDefinitions(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/scales", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "sc_lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("sclim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/scales", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/scales", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
if len(list.Items) == 0 {
|
||||
t.Fatal("expected at least one scale")
|
||||
}
|
||||
id := list.Items[0].ID
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/scales/"+id, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/scales/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestGrowthFunnelDefinitions(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/analytics/funnel-definitions", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("lim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/analytics/funnel-definitions", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/analytics/funnel-definitions", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
var id string
|
||||
for _, it := range list.Items {
|
||||
if it.Code == "signup_to_ask" {
|
||||
id = it.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatalf("missing signup_to_ask: %#v", list.Items)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/analytics/funnel-definitions/"+id, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/analytics/funnel-definitions/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestAskOpsHandoffs(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/handoffs", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("lim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/handoffs", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/handoffs", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
var id string
|
||||
for _, it := range list.Items {
|
||||
if it.Code == "demo_handoff" {
|
||||
id = it.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatalf("missing demo_handoff: %#v", list.Items)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/handoffs/"+id, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/handoffs/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestExploreImageCardDecks(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/image-card-decks", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("lim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/image-card-decks", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/image-card-decks", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
var id string
|
||||
for _, it := range list.Items {
|
||||
if it.Code == "default_deck" {
|
||||
id = it.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatalf("missing default_deck: %#v", list.Items)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/image-card-decks/"+id, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/image-card-decks/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestCrisisCareInterventions(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/interventions", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("lim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/interventions", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/interventions", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
var id string
|
||||
for _, it := range list.Items {
|
||||
if it.Code == "demo_helpline_shown" {
|
||||
id = it.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatalf("missing demo_helpline_shown: %#v", list.Items)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/interventions/"+id, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/interventions/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestAICoreKnowledgeChunks(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/knowledge-chunks", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("lim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/knowledge-chunks", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/knowledge-chunks", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
var id string
|
||||
for _, it := range list.Items {
|
||||
if it.Code == "ask_grounding_intro" {
|
||||
id = it.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatalf("missing ask_grounding_intro: %#v", list.Items)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/knowledge-chunks/"+id, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/knowledge-chunks/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestAICoreKnowledgeSources(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/knowledge-sources", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "ks_lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("kslim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/knowledge-sources", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/knowledge-sources", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
SourceKind string `json:"source_kind"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
var srcID string
|
||||
for _, it := range list.Items {
|
||||
if it.Code == "ask_grounding" {
|
||||
srcID = it.ID
|
||||
if it.SourceKind != "faq" && it.SourceKind != "policy" && it.SourceKind != "guide" {
|
||||
t.Fatalf("bad source_kind %q", it.SourceKind)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if srcID == "" {
|
||||
t.Fatalf("missing ask_grounding: %#v", list.Items)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/knowledge-sources/"+srcID, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
var detail struct {
|
||||
Code string `json:"code"`
|
||||
SourceKind string `json:"source_kind"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &detail)
|
||||
if detail.Code != "ask_grounding" || detail.SourceKind == "" {
|
||||
t.Fatalf("bad detail %#v", detail)
|
||||
}
|
||||
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/knowledge-sources/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMembershipPlans(t *testing.T) {
|
||||
r, _ := setupAPIPool(t)
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/membership-plans", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/membership-plans", nil, tok)
|
||||
if code != 200 || time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list plans http=%d dur=%v msg=%s", code, time.Since(start), env.Message)
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
Code string `json:"code"`
|
||||
DurationDays int `json:"duration_days"`
|
||||
AmountCents int `json:"amount_cents"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
if len(list.Items) < 3 {
|
||||
t.Fatalf("expected 3 plans, got %#v", list.Items)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/membership-plans/month", map[string]any{
|
||||
"title": "月卡测", "duration_days": 30, "amount_cents": 2600, "active": true,
|
||||
}, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("put failed %d %s", code, env.Message)
|
||||
}
|
||||
var plan struct {
|
||||
DurationDays int `json:"duration_days"`
|
||||
AmountCents int `json:"amount_cents"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &plan)
|
||||
if plan.DurationDays != 30 || plan.AmountCents != 2600 {
|
||||
t.Fatalf("unexpected plan %#v", plan)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/membership-plans/month", nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &plan)
|
||||
if plan.DurationDays != 30 {
|
||||
t.Fatalf("get mismatch %#v", plan)
|
||||
}
|
||||
|
||||
_ = mustRegister(t, r)
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, tok)
|
||||
var users struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &users)
|
||||
if len(users.Items) == 0 {
|
||||
t.Fatal("need user")
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+users.Items[0].ID+"/membership/grant",
|
||||
map[string]string{"plan": "month"}, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("grant %d %s", code, env.Message)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/audit-logs", nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("audit %d", code)
|
||||
}
|
||||
var audit struct {
|
||||
Items []struct {
|
||||
Action string `json:"action"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &audit)
|
||||
found := false
|
||||
for _, it := range audit.Items {
|
||||
if it.Action == "membership.plans.update" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("missing membership.plans.update audit")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestContentSafetyModerationCases(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/cases", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("lim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/cases", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/cases", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
var id string
|
||||
for _, it := range list.Items {
|
||||
if it.Code == "demo_case_seed" {
|
||||
id = it.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatalf("missing demo_case_seed: %#v", list.Items)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/cases/"+id, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/cases/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,9 @@ func TestFlowStarDeepAccess(t *testing.T) {
|
||||
if sum["fortune"] != nil {
|
||||
t.Fatal("legacy fortune key must be removed (ECR-003)")
|
||||
}
|
||||
if raw, _ := json.Marshal(sum); strings.Contains(string(raw), `"lucky"`) {
|
||||
t.Fatal("legacy lucky field must not appear in star summary (ECR-012)")
|
||||
}
|
||||
reportID := rep["id"].(string)
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestAdminPrivacyRequests(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/privacy/requests", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("lim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/privacy/requests", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/privacy/requests", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
var id string
|
||||
for _, it := range list.Items {
|
||||
if it.Code == "demo_export_req" {
|
||||
id = it.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatalf("missing demo_export_req: %#v", list.Items)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/privacy/requests/"+id, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/privacy/requests/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestQualityFeedback(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/feedback", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "qf_lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.ask.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("qflim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
|
||||
key := mustRegister(t, r)
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||
"relation": "self", "birth_date": "1993-03-03", "display_name": "评",
|
||||
}, key)
|
||||
profileID := decodeData[map[string]any](t, env.Data)["id"].(string)
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads", map[string]any{
|
||||
"profile_id": profileID, "scene": "self",
|
||||
}, key)
|
||||
threadID := decodeData[map[string]any](t, env.Data)["id"].(string)
|
||||
_, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads/"+threadID+"/messages", map[string]any{
|
||||
"content": "打分测试",
|
||||
}, key)
|
||||
|
||||
_, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/ask/threads/"+threadID+"/feedback",
|
||||
map[string]any{"rating": 4, "tag": "helpful"}, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 without feedback.write, got %d", code)
|
||||
}
|
||||
|
||||
_, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/ask/threads/"+threadID+"/feedback",
|
||||
map[string]any{"rating": 9}, tok)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 bad rating, got %d", code)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/ask/threads/"+threadID+"/feedback",
|
||||
map[string]any{"rating": 5, "tag": "helpful", "note": "ops ok"}, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("admin feedback http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/feedback", nil, tok)
|
||||
if code != 200 || time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list http=%d dur=%v", code, time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ThreadID string `json:"thread_id"`
|
||||
Source string `json:"source"`
|
||||
Rating int `json:"rating"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
found := false
|
||||
for _, it := range list.Items {
|
||||
if it.ThreadID == threadID && it.Source == "admin" && it.Rating == 5 {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("admin feedback missing: %#v", list.Items)
|
||||
}
|
||||
|
||||
env, _, httpCode := doJSONExpect(t, r, http.MethodPost, "/api/v1/ask/threads/"+threadID+"/feedback",
|
||||
map[string]any{"rating": 3, "tag": "other"}, key, 0)
|
||||
if httpCode != 200 {
|
||||
t.Fatalf("user feedback http=%d body=%s", httpCode, env.Data)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/audit-logs", nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("audit %d", code)
|
||||
}
|
||||
var audit struct {
|
||||
Items []struct {
|
||||
Action string `json:"action"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &audit)
|
||||
okAudit := false
|
||||
for _, it := range audit.Items {
|
||||
if it.Action == "ask.feedback.create" {
|
||||
okAudit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !okAudit {
|
||||
t.Fatal("missing ask.feedback.create audit")
|
||||
}
|
||||
_ = key
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRedemptionCodes(t *testing.T) {
|
||||
r, _ := setupAPIPool(t)
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/redemption-batches",
|
||||
map[string]any{"label": "t", "plan_code": "month", "quantity": 2}, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/redemption-batches",
|
||||
map[string]any{"label": "ops-test", "plan_code": "month", "quantity": 3}, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("create batch http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
var created struct {
|
||||
Batch struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"batch"`
|
||||
Codes []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Status string `json:"status"`
|
||||
} `json:"codes"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &created)
|
||||
if len(created.Codes) != 3 {
|
||||
t.Fatalf("want 3 codes, got %#v", created.Codes)
|
||||
}
|
||||
raw := created.Codes[0].Code
|
||||
disableID := created.Codes[2].ID
|
||||
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/redemption-batches", nil, tok)
|
||||
if code != 200 || time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list batches http=%d dur=%v", code, time.Since(start))
|
||||
}
|
||||
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/redemption-batches/"+created.Batch.ID+"/codes", nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("list codes %d", code)
|
||||
}
|
||||
|
||||
key := mustRegister(t, r)
|
||||
_, _, httpCode := doJSONExpect(t, r, http.MethodPost, "/api/v1/membership/redeem",
|
||||
map[string]string{"code": raw}, key, 0)
|
||||
if httpCode != 200 {
|
||||
t.Fatalf("redeem http=%d", httpCode)
|
||||
}
|
||||
|
||||
_, _, httpCode = doJSONExpect(t, r, http.MethodPost, "/api/v1/membership/redeem",
|
||||
map[string]string{"code": raw}, key, 40000)
|
||||
if httpCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected HTTP 400 re-redeem, got %d", httpCode)
|
||||
}
|
||||
|
||||
_, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/redemption-codes/"+disableID+"/disable", nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("disable %d", code)
|
||||
}
|
||||
_, _, httpCode = doJSONExpect(t, r, http.MethodPost, "/api/v1/membership/redeem",
|
||||
map[string]string{"code": created.Codes[2].Code}, key, 40000)
|
||||
if httpCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected HTTP 400 disabled, got %d", httpCode)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/audit-logs", nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("audit %d", code)
|
||||
}
|
||||
var audit struct {
|
||||
Items []struct {
|
||||
Action string `json:"action"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &audit)
|
||||
found := false
|
||||
for _, it := range audit.Items {
|
||||
if it.Action == "redemption.batch.create" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("missing redemption.batch.create audit")
|
||||
}
|
||||
|
||||
if code := deviceGET(t, r, "/api/v1/membership/me", "dev_orphan_"+time.Now().Format("150405"), ""); code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 unregistered membership, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestGrowthReportTemplates(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/growth/report-templates", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("lim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/growth/report-templates", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/growth/report-templates", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
var id string
|
||||
for _, it := range list.Items {
|
||||
if it.Code == "portrait_default" {
|
||||
id = it.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatalf("missing portrait_default: %#v", list.Items)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/growth/report-templates/"+id, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/growth/report-templates/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestExploreRhythmConfigs(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/rhythm-configs", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("lim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/rhythm-configs", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/rhythm-configs", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
var id string
|
||||
for _, it := range list.Items {
|
||||
if it.Code == "default_rhythm" {
|
||||
id = it.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatalf("missing default_rhythm: %#v", list.Items)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/rhythm-configs/"+id, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/rhythm-configs/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestOpsCMSPublications(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/publications", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("lim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/publications", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/publications", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
var id string
|
||||
for _, it := range list.Items {
|
||||
if it.Code == "home_banner_week" {
|
||||
id = it.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatalf("missing home_banner_week: %#v", list.Items)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/publications/"+id, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/publications/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestExploreStarConfigs(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/star-configs", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("lim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/star-configs", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/star-configs", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
var id string
|
||||
for _, it := range list.Items {
|
||||
if it.Code == "default_star" {
|
||||
id = it.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatalf("missing default_star: %#v", list.Items)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/star-configs/"+id, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/star-configs/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestAICoreToolDefinitions(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/tools", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("lim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/tools", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/tools", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
var id string
|
||||
for _, it := range list.Items {
|
||||
if it.Code == "fetch_profile_summary" {
|
||||
id = it.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatalf("missing fetch_profile_summary: %#v", list.Items)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/tools/"+id, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/tools/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUserInsight(t *testing.T) {
|
||||
r, _ := setupAPIPool(t)
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users/"+fakeUUID()+"/insight", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 without token, got %d", code)
|
||||
}
|
||||
|
||||
testBearer = ""
|
||||
phone := fmt.Sprintf("1%010d", time.Now().UnixNano()%10_000_000_000)
|
||||
nick := "insight_" + phone[7:]
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/auth/register", map[string]any{
|
||||
"phone": phone, "password": "secret12", "nickname": nick,
|
||||
}, "")
|
||||
sess := decodeData[map[string]any](t, env.Data)
|
||||
tokUser, _ := sess["token"].(string)
|
||||
if tokUser == "" {
|
||||
t.Fatal("missing token")
|
||||
}
|
||||
testBearer = tokUser
|
||||
t.Cleanup(func() { testBearer = "" })
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||
"relation": "self", "birth_date": "1991-04-08", "display_name": "洞察测",
|
||||
}, key)
|
||||
profile := decodeData[map[string]any](t, env.Data)
|
||||
profileID, _ := profile["id"].(string)
|
||||
|
||||
_, key = doJSON(t, r, http.MethodPost, "/api/v1/reports/portrait", map[string]any{
|
||||
"profile_id": profileID,
|
||||
}, key)
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users?q="+url.QueryEscape(nick), nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("list users http=%d", code)
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
if len(list.Items) == 0 {
|
||||
t.Fatal("expected users")
|
||||
}
|
||||
userID := list.Items[0].ID
|
||||
|
||||
start := time.Now()
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users/"+userID+"/insight", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("insight http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("insight too slow: %v", time.Since(start))
|
||||
}
|
||||
var insight struct {
|
||||
UserID string `json:"user_id"`
|
||||
ProfilesCount int `json:"profiles_count"`
|
||||
ReportsByType []struct {
|
||||
Type string `json:"type"`
|
||||
Count int `json:"count"`
|
||||
} `json:"reports_by_type"`
|
||||
Tags []struct {
|
||||
Code string `json:"code"`
|
||||
Label string `json:"label"`
|
||||
} `json:"tags"`
|
||||
Behavior struct {
|
||||
Events []any `json:"events"`
|
||||
AskThreadCount int `json:"ask_thread_count"`
|
||||
} `json:"behavior"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &insight); err != nil {
|
||||
t.Fatalf("decode insight: %v %s", err, env.Data)
|
||||
}
|
||||
if insight.UserID != userID {
|
||||
t.Fatalf("user_id mismatch %s vs %s", insight.UserID, userID)
|
||||
}
|
||||
if insight.ProfilesCount < 1 {
|
||||
t.Fatalf("expected profiles_count>=1 got %d", insight.ProfilesCount)
|
||||
}
|
||||
foundPortrait := false
|
||||
for _, c := range insight.ReportsByType {
|
||||
if c.Type == "portrait" && c.Count >= 1 {
|
||||
foundPortrait = true
|
||||
}
|
||||
}
|
||||
if !foundPortrait {
|
||||
t.Fatalf("expected portrait in reports_by_type: %#v", insight.ReportsByType)
|
||||
}
|
||||
foundTag := false
|
||||
for _, tag := range insight.Tags {
|
||||
if tag.Code == "portrait" && tag.Label != "" {
|
||||
foundTag = true
|
||||
}
|
||||
}
|
||||
if !foundTag {
|
||||
t.Fatalf("expected portrait tag: %#v", insight.Tags)
|
||||
}
|
||||
if insight.Behavior.Events == nil {
|
||||
t.Fatal("behavior.events must be non-nil array")
|
||||
}
|
||||
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users/"+fakeUUID()+"/insight", nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 missing user, got %d", code)
|
||||
}
|
||||
_ = key
|
||||
}
|
||||
|
||||
func fakeUUID() string {
|
||||
return "00000000-0000-4000-8000-000000000099"
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
// AdminPermissionChecker validates admin permission codes.
|
||||
type AdminPermissionChecker interface {
|
||||
HasPermission(ctx context.Context, adminID uuid.UUID, code string) (bool, error)
|
||||
DenyPermission(ctx context.Context, adminID uuid.UUID, code, path string)
|
||||
}
|
||||
|
||||
// RequireAdminPermission aborts with 403 when the admin lacks code.
|
||||
func RequireAdminPermission(checker AdminPermissionChecker, code string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
adminID, ok := AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
okPerm, err := checker.HasPermission(c.Request.Context(), adminID, code)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, "permission check failed")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !okPerm {
|
||||
checker.DenyPermission(c.Request.Context(), adminID, code, c.FullPath())
|
||||
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,9 @@ func DeviceAuth(pool *pgxpool.Pool) gin.HandlerFunc {
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !ensureActiveUser(c, pool, uid) {
|
||||
return
|
||||
}
|
||||
_, _ = pool.Exec(c.Request.Context(), `
|
||||
INSERT INTO device_identities(device_key, user_id)
|
||||
VALUES ($1,$2)
|
||||
@@ -74,12 +77,29 @@ func DeviceAuth(pool *pgxpool.Pool) gin.HandlerFunc {
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !ensureActiveUser(c, pool, userID) {
|
||||
return
|
||||
}
|
||||
c.Set(string(UserIDKey), userID.String())
|
||||
c.Header(DeviceKeyHeader, key)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ensureActiveUser aborts with 401 when UserStatus is not active.
|
||||
func ensureActiveUser(c *gin.Context, pool *pgxpool.Pool, userID uuid.UUID) bool {
|
||||
var status string
|
||||
err := pool.QueryRow(c.Request.Context(), `
|
||||
SELECT status FROM users WHERE id=$1 AND deleted_at IS NULL`, userID,
|
||||
).Scan(&status)
|
||||
if err != nil || status != "active" {
|
||||
response.Fail(c, http.StatusUnauthorized, 40113, "账户已受限")
|
||||
c.Abort()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func bearerFromHeader(h string) string {
|
||||
if len(h) < 8 {
|
||||
return ""
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// AccountTransition is an append-only UserStatus change.
|
||||
type AccountTransition struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
FromStatus string `json:"from_status"`
|
||||
ToStatus string `json:"to_status"`
|
||||
AdminID uuid.UUID `json:"admin_id"`
|
||||
Reason string `json:"reason"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// GetUserStatus returns users.status or empty if missing.
|
||||
func (r *AdminRepo) GetUserStatus(ctx context.Context, userID uuid.UUID) (string, error) {
|
||||
var status string
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT status FROM users WHERE id=$1 AND deleted_at IS NULL`, userID,
|
||||
).Scan(&status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return status, err
|
||||
}
|
||||
|
||||
// TransitionUserStatusWithAudit updates status, inserts transition + audit in one tx.
|
||||
func (r *AdminRepo) TransitionUserStatusWithAudit(
|
||||
ctx context.Context,
|
||||
adminID, userID uuid.UUID,
|
||||
fromStatus, toStatus, reason string,
|
||||
meta json.RawMessage,
|
||||
) error {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE users SET status=$2, updated_at=now()
|
||||
WHERE id=$1 AND deleted_at IS NULL AND status=$3`,
|
||||
userID, toStatus, fromStatus,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errString("status conflict")
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO account_state_transitions(user_id, from_status, to_status, admin_id, reason)
|
||||
VALUES ($1,$2,$3,$4,$5)`,
|
||||
userID, fromStatus, toStatus, adminID, reason,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if meta == nil {
|
||||
meta = json.RawMessage(`{}`)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
|
||||
VALUES ($1,'users.status.transition','user',$2,$3)`,
|
||||
adminID, userID.String(), meta,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// ListStatusTransitions returns newest first.
|
||||
func (r *AdminRepo) ListStatusTransitions(ctx context.Context, userID uuid.UUID, limit int) ([]AccountTransition, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, user_id, from_status, to_status, admin_id, reason, created_at
|
||||
FROM account_state_transitions
|
||||
WHERE user_id=$1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AccountTransition
|
||||
for rows.Next() {
|
||||
var t AccountTransition
|
||||
if err := rows.Scan(&t.ID, &t.UserID, &t.FromStatus, &t.ToStatus, &t.AdminID, &t.Reason, &t.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// AdminRole is an ops RBAC role.
|
||||
type AdminRole struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
System bool `json:"system"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListAdminRoles returns all roles.
|
||||
func (r *AdminRepo) ListAdminRoles(ctx context.Context) ([]AdminRole, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, name, system, created_at FROM admin_roles ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AdminRole
|
||||
for rows.Next() {
|
||||
var a AdminRole
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.System, &a.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetAdminRole loads one role.
|
||||
func (r *AdminRepo) GetAdminRole(ctx context.Context, id uuid.UUID) (*AdminRole, error) {
|
||||
var a AdminRole
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, name, system, created_at FROM admin_roles WHERE id=$1`, id,
|
||||
).Scan(&a.ID, &a.Name, &a.System, &a.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// ListRolePermissions returns permission codes for a role.
|
||||
func (r *AdminRepo) ListRolePermissions(ctx context.Context, roleID uuid.UUID) ([]string, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT code FROM admin_role_permissions WHERE role_id=$1 ORDER BY code`, roleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var c string
|
||||
if err := rows.Scan(&c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ReplaceRolePermissions replaces the full permission set for a role.
|
||||
func (r *AdminRepo) ReplaceRolePermissions(ctx context.Context, roleID uuid.UUID, codes []string) error {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM admin_role_permissions WHERE role_id=$1`, roleID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, code := range codes {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,$2)`, roleID, code); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// ListPermissionsForAdmin returns permission codes for an admin account.
|
||||
func (r *AdminRepo) ListPermissionsForAdmin(ctx context.Context, adminID uuid.UUID) ([]string, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT p.code
|
||||
FROM admin_accounts a
|
||||
JOIN admin_role_permissions p ON p.role_id = a.role_id
|
||||
WHERE a.id=$1 AND a.deleted_at IS NULL AND a.role_id IS NOT NULL
|
||||
ORDER BY p.code`, adminID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var c string
|
||||
if err := rows.Scan(&c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetAdminRoleMeta returns role id/name for an account.
|
||||
func (r *AdminRepo) GetAdminRoleMeta(ctx context.Context, adminID uuid.UUID) (roleID *uuid.UUID, name string, err error) {
|
||||
var id uuid.UUID
|
||||
err = r.Pool.QueryRow(ctx, `
|
||||
SELECT r.id, r.name
|
||||
FROM admin_accounts a
|
||||
JOIN admin_roles r ON r.id = a.role_id
|
||||
WHERE a.id=$1 AND a.deleted_at IS NULL`, adminID,
|
||||
).Scan(&id, &name)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &id, name, nil
|
||||
}
|
||||
@@ -34,12 +34,15 @@ func (r *AdminRepo) CountAccounts(ctx context.Context) (int, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// CreateAccount inserts an admin account (role defaults to super).
|
||||
// CreateAccount inserts an admin account with seeded super_admin role and legacy super column.
|
||||
func (r *AdminRepo) CreateAccount(ctx context.Context, username, hash string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash, role)
|
||||
VALUES ($1,$2,'super') RETURNING id`, username, hash).Scan(&id)
|
||||
INSERT INTO admin_accounts(username, password_hash, role, role_id)
|
||||
VALUES (
|
||||
$1, $2, 'super',
|
||||
(SELECT id FROM admin_roles WHERE name = 'super_admin' LIMIT 1)
|
||||
) RETURNING id`, username, hash).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// SystemPromptRow is AICoreConfig SystemPrompt catalog row.
|
||||
type SystemPromptRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Scene *string `json:"scene,omitempty"`
|
||||
Body string `json:"body"`
|
||||
Version int `json:"version"`
|
||||
Active bool `json:"active"`
|
||||
System bool `json:"system"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ListSystemPrompts returns prompt catalog (body included for ops read).
|
||||
func (r *AdminRepo) ListSystemPrompts(ctx context.Context) ([]SystemPromptRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, scene, body, version, active, system, updated_at
|
||||
FROM system_prompts
|
||||
ORDER BY active DESC, code ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []SystemPromptRow
|
||||
for rows.Next() {
|
||||
var p SystemPromptRow
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.Code, &p.Title, &p.Scene, &p.Body, &p.Version, &p.Active, &p.System, &p.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetSystemPrompt loads one prompt by id.
|
||||
func (r *AdminRepo) GetSystemPrompt(ctx context.Context, id uuid.UUID) (*SystemPromptRow, error) {
|
||||
var p SystemPromptRow
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, code, title, scene, body, version, active, system, updated_at
|
||||
FROM system_prompts WHERE id=$1`, id,
|
||||
).Scan(&p.ID, &p.Code, &p.Title, &p.Scene, &p.Body, &p.Version, &p.Active, &p.System, &p.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// KnowledgeSourceRow is AICoreConfig KnowledgeSource catalog row.
|
||||
type KnowledgeSourceRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
SourceKind string `json:"source_kind"`
|
||||
Version int `json:"version"`
|
||||
Active bool `json:"active"`
|
||||
System bool `json:"system"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ListKnowledgeSources returns knowledge source catalog.
|
||||
func (r *AdminRepo) ListKnowledgeSources(ctx context.Context) ([]KnowledgeSourceRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, description, source_kind, version, active, system, updated_at
|
||||
FROM knowledge_sources
|
||||
ORDER BY active DESC, code ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []KnowledgeSourceRow
|
||||
for rows.Next() {
|
||||
var k KnowledgeSourceRow
|
||||
if err := rows.Scan(
|
||||
&k.ID, &k.Code, &k.Title, &k.Description, &k.SourceKind,
|
||||
&k.Version, &k.Active, &k.System, &k.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetKnowledgeSource loads one source by id.
|
||||
func (r *AdminRepo) GetKnowledgeSource(ctx context.Context, id uuid.UUID) (*KnowledgeSourceRow, error) {
|
||||
var k KnowledgeSourceRow
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, code, title, description, source_kind, version, active, system, updated_at
|
||||
FROM knowledge_sources WHERE id=$1`, id,
|
||||
).Scan(
|
||||
&k.ID, &k.Code, &k.Title, &k.Description, &k.SourceKind,
|
||||
&k.Version, &k.Active, &k.System, &k.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &k, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// AskSessionView is ops read meta for one ask thread.
|
||||
type AskSessionView struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
ProfileID uuid.UUID `json:"profile_id"`
|
||||
Scene *string `json:"scene,omitempty"`
|
||||
MessageCount int `json:"message_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AskMessageView is a read-only message row for ops.
|
||||
type AskMessageView struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListAskSessions returns recent ask threads (optional user filter).
|
||||
func (r *AdminRepo) ListAskSessions(ctx context.Context, userID *uuid.UUID, limit, offset int) ([]AskSessionView, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT t.id, t.user_id, t.profile_id, t.scene, t.created_at, t.updated_at,
|
||||
(SELECT count(*)::int FROM ask_messages m
|
||||
WHERE m.thread_id=t.id AND m.deleted_at IS NULL) AS message_count
|
||||
FROM ask_threads t
|
||||
WHERE t.deleted_at IS NULL
|
||||
AND ($1::uuid IS NULL OR t.user_id=$1)
|
||||
ORDER BY t.updated_at DESC
|
||||
LIMIT $2 OFFSET $3`, userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AskSessionView
|
||||
for rows.Next() {
|
||||
var s AskSessionView
|
||||
if err := rows.Scan(
|
||||
&s.ID, &s.UserID, &s.ProfileID, &s.Scene, &s.CreatedAt, &s.UpdatedAt, &s.MessageCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetAskSession loads one thread meta or ErrNoRows.
|
||||
func (r *AdminRepo) GetAskSession(ctx context.Context, threadID uuid.UUID) (*AskSessionView, error) {
|
||||
var s AskSessionView
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT t.id, t.user_id, t.profile_id, t.scene, t.created_at, t.updated_at,
|
||||
(SELECT count(*)::int FROM ask_messages m
|
||||
WHERE m.thread_id=t.id AND m.deleted_at IS NULL) AS message_count
|
||||
FROM ask_threads t
|
||||
WHERE t.id=$1 AND t.deleted_at IS NULL`, threadID,
|
||||
).Scan(&s.ID, &s.UserID, &s.ProfileID, &s.Scene, &s.CreatedAt, &s.UpdatedAt, &s.MessageCount)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// ListAskMessagesForAdmin returns messages oldest-first.
|
||||
func (r *AdminRepo) ListAskMessagesForAdmin(ctx context.Context, threadID uuid.UUID) ([]AskMessageView, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, role, content, created_at
|
||||
FROM ask_messages
|
||||
WHERE thread_id=$1 AND deleted_at IS NULL
|
||||
ORDER BY created_at ASC`, threadID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AskMessageView
|
||||
for rows.Next() {
|
||||
var m AskMessageView
|
||||
if err := rows.Scan(&m.ID, &m.Role, &m.Content, &m.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// BlockPolicyRow is BlockPolicy catalog row.
|
||||
type BlockPolicyRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Action string `json:"action"`
|
||||
Active bool `json:"active"`
|
||||
System bool `json:"system"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ListBlockPolicies returns BlockPolicy catalog.
|
||||
func (r *AdminRepo) ListBlockPolicies(ctx context.Context) ([]BlockPolicyRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, action, active, system, updated_at
|
||||
FROM block_policies
|
||||
ORDER BY active DESC, code ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []BlockPolicyRow
|
||||
for rows.Next() {
|
||||
var row BlockPolicyRow
|
||||
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Action, &row.Active, &row.System, &row.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetBlockPolicy loads one by id.
|
||||
func (r *AdminRepo) GetBlockPolicy(ctx context.Context, id uuid.UUID) (*BlockPolicyRow, error) {
|
||||
var row BlockPolicyRow
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, code, title, action, active, system, updated_at
|
||||
FROM block_policies WHERE id=$1`, id,
|
||||
).Scan(&row.ID, &row.Code, &row.Title, &row.Action, &row.Active, &row.System, &row.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// BannerRow is OpsCMS Banner catalog row.
|
||||
type BannerRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Placement string `json:"placement"`
|
||||
ImageURL *string `json:"image_url,omitempty"`
|
||||
LinkPath *string `json:"link_path,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Active bool `json:"active"`
|
||||
System bool `json:"system"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ListBanners returns banner catalog.
|
||||
func (r *AdminRepo) ListBanners(ctx context.Context) ([]BannerRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, placement, image_url, link_path, sort_order, active, system, updated_at
|
||||
FROM ops_banners
|
||||
ORDER BY active DESC, sort_order ASC, code ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []BannerRow
|
||||
for rows.Next() {
|
||||
var b BannerRow
|
||||
if err := rows.Scan(
|
||||
&b.ID, &b.Code, &b.Title, &b.Placement, &b.ImageURL, &b.LinkPath,
|
||||
&b.SortOrder, &b.Active, &b.System, &b.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetBanner loads one banner by id.
|
||||
func (r *AdminRepo) GetBanner(ctx context.Context, id uuid.UUID) (*BannerRow, error) {
|
||||
var b BannerRow
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, code, title, placement, image_url, link_path, sort_order, active, system, updated_at
|
||||
FROM ops_banners WHERE id=$1`, id,
|
||||
).Scan(
|
||||
&b.ID, &b.Code, &b.Title, &b.Placement, &b.ImageURL, &b.LinkPath,
|
||||
&b.SortOrder, &b.Active, &b.System, &b.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// FeedSlotRow is OpsCMS FeedSlot catalog row.
|
||||
type FeedSlotRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
SlotKey string `json:"slot_key"`
|
||||
Placement string `json:"placement"`
|
||||
Active bool `json:"active"`
|
||||
System bool `json:"system"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ListFeedSlots returns feed slot catalog.
|
||||
func (r *AdminRepo) ListFeedSlots(ctx context.Context) ([]FeedSlotRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, slot_key, placement, active, system, updated_at
|
||||
FROM ops_feed_slots
|
||||
ORDER BY active DESC, code ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []FeedSlotRow
|
||||
for rows.Next() {
|
||||
var s FeedSlotRow
|
||||
if err := rows.Scan(
|
||||
&s.ID, &s.Code, &s.Title, &s.SlotKey, &s.Placement, &s.Active, &s.System, &s.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetFeedSlot loads one feed slot by id.
|
||||
func (r *AdminRepo) GetFeedSlot(ctx context.Context, id uuid.UUID) (*FeedSlotRow, error) {
|
||||
var s FeedSlotRow
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, code, title, slot_key, placement, active, system, updated_at
|
||||
FROM ops_feed_slots WHERE id=$1`, id,
|
||||
).Scan(&s.ID, &s.Code, &s.Title, &s.SlotKey, &s.Placement, &s.Active, &s.System, &s.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// MembershipPlanAmountCents returns catalog price or fallback for membership plans.
|
||||
func (r *ReportRepo) MembershipPlanAmountCents(ctx context.Context, plan string) (int, error) {
|
||||
var amount int
|
||||
var active bool
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT amount_cents, active FROM membership_plans WHERE code=$1`, plan,
|
||||
).Scan(&amount, &active)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return membershipAmountFallback(plan), nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !active {
|
||||
return 0, errString("plan inactive")
|
||||
}
|
||||
return amount, nil
|
||||
}
|
||||
|
||||
func membershipAmountFallback(plan string) int {
|
||||
switch plan {
|
||||
case "month":
|
||||
return 2500
|
||||
case "quarter":
|
||||
return 6800
|
||||
case "year":
|
||||
return 19800
|
||||
default:
|
||||
return 2500
|
||||
}
|
||||
}
|
||||
|
||||
// MembershipPlanDurationDays returns catalog days or fallback.
|
||||
func (r *ReportRepo) MembershipPlanDurationDays(ctx context.Context, plan string) (int, error) {
|
||||
var days int
|
||||
var active bool
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT duration_days, active FROM membership_plans WHERE code=$1`, plan,
|
||||
).Scan(&days, &active)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return membershipDaysFallback(plan), nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !active || days <= 0 {
|
||||
return membershipDaysFallback(plan), nil
|
||||
}
|
||||
return days, nil
|
||||
}
|
||||
|
||||
func membershipDaysFallback(plan string) int {
|
||||
switch plan {
|
||||
case "month":
|
||||
return 31
|
||||
case "quarter":
|
||||
return 92
|
||||
case "year":
|
||||
return 366
|
||||
default:
|
||||
return 31
|
||||
}
|
||||
}
|
||||
|
||||
// RedeemCode applies an unused redemption code to user membership.
|
||||
func (r *ReportRepo) RedeemCode(ctx context.Context, userID uuid.UUID, rawCode string) (plan string, err error) {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var codeID uuid.UUID
|
||||
var status string
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id, plan_code, status FROM redemption_codes
|
||||
WHERE code=$1 FOR UPDATE`, rawCode,
|
||||
).Scan(&codeID, &plan, &status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", errString("invalid code")
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if status != "unused" {
|
||||
return "", errString("code not redeemable")
|
||||
}
|
||||
days, err := r.MembershipPlanDurationDays(ctx, plan)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE redemption_codes
|
||||
SET status='redeemed', redeemed_by=$2, redeemed_at=now()
|
||||
WHERE id=$1 AND status='unused'`, codeID, userID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO memberships(user_id, plan, status, expires_at, ask_quota_left)
|
||||
VALUES ($1,$2,'active', now() + ($3 * interval '1 day'), 100)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
plan=EXCLUDED.plan, status='active',
|
||||
expires_at=(CASE
|
||||
WHEN memberships.expires_at IS NOT NULL AND memberships.expires_at > now()
|
||||
THEN memberships.expires_at ELSE now()
|
||||
END) + ($3 * interval '1 day'),
|
||||
ask_quota_left=100, updated_at=now()`,
|
||||
userID, plan, days); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// FilterRuleRow is ContentSafety FilterRule persistence.
|
||||
type FilterRuleRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Category string `json:"category"`
|
||||
Pattern string `json:"pattern"`
|
||||
Action string `json:"action"`
|
||||
Active bool `json:"active"`
|
||||
System bool `json:"system"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// FilterMatch is one evaluate hit.
|
||||
type FilterMatch struct {
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Category string `json:"category"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
// ListFilterRules returns active-first filter rules.
|
||||
func (r *AdminRepo) ListFilterRules(ctx context.Context) ([]FilterRuleRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, category, pattern, action, active, system, updated_at
|
||||
FROM filter_rules
|
||||
ORDER BY active DESC, category ASC, code ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []FilterRuleRow
|
||||
for rows.Next() {
|
||||
var f FilterRuleRow
|
||||
if err := rows.Scan(
|
||||
&f.ID, &f.Code, &f.Title, &f.Category, &f.Pattern, &f.Action, &f.Active, &f.System, &f.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetFilterRule loads one rule by id.
|
||||
func (r *AdminRepo) GetFilterRule(ctx context.Context, id uuid.UUID) (*FilterRuleRow, error) {
|
||||
var f FilterRuleRow
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, code, title, category, pattern, action, active, system, updated_at
|
||||
FROM filter_rules WHERE id=$1`, id,
|
||||
).Scan(&f.ID, &f.Code, &f.Title, &f.Category, &f.Pattern, &f.Action, &f.Active, &f.System, &f.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// EvaluateFilterRules runs simple substring match on active rules (ops preview).
|
||||
func (r *AdminRepo) EvaluateFilterRules(ctx context.Context, text string) ([]FilterMatch, error) {
|
||||
rules, err := r.ListFilterRules(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lower := strings.ToLower(text)
|
||||
var out []FilterMatch
|
||||
for _, rule := range rules {
|
||||
if !rule.Active || rule.Pattern == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(lower, strings.ToLower(rule.Pattern)) {
|
||||
out = append(out, FilterMatch{
|
||||
Code: rule.Code, Title: rule.Title, Category: rule.Category, Action: rule.Action,
|
||||
})
|
||||
}
|
||||
}
|
||||
if out == nil {
|
||||
out = []FilterMatch{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// CrisisEventRow is CrisisEvent catalog row.
|
||||
type CrisisEventRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Severity string `json:"severity"`
|
||||
Active bool `json:"active"`
|
||||
System bool `json:"system"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ListCrisisEvents returns CrisisEvent catalog.
|
||||
func (r *AdminRepo) ListCrisisEvents(ctx context.Context) ([]CrisisEventRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, severity, active, system, updated_at
|
||||
FROM crisis_events
|
||||
ORDER BY active DESC, code ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []CrisisEventRow
|
||||
for rows.Next() {
|
||||
var row CrisisEventRow
|
||||
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Severity, &row.Active, &row.System, &row.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetCrisisEvent loads one by id.
|
||||
func (r *AdminRepo) GetCrisisEvent(ctx context.Context, id uuid.UUID) (*CrisisEventRow, error) {
|
||||
var row CrisisEventRow
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, code, title, severity, active, system, updated_at
|
||||
FROM crisis_events WHERE id=$1`, id,
|
||||
).Scan(&row.ID, &row.Code, &row.Title, &row.Severity, &row.Active, &row.System, &row.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// CrisisPolicyRow is CrisisCare CrisisPolicy catalog.
|
||||
type CrisisPolicyRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Severity string `json:"severity"`
|
||||
Pattern string `json:"pattern"`
|
||||
Action string `json:"action"`
|
||||
HelplineText *string `json:"helpline_text,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
System bool `json:"system"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CrisisMatch is one evaluate hit.
|
||||
type CrisisMatch struct {
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Severity string `json:"severity"`
|
||||
Action string `json:"action"`
|
||||
HelplineText *string `json:"helpline_text,omitempty"`
|
||||
}
|
||||
|
||||
// ListCrisisPolicies returns policies active-first.
|
||||
func (r *AdminRepo) ListCrisisPolicies(ctx context.Context) ([]CrisisPolicyRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, severity, pattern, action, helpline_text, active, system, updated_at
|
||||
FROM crisis_policies
|
||||
ORDER BY active DESC, severity DESC, code ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []CrisisPolicyRow
|
||||
for rows.Next() {
|
||||
var p CrisisPolicyRow
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.Code, &p.Title, &p.Severity, &p.Pattern, &p.Action, &p.HelplineText,
|
||||
&p.Active, &p.System, &p.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetCrisisPolicy loads one policy.
|
||||
func (r *AdminRepo) GetCrisisPolicy(ctx context.Context, id uuid.UUID) (*CrisisPolicyRow, error) {
|
||||
var p CrisisPolicyRow
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, code, title, severity, pattern, action, helpline_text, active, system, updated_at
|
||||
FROM crisis_policies WHERE id=$1`, id,
|
||||
).Scan(
|
||||
&p.ID, &p.Code, &p.Title, &p.Severity, &p.Pattern, &p.Action, &p.HelplineText,
|
||||
&p.Active, &p.System, &p.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// EvaluateCrisisPolicies runs substring match preview (ops only).
|
||||
func (r *AdminRepo) EvaluateCrisisPolicies(ctx context.Context, text string) ([]CrisisMatch, error) {
|
||||
policies, err := r.ListCrisisPolicies(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lower := strings.ToLower(text)
|
||||
var out []CrisisMatch
|
||||
for _, p := range policies {
|
||||
if !p.Active || p.Pattern == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(lower, strings.ToLower(p.Pattern)) {
|
||||
out = append(out, CrisisMatch{
|
||||
Code: p.Code, Title: p.Title, Severity: p.Severity,
|
||||
Action: p.Action, HelplineText: p.HelplineText,
|
||||
})
|
||||
}
|
||||
}
|
||||
if out == nil {
|
||||
out = []CrisisMatch{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// DeepAccessBrief is one deep_access row for ops Entitlement.
|
||||
type DeepAccessBrief struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ReportID uuid.UUID `json:"report_id"`
|
||||
ReportType string `json:"report_type,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListDeepAccessForUser returns recent deep accesses with report type.
|
||||
func (r *AdminRepo) ListDeepAccessForUser(ctx context.Context, userID uuid.UUID, limit int) ([]DeepAccessBrief, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT d.id, d.report_id, coalesce(g.type, ''), d.created_at
|
||||
FROM deep_accesses d
|
||||
LEFT JOIN growth_reports g ON g.id = d.report_id AND g.deleted_at IS NULL
|
||||
WHERE d.user_id=$1 AND d.deleted_at IS NULL
|
||||
ORDER BY d.created_at DESC
|
||||
LIMIT $2`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []DeepAccessBrief
|
||||
for rows.Next() {
|
||||
var b DeepAccessBrief
|
||||
if err := rows.Scan(&b.ID, &b.ReportID, &b.ReportType, &b.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountDeepAccessForUser counts non-deleted deep accesses.
|
||||
func (r *AdminRepo) CountDeepAccessForUser(ctx context.Context, userID uuid.UUID) (int, error) {
|
||||
var n int
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*)::int FROM deep_accesses
|
||||
WHERE user_id=$1 AND deleted_at IS NULL`, userID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// FunnelDefinitionRow is FunnelDefinition catalog row.
|
||||
type FunnelDefinitionRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Active bool `json:"active"`
|
||||
System bool `json:"system"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ListFunnelDefinitions returns FunnelDefinition catalog.
|
||||
func (r *AdminRepo) ListFunnelDefinitions(ctx context.Context) ([]FunnelDefinitionRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, active, system, updated_at
|
||||
FROM funnel_definitions
|
||||
ORDER BY active DESC, code ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []FunnelDefinitionRow
|
||||
for rows.Next() {
|
||||
var row FunnelDefinitionRow
|
||||
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Active, &row.System, &row.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetFunnelDefinition loads one by id.
|
||||
func (r *AdminRepo) GetFunnelDefinition(ctx context.Context, id uuid.UUID) (*FunnelDefinitionRow, error) {
|
||||
var row FunnelDefinitionRow
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, code, title, active, system, updated_at
|
||||
FROM funnel_definitions WHERE id=$1`, id,
|
||||
).Scan(&row.ID, &row.Code, &row.Title, &row.Active, &row.System, &row.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// HandoffCaseRow is HandoffCase catalog row.
|
||||
type HandoffCaseRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Active bool `json:"active"`
|
||||
System bool `json:"system"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ListHandoffCases returns HandoffCase catalog.
|
||||
func (r *AdminRepo) ListHandoffCases(ctx context.Context) ([]HandoffCaseRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, status, active, system, updated_at
|
||||
FROM ask_handoff_cases
|
||||
ORDER BY active DESC, code ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []HandoffCaseRow
|
||||
for rows.Next() {
|
||||
var row HandoffCaseRow
|
||||
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Status, &row.Active, &row.System, &row.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetHandoffCase loads one by id.
|
||||
func (r *AdminRepo) GetHandoffCase(ctx context.Context, id uuid.UUID) (*HandoffCaseRow, error) {
|
||||
var row HandoffCaseRow
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, code, title, status, active, system, updated_at
|
||||
FROM ask_handoff_cases WHERE id=$1`, id,
|
||||
).Scan(&row.ID, &row.Code, &row.Title, &row.Status, &row.Active, &row.System, &row.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ImageCardDeckRow is ImageCardDeck catalog row.
|
||||
type ImageCardDeckRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Active bool `json:"active"`
|
||||
System bool `json:"system"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ListImageCardDecks returns ImageCardDeck catalog.
|
||||
func (r *AdminRepo) ListImageCardDecks(ctx context.Context) ([]ImageCardDeckRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, active, system, updated_at
|
||||
FROM image_card_decks
|
||||
ORDER BY active DESC, code ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ImageCardDeckRow
|
||||
for rows.Next() {
|
||||
var row ImageCardDeckRow
|
||||
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Active, &row.System, &row.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetImageCardDeck loads one by id.
|
||||
func (r *AdminRepo) GetImageCardDeck(ctx context.Context, id uuid.UUID) (*ImageCardDeckRow, error) {
|
||||
var row ImageCardDeckRow
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, code, title, active, system, updated_at
|
||||
FROM image_card_decks WHERE id=$1`, id,
|
||||
).Scan(&row.ID, &row.Code, &row.Title, &row.Active, &row.System, &row.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// InterventionOutcomeRow is InterventionOutcome catalog row.
|
||||
type InterventionOutcomeRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Outcome string `json:"outcome"`
|
||||
Active bool `json:"active"`
|
||||
System bool `json:"system"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ListInterventionOutcomes returns InterventionOutcome catalog.
|
||||
func (r *AdminRepo) ListInterventionOutcomes(ctx context.Context) ([]InterventionOutcomeRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, outcome, active, system, updated_at
|
||||
FROM intervention_outcomes
|
||||
ORDER BY active DESC, code ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []InterventionOutcomeRow
|
||||
for rows.Next() {
|
||||
var row InterventionOutcomeRow
|
||||
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Outcome, &row.Active, &row.System, &row.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetInterventionOutcome loads one by id.
|
||||
func (r *AdminRepo) GetInterventionOutcome(ctx context.Context, id uuid.UUID) (*InterventionOutcomeRow, error) {
|
||||
var row InterventionOutcomeRow
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, code, title, outcome, active, system, updated_at
|
||||
FROM intervention_outcomes WHERE id=$1`, id,
|
||||
).Scan(&row.ID, &row.Code, &row.Title, &row.Outcome, &row.Active, &row.System, &row.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// KnowledgeChunkRow is KnowledgeChunk catalog row.
|
||||
type KnowledgeChunkRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Code string `json:"code"`
|
||||
SourceCode string `json:"source_code"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Active bool `json:"active"`
|
||||
System bool `json:"system"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ListKnowledgeChunks returns KnowledgeChunk catalog.
|
||||
func (r *AdminRepo) ListKnowledgeChunks(ctx context.Context) ([]KnowledgeChunkRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, source_code, title, body, active, system, updated_at
|
||||
FROM knowledge_chunks
|
||||
ORDER BY active DESC, code ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []KnowledgeChunkRow
|
||||
for rows.Next() {
|
||||
var row KnowledgeChunkRow
|
||||
if err := rows.Scan(&row.ID, &row.Code, &row.SourceCode, &row.Title, &row.Body, &row.Active, &row.System, &row.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetKnowledgeChunk loads one by id.
|
||||
func (r *AdminRepo) GetKnowledgeChunk(ctx context.Context, id uuid.UUID) (*KnowledgeChunkRow, error) {
|
||||
var row KnowledgeChunkRow
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, code, source_code, title, body, active, system, updated_at
|
||||
FROM knowledge_chunks WHERE id=$1`, id,
|
||||
).Scan(&row.ID, &row.Code, &row.SourceCode, &row.Title, &row.Body, &row.Active, &row.System, &row.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// MembershipPlanRow is a configurable growth membership SKU.
|
||||
type MembershipPlanRow struct {
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
DurationDays int `json:"duration_days"`
|
||||
AmountCents int `json:"amount_cents"`
|
||||
Active bool `json:"active"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ListMembershipPlans returns all plans ordered by code.
|
||||
func (r *AdminRepo) ListMembershipPlans(ctx context.Context) ([]MembershipPlanRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT code, title, duration_days, amount_cents, active, updated_at
|
||||
FROM membership_plans ORDER BY code`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MembershipPlanRow
|
||||
for rows.Next() {
|
||||
var p MembershipPlanRow
|
||||
if err := rows.Scan(&p.Code, &p.Title, &p.DurationDays, &p.AmountCents, &p.Active, &p.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetMembershipPlan loads one plan by code.
|
||||
func (r *AdminRepo) GetMembershipPlan(ctx context.Context, code string) (*MembershipPlanRow, error) {
|
||||
var p MembershipPlanRow
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT code, title, duration_days, amount_cents, active, updated_at
|
||||
FROM membership_plans WHERE code=$1`, code,
|
||||
).Scan(&p.Code, &p.Title, &p.DurationDays, &p.AmountCents, &p.Active, &p.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// UpdateMembershipPlanWithAudit updates mutable fields and audits.
|
||||
func (r *AdminRepo) UpdateMembershipPlanWithAudit(
|
||||
ctx context.Context,
|
||||
adminID uuid.UUID,
|
||||
code, title string,
|
||||
days, amountCents int,
|
||||
active bool,
|
||||
meta json.RawMessage,
|
||||
) error {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE membership_plans
|
||||
SET title=$2, duration_days=$3, amount_cents=$4, active=$5, updated_at=now()
|
||||
WHERE code=$1`, code, title, days, amountCents, active)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errString("plan not found")
|
||||
}
|
||||
if meta == nil {
|
||||
meta = json.RawMessage(`{}`)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
|
||||
VALUES ($1,'membership.plans.update','membership_plan',$2,$3)`,
|
||||
adminID, code, meta,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ModerationCaseRow is ModerationCase catalog row.
|
||||
type ModerationCaseRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Active bool `json:"active"`
|
||||
System bool `json:"system"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ListModerationCases returns ModerationCase catalog.
|
||||
func (r *AdminRepo) ListModerationCases(ctx context.Context) ([]ModerationCaseRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, status, active, system, updated_at
|
||||
FROM moderation_cases
|
||||
ORDER BY active DESC, code ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ModerationCaseRow
|
||||
for rows.Next() {
|
||||
var row ModerationCaseRow
|
||||
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Status, &row.Active, &row.System, &row.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetModerationCase loads one by id.
|
||||
func (r *AdminRepo) GetModerationCase(ctx context.Context, id uuid.UUID) (*ModerationCaseRow, error) {
|
||||
var row ModerationCaseRow
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, code, title, status, active, system, updated_at
|
||||
FROM moderation_cases WHERE id=$1`, id,
|
||||
).Scan(&row.ID, &row.Code, &row.Title, &row.Status, &row.Active, &row.System, &row.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// PrivacyRequestRow is PrivacyRequest catalog row.
|
||||
type PrivacyRequestRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Kind string `json:"kind"`
|
||||
Status string `json:"status"`
|
||||
Active bool `json:"active"`
|
||||
System bool `json:"system"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ListPrivacyRequests returns PrivacyRequest catalog.
|
||||
func (r *AdminRepo) ListPrivacyRequests(ctx context.Context) ([]PrivacyRequestRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, kind, status, active, system, updated_at
|
||||
FROM privacy_requests
|
||||
ORDER BY active DESC, code ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []PrivacyRequestRow
|
||||
for rows.Next() {
|
||||
var row PrivacyRequestRow
|
||||
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Kind, &row.Status, &row.Active, &row.System, &row.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetPrivacyRequest loads one by id.
|
||||
func (r *AdminRepo) GetPrivacyRequest(ctx context.Context, id uuid.UUID) (*PrivacyRequestRow, error) {
|
||||
var row PrivacyRequestRow
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, code, title, kind, status, active, system, updated_at
|
||||
FROM privacy_requests WHERE id=$1`, id,
|
||||
).Scan(&row.ID, &row.Code, &row.Title, &row.Kind, &row.Status, &row.Active, &row.System, &row.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// QualityFeedbackRow is AskOperations QualityFeedback.
|
||||
type QualityFeedbackRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ThreadID uuid.UUID `json:"thread_id"`
|
||||
MessageID *uuid.UUID `json:"message_id,omitempty"`
|
||||
Source string `json:"source"`
|
||||
Rating int `json:"rating"`
|
||||
Tag *string `json:"tag,omitempty"`
|
||||
Note *string `json:"note,omitempty"`
|
||||
CreatedByAdmin *uuid.UUID `json:"created_by_admin,omitempty"`
|
||||
CreatedByUser *uuid.UUID `json:"created_by_user,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListQualityFeedback returns recent feedback rows.
|
||||
func (r *AdminRepo) ListQualityFeedback(ctx context.Context, limit, offset int) ([]QualityFeedbackRow, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, thread_id, message_id, source, rating, tag, note,
|
||||
created_by_admin, created_by_user, created_at
|
||||
FROM ask_quality_feedback
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanQualityFeedback(rows)
|
||||
}
|
||||
|
||||
func scanQualityFeedback(rows pgx.Rows) ([]QualityFeedbackRow, error) {
|
||||
var out []QualityFeedbackRow
|
||||
for rows.Next() {
|
||||
var f QualityFeedbackRow
|
||||
if err := rows.Scan(
|
||||
&f.ID, &f.ThreadID, &f.MessageID, &f.Source, &f.Rating, &f.Tag, &f.Note,
|
||||
&f.CreatedByAdmin, &f.CreatedByUser, &f.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CreateAdminQualityFeedback inserts ops feedback + audit.
|
||||
func (r *AdminRepo) CreateAdminQualityFeedback(
|
||||
ctx context.Context, adminID, threadID uuid.UUID, messageID *uuid.UUID, rating int, tag, note *string,
|
||||
) (*QualityFeedbackRow, error) {
|
||||
if err := validateFeedback(rating, tag, note); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tag, note = cleanTag(tag), cleanNote(note)
|
||||
ok, err := r.askThreadExists(ctx, threadID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, errors.New("ask thread not found")
|
||||
}
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var f QualityFeedbackRow
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO ask_quality_feedback(thread_id, message_id, source, rating, tag, note, created_by_admin)
|
||||
VALUES ($1,$2,'admin',$3,$4,$5,$6)
|
||||
RETURNING id, thread_id, message_id, source, rating, tag, note, created_by_admin, created_by_user, created_at`,
|
||||
threadID, messageID, rating, tag, note, adminID,
|
||||
).Scan(&f.ID, &f.ThreadID, &f.MessageID, &f.Source, &f.Rating, &f.Tag, &f.Note,
|
||||
&f.CreatedByAdmin, &f.CreatedByUser, &f.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{"rating": rating, "thread_id": threadID.String()})
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
|
||||
VALUES ($1,'ask.feedback.create','ask_thread',$2,$3)`,
|
||||
adminID, threadID.String(), meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// CreateUserQualityFeedback inserts C-end feedback for owned thread.
|
||||
func (r *AskRepo) CreateUserQualityFeedback(
|
||||
ctx context.Context, userID, threadID uuid.UUID, messageID *uuid.UUID, rating int, tag, note *string,
|
||||
) (*QualityFeedbackRow, error) {
|
||||
if err := validateFeedback(rating, tag, note); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tag, note = cleanTag(tag), cleanNote(note)
|
||||
_, err := r.GetThreadForUser(ctx, userID, threadID)
|
||||
if err != nil {
|
||||
return nil, errors.New("ask thread not found")
|
||||
}
|
||||
var f QualityFeedbackRow
|
||||
err = r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO ask_quality_feedback(thread_id, message_id, source, rating, tag, note, created_by_user)
|
||||
VALUES ($1,$2,'user',$3,$4,$5,$6)
|
||||
RETURNING id, thread_id, message_id, source, rating, tag, note, created_by_admin, created_by_user, created_at`,
|
||||
threadID, messageID, rating, tag, note, userID,
|
||||
).Scan(&f.ID, &f.ThreadID, &f.MessageID, &f.Source, &f.Rating, &f.Tag, &f.Note,
|
||||
&f.CreatedByAdmin, &f.CreatedByUser, &f.CreatedAt)
|
||||
return &f, err
|
||||
}
|
||||
|
||||
func (r *AdminRepo) askThreadExists(ctx context.Context, threadID uuid.UUID) (bool, error) {
|
||||
var n int
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT 1 FROM ask_threads WHERE id=$1 AND deleted_at IS NULL`, threadID).Scan(&n)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func validateFeedback(rating int, tag, note *string) error {
|
||||
if rating < 1 || rating > 5 {
|
||||
return errors.New("rating must be 1-5")
|
||||
}
|
||||
if tag != nil && *tag != "" {
|
||||
switch *tag {
|
||||
case "helpful", "off_topic", "unsafe", "other":
|
||||
default:
|
||||
return errors.New("invalid tag")
|
||||
}
|
||||
}
|
||||
if note != nil && utf8.RuneCountInString(*note) > 500 {
|
||||
return errors.New("note too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanTag(tag *string) *string {
|
||||
if tag == nil || *tag == "" {
|
||||
return nil
|
||||
}
|
||||
return tag
|
||||
}
|
||||
|
||||
func cleanNote(note *string) *string {
|
||||
if note == nil || *note == "" {
|
||||
return nil
|
||||
}
|
||||
return note
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// RedemptionBatch is a generation batch of codes.
|
||||
type RedemptionBatch struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Label string `json:"label"`
|
||||
PlanCode string `json:"plan_code"`
|
||||
Quantity int `json:"quantity"`
|
||||
CreatedBy uuid.UUID `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// RedemptionCodeRow is one redeemable code.
|
||||
type RedemptionCodeRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
BatchID uuid.UUID `json:"batch_id"`
|
||||
Code string `json:"code"`
|
||||
PlanCode string `json:"plan_code"`
|
||||
Status string `json:"status"`
|
||||
RedeemedBy *uuid.UUID `json:"redeemed_by,omitempty"`
|
||||
RedeemedAt *time.Time `json:"redeemed_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CreateRedemptionBatchWithCodes inserts batch + codes + audit.
|
||||
func (r *AdminRepo) CreateRedemptionBatchWithCodes(
|
||||
ctx context.Context,
|
||||
adminID uuid.UUID,
|
||||
label, planCode string,
|
||||
codes []string,
|
||||
meta json.RawMessage,
|
||||
) (*RedemptionBatch, []RedemptionCodeRow, error) {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var b RedemptionBatch
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO redemption_batches(label, plan_code, quantity, created_by)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
RETURNING id, label, plan_code, quantity, created_by, created_at`,
|
||||
label, planCode, len(codes), adminID,
|
||||
).Scan(&b.ID, &b.Label, &b.PlanCode, &b.Quantity, &b.CreatedBy, &b.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
out := make([]RedemptionCodeRow, 0, len(codes))
|
||||
for _, code := range codes {
|
||||
var row RedemptionCodeRow
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO redemption_codes(batch_id, code, plan_code, status)
|
||||
VALUES ($1,$2,$3,'unused')
|
||||
RETURNING id, batch_id, code, plan_code, status, created_at`,
|
||||
b.ID, code, planCode,
|
||||
).Scan(&row.ID, &row.BatchID, &row.Code, &row.PlanCode, &row.Status, &row.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
if meta == nil {
|
||||
meta = json.RawMessage(`{}`)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
|
||||
VALUES ($1,'redemption.batch.create','redemption_batch',$2,$3)`,
|
||||
adminID, b.ID.String(), meta,
|
||||
); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &b, out, nil
|
||||
}
|
||||
|
||||
// ListRedemptionBatches newest first.
|
||||
func (r *AdminRepo) ListRedemptionBatches(ctx context.Context, limit int) ([]RedemptionBatch, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, label, plan_code, quantity, created_by, created_at
|
||||
FROM redemption_batches ORDER BY created_at DESC LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []RedemptionBatch
|
||||
for rows.Next() {
|
||||
var b RedemptionBatch
|
||||
if err := rows.Scan(&b.ID, &b.Label, &b.PlanCode, &b.Quantity, &b.CreatedBy, &b.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListRedemptionCodesByBatch returns codes for a batch.
|
||||
func (r *AdminRepo) ListRedemptionCodesByBatch(ctx context.Context, batchID uuid.UUID) ([]RedemptionCodeRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, batch_id, code, plan_code, status, redeemed_by, redeemed_at, created_at
|
||||
FROM redemption_codes WHERE batch_id=$1 ORDER BY created_at`, batchID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanRedemptionCodes(rows)
|
||||
}
|
||||
|
||||
// DisableRedemptionCode marks unused code disabled.
|
||||
func (r *AdminRepo) DisableRedemptionCode(ctx context.Context, adminID, codeID uuid.UUID) error {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE redemption_codes SET status='disabled'
|
||||
WHERE id=$1 AND status='unused'`, codeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errString("code not unused")
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]string{"code_id": codeID.String()})
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
|
||||
VALUES ($1,'redemption.code.disable','redemption_code',$2,$3)`,
|
||||
adminID, codeID.String(), meta,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func scanRedemptionCodes(rows pgx.Rows) ([]RedemptionCodeRow, error) {
|
||||
var out []RedemptionCodeRow
|
||||
for rows.Next() {
|
||||
var c RedemptionCodeRow
|
||||
if err := rows.Scan(&c.ID, &c.BatchID, &c.Code, &c.PlanCode, &c.Status, &c.RedeemedBy, &c.RedeemedAt, &c.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// BatchExists reports whether batch id exists.
|
||||
func (r *AdminRepo) BatchExists(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
var ok bool
|
||||
err := r.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM redemption_batches WHERE id=$1)`, id).Scan(&ok)
|
||||
return ok, err
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ReportTemplateRow is ReportTemplate catalog row.
|
||||
type ReportTemplateRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Scene string `json:"scene"`
|
||||
Active bool `json:"active"`
|
||||
System bool `json:"system"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ListReportTemplates returns ReportTemplate catalog.
|
||||
func (r *AdminRepo) ListReportTemplates(ctx context.Context) ([]ReportTemplateRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, scene, active, system, updated_at
|
||||
FROM report_templates
|
||||
ORDER BY active DESC, code ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ReportTemplateRow
|
||||
for rows.Next() {
|
||||
var row ReportTemplateRow
|
||||
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Scene, &row.Active, &row.System, &row.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetReportTemplate loads one by id.
|
||||
func (r *AdminRepo) GetReportTemplate(ctx context.Context, id uuid.UUID) (*ReportTemplateRow, error) {
|
||||
var row ReportTemplateRow
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, code, title, scene, active, system, updated_at
|
||||
FROM report_templates WHERE id=$1`, id,
|
||||
).Scan(&row.ID, &row.Code, &row.Title, &row.Scene, &row.Active, &row.System, &row.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// RhythmConfigRow is RhythmConfig catalog row.
|
||||
type RhythmConfigRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Active bool `json:"active"`
|
||||
System bool `json:"system"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ListRhythmConfigs returns RhythmConfig catalog.
|
||||
func (r *AdminRepo) ListRhythmConfigs(ctx context.Context) ([]RhythmConfigRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, active, system, updated_at
|
||||
FROM rhythm_configs
|
||||
ORDER BY active DESC, code ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []RhythmConfigRow
|
||||
for rows.Next() {
|
||||
var row RhythmConfigRow
|
||||
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Active, &row.System, &row.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetRhythmConfig loads one by id.
|
||||
func (r *AdminRepo) GetRhythmConfig(ctx context.Context, id uuid.UUID) (*RhythmConfigRow, error) {
|
||||
var row RhythmConfigRow
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, code, title, active, system, updated_at
|
||||
FROM rhythm_configs WHERE id=$1`, id,
|
||||
).Scan(&row.ID, &row.Code, &row.Title, &row.Active, &row.System, &row.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
@@ -122,6 +122,19 @@ func (r *ScaleRepo) ListAllAdmin(ctx context.Context) ([]ScaleAdminItem, error)
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetAdmin loads one scale by id for ops read.
|
||||
func (r *ScaleRepo) GetAdmin(ctx context.Context, id uuid.UUID) (*ScaleAdminItem, error) {
|
||||
var it ScaleAdminItem
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, slug, title, description, status FROM scales
|
||||
WHERE id=$1 AND deleted_at IS NULL`, id,
|
||||
).Scan(&it.ID, &it.Slug, &it.Title, &it.Description, &it.Status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &it, nil
|
||||
}
|
||||
|
||||
// UpdateStatus sets published|draft.
|
||||
func (r *ScaleRepo) UpdateStatus(ctx context.Context, id uuid.UUID, status string) error {
|
||||
return r.UpdateStatusWithAudit(ctx, id, status, uuid.Nil, nil)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ScheduledPublicationRow is ScheduledPublication catalog row.
|
||||
type ScheduledPublicationRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
TargetKind string `json:"target_kind"`
|
||||
TargetCode string `json:"target_code"`
|
||||
Active bool `json:"active"`
|
||||
System bool `json:"system"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ListScheduledPublications returns ScheduledPublication catalog.
|
||||
func (r *AdminRepo) ListScheduledPublications(ctx context.Context) ([]ScheduledPublicationRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, target_kind, target_code, active, system, updated_at
|
||||
FROM ops_scheduled_publications
|
||||
ORDER BY active DESC, code ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ScheduledPublicationRow
|
||||
for rows.Next() {
|
||||
var row ScheduledPublicationRow
|
||||
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.TargetKind, &row.TargetCode, &row.Active, &row.System, &row.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetScheduledPublication loads one by id.
|
||||
func (r *AdminRepo) GetScheduledPublication(ctx context.Context, id uuid.UUID) (*ScheduledPublicationRow, error) {
|
||||
var row ScheduledPublicationRow
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, code, title, target_kind, target_code, active, system, updated_at
|
||||
FROM ops_scheduled_publications WHERE id=$1`, id,
|
||||
).Scan(&row.ID, &row.Code, &row.Title, &row.TargetKind, &row.TargetCode, &row.Active, &row.System, &row.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// StarConfigRow is StarConfig catalog row.
|
||||
type StarConfigRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Active bool `json:"active"`
|
||||
System bool `json:"system"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ListStarConfigs returns StarConfig catalog.
|
||||
func (r *AdminRepo) ListStarConfigs(ctx context.Context) ([]StarConfigRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, active, system, updated_at
|
||||
FROM star_configs
|
||||
ORDER BY active DESC, code ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []StarConfigRow
|
||||
for rows.Next() {
|
||||
var row StarConfigRow
|
||||
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Active, &row.System, &row.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetStarConfig loads one by id.
|
||||
func (r *AdminRepo) GetStarConfig(ctx context.Context, id uuid.UUID) (*StarConfigRow, error) {
|
||||
var row StarConfigRow
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, code, title, active, system, updated_at
|
||||
FROM star_configs WHERE id=$1`, id,
|
||||
).Scan(&row.ID, &row.Code, &row.Title, &row.Active, &row.System, &row.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ToolDefinitionRow is ToolDefinition catalog row.
|
||||
type ToolDefinitionRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
System bool `json:"system"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ListToolDefinitions returns ToolDefinition catalog.
|
||||
func (r *AdminRepo) ListToolDefinitions(ctx context.Context) ([]ToolDefinitionRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, description, active, system, updated_at
|
||||
FROM tool_definitions
|
||||
ORDER BY active DESC, code ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ToolDefinitionRow
|
||||
for rows.Next() {
|
||||
var row ToolDefinitionRow
|
||||
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Description, &row.Active, &row.System, &row.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetToolDefinition loads one by id.
|
||||
func (r *AdminRepo) GetToolDefinition(ctx context.Context, id uuid.UUID) (*ToolDefinitionRow, error) {
|
||||
var row ToolDefinitionRow
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, code, title, description, active, system, updated_at
|
||||
FROM tool_definitions WHERE id=$1`, id,
|
||||
).Scan(&row.ID, &row.Code, &row.Title, &row.Description, &row.Active, &row.System, &row.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ReportTypeCount aggregates growth_reports by type.
|
||||
type ReportTypeCount struct {
|
||||
Type string `json:"type"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// BehaviorEventBrief is a recent analytics event for ops insight.
|
||||
type BehaviorEventBrief struct {
|
||||
Name string `json:"name"`
|
||||
PagePath string `json:"page_path,omitempty"`
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
}
|
||||
|
||||
// CountReportsByType groups non-deleted reports for a user.
|
||||
func (r *AdminRepo) CountReportsByType(ctx context.Context, userID uuid.UUID) ([]ReportTypeCount, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT type, count(*)::int FROM growth_reports
|
||||
WHERE user_id=$1 AND deleted_at IS NULL
|
||||
GROUP BY type ORDER BY count(*) DESC, type ASC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ReportTypeCount
|
||||
for rows.Next() {
|
||||
var c ReportTypeCount
|
||||
if err := rows.Scan(&c.Type, &c.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountProfilesForUser returns active profile count.
|
||||
func (r *AdminRepo) CountProfilesForUser(ctx context.Context, userID uuid.UUID) (int, error) {
|
||||
var n int
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*)::int FROM profiles
|
||||
WHERE user_id=$1 AND deleted_at IS NULL`, userID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// CountAskThreadsForUser returns non-deleted ask threads.
|
||||
func (r *AdminRepo) CountAskThreadsForUser(ctx context.Context, userID uuid.UUID) (int, error) {
|
||||
var n int
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*)::int FROM ask_threads
|
||||
WHERE user_id=$1 AND deleted_at IS NULL`, userID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ListRecentEventsForUser returns recent analytics events (may be empty).
|
||||
func (r *AdminRepo) ListRecentEventsForUser(ctx context.Context, userID uuid.UUID, limit int) ([]BehaviorEventBrief, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT name, coalesce(page_path, ''), received_at
|
||||
FROM analytics_events
|
||||
WHERE user_id=$1
|
||||
ORDER BY received_at DESC
|
||||
LIMIT $2`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []BehaviorEventBrief
|
||||
for rows.Next() {
|
||||
var e BehaviorEventBrief
|
||||
if err := rows.Scan(&e.Name, &e.PagePath, &e.ReceivedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var ErrSystemPromptNotFound = errString("system prompt not found")
|
||||
var ErrKnowledgeSourceNotFound = errString("knowledge source not found")
|
||||
|
||||
// ListSystemPrompts returns SystemPrompt catalog.
|
||||
func (s *Service) ListSystemPrompts(ctx context.Context) ([]repository.SystemPromptRow, error) {
|
||||
items, err := s.Repo.ListSystemPrompts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.SystemPromptRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetSystemPrompt loads one prompt.
|
||||
func (s *Service) GetSystemPrompt(ctx context.Context, id uuid.UUID) (*repository.SystemPromptRow, error) {
|
||||
row, err := s.Repo.GetSystemPrompt(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrSystemPromptNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
|
||||
// ListKnowledgeSources returns KnowledgeSource catalog.
|
||||
func (s *Service) ListKnowledgeSources(ctx context.Context) ([]repository.KnowledgeSourceRow, error) {
|
||||
items, err := s.Repo.ListKnowledgeSources(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.KnowledgeSourceRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetKnowledgeSource loads one source.
|
||||
func (s *Service) GetKnowledgeSource(ctx context.Context, id uuid.UUID) (*repository.KnowledgeSourceRow, error) {
|
||||
row, err := s.Repo.GetKnowledgeSource(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrKnowledgeSourceNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
// AskSessionDetail is AskSessionView plus messages.
|
||||
type AskSessionDetail struct {
|
||||
repository.AskSessionView
|
||||
Messages []repository.AskMessageView `json:"messages"`
|
||||
}
|
||||
|
||||
var ErrAskThreadNotFound = errString("ask thread not found")
|
||||
|
||||
// ListAskSessions lists AskSessionView rows.
|
||||
func (s *Service) ListAskSessions(ctx context.Context, userID *uuid.UUID, limit, offset int) ([]repository.AskSessionView, error) {
|
||||
items, err := s.Repo.ListAskSessions(ctx, userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.AskSessionView{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetAskSessionDetail loads meta + messages.
|
||||
func (s *Service) GetAskSessionDetail(ctx context.Context, threadID uuid.UUID) (*AskSessionDetail, error) {
|
||||
view, err := s.Repo.GetAskSession(ctx, threadID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrAskThreadNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msgs, err := s.Repo.ListAskMessagesForAdmin(ctx, threadID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msgs == nil {
|
||||
msgs = []repository.AskMessageView{}
|
||||
}
|
||||
return &AskSessionDetail{AskSessionView: *view, Messages: msgs}, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var ErrBlockPolicyNotFound = errString("block policy not found")
|
||||
|
||||
// ListBlockPolicies returns catalog.
|
||||
func (s *Service) ListBlockPolicies(ctx context.Context) ([]repository.BlockPolicyRow, error) {
|
||||
items, err := s.Repo.ListBlockPolicies(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.BlockPolicyRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetBlockPolicy loads one.
|
||||
func (s *Service) GetBlockPolicy(ctx context.Context, id uuid.UUID) (*repository.BlockPolicyRow, error) {
|
||||
row, err := s.Repo.GetBlockPolicy(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrBlockPolicyNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var ErrBannerNotFound = errString("banner not found")
|
||||
var ErrFeedSlotNotFound = errString("feed slot not found")
|
||||
|
||||
// ListBanners returns OpsCMS Banner catalog.
|
||||
func (s *Service) ListBanners(ctx context.Context) ([]repository.BannerRow, error) {
|
||||
items, err := s.Repo.ListBanners(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.BannerRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetBanner loads one banner.
|
||||
func (s *Service) GetBanner(ctx context.Context, id uuid.UUID) (*repository.BannerRow, error) {
|
||||
row, err := s.Repo.GetBanner(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrBannerNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
|
||||
// ListFeedSlots returns OpsCMS FeedSlot catalog.
|
||||
func (s *Service) ListFeedSlots(ctx context.Context) ([]repository.FeedSlotRow, error) {
|
||||
items, err := s.Repo.ListFeedSlots(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.FeedSlotRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetFeedSlot loads one feed slot.
|
||||
func (s *Service) GetFeedSlot(ctx context.Context, id uuid.UUID) (*repository.FeedSlotRow, error) {
|
||||
row, err := s.Repo.GetFeedSlot(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrFeedSlotNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
@@ -45,6 +45,18 @@ func (s *Service) ListScalesAdmin(ctx context.Context) ([]repository.ScaleAdminI
|
||||
return s.Scales.ListAllAdmin(ctx)
|
||||
}
|
||||
|
||||
// GetScaleAdmin loads one scale for explore read projection.
|
||||
func (s *Service) GetScaleAdmin(ctx context.Context, id uuid.UUID) (*repository.ScaleAdminItem, error) {
|
||||
if s.Scales == nil {
|
||||
return nil, errors.New("scales unavailable")
|
||||
}
|
||||
row, err := s.Scales.GetAdmin(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrScaleNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
|
||||
// PatchScaleStatus updates published|draft and audits in one transaction.
|
||||
func (s *Service) PatchScaleStatus(ctx context.Context, adminID, scaleID uuid.UUID, status string) error {
|
||||
if err := s.RequireSuper(ctx, adminID); err != nil {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var ErrFilterRuleNotFound = errString("filter rule not found")
|
||||
|
||||
// ListFilterRules returns FilterRule catalog.
|
||||
func (s *Service) ListFilterRules(ctx context.Context) ([]repository.FilterRuleRow, error) {
|
||||
items, err := s.Repo.ListFilterRules(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.FilterRuleRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetFilterRule loads one rule.
|
||||
func (s *Service) GetFilterRule(ctx context.Context, id uuid.UUID) (*repository.FilterRuleRow, error) {
|
||||
row, err := s.Repo.GetFilterRule(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrFilterRuleNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
|
||||
// EvaluateContent runs read-only filter preview.
|
||||
func (s *Service) EvaluateContent(ctx context.Context, text string) ([]repository.FilterMatch, error) {
|
||||
return s.Repo.EvaluateFilterRules(ctx, text)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user