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:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+207
View File
@@ -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
}