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 }