e-li-nps/internal/db/schema.go

152 lines
4.7 KiB
Go

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_]*$`)
// TableNameValido valida se o nome de tabela está no formato esperado do projeto:
// "nps_" + produto normalizado.
//
// Motivação (segurança): identificadores não podem ser parametrizados em SQL.
// Então, sempre que precisarmos interpolar um nome de tabela, validamos aqui
// para evitar SQL injection via identificador.
func TableNameValido(tableName string) bool {
if !strings.HasPrefix(tableName, "nps_") {
return false
}
produto := strings.TrimPrefix(tableName, "nps_")
// produtoRe já garante: ^[a-z_][a-z0-9_]*$
// (e a normalização limita tamanho em NormalizeProduto).
return produtoRe.MatchString(produto)
}
// 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 cria a tabela por produto + índices se não existirem.
// Importante: tableName deve ser criada a partir de um produto normalizado.
func EnsureNPSTable(ctx context.Context, pool *pgxpool.Pool, tableName string) error {
// Identifiers cannot be passed as $1 parameters, so we must interpolate.
// Segurança: tableName deve ser estritamente derivada de NormalizeProduto + prefix.
if !TableNameValido(tableName) {
return fmt.Errorf("nome de tabela invalido")
}
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
}