Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
208 lines
4.7 KiB
Go
208 lines
4.7 KiB
Go
package support
|
|
|
|
import (
|
|
"context"
|
|
"regexp"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const (
|
|
maxSubjectLen = 200
|
|
maxBodyLen = 10000
|
|
maxCategoryLen = 32
|
|
maxCSATCommentLen = 2000
|
|
maxTags = 10
|
|
maxTagLen = 40
|
|
maxRelatedSKULen = 128
|
|
)
|
|
|
|
// Seed categories mirror 031_support_ticket_detail.sql (fallback when table missing).
|
|
var allowedCategories = map[string]struct{}{
|
|
"billing": {},
|
|
"billing_credits": {},
|
|
"bug": {},
|
|
"account": {},
|
|
"integrations": {},
|
|
"processing": {},
|
|
"export": {},
|
|
"migration": {}, // P1-17 hypercare missing/wrong data reports
|
|
"other": {},
|
|
}
|
|
|
|
var allowedStatuses = map[string]struct{}{
|
|
"open": {},
|
|
"pending": {},
|
|
"resolved": {},
|
|
"closed": {},
|
|
}
|
|
|
|
var allowedPriorities = map[string]struct{}{
|
|
"low": {},
|
|
"normal": {},
|
|
"high": {},
|
|
}
|
|
|
|
var tagSlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
|
|
|
func normalizeSubject(s string) (string, error) {
|
|
s = strings.TrimSpace(strings.ReplaceAll(s, "\x00", ""))
|
|
if s == "" {
|
|
return "", ErrSubjectRequired
|
|
}
|
|
if utf8.RuneCountInString(s) > maxSubjectLen {
|
|
s = string([]rune(s)[:maxSubjectLen])
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func normalizeBody(s string) (string, error) {
|
|
s = strings.TrimSpace(strings.ReplaceAll(s, "\x00", ""))
|
|
if s == "" {
|
|
return "", ErrBodyRequired
|
|
}
|
|
if utf8.RuneCountInString(s) > maxBodyLen {
|
|
s = string([]rune(s)[:maxBodyLen])
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func normalizeCategory(s string) (string, error) {
|
|
s = strings.ToLower(strings.TrimSpace(s))
|
|
if s == "" {
|
|
return "other", nil
|
|
}
|
|
if utf8.RuneCountInString(s) > maxCategoryLen {
|
|
return "", ErrInvalidCategory
|
|
}
|
|
if _, ok := allowedCategories[s]; !ok {
|
|
return "", ErrInvalidCategory
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// normalizeCategoryActive prefers active rows in support_categories when available.
|
|
func (s *Service) normalizeCategoryActive(ctx context.Context, category string) (string, error) {
|
|
slug, err := normalizeCategory(category)
|
|
if err != nil {
|
|
// Soft expand: if seed reject but DB has active slug, accept.
|
|
candidate := strings.ToLower(strings.TrimSpace(category))
|
|
if candidate == "" || utf8.RuneCountInString(candidate) > maxCategoryLen {
|
|
return "", err
|
|
}
|
|
if s == nil || s.Pool == nil {
|
|
return "", err
|
|
}
|
|
var active bool
|
|
qerr := s.Pool.QueryRow(ctx, `
|
|
SELECT is_active FROM support_categories WHERE slug = $1`, candidate).Scan(&active)
|
|
if qerr != nil || !active {
|
|
return "", ErrInvalidCategory
|
|
}
|
|
return candidate, nil
|
|
}
|
|
if s == nil || s.Pool == nil {
|
|
return slug, nil
|
|
}
|
|
var active bool
|
|
qerr := s.Pool.QueryRow(ctx, `
|
|
SELECT is_active FROM support_categories WHERE slug = $1`, slug).Scan(&active)
|
|
if qerr != nil {
|
|
if IsMissingRelation(qerr) {
|
|
return slug, nil
|
|
}
|
|
// Table present but slug missing: allow seed defaults (migration may lag seeds).
|
|
return slug, nil
|
|
}
|
|
if !active {
|
|
return "", ErrInvalidCategory
|
|
}
|
|
return slug, nil
|
|
}
|
|
|
|
func normalizeStatus(s string) (string, error) {
|
|
s = strings.ToLower(strings.TrimSpace(s))
|
|
if _, ok := allowedStatuses[s]; !ok {
|
|
return "", ErrInvalidStatus
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func normalizePriority(s string) (string, error) {
|
|
s = strings.ToLower(strings.TrimSpace(s))
|
|
if s == "" {
|
|
return "normal", nil
|
|
}
|
|
if _, ok := allowedPriorities[s]; !ok {
|
|
return "", ErrInvalidPriority
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func normalizeCSATScore(score int) (int, error) {
|
|
if score < 1 || score > 5 {
|
|
return 0, ErrInvalidScore
|
|
}
|
|
return score, nil
|
|
}
|
|
|
|
func normalizeCSATComment(s string) (string, error) {
|
|
s = strings.TrimSpace(strings.ReplaceAll(s, "\x00", ""))
|
|
if utf8.RuneCountInString(s) > maxCSATCommentLen {
|
|
s = string([]rune(s)[:maxCSATCommentLen])
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func normalizeTags(tags []string) ([]string, error) {
|
|
if len(tags) == 0 {
|
|
return []string{}, nil
|
|
}
|
|
out := make([]string, 0, len(tags))
|
|
seen := make(map[string]struct{}, len(tags))
|
|
for _, raw := range tags {
|
|
t := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(raw, "\x00", "")))
|
|
if t == "" {
|
|
continue
|
|
}
|
|
if utf8.RuneCountInString(t) > maxTagLen {
|
|
return nil, ErrInvalidTag
|
|
}
|
|
if !tagSlugPattern.MatchString(t) {
|
|
return nil, ErrInvalidTag
|
|
}
|
|
if _, ok := seen[t]; ok {
|
|
continue
|
|
}
|
|
seen[t] = struct{}{}
|
|
out = append(out, t)
|
|
if len(out) > maxTags {
|
|
return nil, ErrTooManyTags
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func normalizeRelatedSKU(s string) (string, error) {
|
|
s = strings.TrimSpace(strings.ReplaceAll(s, "\x00", ""))
|
|
if s == "" {
|
|
return "", nil
|
|
}
|
|
if utf8.RuneCountInString(s) > maxRelatedSKULen {
|
|
return "", ErrInvalidRelatedSKU
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func normalizeRelatedProductID(id *uuid.UUID) (*uuid.UUID, error) {
|
|
if id == nil {
|
|
return nil, nil
|
|
}
|
|
if *id == uuid.Nil {
|
|
return nil, ErrInvalidRelatedProduct
|
|
}
|
|
return id, nil
|
|
}
|