252 lines
7.7 KiB
Go
252 lines
7.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
|
||
}
|
||
|
||
// EnsurePainelTables cria as tabelas globais usadas pelo painel.
|
||
//
|
||
// Motivação:
|
||
// - as respostas ficam em tabelas dinâmicas por produto (nps_{produto})
|
||
// - precisamos de um lugar único para registrar auditoria de análise (pendente/concluída)
|
||
// e comentários internos do painel
|
||
//
|
||
// Importante:
|
||
// - tudo é criado de forma defensiva (IF NOT EXISTS)
|
||
// - SQL sempre parametrizado (aqui não há identificadores dinâmicos)
|
||
func EnsurePainelTables(ctx context.Context, pool *pgxpool.Pool) error {
|
||
// Status de análise por (produto + resposta_id)
|
||
if _, err := pool.Exec(ctx, `
|
||
CREATE TABLE IF NOT EXISTS painel_resposta_status (
|
||
produto text NOT NULL,
|
||
resposta_id text NOT NULL,
|
||
status text NOT NULL CHECK (status IN ('pendente','concluida')),
|
||
concluida_em timestamptz NULL,
|
||
atualizado_em timestamptz NOT NULL DEFAULT now(),
|
||
PRIMARY KEY (produto, resposta_id)
|
||
)`); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Comentários internos por resposta.
|
||
if _, err := pool.Exec(ctx, `
|
||
CREATE TABLE IF NOT EXISTS painel_resposta_comentario (
|
||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
produto text NOT NULL,
|
||
resposta_id text NOT NULL,
|
||
pessoa_nome text NOT NULL,
|
||
sessao_id uuid NOT NULL,
|
||
comentario text NOT NULL,
|
||
criado_em timestamptz NOT NULL DEFAULT now(),
|
||
atualizado_em timestamptz NOT NULL DEFAULT now()
|
||
)`); err != nil {
|
||
return err
|
||
}
|
||
|
||
if _, err := pool.Exec(ctx, `
|
||
CREATE INDEX IF NOT EXISTS idx_painel_resp_comentario
|
||
ON painel_resposta_comentario (produto, resposta_id, criado_em ASC)
|
||
`); err != nil {
|
||
return err
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// 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(),
|
||
-- Escala NPS do projeto: 0–10.
|
||
nota int NULL CHECK (nota BETWEEN 0 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;
|
||
|
||
-- Migração defensiva (em runtime) da constraint de nota.
|
||
--
|
||
-- Motivação:
|
||
-- - Em versões anteriores a escala era 1–10.
|
||
-- - As tabelas por produto são criadas automaticamente; portanto, podem existir
|
||
-- tabelas antigas com CHECK antigo em nota.
|
||
--
|
||
-- Estratégia:
|
||
-- - Remover qualquer CHECK existente que mencione a coluna nota.
|
||
-- - Recriar uma constraint nomeada ck_nota_0_10 com a regra atual (0–10).
|
||
--
|
||
-- Segurança: tableName é validado por TableNameValido (regex) antes de ser
|
||
-- interpolado e usado como regclass/identificador.
|
||
DO $$
|
||
DECLARE c record;
|
||
BEGIN
|
||
FOR c IN
|
||
SELECT conname
|
||
FROM pg_constraint
|
||
WHERE conrelid = '%s'::regclass
|
||
AND contype='c'
|
||
AND pg_get_constraintdef(oid) ILIKE '%%nota%%'
|
||
LOOP
|
||
EXECUTE format('ALTER TABLE %%I DROP CONSTRAINT %%I', '%s', c.conname);
|
||
END LOOP;
|
||
|
||
IF NOT EXISTS (
|
||
SELECT 1
|
||
FROM pg_constraint
|
||
WHERE conrelid = '%s'::regclass
|
||
AND contype='c'
|
||
AND conname='ck_nota_0_10'
|
||
) THEN
|
||
EXECUTE format('ALTER TABLE %%I ADD CONSTRAINT ck_nota_0_10 CHECK (nota BETWEEN 0 AND 10)', '%s');
|
||
END IF;
|
||
END$$;
|
||
|
||
-- 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, // CREATE TABLE
|
||
tableName, // ALTER TABLE add usuario_codigo
|
||
tableName, // ALTER TABLE add produto_nome
|
||
tableName, // ALTER TABLE add ip_real
|
||
tableName, // DO block: conrelid (1)
|
||
tableName, // DO block: DROP CONSTRAINT (identificador)
|
||
tableName, // DO block: conrelid (2)
|
||
tableName, // DO block: ADD CONSTRAINT (identificador)
|
||
tableName, // idx_nps_resp_recente_%s
|
||
tableName, // ON %s
|
||
tableName, // idx_nps_pedido_aberto_%s
|
||
tableName, // ON %s
|
||
)
|
||
|
||
_, err := pool.Exec(ctx, q)
|
||
return err
|
||
}
|