primeira versão do e-li-nps construido com IA

This commit is contained in:
Luiz Silva 2025-12-31 11:18:20 -03:00
commit 06950d6e2c
34 changed files with 2524 additions and 0 deletions

35
internal/db/pool.go Normal file
View file

@ -0,0 +1,35 @@
package db
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func NewPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parse DATABASE_URL: %w", err)
}
// Reasonable defaults
cfg.MaxConns = 10
cfg.MinConns = 0
cfg.MaxConnLifetime = 60 * time.Minute
cfg.MaxConnIdleTime = 10 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, err
}
ctxPing, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := pool.Ping(ctxPing); err != nil {
pool.Close()
return nil, err
}
return pool, nil
}

133
internal/db/schema.go Normal file
View 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
}

183
internal/elinps/handlers.go Normal file
View file

@ -0,0 +1,183 @@
package elinps
import (
"encoding/json"
"errors"
"net/http"
"strings"
"e-li.nps/internal/db"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Handlers struct {
store *Store
tpl *TemplateRenderer
}
func NewHandlers(pool *pgxpool.Pool) *Handlers {
return &Handlers{
store: NewStore(pool),
tpl: NewTemplateRenderer(mustParseTemplates()),
}
}
func (h *Handlers) PostPedido(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var in PedidoInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "json_invalido"})
return
}
if err := ValidatePedidoInput(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
return
}
// Ensure per-product table exists (also normalizes produto).
table, err := h.store.EnsureTableForProduto(ctx, in.ProdutoNome)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "produto_invalido"})
return
}
// Keep normalized form for the widget to build URLs safely.
// table = "nps_" + produto_normalizado
produtoNormalizado := strings.TrimPrefix(table, "nps_")
// Rules
respRecente, err := h.store.HasRespostaValidaRecente(ctx, table, in.InquilinoCodigo, in.UsuarioCodigo)
if err != nil {
// Fail-closed
writeJSON(w, http.StatusOK, PedidoResponse{PodeAbrir: false, Motivo: "erro"})
return
}
if respRecente {
writeJSON(w, http.StatusOK, PedidoResponse{PodeAbrir: false, Motivo: "resposta_recente"})
return
}
pedidoAberto, err := h.store.HasPedidoEmAbertoRecente(ctx, table, in.InquilinoCodigo, in.UsuarioCodigo)
if err != nil {
writeJSON(w, http.StatusOK, PedidoResponse{PodeAbrir: false, Motivo: "erro"})
return
}
if pedidoAberto {
writeJSON(w, http.StatusOK, PedidoResponse{PodeAbrir: false, Motivo: "pedido_em_aberto"})
return
}
id, err := h.store.CreatePedido(ctx, table, in, r)
if err != nil {
writeJSON(w, http.StatusOK, PedidoResponse{PodeAbrir: false, Motivo: "erro"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"pode_abrir": true, "id": id, "produto": produtoNormalizado})
}
func (h *Handlers) PatchResposta(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
produtoParam := chi.URLParam(r, "produto")
id := chi.URLParam(r, "id")
// produtoParam already in path; sanitize again.
prod, err := db.NormalizeProduto(produtoParam)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "produto_invalido"})
return
}
table := db.TableNameForProduto(prod)
if err := db.EnsureNPSTable(ctx, h.store.pool, table); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "db"})
return
}
var in PatchInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "json_invalido"})
return
}
if err := ValidatePatchInput(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
return
}
if in.Nota == nil && in.Justificativa == nil && !in.Finalizar {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "nada_para_atualizar"})
return
}
if err := h.store.PatchRegistro(ctx, table, id, in); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "db"})
return
}
// If called via HTMX, respond with refreshed HTML fragment.
if r.Header.Get("HX-Request") == "true" {
reg, err := h.store.GetRegistro(ctx, table, id)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("db"))
return
}
data := FormPageData{Produto: prod, ID: id, Reg: reg}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
h.tpl.Render(w, "form_inner.html", data)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *Handlers) GetForm(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
produtoParam := chi.URLParam(r, "produto")
id := chi.URLParam(r, "id")
prod, err := db.NormalizeProduto(produtoParam)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("produto invalido"))
return
}
table := db.TableNameForProduto(prod)
if err := db.EnsureNPSTable(ctx, h.store.pool, table); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("db"))
return
}
reg, err := h.store.GetRegistro(ctx, table, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("nao encontrado"))
return
}
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("db"))
return
}
data := FormPageData{
Produto: prod,
ID: id,
Reg: reg,
}
// Always return a standalone HTML page so the widget can use iframe.
// But the inner container is also HTMX-friendly (it swaps itself).
w.Header().Set("Content-Type", "text/html; charset=utf-8")
h.tpl.Render(w, "form_page.html", data)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}

View file

@ -0,0 +1,28 @@
package elinps
import "net/http"
func CORSMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PATCH,OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, HX-Request")
w.Header().Set("Access-Control-Max-Age", "600")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
func MaxBodyBytesMiddleware(n int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, n)
next.ServeHTTP(w, r)
})
}
}

40
internal/elinps/models.go Normal file
View file

@ -0,0 +1,40 @@
package elinps
import "time"
type PedidoInput struct {
ProdutoNome string `json:"produto_nome"`
InquilinoCodigo string `json:"inquilino_codigo"`
InquilinoNome string `json:"inquilino_nome"`
UsuarioCodigo string `json:"usuario_codigo"`
UsuarioNome string `json:"usuario_nome"`
UsuarioTelefone string `json:"usuario_telefone"`
UsuarioEmail string `json:"usuario_email"`
}
type PedidoResponse struct {
PodeAbrir bool `json:"pode_abrir"`
Motivo string `json:"motivo,omitempty"`
ID string `json:"id,omitempty"`
}
type PatchInput struct {
Nota *int `json:"nota,omitempty"`
Justificativa *string `json:"justificativa,omitempty"`
Finalizar bool `json:"finalizar,omitempty"`
}
type Registro struct {
// ProdutoNome é o nome original do produto como enviado pela integração/widget.
// Ele existe apenas para exibição ao usuário.
//
// Importante: a normalização (remoção de acentos/símbolos) é usada apenas
// para formar o nome da tabela no Postgres e o parâmetro {produto} da rota.
ProdutoNome string
ID string
Status string
Nota *int
Justificativa *string
PedidoCriadoEm time.Time
RespondidoEm *time.Time
}

319
internal/elinps/painel.go Normal file
View file

@ -0,0 +1,319 @@
package elinps
import (
"crypto/subtle"
"fmt"
"html/template"
"net/http"
"net/url"
"strings"
"time"
"e-li.nps/internal/db"
"github.com/jackc/pgx/v5"
)
// Proteção simples do painel administrativo.
//
// Objetivo: bloquear acesso ao painel com uma senha definida no .env.
// Implementação: cookie assinado de forma simples (token aleatório por boot).
//
// Observação: é propositalmente simples (sem banco) para manter o projeto leve.
// Se precisar evoluir depois, podemos trocar por JWT ou sessão persistida.
type AuthPainel struct {
Senha string
Token string
}
func (a AuthPainel) handlerLoginPost(w http.ResponseWriter, r *http.Request) {
if !a.habilitado() {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("painel desabilitado"))
return
}
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
senha := r.FormValue("senha")
if subtle.ConstantTimeCompare([]byte(senha), []byte(a.Senha)) != 1 {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("senha invalida"))
return
}
http.SetCookie(w, &http.Cookie{
Name: a.cookieName(),
Value: a.Token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
// Secure deve ser true em produção com HTTPS.
Secure: false,
// Expira em 24h (relogin simples).
Expires: time.Now().Add(24 * time.Hour),
})
http.Redirect(w, r, "/painel", http.StatusFound)
}
// NPSMensal representa o cálculo do NPS agregado por mês.
type NPSMensal struct {
Mes string
Detratores int
Neutros int
Promotores int
Total int
NPS int
}
// RespostaPainel representa uma resposta para listagem no painel.
type RespostaPainel struct {
ID string
RespondidoEm *time.Time
PedidoCriadoEm time.Time
UsuarioCodigo *string
UsuarioNome string
UsuarioEmail *string
Nota *int
Justificativa *string
}
type PainelDados struct {
Produto string
Produtos []string
Meses []NPSMensal
Respostas []RespostaPainel
Pagina int
SomenteBaixas bool
MsgErro string
}
func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store *Store) {
ctx := r.Context()
// Query params
produto := r.URL.Query().Get("produto")
pagina := 1
if p := r.URL.Query().Get("pagina"); p != "" {
// best-effort parse
_, _ = fmt.Sscanf(p, "%d", &pagina)
if pagina <= 0 {
pagina = 1
}
}
somenteBaixas := r.URL.Query().Get("baixas") == "1"
produtos, err := store.ListarProdutos(ctx)
if err != nil {
a.renderPainelHTML(w, PainelDados{MsgErro: "erro ao listar produtos"})
return
}
if produto == "" && len(produtos) > 0 {
produto = produtos[0]
}
dados := PainelDados{Produto: produto, Produtos: produtos, Pagina: pagina, SomenteBaixas: somenteBaixas}
if produto == "" {
a.renderPainelHTML(w, dados)
return
}
// tabela segura
prodNorm, err := db.NormalizeProduto(produto)
if err != nil {
dados.MsgErro = "produto inválido"
a.renderPainelHTML(w, dados)
return
}
tabela := db.TableNameForProduto(prodNorm)
if err := db.EnsureNPSTable(ctx, store.poolRef(), tabela); err != nil {
dados.MsgErro = "erro ao garantir tabela"
a.renderPainelHTML(w, dados)
return
}
meses, err := store.NPSMesAMes(ctx, tabela, 12)
if err != nil {
dados.MsgErro = "erro ao calcular NPS"
a.renderPainelHTML(w, dados)
return
}
dados.Meses = meses
respostas, err := store.ListarRespostas(ctx, tabela, ListarRespostasFiltro{SomenteNotasBaixas: somenteBaixas, Pagina: pagina, PorPagina: 50})
if err != nil {
// Se a tabela ainda não tem coluna ip_real/ etc, EnsureNPSTable deveria ajustar.
if err == pgx.ErrNoRows {
respostas = []RespostaPainel{}
} else {
dados.MsgErro = "erro ao listar respostas"
}
}
dados.Respostas = respostas
a.renderPainelHTML(w, dados)
}
func (a AuthPainel) renderPainelHTML(w http.ResponseWriter, d PainelDados) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// HTML propositalmente simples (sem template engine) para manter isolado.
// Se quiser evoluir, dá pra migrar para templates.
var b strings.Builder
b.WriteString("<!doctype html><html lang=\"pt-br\"><head><meta charset=\"utf-8\"/><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>")
b.WriteString("<title>e-li.nps • Painel</title>")
b.WriteString(`<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;margin:0;padding:18px;background:#fafafa;color:#111;}
.top{display:flex;gap:12px;flex-wrap:wrap;align-items:center;}
.card{background:#fff;border:1px solid #e5e5e5;border-radius:12px;padding:14px;}
select,input{padding:10px;border:1px solid #ddd;border-radius:10px;}
a{color:#111}
table{width:100%;border-collapse:collapse;font-size:13px;}
th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:top;}
.muted{color:#666;font-size:12px;}
.badge{display:inline-block;padding:2px 8px;border-radius:999px;background:#f2f2f2;border:1px solid #e5e5e5;font-size:12px;}
</style></head><body>`)
b.WriteString("<div class=\"top\">")
b.WriteString("<div class=\"card\"><h1 style=\"margin:0 0 8px\">e-li.nps • Painel</h1>")
if d.MsgErro != "" {
b.WriteString("<p class=\"badge\">" + template.HTMLEscapeString(d.MsgErro) + "</p>")
}
b.WriteString("<form method=\"GET\" action=\"/painel\" style=\"display:flex;gap:10px;flex-wrap:wrap;align-items:center\">")
b.WriteString("<label class=\"muted\">Produto</label>")
b.WriteString("<select name=\"produto\">")
for _, p := range d.Produtos {
sel := ""
if p == d.Produto {
sel = " selected"
}
b.WriteString("<option value=\"" + template.HTMLEscapeString(p) + "\"" + sel + ">" + template.HTMLEscapeString(p) + "</option>")
}
b.WriteString("</select>")
chk := ""
if d.SomenteBaixas {
chk = "checked"
}
b.WriteString("<label class=\"muted\"><input type=\"checkbox\" name=\"baixas\" value=\"1\" " + chk + "/> notas baixas (<=6)</label>")
b.WriteString("<button type=\"submit\" style=\"padding:10px 14px;border-radius:10px;border:1px solid #111;background:#111;color:#fff;cursor:pointer\">Aplicar</button>")
b.WriteString("</form></div>")
b.WriteString("</div>")
// NPS mês a mês
b.WriteString("<div class=\"card\" style=\"margin-top:12px\"><h2 style=\"margin:0 0 8px\">NPS mês a mês</h2>")
b.WriteString("<table><thead><tr><th>Mês</th><th>Detratores</th><th>Neutros</th><th>Promotores</th><th>Total</th><th>NPS</th></tr></thead><tbody>")
for _, m := range d.Meses {
b.WriteString(fmt.Sprintf("<tr><td>%s</td><td>%d</td><td>%d</td><td>%d</td><td>%d</td><td><b>%d</b></td></tr>",
template.HTMLEscapeString(m.Mes), m.Detratores, m.Neutros, m.Promotores, m.Total, m.NPS))
}
b.WriteString("</tbody></table></div>")
// Respostas
b.WriteString("<div class=\"card\" style=\"margin-top:12px\"><h2 style=\"margin:0 0 8px\">Respostas</h2>")
b.WriteString("<table><thead><tr><th>Data</th><th>Nota</th><th>Usuário</th><th>Comentário</th></tr></thead><tbody>")
for _, r := range d.Respostas {
data := "-"
if r.RespondidoEm != nil {
data = r.RespondidoEm.Format("2006-01-02 15:04")
}
nota := "-"
if r.Nota != nil {
nota = fmt.Sprintf("%d", *r.Nota)
}
usuario := template.HTMLEscapeString(r.UsuarioNome)
if r.UsuarioCodigo != nil {
usuario += " <span class=\"muted\">(" + template.HTMLEscapeString(*r.UsuarioCodigo) + ")</span>"
}
coment := ""
if r.Justificativa != nil {
coment = template.HTMLEscapeString(*r.Justificativa)
}
b.WriteString("<tr><td>" + template.HTMLEscapeString(data) + "</td><td><b>" + template.HTMLEscapeString(nota) + "</b></td><td>" + usuario + "</td><td>" + coment + "</td></tr>")
}
b.WriteString("</tbody></table>")
// Navegação
base := "/painel?produto=" + url.QueryEscape(d.Produto)
if d.SomenteBaixas {
base += "&baixas=1"
}
prev := d.Pagina - 1
if prev < 1 {
prev = 1
}
next := d.Pagina + 1
b.WriteString("<div style=\"display:flex;gap:10px;justify-content:flex-end;margin-top:10px\">")
b.WriteString("<a class=\"badge\" href=\"" + base + "&pagina=" + fmt.Sprintf("%d", prev) + "\">Anterior</a>")
b.WriteString("<span class=\"muted\">Página " + fmt.Sprintf("%d", d.Pagina) + "</span>")
b.WriteString("<a class=\"badge\" href=\"" + base + "&pagina=" + fmt.Sprintf("%d", next) + "\">Próxima</a>")
b.WriteString("</div>")
b.WriteString("</div>")
b.WriteString("</body></html>")
w.Write([]byte(b.String()))
}
func (a AuthPainel) habilitado() bool {
return a.Senha != "" && a.Token != ""
}
func (a AuthPainel) cookieName() string { return "eli_nps_painel" }
func (a AuthPainel) isAutenticado(r *http.Request) bool {
c, err := r.Cookie(a.cookieName())
if err != nil {
return false
}
return subtle.ConstantTimeCompare([]byte(c.Value), []byte(a.Token)) == 1
}
func (a AuthPainel) middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !a.habilitado() {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("painel desabilitado"))
return
}
if a.isAutenticado(r) {
next.ServeHTTP(w, r)
return
}
http.Redirect(w, r, "/painel/login", http.StatusFound)
})
}
func (a AuthPainel) handlerLoginGet(w http.ResponseWriter, r *http.Request) {
// HTML mínimo para evitar dependências.
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!doctype html>
<html lang="pt-br">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>e-li.nps Painel</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;padding:24px;}
.card{max-width:420px;margin:0 auto;border:1px solid #e5e5e5;border-radius:12px;padding:16px;}
label{display:block;font-size:12px;color:#444;margin-bottom:6px;}
input{width:100%;padding:10px;border:1px solid #ddd;border-radius:10px;}
button{margin-top:12px;width:100%;padding:10px 14px;border-radius:10px;border:1px solid #111;background:#111;color:#fff;cursor:pointer;}
.muted{color:#555;font-size:13px;}
</style>
</head>
<body>
<div class="card">
<h1>e-li.nps Painel</h1>
<p class="muted">Acesso protegido por senha (SENHA_PAINEL).</p>
<form method="POST" action="/painel/login">
<label>Senha</label>
<input type="password" name="senha" autocomplete="current-password" />
<button type="submit">Entrar</button>
</form>
</div>
</body>
</html>`))
}
// (handlerLoginPost duplicado removido)

View file

@ -0,0 +1,51 @@
package elinps
import (
"crypto/rand"
"encoding/hex"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// PainelHandlers expõe o painel de exploração em /painel.
//
// O painel é protegido por senha via SENHA_PAINEL.
// A sessão é um cookie simples com token gerado a cada inicialização.
type PainelHandlers struct {
auth AuthPainel
store *Store
}
func NewPainelHandlers(pool *pgxpool.Pool, senha string) *PainelHandlers {
token := gerarTokenPainel()
return &PainelHandlers{
auth: AuthPainel{Senha: senha, Token: token},
store: NewStore(pool),
}
}
// Router monta as rotas do painel.
func (p *PainelHandlers) Router() http.Handler {
r := chi.NewRouter()
// Login
r.Get("/login", p.auth.handlerLoginGet)
r.Post("/login", p.auth.handlerLoginPost)
// Dashboard
r.With(func(next http.Handler) http.Handler { return p.auth.middleware(next) }).Get("/", func(w http.ResponseWriter, r *http.Request) {
p.auth.handlerPainel(w, r, p.store)
})
return r
}
func gerarTokenPainel() string {
// Token aleatório para o cookie do painel.
// Importante: muda a cada boot (ao reiniciar o servidor, precisa logar de novo).
b := make([]byte, 32)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}

View file

@ -0,0 +1,157 @@
package elinps
import (
"context"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// ListarProdutos retorna os produtos existentes a partir das tabelas `nps_*`.
//
// Importante: este painel é para exploração interna. Mesmo assim, mantemos uma
// sanitização mínima no nome (prefixo nps_ removido).
func (s *Store) ListarProdutos(ctx context.Context) ([]string, error) {
rows, err := s.pool.Query(ctx, `
SELECT tablename
FROM pg_catalog.pg_tables
WHERE schemaname='public' AND tablename LIKE 'nps_%'
ORDER BY tablename`)
if err != nil {
return nil, err
}
defer rows.Close()
produtos := []string{}
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
return nil, err
}
produtos = append(produtos, strings.TrimPrefix(t, "nps_"))
}
return produtos, rows.Err()
}
// NPSMesAMes calcula o NPS por mês para um produto (tabela `nps_{produto}`).
//
// Regra NPS (110):
// - 16 detratores
// - 78 neutros
// - 910 promotores
func (s *Store) NPSMesAMes(ctx context.Context, tabela string, meses int) ([]NPSMensal, error) {
// Segurança: tabela deve ser derivada de NormalizeProduto + prefixo.
q := fmt.Sprintf(`
WITH base AS (
SELECT
date_trunc('month', respondido_em) AS mes,
nota
FROM %s
WHERE status='respondido'
AND valida=true
AND respondido_em IS NOT NULL
AND respondido_em >= date_trunc('month', now()) - ($1::int * interval '1 month')
)
SELECT
to_char(mes, 'YYYY-MM') AS mes,
SUM(CASE WHEN nota BETWEEN 1 AND 6 THEN 1 ELSE 0 END)::int AS detratores,
SUM(CASE WHEN nota BETWEEN 7 AND 8 THEN 1 ELSE 0 END)::int AS neutros,
SUM(CASE WHEN nota BETWEEN 9 AND 10 THEN 1 ELSE 0 END)::int AS promotores,
COUNT(*)::int AS total
FROM base
GROUP BY mes
ORDER BY mes ASC`, tabela)
rows, err := s.pool.Query(ctx, q, meses)
if err != nil {
return nil, err
}
defer rows.Close()
out := []NPSMensal{}
for rows.Next() {
var m NPSMensal
if err := rows.Scan(&m.Mes, &m.Detratores, &m.Neutros, &m.Promotores, &m.Total); err != nil {
return nil, err
}
if m.Total > 0 {
pctProm := float64(m.Promotores) / float64(m.Total) * 100
pctDet := float64(m.Detratores) / float64(m.Total) * 100
m.NPS = int((pctProm - pctDet) + 0.5) // arredonda para inteiro
}
out = append(out, m)
}
return out, rows.Err()
}
type ListarRespostasFiltro struct {
SomenteNotasBaixas bool
Pagina int
PorPagina int
}
func (f *ListarRespostasFiltro) normalizar() {
if f.Pagina <= 0 {
f.Pagina = 1
}
if f.PorPagina <= 0 || f.PorPagina > 200 {
f.PorPagina = 50
}
}
// ListarRespostas retorna respostas respondidas, com paginação e filtro.
func (s *Store) ListarRespostas(ctx context.Context, tabela string, filtro ListarRespostasFiltro) ([]RespostaPainel, error) {
filtro.normalizar()
offset := (filtro.Pagina - 1) * filtro.PorPagina
cond := "status='respondido' AND valida=true"
if filtro.SomenteNotasBaixas {
cond += " AND nota BETWEEN 1 AND 6"
}
q := fmt.Sprintf(`
SELECT
id,
respondido_em,
pedido_criado_em,
usuario_codigo,
usuario_nome,
usuario_email,
nota,
justificativa
FROM %s
WHERE %s
ORDER BY respondido_em DESC NULLS LAST
LIMIT $1 OFFSET $2`, tabela, cond)
rows, err := s.pool.Query(ctx, q, filtro.PorPagina, offset)
if err != nil {
return nil, err
}
defer rows.Close()
respostas := []RespostaPainel{}
for rows.Next() {
var r RespostaPainel
if err := rows.Scan(
&r.ID,
&r.RespondidoEm,
&r.PedidoCriadoEm,
&r.UsuarioCodigo,
&r.UsuarioNome,
&r.UsuarioEmail,
&r.Nota,
&r.Justificativa,
); err != nil {
return nil, err
}
respostas = append(respostas, r)
}
return respostas, rows.Err()
}
// ensure interface imports
var _ = pgx.ErrNoRows
var _ = time.Second

154
internal/elinps/queries.go Normal file
View file

@ -0,0 +1,154 @@
package elinps
import (
"context"
"fmt"
"net"
"net/http"
"time"
"e-li.nps/internal/db"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
func (s *Store) poolRef() *pgxpool.Pool { return s.pool }
func ipReal(r *http.Request) string {
// IP real do cliente.
//
// Importante:
// - No servidor, usamos middleware.RealIP (chi) que resolve o IP considerando
// headers comuns de proxy (X-Forwarded-For / X-Real-IP).
// - Aqui usamos o r.RemoteAddr já processado e extraímos apenas o host.
// - Se não for possível parsear, retornamos vazio.
ip := r.RemoteAddr
if host, _, err := net.SplitHostPort(ip); err == nil {
ip = host
}
if net.ParseIP(ip) == nil {
return ""
}
return ip
}
func (s *Store) EnsureTableForProduto(ctx context.Context, produtoNome string) (table string, err error) {
prod, err := db.NormalizeProduto(produtoNome)
if err != nil {
return "", err
}
table = db.TableNameForProduto(prod)
if err := db.EnsureNPSTable(ctx, s.pool, table); err != nil {
return "", err
}
return table, nil
}
func (s *Store) HasRespostaValidaRecente(ctx context.Context, table, inquilinoCodigo, usuarioCodigo string) (bool, error) {
q := fmt.Sprintf(`
SELECT 1
FROM %s
WHERE inquilino_codigo=$1 AND usuario_codigo=$2
AND status='respondido' AND valida=true
AND respondido_em >= now() - interval '45 days'
LIMIT 1`, table)
var one int
err := s.pool.QueryRow(ctx, q, inquilinoCodigo, usuarioCodigo).Scan(&one)
if err == pgx.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func (s *Store) HasPedidoEmAbertoRecente(ctx context.Context, table, inquilinoCodigo, usuarioCodigo string) (bool, error) {
q := fmt.Sprintf(`
SELECT 1
FROM %s
WHERE inquilino_codigo=$1 AND usuario_codigo=$2
AND status='pedido'
AND pedido_criado_em >= now() - interval '10 days'
LIMIT 1`, table)
var one int
err := s.pool.QueryRow(ctx, q, inquilinoCodigo, usuarioCodigo).Scan(&one)
if err == pgx.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func (s *Store) CreatePedido(ctx context.Context, table string, in PedidoInput, r *http.Request) (string, error) {
q := fmt.Sprintf(`
INSERT INTO %s (
produto_nome,
inquilino_codigo, inquilino_nome,
usuario_codigo, usuario_nome, usuario_email, usuario_telefone,
status, origem, user_agent, ip_real
) VALUES ($1,$2,$3,$4,$5,$6,$7,'pedido','widget_iframe',$8,$9)
RETURNING id`, table)
var id string
err := s.pool.QueryRow(ctx, q,
in.ProdutoNome,
in.InquilinoCodigo, in.InquilinoNome,
in.UsuarioCodigo, in.UsuarioNome, in.UsuarioEmail, in.UsuarioTelefone,
r.UserAgent(), ipReal(r),
).Scan(&id)
return id, err
}
func (s *Store) GetRegistro(ctx context.Context, table, id string) (Registro, error) {
q := fmt.Sprintf(`
SELECT id, produto_nome, status, nota, justificativa, pedido_criado_em, respondido_em
FROM %s
WHERE id=$1`, table)
var reg Registro
err := s.pool.QueryRow(ctx, q, id).Scan(
&reg.ID, &reg.ProdutoNome, &reg.Status, &reg.Nota, &reg.Justificativa, &reg.PedidoCriadoEm, &reg.RespondidoEm,
)
return reg, err
}
func (s *Store) PatchRegistro(ctx context.Context, table, id string, in PatchInput) error {
// UPDATE único com campos opcionais.
q := fmt.Sprintf(`
UPDATE %s
SET
nota = COALESCE($2, nota),
justificativa = COALESCE($3, justificativa),
status = CASE WHEN $4 THEN 'respondido' ELSE status END,
respondido_em = CASE WHEN $4 THEN COALESCE(respondido_em, now()) ELSE respondido_em END,
atualizado_em = now()
WHERE id=$1`, table)
_, err := s.pool.Exec(ctx, q, id, in.Nota, in.Justificativa, in.Finalizar)
return err
}
func (s *Store) TouchAtualizadoEm(ctx context.Context, table, id string) error {
q := fmt.Sprintf(`UPDATE %s SET atualizado_em=now() WHERE id=$1`, table)
_, err := s.pool.Exec(ctx, q, id)
return err
}
func (s *Store) CooldownSuggested(reg Registro) time.Duration {
// Não é usado pelo servidor hoje; fica como helper se precisarmos.
if reg.Status == "respondido" {
return 45 * 24 * time.Hour
}
return 24 * time.Hour
}

View file

@ -0,0 +1,134 @@
package elinps
import (
"bytes"
"fmt"
"html/template"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/yuin/goldmark"
)
// ReadmePage serve o README.md renderizado como HTML.
//
// Motivação: dar uma "home" simples para o serviço (documentação em tempo real).
// Sem autenticação, conforme solicitado.
//
// Implementação: cache em memória por mtime para evitar renderização em toda request.
type ReadmePage struct {
caminho string
mu sync.Mutex
ultimoMTime time.Time
html []byte
errMsg string
}
func NewReadmePage(caminho string) *ReadmePage {
return &ReadmePage{caminho: caminho}
}
func (p *ReadmePage) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Só respondemos GET/HEAD.
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
html, errMsg := p.renderIfNeeded()
if errMsg != "" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(errMsg))
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodHead {
return
}
w.Write(html)
}
func (p *ReadmePage) renderIfNeeded() ([]byte, string) {
p.mu.Lock()
defer p.mu.Unlock()
st, err := os.Stat(p.caminho)
if err != nil {
p.errMsg = fmt.Sprintf("README não encontrado: %s", p.caminho)
p.html = nil
p.ultimoMTime = time.Time{}
return nil, p.errMsg
}
// Cache: se o arquivo não mudou, devolve o HTML já renderizado.
if p.html != nil && st.ModTime().Equal(p.ultimoMTime) {
return p.html, ""
}
md, err := os.ReadFile(p.caminho)
if err != nil {
p.errMsg = "erro ao ler README"
p.html = nil
p.ultimoMTime = time.Time{}
return nil, p.errMsg
}
var buf bytes.Buffer
if err := goldmark.Convert(md, &buf); err != nil {
p.errMsg = "erro ao renderizar README"
p.html = nil
p.ultimoMTime = time.Time{}
return nil, p.errMsg
}
// Envelopa em uma página com estilo básico.
// Importante: NÃO usamos fmt.Sprintf com o HTML/CSS diretamente,
// porque o CSS pode conter "%" (ex.: width:100%) e o fmt interpreta
// como placeholders.
page := `<!doctype html>
<html lang="pt-br">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>e-li.nps README</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;margin:0;background:#fafafa;color:#111;}
.wrap{max-width:980px;margin:0 auto;padding:24px;}
.card{background:#fff;border:1px solid #e5e5e5;border-radius:12px;padding:22px;}
h1,h2,h3{margin-top:1.2em;}
pre{background:#0b1020;color:#e6e6e6;padding:14px;border-radius:12px;overflow:auto;}
code{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,\"Liberation Mono\",\"Courier New\",monospace;}
table{border-collapse:collapse;width:100%;}
th,td{border:1px solid #e5e5e5;padding:8px;text-align:left;}
a{color:#111;}
.muted{color:#666;font-size:12px;}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<!--CONTEUDO_README-->
<p class="muted" style="margin-top:16px;">Página gerada automaticamente a partir de README.md</p>
</div>
</div>
</body>
</html>`
html := []byte(strings.Replace(page, "<!--CONTEUDO_README-->", buf.String(), 1))
// Sanitização mínima: como o README é do próprio projeto, aceitamos o HTML gerado.
// Se quiser endurecer segurança, podemos usar um sanitizer (bluemonday).
_ = template.HTMLEscapeString
p.html = html
p.errMsg = ""
p.ultimoMTime = st.ModTime()
return p.html, ""
}

22
internal/elinps/render.go Normal file
View file

@ -0,0 +1,22 @@
package elinps
import (
"html/template"
"net/http"
)
type TemplateRenderer struct {
t *template.Template
}
func NewTemplateRenderer(t *template.Template) *TemplateRenderer { return &TemplateRenderer{t: t} }
func (r *TemplateRenderer) Render(w http.ResponseWriter, name string, data any) {
_ = r.t.ExecuteTemplate(w, name, data)
}
type FormPageData struct {
Produto string
ID string
Reg Registro
}

View file

@ -0,0 +1,43 @@
package elinps
import (
"html/template"
"path/filepath"
"strings"
)
func mustParseTemplates() *template.Template {
// Local filesystem parsing (keeps the repo simple).
// If you want a single-binary deploy, we can switch to go:embed by moving
// templates into internal/elinps and embedding without "..".
funcs := template.FuncMap{
"seq": func(start, end int) []int {
if end < start {
return []int{}
}
out := make([]int, 0, end-start+1)
for i := start; i <= end; i++ {
out = append(out, i)
}
return out
},
"noteEq": func(ptr *int, v int) bool {
return ptr != nil && *ptr == v
},
"produtoLabel": func(produto string) string {
// Best-effort label from normalized produto.
p := strings.ReplaceAll(produto, "_", " ")
parts := strings.Fields(p)
for i := range parts {
if len(parts[i]) == 0 {
continue
}
parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]
}
return strings.Join(parts, " ")
},
}
pattern := filepath.ToSlash("web/templates/*.html")
return template.Must(template.New("").Funcs(funcs).ParseGlob(pattern))
}

View file

@ -0,0 +1,66 @@
package elinps
import (
"errors"
"regexp"
"strings"
)
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
func normalizeEmail(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
func ValidatePedidoInput(in *PedidoInput) error {
in.ProdutoNome = strings.TrimSpace(in.ProdutoNome)
in.InquilinoCodigo = strings.TrimSpace(in.InquilinoCodigo)
in.InquilinoNome = strings.TrimSpace(in.InquilinoNome)
in.UsuarioCodigo = strings.TrimSpace(in.UsuarioCodigo)
in.UsuarioNome = strings.TrimSpace(in.UsuarioNome)
in.UsuarioTelefone = strings.TrimSpace(in.UsuarioTelefone)
in.UsuarioEmail = normalizeEmail(in.UsuarioEmail)
if in.ProdutoNome == "" || len(in.ProdutoNome) > 64 {
return errors.New("produto_nome invalido")
}
if in.InquilinoCodigo == "" || len(in.InquilinoCodigo) > 64 {
return errors.New("inquilino_codigo invalido")
}
if in.InquilinoNome == "" || len(in.InquilinoNome) > 128 {
return errors.New("inquilino_nome invalido")
}
if in.UsuarioCodigo == "" || len(in.UsuarioCodigo) > 64 {
return errors.New("usuario_codigo invalido")
}
if in.UsuarioNome == "" || len(in.UsuarioNome) > 128 {
return errors.New("usuario_nome invalido")
}
// E-mail passa a ser opcional: o controle de exibição é por
// (produto + inquilino_codigo + usuario_codigo).
if in.UsuarioEmail != "" {
if len(in.UsuarioEmail) > 254 || !emailRe.MatchString(in.UsuarioEmail) {
return errors.New("usuario_email invalido")
}
}
if len(in.UsuarioTelefone) > 64 {
return errors.New("usuario_telefone invalido")
}
return nil
}
func ValidatePatchInput(in *PatchInput) error {
if in.Nota != nil {
if *in.Nota < 1 || *in.Nota > 10 {
return errors.New("nota invalida")
}
}
if in.Justificativa != nil {
j := strings.TrimSpace(*in.Justificativa)
if len(j) > 2000 {
return errors.New("justificativa muito longa")
}
*in.Justificativa = j
}
return nil
}