package db import ( "context" "fmt" "regexp" "strings" "unicode" "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/text/unicode/norm" ) var produtoRe = regexp.MustCompile(`^[a-z_][a-z0-9_]*$`) // NormalizeProduto normaliza e valida um nome de produto para uso em: // - nomes de tabela no Postgres (prefixo nps_) // - rotas/URLs (parâmetro {produto}) // // Regras: // - minúsculo + trim // - remove diacríticos // - converte qualquer caractere fora de [a-z0-9_] para '_' // - colapsa '_' repetidos // - valida contra regex e tamanho máximo de identificador // // Importante: isso NÃO é usado para exibição ao usuário. func NormalizeProduto(produtoNome string) (string, error) { p := strings.ToLower(strings.TrimSpace(produtoNome)) if p == "" { return "", fmt.Errorf("produto invalido") } // Remove diacritics (NFD + strip marks) p = norm.NFD.String(p) p = strings.Map(func(r rune) rune { if unicode.Is(unicode.Mn, r) { return -1 } return r }, p) // Replace anything not allowed with underscore p = strings.Map(func(r rune) rune { switch { case r >= 'a' && r <= 'z': return r case r >= '0' && r <= '9': return r case r == '_': return r default: return '_' } }, p) // Collapse underscores for strings.Contains(p, "__") { p = strings.ReplaceAll(p, "__", "_") } p = strings.Trim(p, "_") // Postgres identifiers are max 63 chars. Table name is "nps_" + produto. if len(p) > 59 { return "", fmt.Errorf("produto invalido") } if !produtoRe.MatchString(p) { return "", fmt.Errorf("produto invalido") } return p, nil } func TableNameForProduto(produto string) string { return "nps_" + produto } func EnsurePgcrypto(ctx context.Context, pool *pgxpool.Pool) error { _, err := pool.Exec(ctx, `CREATE EXTENSION IF NOT EXISTS pgcrypto`) return err } // EnsureNPSTable creates the per-product table + indexes if they do not exist. // IMPORTANT: tableName must be created from a sanitized product name. func EnsureNPSTable(ctx context.Context, pool *pgxpool.Pool, tableName string) error { // Identifiers cannot be passed as $1 parameters, so we must interpolate. // Safety: tableName is strictly derived from NormalizeProduto + prefix. q := fmt.Sprintf(` CREATE TABLE IF NOT EXISTS %s ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- Nome do produto como informado pela integração/widget. -- Importante: NÃO é usado para nome de tabela; é apenas para exibição. produto_nome text NOT NULL DEFAULT '', inquilino_codigo text NOT NULL, inquilino_nome text NOT NULL, usuario_codigo text, usuario_nome text NOT NULL, usuario_email text, usuario_telefone text, status text NOT NULL CHECK (status IN ('pedido','respondido')), pedido_criado_em timestamptz NOT NULL DEFAULT now(), respondido_em timestamptz NULL, atualizado_em timestamptz NOT NULL DEFAULT now(), nota int NULL CHECK (nota BETWEEN 1 AND 10), justificativa text NULL, valida bool NOT NULL DEFAULT true, origem text NOT NULL DEFAULT 'widget_iframe', user_agent text NULL, -- IP real do usuário (após middleware RealIP). Pode conter IPv4 ou IPv6. -- Importante: quando rodar atrás de proxy (ex.: Docker + Nginx/Traefik), -- garanta que o proxy repasse X-Forwarded-For/X-Real-IP. ip_real text NULL ); ALTER TABLE %s ADD COLUMN IF NOT EXISTS usuario_codigo text; ALTER TABLE %s ADD COLUMN IF NOT EXISTS produto_nome text NOT NULL DEFAULT ''; ALTER TABLE %s ADD COLUMN IF NOT EXISTS ip_real text; -- NOTE: controle de exibição é por (produto + inquilino_codigo + usuario_codigo) -- então os índices são baseados em usuario_codigo. CREATE INDEX IF NOT EXISTS idx_nps_resp_recente_%s ON %s (inquilino_codigo, usuario_codigo, respondido_em DESC) WHERE status='respondido' AND valida=true; CREATE INDEX IF NOT EXISTS idx_nps_pedido_aberto_%s ON %s (inquilino_codigo, usuario_codigo, pedido_criado_em DESC) WHERE status='pedido'; `, tableName, tableName, tableName, tableName, tableName, tableName, tableName, tableName) _, err := pool.Exec(ctx, q) return err }