exportação csv

This commit is contained in:
Luiz Silva 2026-01-03 16:12:08 -03:00
parent 65118f2838
commit 6be329f7e6
3 changed files with 281 additions and 95 deletions

View file

@ -2,11 +2,13 @@ package elinps
import ( import (
"crypto/subtle" "crypto/subtle"
"encoding/csv"
"fmt" "fmt"
"html/template" "html/template"
"net/http" "net/http"
"net/url" "net/url"
"regexp" "regexp"
"strconv"
"strings" "strings"
"time" "time"
@ -18,15 +20,17 @@ import (
// Proteção simples do painel administrativo. // Proteção simples do painel administrativo.
// //
// Objetivo: bloquear acesso ao painel com uma senha definida no .env. // Objetivo: bloquear acesso ao painel com uma senha definida no .env.
// Implementação: cookie assinado de forma simples (token aleatório por boot). // Implementação: cookie simples com token aleatório por boot.
// //
// Observação: é propositalmente simples (sem banco) para manter o projeto leve. // 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 { type AuthPainel struct {
Senha string Senha string
Token string Token string
} }
// Regex pré-compilada (evita recompilar a cada request).
var naoDigitosRe = regexp.MustCompile(`\D`)
func (a AuthPainel) handlerLoginPost(w http.ResponseWriter, r *http.Request) { func (a AuthPainel) handlerLoginPost(w http.ResponseWriter, r *http.Request) {
if !a.habilitado() { if !a.habilitado() {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
@ -59,10 +63,66 @@ func (a AuthPainel) handlerLoginPost(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/painel", http.StatusFound) http.Redirect(w, r, "/painel", 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;background:#fff;}
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>`))
}
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)
})
}
type NPSMensal = contratos.NPSMensal type NPSMensal = contratos.NPSMensal
type RespostaPainel = contratos.RespostaPainel type RespostaPainel = contratos.RespostaPainel
type PainelDados = contratos.PainelDados type PainelDados = contratos.PainelDados
func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store *Store) { func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store *Store) {
@ -119,7 +179,6 @@ func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store
respostas, err := store.ListarRespostas(ctx, tabela, ListarRespostasFiltro{SomenteNotasBaixas: somenteBaixas, Pagina: pagina, PorPagina: 50}) respostas, err := store.ListarRespostas(ctx, tabela, ListarRespostasFiltro{SomenteNotasBaixas: somenteBaixas, Pagina: pagina, PorPagina: 50})
if err != nil { if err != nil {
// Se a tabela ainda não tem coluna ip_real/ etc, EnsureNPSTable deveria ajustar.
if err == pgx.ErrNoRows { if err == pgx.ErrNoRows {
respostas = []contratos.RespostaPainel{} respostas = []contratos.RespostaPainel{}
} else { } else {
@ -131,26 +190,159 @@ func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store
a.renderPainelHTML(w, dados) a.renderPainelHTML(w, dados)
} }
func (a AuthPainel) handlerExportCSV(w http.ResponseWriter, r *http.Request, store *Store) {
ctx := r.Context()
produto := r.URL.Query().Get("produto")
somenteBaixas := r.URL.Query().Get("baixas") == "1"
if strings.TrimSpace(produto) == "" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("produto obrigatorio"))
return
}
prodNorm, err := db.NormalizeProduto(produto)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("produto invalido"))
return
}
tabela := db.TableNameForProduto(prodNorm)
if err := db.EnsureNPSTable(ctx, store.poolRef(), tabela); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("erro ao garantir tabela"))
return
}
rows, err := store.ExportarRespostas(ctx, tabela, ExportarRespostasFiltro{SomenteNotasBaixas: somenteBaixas})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("erro ao listar respostas"))
return
}
defer rows.Close()
agora := time.Now().Format("20060102_150405")
nome := "respostas_" + prodNorm
if somenteBaixas {
nome += "_baixas"
}
nome += "_" + agora + ".csv"
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", nome))
// BOM UTF-8 para Excel/PT-BR abrir corretamente.
_, _ = w.Write([]byte("\xEF\xBB\xBF"))
cw := csv.NewWriter(w)
cw.Comma = ';'
defer cw.Flush()
formatarDataBR := func(t time.Time) string {
loc, err := time.LoadLocation("America/Sao_Paulo")
if err != nil {
loc = time.FixedZone("America/Sao_Paulo", -3*60*60)
}
return t.In(loc).Format("02/01/2006 15:04")
}
_ = cw.Write([]string{
"data",
"nota",
"usuario_nome",
"usuario_codigo",
"inquilino_nome",
"inquilino_codigo",
"email",
"telefone",
"comentario",
})
for rows.Next() {
var rr RespostaPainel
if err := rows.Scan(
&rr.ID,
&rr.RespondidoEm,
&rr.PedidoCriadoEm,
&rr.InquilinoCodigo,
&rr.InquilinoNome,
&rr.UsuarioCodigo,
&rr.UsuarioNome,
&rr.UsuarioEmail,
&rr.UsuarioTelefone,
&rr.Nota,
&rr.Justificativa,
); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
data := ""
if rr.RespondidoEm != nil {
data = formatarDataBR(*rr.RespondidoEm)
} else {
data = formatarDataBR(rr.PedidoCriadoEm)
}
nota := ""
if rr.Nota != nil {
nota = strconv.Itoa(*rr.Nota)
}
usuarioCod := ""
if rr.UsuarioCodigo != nil {
usuarioCod = *rr.UsuarioCodigo
}
email := ""
if rr.UsuarioEmail != nil {
email = *rr.UsuarioEmail
}
tel := ""
if rr.UsuarioTelefone != nil {
tel = *rr.UsuarioTelefone
}
coment := ""
if rr.Justificativa != nil {
coment = *rr.Justificativa
}
_ = cw.Write([]string{
data,
nota,
rr.UsuarioNome,
usuarioCod,
rr.InquilinoNome,
rr.InquilinoCodigo,
email,
tel,
coment,
})
// Flush incremental para streaming.
cw.Flush()
if err := cw.Error(); err != nil {
return
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
}
func (a AuthPainel) renderPainelHTML(w http.ResponseWriter, d PainelDados) { func (a AuthPainel) renderPainelHTML(w http.ResponseWriter, d PainelDados) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
formatarDataPainel := func(t time.Time) string { formatarDataPainel := func(t time.Time) string {
// O banco armazena timestamptz (UTC normalizado). O requisito do painel é
// exibir no fuso de Brasília.
loc, err := time.LoadLocation("America/Sao_Paulo") loc, err := time.LoadLocation("America/Sao_Paulo")
if err != nil { if err != nil {
// Fallback best-effort (sem regras de DST antigas, mas suficiente para
// ambientes sem tzdata).
loc = time.FixedZone("America/Sao_Paulo", -3*60*60) loc = time.FixedZone("America/Sao_Paulo", -3*60*60)
} }
return t.In(loc).Format("02/01/2006 15:04") return t.In(loc).Format("02/01/2006 15:04")
} }
soDigitos := func(s string) string { soDigitos := func(s string) string {
// Normalização best-effort para criar links tel/WhatsApp. return naoDigitosRe.ReplaceAllString(s, "")
// Mantemos apenas dígitos e descartamos qualquer outro caractere.
re := regexp.MustCompile(`\D`)
return re.ReplaceAllString(s, "")
} }
linkEmail := func(email string) string { linkEmail := func(email string) string {
@ -158,7 +350,6 @@ func (a AuthPainel) renderPainelHTML(w http.ResponseWriter, d PainelDados) {
if email == "" { if email == "" {
return "-" return "-"
} }
// mailto aceita percent-encoding; usar PathEscape evita injeção no href.
href := "mailto:" + url.PathEscape(email) href := "mailto:" + url.PathEscape(email)
txt := template.HTMLEscapeString(email) txt := template.HTMLEscapeString(email)
return "<a href=\"" + href + "\">" + txt + "</a>" return "<a href=\"" + href + "\">" + txt + "</a>"
@ -171,27 +362,18 @@ func (a AuthPainel) renderPainelHTML(w http.ResponseWriter, d PainelDados) {
} }
dig := soDigitos(telOriginal) dig := soDigitos(telOriginal)
if dig == "" { if dig == "" {
// Se não há dígitos, exibimos apenas como texto.
return "", "", template.HTMLEscapeString(telOriginal) return "", "", template.HTMLEscapeString(telOriginal)
} }
// tel: — preferimos com '+' quando possível.
hrefTel = "tel:+" + dig hrefTel = "tel:+" + dig
// WhatsApp wa.me exige número em formato internacional com dígitos.
waNum := dig waNum := dig
// Heurística: se parece número BR (10/11 dígitos) e não tem DDI, prefixa 55.
if (len(waNum) == 10 || len(waNum) == 11) && !strings.HasPrefix(waNum, "55") { if (len(waNum) == 10 || len(waNum) == 11) && !strings.HasPrefix(waNum, "55") {
waNum = "55" + waNum waNum = "55" + waNum
} }
hrefWA = "https://wa.me/" + waNum hrefWA = "https://wa.me/" + waNum
// Exibição do número: mantém o original (mais amigável), mas escapado.
htmlTel = "<a href=\"" + hrefTel + "\">" + template.HTMLEscapeString(telOriginal) + "</a>" htmlTel = "<a href=\"" + hrefTel + "\">" + template.HTMLEscapeString(telOriginal) + "</a>"
return hrefTel, hrefWA, htmlTel return hrefTel, hrefWA, htmlTel
} }
// HTML propositalmente simples (sem template engine) para manter isolado.
// Se quiser evoluir, dá pra migrar para templates.
var b strings.Builder 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("<!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("<title>e-li.nps • Painel</title>")
@ -208,6 +390,16 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
.iconbtn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:10px;border:1px solid #e5e5e5;background:#fff;margin-right:6px;text-decoration:none;} .iconbtn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:10px;border:1px solid #e5e5e5;background:#fff;margin-right:6px;text-decoration:none;}
.iconbtn:hover{border-color:#bbb;background:#fafafa;} .iconbtn:hover{border-color:#bbb;background:#fafafa;}
.icon{width:16px;height:16px;display:block;} .icon{width:16px;height:16px;display:block;}
/* Responsivo */
.tablewrap{overflow-x:auto;-webkit-overflow-scrolling:touch;}
.tablewrap table{min-width:900px;}
@media (max-width: 640px){
body{padding:12px;}
.card{padding:12px;}
h1{font-size:18px;}
}
</style></head><body>`) </style></head><body>`)
b.WriteString("<div class=\"top\">") b.WriteString("<div class=\"top\">")
@ -232,27 +424,34 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
} }
b.WriteString("<label class=\"muted\"><input type=\"checkbox\" name=\"baixas\" value=\"1\" " + chk + "/> notas baixas (<=6)</label>") 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("<button type=\"submit\" style=\"padding:10px 14px;border-radius:10px;border:1px solid #111;background:#111;color:#fff;cursor:pointer\">Aplicar</button>")
// Export CSV (mantém filtros atuais)
exportURL := "/painel/export.csv?produto=" + url.QueryEscape(d.Produto)
if d.SomenteBaixas {
exportURL += "&baixas=1"
}
b.WriteString("<a class=\"badge\" style=\"text-decoration:none;display:inline-block;padding:10px 14px\" href=\"" + exportURL + "\">Exportar CSV</a>")
b.WriteString("</form></div>") b.WriteString("</form></div>")
b.WriteString("</div>") b.WriteString("</div>")
// NPS mês a mês // 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("<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>") b.WriteString("<div class=\"tablewrap\"><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 { 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>", 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)) template.HTMLEscapeString(m.Mes), m.Detratores, m.Neutros, m.Promotores, m.Total, m.NPS))
} }
b.WriteString("</tbody></table></div>") b.WriteString("</tbody></table></div></div>")
// Respostas // Respostas
b.WriteString("<div class=\"card\" style=\"margin-top:12px\"><h2 style=\"margin:0 0 8px\">Respostas</h2>") 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>Inquilino</th><th>Email</th><th>Telefone</th><th>Comentário</th><th>Ações</th></tr></thead><tbody>") b.WriteString("<div class=\"tablewrap\"><table><thead><tr><th>Data</th><th>Nota</th><th>Usuário</th><th>Inquilino</th><th>Email</th><th>Telefone</th><th>Comentário</th><th>Ações</th></tr></thead><tbody>")
for _, r := range d.Respostas { for _, r := range d.Respostas {
data := "-" data := "-"
if r.RespondidoEm != nil { if r.RespondidoEm != nil {
data = formatarDataPainel(*r.RespondidoEm) data = formatarDataPainel(*r.RespondidoEm)
} else { } else {
// fallback: apesar do painel listar "respondido", mantemos robustez.
data = formatarDataPainel(r.PedidoCriadoEm) data = formatarDataPainel(r.PedidoCriadoEm)
} }
nota := "-" nota := "-"
@ -264,12 +463,14 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
usuario += " <span class=\"muted\">(" + template.HTMLEscapeString(*r.UsuarioCodigo) + ")</span>" usuario += " <span class=\"muted\">(" + template.HTMLEscapeString(*r.UsuarioCodigo) + ")</span>"
} }
inquilino := template.HTMLEscapeString(r.InquilinoNome) + " <span class=\"muted\">(" + template.HTMLEscapeString(r.InquilinoCodigo) + ")</span>" inquilino := template.HTMLEscapeString(r.InquilinoNome) + " <span class=\"muted\">(" + template.HTMLEscapeString(r.InquilinoCodigo) + ")</span>"
emailHTML := "-" emailHTML := "-"
emailHref := "" emailHref := ""
if r.UsuarioEmail != nil && strings.TrimSpace(*r.UsuarioEmail) != "" { if r.UsuarioEmail != nil && strings.TrimSpace(*r.UsuarioEmail) != "" {
emailHTML = linkEmail(*r.UsuarioEmail) emailHTML = linkEmail(*r.UsuarioEmail)
emailHref = "mailto:" + url.PathEscape(strings.TrimSpace(*r.UsuarioEmail)) emailHref = "mailto:" + url.PathEscape(strings.TrimSpace(*r.UsuarioEmail))
} }
telHref := "" telHref := ""
waHref := "" waHref := ""
telefoneHTML := "-" telefoneHTML := "-"
@ -277,7 +478,6 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
telHref, waHref, telefoneHTML = linkTelefone(*r.UsuarioTelefone) telHref, waHref, telefoneHTML = linkTelefone(*r.UsuarioTelefone)
} }
// Ícones (inline SVG) — simples e sem dependências.
iconMail := `<svg class="icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 6h16v12H4V6Z" stroke="currentColor" stroke-width="2"/><path d="m4 7 8 6 8-6" stroke="currentColor" stroke-width="2"/></svg>` iconMail := `<svg class="icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 6h16v12H4V6Z" stroke="currentColor" stroke-width="2"/><path d="m4 7 8 6 8-6" stroke="currentColor" stroke-width="2"/></svg>`
iconPhone := `<svg class="icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 3h4l2 6-3 2c1.5 3 4 5.5 7 7l2-3 6 2v4c0 1-1 2-2 2C11 23 1 13 2 5c0-1 1-2 2-2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>` iconPhone := `<svg class="icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 3h4l2 6-3 2c1.5 3 4 5.5 7 7l2-3 6 2v4c0 1-1 2-2 2C11 23 1 13 2 5c0-1 1-2 2-2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>`
iconWA := `<svg class="icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M20 11.5c0 4.142-3.582 7.5-8 7.5-1.403 0-2.722-.339-3.87-.937L4 19l1.05-3.322A7.08 7.08 0 0 1 4 11.5C4 7.358 7.582 4 12 4s8 3.358 8 7.5Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>` iconWA := `<svg class="icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M20 11.5c0 4.142-3.582 7.5-8 7.5-1.403 0-2.722-.339-3.87-.937L4 19l1.05-3.322A7.08 7.08 0 0 1 4 11.5C4 7.358 7.582 4 12 4s8 3.358 8 7.5Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>`
@ -295,13 +495,14 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
if acoes == "" { if acoes == "" {
acoes = "-" acoes = "-"
} }
coment := "" coment := ""
if r.Justificativa != nil { if r.Justificativa != nil {
coment = template.HTMLEscapeString(*r.Justificativa) coment = template.HTMLEscapeString(*r.Justificativa)
} }
b.WriteString("<tr><td>" + template.HTMLEscapeString(data) + "</td><td><b>" + template.HTMLEscapeString(nota) + "</b></td><td>" + usuario + "</td><td>" + inquilino + "</td><td>" + emailHTML + "</td><td>" + telefoneHTML + "</td><td>" + coment + "</td><td>" + acoes + "</td></tr>") b.WriteString("<tr><td>" + template.HTMLEscapeString(data) + "</td><td><b>" + template.HTMLEscapeString(nota) + "</b></td><td>" + usuario + "</td><td>" + inquilino + "</td><td>" + emailHTML + "</td><td>" + telefoneHTML + "</td><td>" + coment + "</td><td>" + acoes + "</td></tr>")
} }
b.WriteString("</tbody></table>") b.WriteString("</tbody></table></div>")
// Navegação // Navegação
base := "/painel?produto=" + url.QueryEscape(d.Produto) base := "/painel?produto=" + url.QueryEscape(d.Produto)
@ -321,73 +522,9 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
b.WriteString("</div>") b.WriteString("</div>")
// JS do painel: apenas bootstrap para executar a lógica no WASM. // JS do painel: bootstrap do WASM.
// Regras (.agent): sem dependências externas. A lógica fica no WASM.
b.WriteString(`<script src="/static/wasm_exec.js"></script><script src="/static/painel.js"></script>`) b.WriteString(`<script src="/static/wasm_exec.js"></script><script src="/static/painel.js"></script>`)
b.WriteString("</body></html>") b.WriteString("</body></html>")
w.Write([]byte(b.String())) 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

@ -40,6 +40,12 @@ func (p *PainelHandlers) Router() http.Handler {
p.auth.handlerPainel(w, r, p.store) p.auth.handlerPainel(w, r, p.store)
}) })
// Export CSV (todas as respostas do filtro atual)
// Protegido pelo mesmo middleware do painel.
r.With(func(next http.Handler) http.Handler { return p.auth.middleware(next) }).Get("/export.csv", func(w http.ResponseWriter, r *http.Request) {
p.auth.handlerExportCSV(w, r, p.store)
})
// Debug: conferir IP real / headers. // Debug: conferir IP real / headers.
// Protegido pelo mesmo middleware do painel. // Protegido pelo mesmo middleware do painel.
r.With(func(next http.Handler) http.Handler { return p.auth.middleware(next) }).Get("/debug/ip", func(w http.ResponseWriter, r *http.Request) { r.With(func(next http.Handler) http.Handler { return p.auth.middleware(next) }).Get("/debug/ip", func(w http.ResponseWriter, r *http.Request) {

View file

@ -100,6 +100,10 @@ type ListarRespostasFiltro struct {
func (f *ListarRespostasFiltro) normalizar() { (*contratos.ListarRespostasFiltro)(f).Normalizar() } func (f *ListarRespostasFiltro) normalizar() { (*contratos.ListarRespostasFiltro)(f).Normalizar() }
type ExportarRespostasFiltro struct {
SomenteNotasBaixas bool
}
// ListarRespostas retorna respostas respondidas, com paginação e filtro. // ListarRespostas retorna respostas respondidas, com paginação e filtro.
func (s *Store) ListarRespostas(ctx context.Context, tabela string, filtro ListarRespostasFiltro) ([]contratos.RespostaPainel, error) { func (s *Store) ListarRespostas(ctx context.Context, tabela string, filtro ListarRespostasFiltro) ([]contratos.RespostaPainel, error) {
// Segurança: a tabela é um identificador interpolado. Validamos sempre. // Segurança: a tabela é um identificador interpolado. Validamos sempre.
@ -165,6 +169,45 @@ LIMIT $1 OFFSET $2`, tabela, cond)
return respostas, rows.Err() return respostas, rows.Err()
} }
// ExportarRespostas abre um cursor/stream de respostas (sem paginação) para export.
//
// Importante: o caller deve SEMPRE fechar o rows.
func (s *Store) ExportarRespostas(ctx context.Context, tabela string, filtro ExportarRespostasFiltro) (pgx.Rows, error) {
// Segurança: a tabela é um identificador interpolado. Validamos sempre.
if !db.TableNameValido(tabela) {
return nil, fmt.Errorf("tabela invalida")
}
cond := "status='respondido' AND valida=true"
if filtro.SomenteNotasBaixas {
cond += " AND nota BETWEEN 1 AND 6"
}
// Sem LIMIT/OFFSET (export completo).
q := fmt.Sprintf(`
SELECT
id,
respondido_em,
pedido_criado_em,
inquilino_codigo,
inquilino_nome,
usuario_codigo,
usuario_nome,
usuario_email,
usuario_telefone,
nota,
justificativa
FROM %s
WHERE %s
ORDER BY respondido_em DESC NULLS LAST`, tabela, cond)
rows, err := s.pool.Query(ctx, q)
if err != nil {
return nil, err
}
return rows, nil
}
// ensure interface imports // ensure interface imports
var _ = pgx.ErrNoRows var _ = pgx.ErrNoRows
var _ = time.Second var _ = time.Second