package elinps import ( "crypto/subtle" "encoding/csv" "fmt" "html/template" "net/http" "net/url" "regexp" "strconv" "strings" "time" "e-li.nps/internal/contratos" "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 simples com token aleatório por boot. // // 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) 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) } 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) { 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 { if err == pgx.ErrNoRows { respostas = []contratos.RespostaPainel{} } else { dados.MsgErro = "erro ao listar respostas" } } dados.Respostas = respostas 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 { 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") } soDigitos := func(s string) string { return naoDigitosRe.ReplaceAllString(s, "") } linkEmail := func(email string) string { email = strings.TrimSpace(email) if email == "" { return "-" } href := "mailto:" + url.PathEscape(email) txt := template.HTMLEscapeString(email) return "" + txt + "" } linkTelefone := func(telOriginal string) (hrefTel string, hrefWA string, htmlTel string) { telOriginal = strings.TrimSpace(telOriginal) if telOriginal == "" { return "", "", "-" } dig := soDigitos(telOriginal) if dig == "" { return "", "", template.HTMLEscapeString(telOriginal) } hrefTel = "tel:+" + dig waNum := dig if (len(waNum) == 10 || len(waNum) == 11) && !strings.HasPrefix(waNum, "55") { waNum = "55" + waNum } hrefWA = "https://wa.me/" + waNum htmlTel = "" + template.HTMLEscapeString(telOriginal) + "" return hrefTel, hrefWA, htmlTel } var b strings.Builder b.WriteString("") b.WriteString("e-li.nps • Painel") b.WriteString(``) b.WriteString("
") b.WriteString("

e-li.nps • Painel

") if d.MsgErro != "" { b.WriteString("

" + template.HTMLEscapeString(d.MsgErro) + "

") } b.WriteString("
") b.WriteString("") b.WriteString("") chk := "" if d.SomenteBaixas { chk = "checked" } 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("
") 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
") // Respostas b.WriteString("

Respostas

") b.WriteString("
") for _, r := range d.Respostas { data := "-" if r.RespondidoEm != nil { data = formatarDataPainel(*r.RespondidoEm) } else { data = formatarDataPainel(r.PedidoCriadoEm) } nota := "-" if r.Nota != nil { nota = fmt.Sprintf("%d", *r.Nota) } usuario := template.HTMLEscapeString(r.UsuarioNome) if r.UsuarioCodigo != nil { 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 := "-" if r.UsuarioTelefone != nil && strings.TrimSpace(*r.UsuarioTelefone) != "" { telHref, waHref, telefoneHTML = linkTelefone(*r.UsuarioTelefone) } iconMail := `` iconPhone := `` iconWA := `` acoes := "" if emailHref != "" { acoes += "" + iconMail + "" } if waHref != "" { acoes += "" + iconWA + "" } if telHref != "" { acoes += "" + iconPhone + "" } 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 + "
") // 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("
") b.WriteString("Anterior") b.WriteString("Página " + fmt.Sprintf("%d", d.Pagina) + "") b.WriteString("Próxima") b.WriteString("
") b.WriteString("
") // JS do painel: bootstrap do WASM. b.WriteString(``) b.WriteString("") w.Write([]byte(b.String())) }