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

252 lines
7.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: 010.
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 110.
-- - 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 (010).
--
-- 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
}