diff --git a/internal/elinps/painel.go b/internal/elinps/painel.go index 690617b..3847b82 100644 --- a/internal/elinps/painel.go +++ b/internal/elinps/painel.go @@ -2,11 +2,13 @@ package elinps import ( "crypto/subtle" + "encoding/csv" "fmt" "html/template" "net/http" "net/url" "regexp" + "strconv" "strings" "time" @@ -18,15 +20,17 @@ import ( // 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). +// Implementação: cookie simples com 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. +// Observação: propositalmente simples (sem banco) para manter o projeto leve. type AuthPainel struct { Senha 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) { if !a.habilitado() { 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) } +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(` + + + + + e-li.nps • Painel + + + +
+

e-li.nps • Painel

+

Acesso protegido por senha (SENHA_PAINEL).

+
+ + + +
+
+ +`)) +} + +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 RespostaPainel = contratos.RespostaPainel - type PainelDados = contratos.PainelDados 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}) if err != nil { - // Se a tabela ainda não tem coluna ip_real/ etc, EnsureNPSTable deveria ajustar. if err == pgx.ErrNoRows { respostas = []contratos.RespostaPainel{} } else { @@ -131,26 +190,159 @@ func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store 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) { w.Header().Set("Content-Type", "text/html; charset=utf-8") 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") 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) } return t.In(loc).Format("02/01/2006 15:04") } soDigitos := func(s string) string { - // Normalização best-effort para criar links tel/WhatsApp. - // Mantemos apenas dígitos e descartamos qualquer outro caractere. - re := regexp.MustCompile(`\D`) - return re.ReplaceAllString(s, "") + return naoDigitosRe.ReplaceAllString(s, "") } linkEmail := func(email string) string { @@ -158,7 +350,6 @@ func (a AuthPainel) renderPainelHTML(w http.ResponseWriter, d PainelDados) { if email == "" { return "-" } - // mailto aceita percent-encoding; usar PathEscape evita injeção no href. href := "mailto:" + url.PathEscape(email) txt := template.HTMLEscapeString(email) return "" + txt + "" @@ -171,27 +362,18 @@ func (a AuthPainel) renderPainelHTML(w http.ResponseWriter, d PainelDados) { } dig := soDigitos(telOriginal) if dig == "" { - // Se não há dígitos, exibimos apenas como texto. return "", "", template.HTMLEscapeString(telOriginal) } - - // tel: — preferimos com '+' quando possível. hrefTel = "tel:+" + dig - // WhatsApp wa.me exige número em formato internacional com dígitos. 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") { waNum = "55" + waNum } hrefWA = "https://wa.me/" + waNum - - // Exibição do número: mantém o original (mais amigável), mas escapado. htmlTel = "" + template.HTMLEscapeString(telOriginal) + "" 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 b.WriteString("") b.WriteString("e-li.nps • Painel") @@ -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:hover{border-color:#bbb;background:#fafafa;} .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;} +} `) b.WriteString("
") @@ -232,27 +424,34 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to } b.WriteString("") b.WriteString("") + + // Export CSV (mantém filtros atuais) + exportURL := "/painel/export.csv?produto=" + url.QueryEscape(d.Produto) + if d.SomenteBaixas { + exportURL += "&baixas=1" + } + b.WriteString("Exportar CSV") + b.WriteString("
") b.WriteString("") // NPS mês a mês b.WriteString("

NPS mês a mês

") - b.WriteString("") + b.WriteString("
MêsDetratoresNeutrosPromotoresTotalNPS
") for _, m := range d.Meses { b.WriteString(fmt.Sprintf("", template.HTMLEscapeString(m.Mes), m.Detratores, m.Neutros, m.Promotores, m.Total, m.NPS)) } - b.WriteString("
MêsDetratoresNeutrosPromotoresTotalNPS
%s%d%d%d%d%d
") + b.WriteString("") // Respostas b.WriteString("

Respostas

") - b.WriteString("") + b.WriteString("
DataNotaUsuárioInquilinoEmailTelefoneComentárioAções
") for _, r := range d.Respostas { data := "-" if r.RespondidoEm != nil { data = formatarDataPainel(*r.RespondidoEm) } else { - // fallback: apesar do painel listar "respondido", mantemos robustez. data = formatarDataPainel(r.PedidoCriadoEm) } nota := "-" @@ -264,12 +463,14 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to usuario += " (" + template.HTMLEscapeString(*r.UsuarioCodigo) + ")" } inquilino := template.HTMLEscapeString(r.InquilinoNome) + " (" + template.HTMLEscapeString(r.InquilinoCodigo) + ")" + emailHTML := "-" emailHref := "" if r.UsuarioEmail != nil && strings.TrimSpace(*r.UsuarioEmail) != "" { emailHTML = linkEmail(*r.UsuarioEmail) emailHref = "mailto:" + url.PathEscape(strings.TrimSpace(*r.UsuarioEmail)) } + telHref := "" waHref := "" 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) } - // Ícones (inline SVG) — simples e sem dependências. iconMail := `` iconPhone := `` iconWA := `` @@ -295,13 +495,14 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to if acoes == "" { acoes = "-" } + coment := "" if r.Justificativa != nil { coment = template.HTMLEscapeString(*r.Justificativa) } b.WriteString("") } - b.WriteString("
DataNotaUsuárioInquilinoEmailTelefoneComentárioAções
" + template.HTMLEscapeString(data) + "" + template.HTMLEscapeString(nota) + "" + usuario + "" + inquilino + "" + emailHTML + "" + telefoneHTML + "" + coment + "" + acoes + "
") + b.WriteString("
") // Navegação 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("") - // JS do painel: apenas bootstrap para executar a lógica no WASM. - // Regras (.agent): sem dependências externas. A lógica fica no WASM. + // JS do painel: bootstrap do WASM. b.WriteString(``) b.WriteString("") 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(` - - - - - e-li.nps • Painel - - - -
-

e-li.nps • Painel

-

Acesso protegido por senha (SENHA_PAINEL).

-
- - - -
-
- -`)) -} - -// (handlerLoginPost duplicado removido) diff --git a/internal/elinps/painel_handlers.go b/internal/elinps/painel_handlers.go index 7720b8e..85d92c0 100644 --- a/internal/elinps/painel_handlers.go +++ b/internal/elinps/painel_handlers.go @@ -40,6 +40,12 @@ func (p *PainelHandlers) Router() http.Handler { 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. // 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) { diff --git a/internal/elinps/painel_queries.go b/internal/elinps/painel_queries.go index 2eb3c39..22ac1ee 100644 --- a/internal/elinps/painel_queries.go +++ b/internal/elinps/painel_queries.go @@ -100,6 +100,10 @@ type ListarRespostasFiltro struct { func (f *ListarRespostasFiltro) normalizar() { (*contratos.ListarRespostasFiltro)(f).Normalizar() } +type ExportarRespostasFiltro struct { + SomenteNotasBaixas bool +} + // ListarRespostas retorna respostas respondidas, com paginação e filtro. func (s *Store) ListarRespostas(ctx context.Context, tabela string, filtro ListarRespostasFiltro) ([]contratos.RespostaPainel, error) { // Segurança: a tabela é um identificador interpolado. Validamos sempre. @@ -165,6 +169,45 @@ LIMIT $1 OFFSET $2`, tabela, cond) 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 var _ = pgx.ErrNoRows var _ = time.Second