Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
589 lines
16 KiB
Go
589 lines
16 KiB
Go
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
|
|
}
|