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
319
internal/elinps/painel.go
Normal file
319
internal/elinps/painel.go
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue