chore: 合入 stash Ops hardening 与 migration 000041

Ask/catalog 权限与审计加固、量表读权限统一,以及未提交的 ops hardening 变更。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-13 01:46:34 +08:00
co-authored by Cursor
parent 4889ff5916
commit 62cd8c45dd
70 changed files with 848 additions and 69 deletions
+25 -1
View File
@@ -16,6 +16,7 @@ import (
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)
authed.GET("/ask/threads/:id/messages", middleware.RequireAdminPermission(h.Svc, admin.PermAskTranscriptRead), h.GetAskThreadMessages)
}
func (h *AdminHandler) ListAskThreads(c *gin.Context) {
@@ -44,7 +45,7 @@ func (h *AdminHandler) GetAskThread(c *gin.Context) {
response.Fail(c, http.StatusBadRequest, 40002, "invalid thread id")
return
}
detail, err := h.Svc.GetAskSessionDetail(c.Request.Context(), id)
meta, err := h.Svc.GetAskSessionMeta(c.Request.Context(), id)
if errors.Is(err, admin.ErrAskThreadNotFound) {
response.Fail(c, http.StatusNotFound, 40402, "ask thread not found")
return
@@ -53,5 +54,28 @@ func (h *AdminHandler) GetAskThread(c *gin.Context) {
response.Fail(c, http.StatusInternalServerError, 50020, "get ask thread failed")
return
}
response.OK(c, meta)
}
func (h *AdminHandler) GetAskThreadMessages(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, 40002, "invalid thread id")
return
}
detail, err := h.Svc.GetAskSessionTranscript(c.Request.Context(), adminID, 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, 50021, "get ask transcript failed")
return
}
response.OK(c, detail)
}
+1 -1
View File
@@ -16,7 +16,7 @@ import (
func (h *AdminHandler) registerContent(authed *gin.RouterGroup) {
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.GET("/scales", middleware.RequireAnyAdminPermission(h.Svc, admin.PermExploreRead, admin.PermContentWrite), h.ListScales)
authed.PATCH("/scales/:id", middleware.RequireAdminPermission(h.Svc, admin.PermContentWrite), h.PatchScale)
}
@@ -14,8 +14,8 @@ import (
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)
g.GET("/scales", middleware.RequireAnyAdminPermission(h.Svc, admin.PermExploreRead, admin.PermContentWrite), h.ListExploreScales)
g.GET("/scales/:id", middleware.RequireAnyAdminPermission(h.Svc, admin.PermExploreRead, admin.PermContentWrite), h.GetExploreScale)
}
func (h *AdminHandler) ListExploreScales(c *gin.Context) {
@@ -14,8 +14,8 @@ import (
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)
g.GET("/funnel-definitions", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthRead), h.ListFunnelDefinitions)
g.GET("/funnel-definitions/:id", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthRead), h.GetFunnelDefinition)
}
func (h *AdminHandler) ListFunnelDefinitions(c *gin.Context) {
@@ -68,6 +68,10 @@ func (h *AdminHandler) CreateAskFeedback(c *gin.Context) {
response.Fail(c, http.StatusNotFound, 40402, "ask thread not found")
return
}
if errors.Is(err, admin.ErrMessageNotInThread) {
response.Fail(c, http.StatusBadRequest, 40030, "message not in thread")
return
}
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50026, "create feedback failed")
return
@@ -60,6 +60,10 @@ func (h *AskHandler) SubmitFeedback(c *gin.Context) {
response.Fail(c, http.StatusNotFound, 40410, msg)
return
}
if strings.Contains(msg, "message not in thread") {
response.Fail(c, http.StatusBadRequest, 40030, msg)
return
}
response.Fail(c, http.StatusInternalServerError, 50000, msg)
return
}
+62 -1
View File
@@ -99,7 +99,47 @@ func TestAskOperations(t *testing.T) {
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)
t.Fatalf("meta http=%d msg=%s", code, env.Message)
}
var meta map[string]any
_ = json.Unmarshal(env.Data, &meta)
if _, has := meta["messages"]; has {
t.Fatalf("meta must not include messages: %#v", meta)
}
if meta["id"] != threadID {
t.Fatalf("meta id mismatch: %#v", meta)
}
metaOnlyRole := uuid.New()
_, err = pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
metaOnlyRole, "ask_meta_"+metaOnlyRole.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')`, metaOnlyRole)
if err != nil {
t.Fatal(err)
}
metaUser := fmt.Sprintf("askmeta_%d", time.Now().UnixNano())
hash2, _ := bcrypt.GenerateFromPassword([]byte("meta-pass"), bcrypt.DefaultCost)
_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
metaUser, string(hash2), metaOnlyRole)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, metaUser)
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, metaOnlyRole)
})
metaTok := adminLogin(t, r, metaUser, "meta-pass")
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/threads/"+threadID+"/messages", nil, metaTok)
if code != http.StatusForbidden {
t.Fatalf("expected 403 without transcript.read, got %d", code)
}
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/threads/"+threadID+"/messages", nil, tok)
if code != 200 {
t.Fatalf("transcript http=%d msg=%s", code, env.Message)
}
var detail struct {
Messages []struct {
@@ -111,5 +151,26 @@ func TestAskOperations(t *testing.T) {
if len(detail.Messages) < 2 {
t.Fatalf("expected user+assistant, got %#v", detail.Messages)
}
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)
audited := false
for _, it := range audit.Items {
if it.Action == "ask.transcript.read" {
audited = true
break
}
}
if !audited {
t.Fatalf("missing ask.transcript.read audit: %#v", audit.Items)
}
_ = key
}
@@ -132,5 +132,34 @@ func TestQualityFeedback(t *testing.T) {
if !okAudit {
t.Fatal("missing ask.feedback.create audit")
}
// cross-thread message_id must be rejected
env, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads", map[string]any{
"profile_id": profileID, "scene": "self",
}, key)
otherThread := decodeData[map[string]any](t, env.Data)["id"].(string)
env, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads/"+otherThread+"/messages", map[string]any{
"content": "另一线程",
}, key)
// fetch a message id from other thread via admin transcript
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/threads/"+otherThread+"/messages", nil, tok)
if code != 200 {
t.Fatalf("other transcript http=%d", code)
}
var otherDetail struct {
Messages []struct {
ID string `json:"id"`
} `json:"messages"`
}
_ = json.Unmarshal(env.Data, &otherDetail)
if len(otherDetail.Messages) == 0 {
t.Fatal("expected messages on other thread")
}
foreignMsg := otherDetail.Messages[0].ID
_, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/ask/threads/"+threadID+"/feedback",
map[string]any{"rating": 2, "tag": "other", "message_id": foreignMsg}, tok)
if code != http.StatusBadRequest {
t.Fatalf("expected 400 for foreign message_id, got %d", code)
}
_ = key
}
@@ -18,6 +18,11 @@ type AdminPermissionChecker interface {
// RequireAdminPermission aborts with 403 when the admin lacks code.
func RequireAdminPermission(checker AdminPermissionChecker, code string) gin.HandlerFunc {
return RequireAnyAdminPermission(checker, code)
}
// RequireAnyAdminPermission aborts with 403 when the admin lacks all of codes.
func RequireAnyAdminPermission(checker AdminPermissionChecker, codes ...string) gin.HandlerFunc {
return func(c *gin.Context) {
adminID, ok := AdminIDFromContext(c)
if !ok {
@@ -25,18 +30,27 @@ func RequireAdminPermission(checker AdminPermissionChecker, code string) gin.Han
c.Abort()
return
}
okPerm, err := checker.HasPermission(c.Request.Context(), adminID, code)
if err != nil {
if len(codes) == 0 {
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
var lastCode string
for _, code := range codes {
lastCode = code
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 {
c.Next()
return
}
}
c.Next()
checker.DenyPermission(c.Request.Context(), adminID, lastCode, c.FullPath())
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
c.Abort()
}
}
@@ -27,7 +27,7 @@ func (r *AdminRepo) ListSystemPrompts(ctx context.Context) ([]SystemPromptRow, e
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`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -79,7 +79,7 @@ func (r *AdminRepo) ListKnowledgeSources(ctx context.Context) ([]KnowledgeSource
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`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListBlockPolicies(ctx context.Context) ([]BlockPolicyRow, er
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, action, active, system, updated_at
FROM block_policies
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
+2 -2
View File
@@ -28,7 +28,7 @@ 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`)
ORDER BY active DESC, sort_order ASC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -83,7 +83,7 @@ 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`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -36,7 +36,7 @@ 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`)
ORDER BY active DESC, category ASC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListCrisisEvents(ctx context.Context) ([]CrisisEventRow, err
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, severity, active, system, updated_at
FROM crisis_events
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
+1 -1
View File
@@ -38,7 +38,7 @@ func (r *AdminRepo) ListCrisisPolicies(ctx context.Context) ([]CrisisPolicyRow,
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`)
ORDER BY active DESC, severity DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -24,7 +24,7 @@ func (r *AdminRepo) ListFunnelDefinitions(ctx context.Context) ([]FunnelDefiniti
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, active, system, updated_at
FROM funnel_definitions
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListHandoffCases(ctx context.Context) ([]HandoffCaseRow, err
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`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -24,7 +24,7 @@ func (r *AdminRepo) ListImageCardDecks(ctx context.Context) ([]ImageCardDeckRow,
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, active, system, updated_at
FROM image_card_decks
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListInterventionOutcomes(ctx context.Context) ([]Interventio
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, outcome, active, system, updated_at
FROM intervention_outcomes
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -26,7 +26,7 @@ func (r *AdminRepo) ListKnowledgeChunks(ctx context.Context) ([]KnowledgeChunkRo
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`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListModerationCases(ctx context.Context) ([]ModerationCaseRo
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, status, active, system, updated_at
FROM moderation_cases
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -26,7 +26,7 @@ func (r *AdminRepo) ListPrivacyRequests(ctx context.Context) ([]PrivacyRequestRo
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`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -76,6 +76,15 @@ func (r *AdminRepo) CreateAdminQualityFeedback(
if !ok {
return nil, errors.New("ask thread not found")
}
if messageID != nil {
inThread, err := r.askMessageInThread(ctx, threadID, *messageID)
if err != nil {
return nil, err
}
if !inThread {
return nil, errors.New("message not in thread")
}
}
tx, err := r.Pool.Begin(ctx)
if err != nil {
return nil, err
@@ -117,6 +126,18 @@ func (r *AskRepo) CreateUserQualityFeedback(
if err != nil {
return nil, errors.New("ask thread not found")
}
if messageID != nil {
var n int
err = r.Pool.QueryRow(ctx, `
SELECT 1 FROM ask_messages
WHERE id=$1 AND thread_id=$2 AND deleted_at IS NULL`, *messageID, threadID).Scan(&n)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errors.New("message not in thread")
}
if err != nil {
return nil, err
}
}
var f QualityFeedbackRow
err = r.Pool.QueryRow(ctx, `
INSERT INTO ask_quality_feedback(thread_id, message_id, source, rating, tag, note, created_by_user)
@@ -138,6 +159,17 @@ func (r *AdminRepo) askThreadExists(ctx context.Context, threadID uuid.UUID) (bo
return err == nil, err
}
func (r *AdminRepo) askMessageInThread(ctx context.Context, threadID, messageID uuid.UUID) (bool, error) {
var n int
err := r.Pool.QueryRow(ctx, `
SELECT 1 FROM ask_messages
WHERE id=$1 AND thread_id=$2 AND deleted_at IS NULL`, messageID, 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")
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListReportTemplates(ctx context.Context) ([]ReportTemplateRo
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, scene, active, system, updated_at
FROM report_templates
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -24,7 +24,7 @@ func (r *AdminRepo) ListRhythmConfigs(ctx context.Context) ([]RhythmConfigRow, e
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, active, system, updated_at
FROM rhythm_configs
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
+1 -1
View File
@@ -106,7 +106,7 @@ type ScaleAdminItem struct {
func (r *ScaleRepo) ListAllAdmin(ctx context.Context) ([]ScaleAdminItem, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, slug, title, description, status FROM scales
WHERE deleted_at IS NULL ORDER BY created_at`)
WHERE deleted_at IS NULL ORDER BY created_at LIMIT 500`)
if err != nil {
return nil, err
}
@@ -26,7 +26,7 @@ func (r *AdminRepo) ListScheduledPublications(ctx context.Context) ([]ScheduledP
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`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -24,7 +24,7 @@ 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`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListToolDefinitions(ctx context.Context) ([]ToolDefinitionRo
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, description, active, system, updated_at
FROM tool_definitions
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
+21 -3
View File
@@ -2,6 +2,7 @@ package admin
import (
"context"
"encoding/json"
"errors"
"github.com/google/uuid"
@@ -10,7 +11,7 @@ import (
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
// AskSessionDetail is AskSessionView plus messages.
// AskSessionDetail is AskSessionView plus messages (transcript).
type AskSessionDetail struct {
repository.AskSessionView
Messages []repository.AskMessageView `json:"messages"`
@@ -30,8 +31,20 @@ func (s *Service) ListAskSessions(ctx context.Context, userID *uuid.UUID, limit,
return items, nil
}
// GetAskSessionDetail loads meta + messages.
func (s *Service) GetAskSessionDetail(ctx context.Context, threadID uuid.UUID) (*AskSessionDetail, error) {
// GetAskSessionMeta loads thread meta without message bodies.
func (s *Service) GetAskSessionMeta(ctx context.Context, threadID uuid.UUID) (*repository.AskSessionView, error) {
view, err := s.Repo.GetAskSession(ctx, threadID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrAskThreadNotFound
}
if err != nil {
return nil, err
}
return view, nil
}
// GetAskSessionTranscript loads message bodies and audits access.
func (s *Service) GetAskSessionTranscript(ctx context.Context, adminID, threadID uuid.UUID) (*AskSessionDetail, error) {
view, err := s.Repo.GetAskSession(ctx, threadID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrAskThreadNotFound
@@ -46,5 +59,10 @@ func (s *Service) GetAskSessionDetail(ctx context.Context, threadID uuid.UUID) (
if msgs == nil {
msgs = []repository.AskMessageView{}
}
meta, _ := json.Marshal(map[string]any{
"message_count": len(msgs),
"user_id": view.UserID.String(),
})
_ = s.Repo.InsertAudit(ctx, adminID, "ask.transcript.read", "ask_thread", threadID.String(), meta)
return &AskSessionDetail{AskSessionView: *view, Messages: msgs}, nil
}
@@ -10,9 +10,10 @@ import (
)
var (
ErrBadFeedbackRating = errString("rating must be 1-5")
ErrBadFeedbackTag = errString("invalid tag")
ErrFeedbackNoteLong = errString("note too long")
ErrBadFeedbackRating = errString("rating must be 1-5")
ErrBadFeedbackTag = errString("invalid tag")
ErrFeedbackNoteLong = errString("note too long")
ErrMessageNotInThread = errString("message not in thread")
)
// ListQualityFeedback lists recent QualityFeedback.
@@ -54,6 +55,8 @@ func (s *Service) CreateQualityFeedback(
return nil, ErrFeedbackNoteLong
case strings.Contains(msg, "thread not found"):
return nil, ErrAskThreadNotFound
case strings.Contains(msg, "message not in thread"):
return nil, ErrMessageNotInThread
default:
return nil, err
}
+2 -1
View File
@@ -25,6 +25,7 @@ const (
PermMembershipCodesRead = "admin.membership.codes.read"
PermMembershipCodesWrite = "admin.membership.codes.write"
PermAskRead = "admin.ask.read"
PermAskTranscriptRead = "admin.ask.transcript.read"
PermAskFeedbackWrite = "admin.ask.feedback.write"
PermContentSafetyRead = "admin.content_safety.read"
PermAIConfigRead = "admin.ai_config.read"
@@ -41,7 +42,7 @@ var knownPermissions = map[string]struct{}{
PermContentWrite: {}, PermRolesRead: {}, PermRolesWrite: {},
PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {},
PermMembershipCodesRead: {}, PermMembershipCodesWrite: {},
PermAskRead: {}, PermAskFeedbackWrite: {}, PermContentSafetyRead: {},
PermAskRead: {}, PermAskTranscriptRead: {}, PermAskFeedbackWrite: {}, PermContentSafetyRead: {},
PermAIConfigRead: {}, PermCrisisRead: {}, PermCMSRead: {}, PermGrowthRead: {}, PermExploreRead: {}, PermPrivacyRead: {},
}
@@ -38,5 +38,8 @@ func (s *Service) SubmitFeedback(ctx context.Context, userID, threadID uuid.UUID
if strings.Contains(msg, "thread not found") {
return nil, errors.New("ask thread not found")
}
if strings.Contains(msg, "message not in thread") {
return nil, errors.New("message not in thread")
}
return nil, err
}
@@ -0,0 +1,7 @@
DELETE FROM admin_role_permissions WHERE code = 'admin.ask.transcript.read';
COMMENT ON TABLE crisis_events IS NULL;
COMMENT ON TABLE intervention_outcomes IS NULL;
COMMENT ON TABLE privacy_requests IS NULL;
COMMENT ON TABLE ask_handoff_cases IS NULL;
COMMENT ON TABLE moderation_cases IS NULL;
@@ -0,0 +1,13 @@
-- Ops hardening: Ask transcript perm + catalog≠case comments (review follow-up)
INSERT INTO admin_role_permissions(role_id, code)
SELECT r.id, 'admin.ask.transcript.read'
FROM admin_roles r
WHERE r.name = 'super_admin'
ON CONFLICT DO NOTHING;
COMMENT ON TABLE crisis_events IS 'CATALOG ONLY: crisis event type placeholders. Not user crisis cases.';
COMMENT ON TABLE intervention_outcomes IS 'CATALOG ONLY: intervention outcome type placeholders. Not user case outcomes.';
COMMENT ON TABLE privacy_requests IS 'CATALOG ONLY: privacy request type placeholders. Not user privacy tickets.';
COMMENT ON TABLE ask_handoff_cases IS 'CATALOG ONLY: handoff case type placeholders. Not live handoff tickets.';
COMMENT ON TABLE moderation_cases IS 'CATALOG ONLY: moderation case type placeholders. Not live moderation tickets.';