primeira versão do e-li-nps construido com IA
This commit is contained in:
commit
06950d6e2c
34 changed files with 2524 additions and 0 deletions
133
internal/db/schema.go
Normal file
133
internal/db/schema.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue