Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user