package support import ( "context" "errors" "strconv" "strings" "sync" "time" "unicode" "unicode/utf8" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" ) const ( maxKBSlugLen = 120 maxKBTitleLen = 200 maxKBBodyLen = 100000 maxTemplateName = 120 maxTemplateBody = 10000 maxKeywordLen = 64 maxKeywordsCount = 40 maxIntentCount = 20 maxCatSlugCount = 20 kbCacheTTL = 60 * time.Second ) type kbCorpusCache struct { mu sync.RWMutex articles []KBArticle templates []ReplyTemplate loadedAt time.Time } var sharedKBCache = &kbCorpusCache{} func invalidateKBCache() { sharedKBCache.mu.Lock() sharedKBCache.loadedAt = time.Time{} sharedKBCache.articles = nil sharedKBCache.templates = nil sharedKBCache.mu.Unlock() } func normalizeSlug(s string) (string, error) { s = strings.ToLower(strings.TrimSpace(s)) s = strings.ReplaceAll(s, " ", "-") if s == "" { return "", ErrKBSlugRequired } if utf8.RuneCountInString(s) > maxKBSlugLen { return "", ErrInvalidKBSlug } for _, r := range s { if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' { continue } return "", ErrInvalidKBSlug } return s, nil } func normalizeStringList(in []string, maxItem, maxCount int) []string { if len(in) == 0 { return []string{} } seen := make(map[string]struct{}, len(in)) out := make([]string, 0, len(in)) for _, raw := range in { s := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(raw, "\x00", ""))) if s == "" { continue } if utf8.RuneCountInString(s) > maxItem { s = string([]rune(s)[:maxItem]) } if _, ok := seen[s]; ok { continue } seen[s] = struct{}{} out = append(out, s) if len(out) >= maxCount { break } } return out } func normalizeKBArticleInput(in KBArticleInput, forUpdate bool) (KBArticleInput, error) { out := in slug, err := normalizeSlug(in.Slug) if err != nil && (!forUpdate || strings.TrimSpace(in.Slug) != "") { return KBArticleInput{}, err } out.Slug = slug title := strings.TrimSpace(strings.ReplaceAll(in.Title, "\x00", "")) if title == "" && !forUpdate { return KBArticleInput{}, ErrKBTitleRequired } if title != "" && utf8.RuneCountInString(title) > maxKBTitleLen { title = string([]rune(title)[:maxKBTitleLen]) } out.Title = title body := strings.TrimSpace(strings.ReplaceAll(in.BodyMD, "\x00", "")) if body == "" && !forUpdate { return KBArticleInput{}, ErrKBBodyRequired } if body != "" && utf8.RuneCountInString(body) > maxKBBodyLen { body = string([]rune(body)[:maxKBBodyLen]) } out.BodyMD = body out.CategorySlugs = normalizeStringList(in.CategorySlugs, maxCategoryLen, maxCatSlugCount) out.Keywords = normalizeStringList(in.Keywords, maxKeywordLen, maxKeywordsCount) out.IntentKeys = normalizeStringList(in.IntentKeys, maxKeywordLen, maxIntentCount) return out, nil } func normalizeTemplateInput(in ReplyTemplateInput, forUpdate bool) (ReplyTemplateInput, error) { out := in name := strings.TrimSpace(strings.ReplaceAll(in.Name, "\x00", "")) if name == "" && !forUpdate { return ReplyTemplateInput{}, ErrTemplateNameRequired } if name != "" && utf8.RuneCountInString(name) > maxTemplateName { name = string([]rune(name)[:maxTemplateName]) } out.Name = name body := strings.TrimSpace(strings.ReplaceAll(in.Body, "\x00", "")) if body == "" && !forUpdate { return ReplyTemplateInput{}, ErrTemplateBodyRequired } if body != "" && utf8.RuneCountInString(body) > maxTemplateBody { body = string([]rune(body)[:maxTemplateBody]) } out.Body = body out.CategorySlugs = normalizeStringList(in.CategorySlugs, maxCategoryLen, maxCatSlugCount) out.Keywords = normalizeStringList(in.Keywords, maxKeywordLen, maxKeywordsCount) out.IntentKeys = normalizeStringList(in.IntentKeys, maxKeywordLen, maxIntentCount) return out, nil } func scanKBArticle(row pgx.Row) (KBArticle, error) { var a KBArticle err := row.Scan( &a.ID, &a.Slug, &a.Title, &a.BodyMD, &a.CategorySlugs, &a.Keywords, &a.IntentKeys, &a.IsPublished, &a.PriorityWeight, &a.CreatedAt, &a.UpdatedAt, ) if a.CategorySlugs == nil { a.CategorySlugs = []string{} } if a.Keywords == nil { a.Keywords = []string{} } if a.IntentKeys == nil { a.IntentKeys = []string{} } return a, err } func scanReplyTemplate(row pgx.Row) (ReplyTemplate, error) { var t ReplyTemplate err := row.Scan( &t.ID, &t.Name, &t.Body, &t.CategorySlugs, &t.Keywords, &t.IntentKeys, &t.IsActive, &t.PriorityWeight, &t.CreatedAt, &t.UpdatedAt, ) if t.CategorySlugs == nil { t.CategorySlugs = []string{} } if t.Keywords == nil { t.Keywords = []string{} } if t.IntentKeys == nil { t.IntentKeys = []string{} } return t, err } const kbArticleCols = `id, slug, title, body_md, category_slugs, keywords, intent_keys, is_published, priority_weight, created_at, updated_at` // kbArticleListCols omits body_md blobs on index pages (detail loads full body via GetKBArticle). const kbArticleListCols = `id, slug, title, ''::text AS body_md, category_slugs, keywords, intent_keys, is_published, priority_weight, created_at, updated_at` const replyTemplateCols = `id, name, body, category_slugs, keywords, intent_keys, is_active, priority_weight, created_at, updated_at` const replyTemplateListCols = `id, name, ''::text AS body, category_slugs, keywords, intent_keys, is_active, priority_weight, created_at, updated_at` const ( kbAdminListMaxLimit = 100 kbAdminListDefault = 50 matchCorpusMaxArticles = 500 ) // KBArticleListOpts filters the admin article index (bodies omitted). type KBArticleListOpts struct { PublishedOnly bool Category string Query string Limit int Offset int } // ListKBArticles returns platform KB articles (admin index — no body_md payload). func (s *Service) ListKBArticles(ctx context.Context, publishedOnly bool, limit, offset int) ([]KBArticle, int64, error) { return s.ListKBArticlesOpts(ctx, KBArticleListOpts{ PublishedOnly: publishedOnly, Limit: limit, Offset: offset, }) } // ListKBArticlesOpts returns a filtered admin article index (no body_md payload). func (s *Service) ListKBArticlesOpts(ctx context.Context, opts KBArticleListOpts) ([]KBArticle, int64, error) { limit := opts.Limit if limit <= 0 || limit > kbAdminListMaxLimit { limit = kbAdminListDefault } offset := opts.Offset if offset < 0 { offset = 0 } where := make([]string, 0, 4) args := make([]any, 0, 6) where = append(where, "TRUE") if opts.PublishedOnly { where = append(where, "is_published = true") } cat := strings.ToLower(strings.TrimSpace(opts.Category)) if cat != "" { args = append(args, cat) where = append(where, "category_slugs @> ARRAY[$"+strconv.Itoa(len(args))+"]::text[]") } q := strings.TrimSpace(opts.Query) if q != "" { if utf8.RuneCountInString(q) > 120 { q = string([]rune(q)[:120]) } args = append(args, "%"+strings.ToLower(q)+"%") n := strconv.Itoa(len(args)) where = append(where, "(lower(title) LIKE $"+n+" OR lower(slug) LIKE $"+n+" OR EXISTS (SELECT 1 FROM unnest(keywords) k WHERE lower(k) LIKE $"+n+"))") } whereSQL := strings.Join(where, " AND ") var total int64 if err := s.Pool.QueryRow(ctx, `SELECT count(*) FROM support_kb_articles WHERE `+whereSQL, args...).Scan(&total); err != nil { return nil, 0, err } limitArg := len(args) + 1 offsetArg := len(args) + 2 args = append(args, limit, offset) rows, err := s.Pool.Query(ctx, ` SELECT `+kbArticleListCols+` FROM support_kb_articles WHERE `+whereSQL+` ORDER BY priority_weight DESC, updated_at DESC LIMIT $`+strconv.Itoa(limitArg)+` OFFSET $`+strconv.Itoa(offsetArg), args...) if err != nil { return nil, 0, err } defer rows.Close() out := make([]KBArticle, 0, limit) for rows.Next() { a, err := scanKBArticle(rows) if err != nil { return nil, 0, err } out = append(out, a) } return out, total, rows.Err() } // GetKBArticle loads one article by id. func (s *Service) GetKBArticle(ctx context.Context, id uuid.UUID) (KBArticle, error) { a, err := scanKBArticle(s.Pool.QueryRow(ctx, ` SELECT `+kbArticleCols+` FROM support_kb_articles WHERE id = $1`, id)) if errors.Is(err, pgx.ErrNoRows) { return KBArticle{}, ErrKBNotFound } return a, err } // CreateKBArticle inserts a new knowledge article. func (s *Service) CreateKBArticle(ctx context.Context, in KBArticleInput) (KBArticle, error) { norm, err := normalizeKBArticleInput(in, false) if err != nil { return KBArticle{}, err } published := false if in.IsPublished != nil { published = *in.IsPublished } weight := 0 if in.PriorityWeight != nil { weight = *in.PriorityWeight } a, err := scanKBArticle(s.Pool.QueryRow(ctx, ` INSERT INTO support_kb_articles ( slug, title, body_md, category_slugs, keywords, intent_keys, is_published, priority_weight ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING `+kbArticleCols, norm.Slug, norm.Title, norm.BodyMD, norm.CategorySlugs, norm.Keywords, norm.IntentKeys, published, weight)) if err != nil { if isUniqueViolation(err) { return KBArticle{}, ErrKBSlugTaken } return KBArticle{}, err } invalidateKBCache() return a, nil } // UpdateKBArticle patches an existing article. func (s *Service) UpdateKBArticle(ctx context.Context, id uuid.UUID, in KBArticleInput) (KBArticle, error) { cur, err := s.GetKBArticle(ctx, id) if err != nil { return KBArticle{}, err } norm, err := normalizeKBArticleInput(in, true) if err != nil { return KBArticle{}, err } if norm.Slug != "" { cur.Slug = norm.Slug } if norm.Title != "" { cur.Title = norm.Title } if norm.BodyMD != "" { cur.BodyMD = norm.BodyMD } if in.CategorySlugs != nil { cur.CategorySlugs = norm.CategorySlugs } if in.Keywords != nil { cur.Keywords = norm.Keywords } if in.IntentKeys != nil { cur.IntentKeys = norm.IntentKeys } if in.IsPublished != nil { cur.IsPublished = *in.IsPublished } if in.PriorityWeight != nil { cur.PriorityWeight = *in.PriorityWeight } a, err := scanKBArticle(s.Pool.QueryRow(ctx, ` UPDATE support_kb_articles SET slug = $2, title = $3, body_md = $4, category_slugs = $5, keywords = $6, intent_keys = $7, is_published = $8, priority_weight = $9, updated_at = now() WHERE id = $1 RETURNING `+kbArticleCols, id, cur.Slug, cur.Title, cur.BodyMD, cur.CategorySlugs, cur.Keywords, cur.IntentKeys, cur.IsPublished, cur.PriorityWeight)) if err != nil { if isUniqueViolation(err) { return KBArticle{}, ErrKBSlugTaken } return KBArticle{}, err } invalidateKBCache() return a, nil } // DeleteKBArticle removes an article. func (s *Service) DeleteKBArticle(ctx context.Context, id uuid.UUID) error { tag, err := s.Pool.Exec(ctx, `DELETE FROM support_kb_articles WHERE id = $1`, id) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrKBNotFound } invalidateKBCache() return nil } // ListReplyTemplates returns platform reply templates (admin index — no body payload). func (s *Service) ListReplyTemplates(ctx context.Context, activeOnly bool, limit, offset int) ([]ReplyTemplate, int64, error) { if limit <= 0 || limit > kbAdminListMaxLimit { limit = kbAdminListDefault } if offset < 0 { offset = 0 } where := `TRUE` if activeOnly { where = `is_active = true` } var total int64 if err := s.Pool.QueryRow(ctx, `SELECT count(*) FROM support_reply_templates WHERE `+where).Scan(&total); err != nil { return nil, 0, err } rows, err := s.Pool.Query(ctx, ` SELECT `+replyTemplateListCols+` FROM support_reply_templates WHERE `+where+` ORDER BY priority_weight DESC, updated_at DESC LIMIT $1 OFFSET $2`, limit, offset) if err != nil { return nil, 0, err } defer rows.Close() out := make([]ReplyTemplate, 0, limit) for rows.Next() { t, err := scanReplyTemplate(rows) if err != nil { return nil, 0, err } out = append(out, t) } return out, total, rows.Err() } // GetReplyTemplate loads one template by id. func (s *Service) GetReplyTemplate(ctx context.Context, id uuid.UUID) (ReplyTemplate, error) { t, err := scanReplyTemplate(s.Pool.QueryRow(ctx, ` SELECT `+replyTemplateCols+` FROM support_reply_templates WHERE id = $1`, id)) if errors.Is(err, pgx.ErrNoRows) { return ReplyTemplate{}, ErrTemplateNotFound } return t, err } // CreateReplyTemplate inserts a canned reply template. func (s *Service) CreateReplyTemplate(ctx context.Context, in ReplyTemplateInput) (ReplyTemplate, error) { norm, err := normalizeTemplateInput(in, false) if err != nil { return ReplyTemplate{}, err } active := true if in.IsActive != nil { active = *in.IsActive } weight := 0 if in.PriorityWeight != nil { weight = *in.PriorityWeight } t, err := scanReplyTemplate(s.Pool.QueryRow(ctx, ` INSERT INTO support_reply_templates ( name, body, category_slugs, keywords, intent_keys, is_active, priority_weight ) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING `+replyTemplateCols, norm.Name, norm.Body, norm.CategorySlugs, norm.Keywords, norm.IntentKeys, active, weight)) if err != nil { return ReplyTemplate{}, err } invalidateKBCache() return t, nil } // UpdateReplyTemplate patches a template. func (s *Service) UpdateReplyTemplate(ctx context.Context, id uuid.UUID, in ReplyTemplateInput) (ReplyTemplate, error) { cur, err := s.GetReplyTemplate(ctx, id) if err != nil { return ReplyTemplate{}, err } norm, err := normalizeTemplateInput(in, true) if err != nil { return ReplyTemplate{}, err } if norm.Name != "" { cur.Name = norm.Name } if norm.Body != "" { cur.Body = norm.Body } if in.CategorySlugs != nil { cur.CategorySlugs = norm.CategorySlugs } if in.Keywords != nil { cur.Keywords = norm.Keywords } if in.IntentKeys != nil { cur.IntentKeys = norm.IntentKeys } if in.IsActive != nil { cur.IsActive = *in.IsActive } if in.PriorityWeight != nil { cur.PriorityWeight = *in.PriorityWeight } t, err := scanReplyTemplate(s.Pool.QueryRow(ctx, ` UPDATE support_reply_templates SET name = $2, body = $3, category_slugs = $4, keywords = $5, intent_keys = $6, is_active = $7, priority_weight = $8, updated_at = now() WHERE id = $1 RETURNING `+replyTemplateCols, id, cur.Name, cur.Body, cur.CategorySlugs, cur.Keywords, cur.IntentKeys, cur.IsActive, cur.PriorityWeight)) if err != nil { return ReplyTemplate{}, err } invalidateKBCache() return t, nil } // DeleteReplyTemplate removes a template. func (s *Service) DeleteReplyTemplate(ctx context.Context, id uuid.UUID) error { tag, err := s.Pool.Exec(ctx, `DELETE FROM support_reply_templates WHERE id = $1`, id) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrTemplateNotFound } invalidateKBCache() return nil } func (s *Service) loadMatchCorpus(ctx context.Context) ([]KBArticle, []ReplyTemplate, error) { sharedKBCache.mu.RLock() if !sharedKBCache.loadedAt.IsZero() && time.Since(sharedKBCache.loadedAt) < kbCacheTTL { arts := sharedKBCache.articles tmps := sharedKBCache.templates sharedKBCache.mu.RUnlock() return arts, tmps, nil } sharedKBCache.mu.RUnlock() // Dedicated full-body load (admin List* omits bodies and caps at 100). arts, err := s.loadPublishedKBArticlesForMatch(ctx) if err != nil { return nil, nil, err } tmps, err := s.loadActiveReplyTemplatesForMatch(ctx) if err != nil { return nil, nil, err } sharedKBCache.mu.Lock() sharedKBCache.articles = arts sharedKBCache.templates = tmps sharedKBCache.loadedAt = time.Now() sharedKBCache.mu.Unlock() return arts, tmps, nil } func (s *Service) loadPublishedKBArticlesForMatch(ctx context.Context) ([]KBArticle, error) { rows, err := s.Pool.Query(ctx, ` SELECT `+kbArticleCols+` FROM support_kb_articles WHERE is_published = true ORDER BY priority_weight DESC, updated_at DESC LIMIT $1`, matchCorpusMaxArticles) if err != nil { return nil, err } defer rows.Close() out := make([]KBArticle, 0) for rows.Next() { a, err := scanKBArticle(rows) if err != nil { return nil, err } out = append(out, a) } return out, rows.Err() } func (s *Service) loadActiveReplyTemplatesForMatch(ctx context.Context) ([]ReplyTemplate, error) { rows, err := s.Pool.Query(ctx, ` SELECT `+replyTemplateCols+` FROM support_reply_templates WHERE is_active = true ORDER BY priority_weight DESC, updated_at DESC LIMIT $1`, matchCorpusMaxArticles) if err != nil { return nil, err } defer rows.Close() out := make([]ReplyTemplate, 0) for rows.Next() { t, err := scanReplyTemplate(rows) if err != nil { return nil, err } out = append(out, t) } return out, rows.Err() } func isUniqueViolation(err error) bool { var pgErr *pgconn.PgError if errors.As(err, &pgErr) { return pgErr.Code == "23505" } return false }