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

203 lines
6.2 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
}
// 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
}