diff --git a/.agent b/.agent index 5ed5ad1..8bb558a 100644 --- a/.agent +++ b/.agent @@ -33,21 +33,6 @@ project_stack: optional_logic_layer: - "Go → WebAssembly (WASM), apenas se adotado explicitamente no projeto" -# ------------------------------------------------------------------- -# Painel administrativo (atualizações recentes) -# ------------------------------------------------------------------- -painel_policy: - - "Painel é server-side HTML (sem framework) e protegido por SENHA_PAINEL." - - "O painel deve ser usável em mobile: tabelas com scroll horizontal (overflow-x) e layout responsivo." - - "O painel exibe datas no fuso de Brasília (America/Sao_Paulo) no formato DD/MM/YYYY HH:mm." - - "Email deve ser exibido como link mailto:." - - "Telefone deve ter link tel: e ação para WhatsApp (https://wa.me/...), com normalização best-effort (somente dígitos; prefixo 55 quando parecer número BR de 10/11 dígitos)." - - "Ações do painel devem ser botões/ícones sem dependências externas (ex.: SVG inline)." - - "Persistência de preferências do painel (produto selecionado e filtro 'baixas') deve ficar no WASM (Go→WASM), sem JS de lógica inline no HTML." - - "O HTML do painel pode conter apenas bootstrap mínimo para carregar WASM (wasm_exec.js + painel.js)." - - "Exportação CSV do painel deve ser via endpoint protegido e streaming (sem carregar tudo em memória) e respeitar filtros atuais (produto e baixas)." - - "CSV deve usar ';' como separador e incluir BOM UTF-8 para compatibilidade com Excel PT-BR." - # ------------------------------------------------------------------- # Regras gerais # ------------------------------------------------------------------- @@ -57,7 +42,6 @@ rules: - "Evitar mudanças que quebrem widgets já embedados em clientes." - "Mudanças que impactem desenvolvedores OU usuários DEVEM ser documentadas." - "Código deve ser previsível, explícito e fácil de auditar." - - "Evitar dependências externas no painel e no widget (inclui ícones, libs e CDNs)." # ------------------------------------------------------------------- # Linguagem, nomes e comentários diff --git a/README.md b/README.md index e54a623..cd90522 100644 --- a/README.md +++ b/README.md @@ -222,15 +222,6 @@ Depois acesse: - Teste do widget: `http://localhost:8080/teste.html` - Painel: `http://localhost:8080/painel` (senha em `SENHA_PAINEL`) -Exportação CSV (painel): - -- Endpoint protegido: `GET /painel/export.csv` -- Parâmetros: - - `produto=` (obrigatório) - - `baixas=1` (opcional; exporta apenas notas baixas <=6) -- Exemplo: - - `http://localhost:8080/painel/export.csv?produto=exemplo&baixas=1` - Painel: - Acesse `http://localhost:8080/painel` diff --git a/internal/elinps/painel.go b/internal/elinps/painel.go index 3847b82..690617b 100644 --- a/internal/elinps/painel.go +++ b/internal/elinps/painel.go @@ -2,13 +2,11 @@ package elinps import ( "crypto/subtle" - "encoding/csv" "fmt" "html/template" "net/http" "net/url" "regexp" - "strconv" "strings" "time" @@ -20,17 +18,15 @@ import ( // Proteção simples do painel administrativo. // // Objetivo: bloquear acesso ao painel com uma senha definida no .env. -// Implementação: cookie simples com token aleatório por boot. +// Implementação: cookie assinado de forma simples (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 { 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) @@ -63,66 +59,10 @@ 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) { @@ -179,6 +119,7 @@ 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 { @@ -190,159 +131,26 @@ 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 { - return naoDigitosRe.ReplaceAllString(s, "") + // 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, "") } linkEmail := func(email string) string { @@ -350,6 +158,7 @@ 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 + "" @@ -362,18 +171,27 @@ 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") @@ -390,16 +208,6 @@ 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("
") @@ -424,34 +232,27 @@ 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 := "-" @@ -463,14 +264,12 @@ 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 := "-" @@ -478,6 +277,7 @@ 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 := `` @@ -495,14 +295,13 @@ 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) @@ -522,9 +321,73 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to b.WriteString("
") - // JS do painel: bootstrap do WASM. + // JS do painel: apenas bootstrap para executar a lógica no WASM. + // Regras (.agent): sem dependências externas. A lógica fica no 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 85d92c0..7720b8e 100644 --- a/internal/elinps/painel_handlers.go +++ b/internal/elinps/painel_handlers.go @@ -40,12 +40,6 @@ 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 22ac1ee..2eb3c39 100644 --- a/internal/elinps/painel_queries.go +++ b/internal/elinps/painel_queries.go @@ -100,10 +100,6 @@ 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. @@ -169,45 +165,6 @@ 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