From e796b29e1d6cd555dcb0fe4db0f262e7c1276b65 Mon Sep 17 00:00:00 2001 From: Luiz Silva Date: Mon, 5 Jan 2026 15:52:44 -0300 Subject: [PATCH] =?UTF-8?q?adicionado=20coment=C3=A1rios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 21 + cmd/server/main.go | 4 + internal/contratos/tipos.go | 44 +- internal/db/schema.go | 49 ++ internal/elinps/painel.go | 533 ++++++++++++++++++- internal/elinps/painel_handlers.go | 20 + internal/elinps/queries.go | 186 +++++++ migrations/002_painel_comentarios_status.sql | 31 ++ 8 files changed, 875 insertions(+), 13 deletions(-) create mode 100644 migrations/002_painel_comentarios_status.sql diff --git a/README.md b/README.md index 79f5dfa..333e4db 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,27 @@ Widget NPS embutível via **1 arquivo JS** + API em Go. - Se definida, habilita o painel em `/painel`. - Se vazia, o painel fica desabilitado. +### Painel: auditoria de análise (pendente/concluída) e comentários internos + +O painel agora permite: + +- marcar cada resposta como **Pendente** (default) ou **Concluída** +- registrar **comentários internos** por resposta (auditoria de ações) + +Regras: + +- o login do painel pede **Operador + Senha** +- o operador fica salvo em cookie e é gravado em cada comentário +- **editar/deletar comentário só é permitido na mesma sessão de login** + +Persistência: + +- são criadas tabelas globais no Postgres: + - `painel_resposta_status` + - `painel_resposta_comentario` + +> Nota: as respostas continuam nas tabelas por produto (`nps_{produto}`). + ### Cache do widget (e-li.nps.js) O servidor controla o cache de `/static/e-li.nps.js` via **ETag**. diff --git a/cmd/server/main.go b/cmd/server/main.go index bab5978..15feac8 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -45,6 +45,10 @@ func main() { if err := db.EnsurePgcrypto(ctx, pool); err != nil { log.Fatalf("ensure pgcrypto: %v", err) } + // Tabelas globais do painel (comentários/status) — criação defensiva. + if err := db.EnsurePainelTables(ctx, pool); err != nil { + log.Fatalf("ensure painel tables: %v", err) + } r := chi.NewRouter() r.Use(middleware.RequestID) diff --git a/internal/contratos/tipos.go b/internal/contratos/tipos.go index 89bd980..d309c42 100644 --- a/internal/contratos/tipos.go +++ b/internal/contratos/tipos.go @@ -88,13 +88,43 @@ type RespostaPainel struct { } type PainelDados struct { - Produto string - Produtos []string - Meses []NPSMensal - Respostas []RespostaPainel - Pagina int - SomenteBaixas bool - MsgErro string + Produto string + Produtos []string + Meses []NPSMensal + Respostas []RespostaPainel + // Operador logado no painel (usado para auditoria de comentários/ações). + Operador string + // ID da sessão do painel (por login). Serve para permitir editar/deletar + // comentários apenas na mesma sessão. + SessaoID string + // Status de análise por resposta_id. + StatusAnalise map[string]StatusAnalisePainel + // Comentários internos por resposta_id. + Comentarios map[string][]ComentarioPainel + Pagina int + SomenteBaixas bool + SomentePendentes bool + MsgErro string +} + +// ------------------------------ +// Painel: comentários e status de análise +// ------------------------------ + +type StatusAnalisePainel string + +const ( + StatusAnalisePendente StatusAnalisePainel = "pendente" + StatusAnaliseConcluida StatusAnalisePainel = "concluida" +) + +type ComentarioPainel struct { + ID string + PessoaNome string + SessaoID string + Comentario string + CriadoEm time.Time + AtualizadoEm time.Time } type ListarRespostasFiltro struct { diff --git a/internal/db/schema.go b/internal/db/schema.go index eeda0ea..0441523 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -96,6 +96,55 @@ func EnsurePgcrypto(ctx context.Context, pool *pgxpool.Pool) error { return err } +// EnsurePainelTables cria as tabelas globais usadas pelo painel. +// +// Motivação: +// - as respostas ficam em tabelas dinâmicas por produto (nps_{produto}) +// - precisamos de um lugar único para registrar auditoria de análise (pendente/concluída) +// e comentários internos do painel +// +// Importante: +// - tudo é criado de forma defensiva (IF NOT EXISTS) +// - SQL sempre parametrizado (aqui não há identificadores dinâmicos) +func EnsurePainelTables(ctx context.Context, pool *pgxpool.Pool) error { + // Status de análise por (produto + resposta_id) + if _, err := pool.Exec(ctx, ` +CREATE TABLE IF NOT EXISTS painel_resposta_status ( + produto text NOT NULL, + resposta_id text NOT NULL, + status text NOT NULL CHECK (status IN ('pendente','concluida')), + concluida_em timestamptz NULL, + atualizado_em timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (produto, resposta_id) +)`); err != nil { + return err + } + + // Comentários internos por resposta. + if _, err := pool.Exec(ctx, ` +CREATE TABLE IF NOT EXISTS painel_resposta_comentario ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + produto text NOT NULL, + resposta_id text NOT NULL, + pessoa_nome text NOT NULL, + sessao_id uuid NOT NULL, + comentario text NOT NULL, + criado_em timestamptz NOT NULL DEFAULT now(), + atualizado_em timestamptz NOT NULL DEFAULT now() +)`); err != nil { + return err + } + + if _, err := pool.Exec(ctx, ` +CREATE INDEX IF NOT EXISTS idx_painel_resp_comentario + ON painel_resposta_comentario (produto, resposta_id, criado_em ASC) +`); err != nil { + return err + } + + return nil +} + // EnsureNPSTable cria a tabela por produto + índices se não existirem. // Importante: tableName deve ser criada a partir de um produto normalizado. func EnsureNPSTable(ctx context.Context, pool *pgxpool.Pool, tableName string) error { diff --git a/internal/elinps/painel.go b/internal/elinps/painel.go index 3847b82..a216fd9 100644 --- a/internal/elinps/painel.go +++ b/internal/elinps/painel.go @@ -1,6 +1,7 @@ package elinps import ( + crand "crypto/rand" "crypto/subtle" "encoding/csv" "fmt" @@ -14,6 +15,7 @@ import ( "e-li.nps/internal/contratos" "e-li.nps/internal/db" + chi "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" ) @@ -28,6 +30,12 @@ type AuthPainel struct { Token string } +const ( + cookiePainelToken = "eli_nps_painel" + cookiePainelOper = "eli_nps_operador" + cookiePainelSessao = "eli_nps_painel_sessao" +) + // Regex pré-compilada (evita recompilar a cada request). var naoDigitosRe = regexp.MustCompile(`\D`) @@ -42,11 +50,22 @@ func (a AuthPainel) handlerLoginPost(w http.ResponseWriter, r *http.Request) { return } senha := r.FormValue("senha") + operador := strings.TrimSpace(r.FormValue("operador")) if subtle.ConstantTimeCompare([]byte(senha), []byte(a.Senha)) != 1 { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte("senha invalida")) return } + if len(operador) < 2 || len(operador) > 80 { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("operador invalido")) + return + } + + // Sessão do painel (por login) para aplicar regra: só edita/deleta comentário + // na mesma sessão. + // Observação: diferente do cookie de autenticação do painel (token por boot). + sessaoID := gerarSessaoUUID() http.SetCookie(w, &http.Cookie{ Name: a.cookieName(), @@ -60,6 +79,28 @@ func (a AuthPainel) handlerLoginPost(w http.ResponseWriter, r *http.Request) { Expires: time.Now().Add(24 * time.Hour), }) + // Operador (auditoria de comentários/ações) + http.SetCookie(w, &http.Cookie{ + Name: cookiePainelOper, + Value: url.PathEscape(operador), + Path: "/painel", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: false, + Expires: time.Now().Add(180 * 24 * time.Hour), + }) + + // Sessão por login + http.SetCookie(w, &http.Cookie{ + Name: cookiePainelSessao, + Value: sessaoID, + Path: "/painel", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: false, + Expires: time.Now().Add(24 * time.Hour), + }) + http.Redirect(w, r, "/painel", http.StatusFound) } @@ -86,6 +127,8 @@ func (a AuthPainel) handlerLoginGet(w http.ResponseWriter, r *http.Request) {

e-li.nps • Painel

Acesso protegido por senha (SENHA_PAINEL).

+ + @@ -96,7 +139,7 @@ func (a AuthPainel) handlerLoginGet(w http.ResponseWriter, r *http.Request) { } func (a AuthPainel) habilitado() bool { return a.Senha != "" && a.Token != "" } -func (a AuthPainel) cookieName() string { return "eli_nps_painel" } +func (a AuthPainel) cookieName() string { return cookiePainelToken } func (a AuthPainel) isAutenticado(r *http.Request) bool { c, err := r.Cookie(a.cookieName()) @@ -106,6 +149,36 @@ func (a AuthPainel) isAutenticado(r *http.Request) bool { return subtle.ConstantTimeCompare([]byte(c.Value), []byte(a.Token)) == 1 } +func operadorFromCookie(r *http.Request) string { + c, err := r.Cookie(cookiePainelOper) + if err != nil { + return "" + } + v, err := url.PathUnescape(c.Value) + if err != nil { + return "" + } + return strings.TrimSpace(v) +} + +func sessaoPainelFromCookie(r *http.Request) string { + c, err := r.Cookie(cookiePainelSessao) + if err != nil { + return "" + } + return strings.TrimSpace(c.Value) +} + +func gerarSessaoUUID() string { + // UUID v4 via crypto/rand. + b := make([]byte, 16) + _, _ = crand.Read(b) + // Set version (4) and variant (RFC4122) + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + func (a AuthPainel) middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !a.habilitado() { @@ -128,6 +201,12 @@ type PainelDados = contratos.PainelDados func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store *Store) { ctx := r.Context() + // Garante tabelas globais do painel (comentários/status). + if err := db.EnsurePainelTables(ctx, store.poolRef()); err != nil { + a.renderPainelHTML(w, PainelDados{MsgErro: "erro ao garantir tabelas do painel"}) + return + } + // Query params produto := r.URL.Query().Get("produto") pagina := 1 @@ -139,6 +218,7 @@ func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store } } somenteBaixas := r.URL.Query().Get("baixas") == "1" + somentePendentes := r.URL.Query().Get("pendentes") == "1" produtos, err := store.ListarProdutos(ctx) if err != nil { @@ -149,7 +229,10 @@ func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store produto = produtos[0] } - dados := PainelDados{Produto: produto, Produtos: produtos, Pagina: pagina, SomenteBaixas: somenteBaixas} + operador := operadorFromCookie(r) + sessaoID := sessaoPainelFromCookie(r) + + dados := PainelDados{Produto: produto, Produtos: produtos, Pagina: pagina, SomenteBaixas: somenteBaixas, SomentePendentes: somentePendentes, Operador: operador, SessaoID: sessaoID} if produto == "" { a.renderPainelHTML(w, dados) return @@ -187,6 +270,37 @@ func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store } dados.Respostas = respostas + // Enriquecimento: status de análise + comentários internos (em lote). + ids := make([]string, 0, len(respostas)) + for _, rr := range respostas { + ids = append(ids, rr.ID) + } + st, err := store.GetStatusAnaliseBatch(ctx, prodNorm, ids) + if err == nil { + dados.StatusAnalise = st + } + coms, err := store.ListarComentariosPainelBatch(ctx, prodNorm, ids) + if err == nil { + dados.Comentarios = coms + } + + // Filtro: somente pendentes (após carregar status em lote). + if somentePendentes { + filtradas := make([]contratos.RespostaPainel, 0, len(dados.Respostas)) + for _, rr := range dados.Respostas { + st := contratos.StatusAnalisePendente + if dados.StatusAnalise != nil { + if v, ok := dados.StatusAnalise[rr.ID]; ok { + st = v + } + } + if st != contratos.StatusAnaliseConcluida { + filtradas = append(filtradas, rr) + } + } + dados.Respostas = filtradas + } + a.renderPainelHTML(w, dados) } @@ -330,6 +444,328 @@ func (a AuthPainel) handlerExportCSV(w http.ResponseWriter, r *http.Request, sto } } +// ------------------------------ +// Ações do painel (comentários/status) +// ------------------------------ + +func (a AuthPainel) redirectVoltarPainel(w http.ResponseWriter, r *http.Request, produto string) { + // Mantém querystring original (pagina/baixas/etc). + q := r.URL.Query() + q.Set("produto", produto) + u := "/painel?" + q.Encode() + http.Redirect(w, r, u, http.StatusFound) +} + +func (a AuthPainel) handlerConcluirResposta(w http.ResponseWriter, r *http.Request, store *Store) { + ctx := r.Context() + if err := db.EnsurePainelTables(ctx, store.poolRef()); err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("db")) + return + } + + produto := chi.URLParam(r, "produto") + prodNorm, err := db.NormalizeProduto(produto) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("produto invalido")) + return + } + id := chi.URLParam(r, "id") + operador := operadorFromCookie(r) + if operador == "" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("operador obrigatorio")) + return + } + + _ = operador // reservado: se no futuro quisermos registrar operador no status + + if err := store.SetStatusAnalise(ctx, prodNorm, id, StatusAnaliseConcluida); err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("db")) + return + } + a.redirectVoltarPainel(w, r, prodNorm) +} + +func (a AuthPainel) handlerReabrirResposta(w http.ResponseWriter, r *http.Request, store *Store) { + ctx := r.Context() + if err := db.EnsurePainelTables(ctx, store.poolRef()); err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("db")) + return + } + + produto := chi.URLParam(r, "produto") + prodNorm, err := db.NormalizeProduto(produto) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("produto invalido")) + return + } + id := chi.URLParam(r, "id") + operador := operadorFromCookie(r) + if operador == "" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("operador obrigatorio")) + return + } + + _ = operador + if err := store.SetStatusAnalise(ctx, prodNorm, id, StatusAnalisePendente); err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("db")) + return + } + a.redirectVoltarPainel(w, r, prodNorm) +} + +func (a AuthPainel) handlerCriarComentario(w http.ResponseWriter, r *http.Request, store *Store) { + ctx := r.Context() + if err := db.EnsurePainelTables(ctx, store.poolRef()); err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("db")) + return + } + + produto := chi.URLParam(r, "produto") + prodNorm, err := db.NormalizeProduto(produto) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("produto invalido")) + return + } + id := chi.URLParam(r, "id") + operador := operadorFromCookie(r) + sessaoID := sessaoPainelFromCookie(r) + if operador == "" || sessaoID == "" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("operador/sessao obrigatorios")) + return + } + + if err := r.ParseForm(); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + comentario := strings.TrimSpace(r.FormValue("comentario")) + if comentario == "" || len(comentario) > 2000 { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("comentario invalido")) + return + } + + if err := store.CriarComentarioPainel(ctx, prodNorm, id, operador, sessaoID, comentario); err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("db")) + return + } + a.redirectVoltarPainel(w, r, prodNorm) +} + +func (a AuthPainel) handlerEditarComentario(w http.ResponseWriter, r *http.Request, store *Store) { + ctx := r.Context() + if err := db.EnsurePainelTables(ctx, store.poolRef()); err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("db")) + return + } + + produto := chi.URLParam(r, "produto") + prodNorm, err := db.NormalizeProduto(produto) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("produto invalido")) + return + } + _ = chi.URLParam(r, "id") + comentarioID := chi.URLParam(r, "comentarioID") + sessaoID := sessaoPainelFromCookie(r) + if sessaoID == "" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("sessao obrigatoria")) + return + } + + if err := r.ParseForm(); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + comentario := strings.TrimSpace(r.FormValue("comentario")) + if comentario == "" || len(comentario) > 2000 { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("comentario invalido")) + return + } + + ok, err := store.EditarComentarioPainel(ctx, comentarioID, sessaoID, comentario) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("db")) + return + } + if !ok { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("nao permitido")) + return + } + a.redirectVoltarPainel(w, r, prodNorm) +} + +func (a AuthPainel) handlerDeletarComentario(w http.ResponseWriter, r *http.Request, store *Store) { + ctx := r.Context() + if err := db.EnsurePainelTables(ctx, store.poolRef()); err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("db")) + return + } + + produto := chi.URLParam(r, "produto") + prodNorm, err := db.NormalizeProduto(produto) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("produto invalido")) + return + } + _ = chi.URLParam(r, "id") + comentarioID := chi.URLParam(r, "comentarioID") + sessaoID := sessaoPainelFromCookie(r) + if sessaoID == "" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("sessao obrigatoria")) + return + } + + ok, err := store.DeletarComentarioPainel(ctx, comentarioID, sessaoID) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("db")) + return + } + if !ok { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("nao permitido")) + return + } + a.redirectVoltarPainel(w, r, prodNorm) +} + +// handlerComentariosModal renderiza um modal (HTML completo) para gestão de comentários +// de uma resposta específica. +// +// Motivação: manter o painel sem JS e ainda permitir uma UI melhor para comentários. +func (a AuthPainel) handlerComentariosModal(w http.ResponseWriter, r *http.Request, store *Store) { + ctx := r.Context() + if err := db.EnsurePainelTables(ctx, store.poolRef()); err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("db")) + return + } + + produto := chi.URLParam(r, "produto") + prodNorm, err := db.NormalizeProduto(produto) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("produto invalido")) + return + } + respostaID := chi.URLParam(r, "id") + + comentarios, err := store.ListarComentariosPainel(ctx, prodNorm, respostaID) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("db")) + return + } + + operador := operadorFromCookie(r) + sessaoID := sessaoPainelFromCookie(r) + + // Para voltar ao painel mantendo filtros. + back := "/painel?" + r.URL.Query().Encode() + if !strings.Contains(back, "produto=") { + if strings.Contains(back, "?") { + back += "&produto=" + url.QueryEscape(prodNorm) + } else { + back = "/painel?produto=" + url.QueryEscape(prodNorm) + } + } + + 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") + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + var b strings.Builder + b.WriteString("") + b.WriteString("") + b.WriteString("Comentários • Painel") + b.WriteString(``) + b.WriteString("") + b.WriteString("
") + b.WriteString("
") + b.WriteString("

Comentários internos

") + b.WriteString("
Produto: " + template.HTMLEscapeString(prodNorm) + " • Resposta: " + template.HTMLEscapeString(respostaID) + "
") + if operador != "" { + b.WriteString("
Operador: " + template.HTMLEscapeString(operador) + "
") + } + b.WriteString("
") + b.WriteString("Fechar") + b.WriteString("
") + + // Lista + for _, c := range comentarios { + b.WriteString("
") + b.WriteString("
" + template.HTMLEscapeString(c.PessoaNome) + " • " + template.HTMLEscapeString(formatarDataPainel(c.CriadoEm)) + "
") + b.WriteString("
" + template.HTMLEscapeString(c.Comentario) + "
") + if strings.TrimSpace(sessaoID) != "" && c.SessaoID == sessaoID { + // Editar + b.WriteString("
Editar") + b.WriteString("") + b.WriteString("") + b.WriteString("
") + b.WriteString("
") + // Deletar + b.WriteString("
") + b.WriteString("") + b.WriteString("
") + } + b.WriteString("
") + } + + // Criar + if strings.TrimSpace(operador) != "" && strings.TrimSpace(sessaoID) != "" { + b.WriteString("
") + b.WriteString("
Novo comentário
") + b.WriteString("
") + b.WriteString("") + b.WriteString("
Voltar
") + b.WriteString("
") + b.WriteString("
") + } else { + b.WriteString("

Faça login novamente informando Operador para comentar.

") + } + + b.WriteString("
") + w.Write([]byte(b.String())) +} + func (a AuthPainel) renderPainelHTML(w http.ResponseWriter, d PainelDados) { w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -341,6 +777,32 @@ func (a AuthPainel) renderPainelHTML(w http.ResponseWriter, d PainelDados) { return t.In(loc).Format("02/01/2006 15:04") } + isConcluida := func(id string) bool { + if d.StatusAnalise == nil { + return false + } + if st, ok := d.StatusAnalise[id]; ok { + return st == contratos.StatusAnaliseConcluida + } + return false + } + + trunc := func(s string, max int) string { + s = strings.TrimSpace(s) + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\r", " ") + for strings.Contains(s, " ") { + s = strings.ReplaceAll(s, " ", " ") + } + if max <= 0 { + return "" + } + if len(s) <= max { + return s + } + return s[:max] + "…" + } + soDigitos := func(s string) string { return naoDigitosRe.ReplaceAllString(s, "") } @@ -407,6 +869,9 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to if d.MsgErro != "" { b.WriteString("

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

") } + if strings.TrimSpace(d.Operador) != "" { + b.WriteString("

Operador: " + template.HTMLEscapeString(d.Operador) + "

") + } b.WriteString("
") b.WriteString("") b.WriteString(" notas baixas (<=6)") + chkPend := "" + if d.SomentePendentes { + chkPend = "checked" + } + b.WriteString("") b.WriteString("") // Export CSV (mantém filtros atuais) @@ -430,6 +900,9 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to if d.SomenteBaixas { exportURL += "&baixas=1" } + if d.SomentePendentes { + exportURL += "&pendentes=1" + } b.WriteString("Exportar CSV") b.WriteString("
") @@ -446,7 +919,10 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to // Respostas b.WriteString("

Respostas

") - b.WriteString("
") + if strings.TrimSpace(d.Operador) == "" { + b.WriteString("

Defina um operador (faça login novamente) para comentar e concluir respostas.

") + } + b.WriteString("
DataNotaUsuárioInquilinoEmailTelefoneComentárioAções
") for _, r := range d.Respostas { data := "-" if r.RespondidoEm != nil { @@ -496,11 +972,53 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to acoes = "-" } - coment := "" + justificativa := "" if r.Justificativa != nil { - coment = template.HTMLEscapeString(*r.Justificativa) + justificativa = template.HTMLEscapeString(*r.Justificativa) } - b.WriteString("") + + // Pendente? + pendente := !isConcluida(r.ID) + pendenteTxt := "Sim" + if !pendente { + pendenteTxt = "Não" + } + badgePendente := "" + template.HTMLEscapeString(pendenteTxt) + "" + qBase := "?produto=" + url.QueryEscape(d.Produto) + if d.SomenteBaixas { + qBase += "&baixas=1" + } + if d.SomentePendentes { + qBase += "&pendentes=1" + } + qBase += "&pagina=" + fmt.Sprintf("%d", d.Pagina) + statusCell := badgePendente + + // Último comentário (resumo) + comentarios := d.Comentarios[r.ID] + ultimo := "-" + if len(comentarios) > 0 { + c := comentarios[len(comentarios)-1] + ultimo = template.HTMLEscapeString(c.PessoaNome) + " • " + template.HTMLEscapeString(formatarDataPainel(c.CriadoEm)) + " — " + template.HTMLEscapeString(trunc(c.Comentario, 90)) + } + + // Botão ação: abre modal server-side (sem JS) + acaoModal := "-" + if strings.TrimSpace(d.Produto) != "" { + acaoModal = "Comentários" + } + + // Botão de ação: alternar status (concluída/pendente) + actionToggle := "" + if strings.TrimSpace(d.Operador) != "" { + if pendente { + actionToggle = "" + } else { + actionToggle = "" + } + } + + b.WriteString("") } b.WriteString("
DataNotaPendente?UsuárioInquilinoEmailTelefoneJustificativaÚltimo comentárioAções
" + template.HTMLEscapeString(data) + "" + template.HTMLEscapeString(nota) + "" + usuario + "" + inquilino + "" + emailHTML + "" + telefoneHTML + "" + coment + "" + acoes + "
" + template.HTMLEscapeString(data) + "" + template.HTMLEscapeString(nota) + "" + statusCell + "" + usuario + "" + inquilino + "" + emailHTML + "" + telefoneHTML + "" + justificativa + "" + ultimo + "" + acoes + " " + actionToggle + " " + acaoModal + "
") @@ -509,6 +1027,9 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to if d.SomenteBaixas { base += "&baixas=1" } + if d.SomentePendentes { + base += "&pendentes=1" + } prev := d.Pagina - 1 if prev < 1 { prev = 1 diff --git a/internal/elinps/painel_handlers.go b/internal/elinps/painel_handlers.go index 85d92c0..e047ef5 100644 --- a/internal/elinps/painel_handlers.go +++ b/internal/elinps/painel_handlers.go @@ -40,6 +40,26 @@ func (p *PainelHandlers) Router() http.Handler { p.auth.handlerPainel(w, r, p.store) }) + // Ações do painel (status + comentários) + r.With(func(next http.Handler) http.Handler { return p.auth.middleware(next) }).Post("/respostas/{produto}/{id}/concluir", func(w http.ResponseWriter, r *http.Request) { + p.auth.handlerConcluirResposta(w, r, p.store) + }) + r.With(func(next http.Handler) http.Handler { return p.auth.middleware(next) }).Post("/respostas/{produto}/{id}/reabrir", func(w http.ResponseWriter, r *http.Request) { + p.auth.handlerReabrirResposta(w, r, p.store) + }) + r.With(func(next http.Handler) http.Handler { return p.auth.middleware(next) }).Post("/respostas/{produto}/{id}/comentarios", func(w http.ResponseWriter, r *http.Request) { + p.auth.handlerCriarComentario(w, r, p.store) + }) + r.With(func(next http.Handler) http.Handler { return p.auth.middleware(next) }).Post("/respostas/{produto}/{id}/comentarios/{comentarioID}/editar", func(w http.ResponseWriter, r *http.Request) { + p.auth.handlerEditarComentario(w, r, p.store) + }) + r.With(func(next http.Handler) http.Handler { return p.auth.middleware(next) }).Post("/respostas/{produto}/{id}/comentarios/{comentarioID}/deletar", func(w http.ResponseWriter, r *http.Request) { + p.auth.handlerDeletarComentario(w, r, p.store) + }) + r.With(func(next http.Handler) http.Handler { return p.auth.middleware(next) }).Get("/respostas/{produto}/{id}/comentarios", func(w http.ResponseWriter, r *http.Request) { + p.auth.handlerComentariosModal(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) { diff --git a/internal/elinps/queries.go b/internal/elinps/queries.go index 4ee3c99..20b5768 100644 --- a/internal/elinps/queries.go +++ b/internal/elinps/queries.go @@ -22,6 +22,192 @@ func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } func (s *Store) poolRef() *pgxpool.Pool { return s.pool } +// ------------------------------ +// Painel: comentários e status de análise +// ------------------------------ + +type StatusAnalisePainel = contratos.StatusAnalisePainel + +const ( + StatusAnalisePendente = contratos.StatusAnalisePendente + StatusAnaliseConcluida = contratos.StatusAnaliseConcluida +) + +type ComentarioPainel = contratos.ComentarioPainel + +// GetStatusAnalise retorna o status do painel para uma resposta. +// Se não existir registro, considera "pendente". +func (s *Store) GetStatusAnalise(ctx context.Context, produto, respostaID string) (StatusAnalisePainel, error) { + var status string + err := s.pool.QueryRow(ctx, ` +SELECT status +FROM painel_resposta_status +WHERE produto=$1 AND resposta_id=$2 +`, produto, respostaID).Scan(&status) + if err == pgx.ErrNoRows { + return StatusAnalisePendente, nil + } + if err != nil { + return "", err + } + switch status { + case string(StatusAnalisePendente): + return StatusAnalisePendente, nil + case string(StatusAnaliseConcluida): + return StatusAnaliseConcluida, nil + default: + // fallback defensivo + return StatusAnalisePendente, nil + } +} + +func (s *Store) SetStatusAnalise(ctx context.Context, produto, respostaID string, status StatusAnalisePainel) error { + if status != StatusAnalisePendente && status != StatusAnaliseConcluida { + return fmt.Errorf("status invalido") + } + concluidaEm := (*time.Time)(nil) + if status == StatusAnaliseConcluida { + agora := time.Now() + concluidaEm = &agora + } + + _, err := s.pool.Exec(ctx, ` +INSERT INTO painel_resposta_status (produto, resposta_id, status, concluida_em, atualizado_em) +VALUES ($1,$2,$3,$4, now()) +ON CONFLICT (produto, resposta_id) +DO UPDATE SET + status=EXCLUDED.status, + concluida_em=EXCLUDED.concluida_em, + atualizado_em=now() +`, produto, respostaID, string(status), concluidaEmOrNil(concluidaEm)) + return err +} + +func concluidaEmOrNil(t *time.Time) any { + if t == nil { + return nil + } + return *t +} + +func (s *Store) ListarComentariosPainel(ctx context.Context, produto, respostaID string) ([]ComentarioPainel, error) { + rows, err := s.pool.Query(ctx, ` +SELECT id::text, pessoa_nome, sessao_id::text, comentario, criado_em, atualizado_em +FROM painel_resposta_comentario +WHERE produto=$1 AND resposta_id=$2 +ORDER BY criado_em ASC +`, produto, respostaID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []ComentarioPainel{} + for rows.Next() { + var c ComentarioPainel + if err := rows.Scan(&c.ID, &c.PessoaNome, &c.SessaoID, &c.Comentario, &c.CriadoEm, &c.AtualizadoEm); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +func (s *Store) CriarComentarioPainel(ctx context.Context, produto, respostaID, pessoaNome, sessaoID, comentario string) error { + _, err := s.pool.Exec(ctx, ` +INSERT INTO painel_resposta_comentario (produto, resposta_id, pessoa_nome, sessao_id, comentario, criado_em, atualizado_em) +VALUES ($1,$2,$3,$4::uuid,$5, now(), now()) +`, produto, respostaID, pessoaNome, sessaoID, comentario) + return err +} + +// EditarComentarioPainel edita um comentário, mas somente se pertencer à mesma sessão. +func (s *Store) EditarComentarioPainel(ctx context.Context, comentarioID, sessaoID, comentario string) (bool, error) { + tag, err := s.pool.Exec(ctx, ` +UPDATE painel_resposta_comentario +SET comentario=$3, atualizado_em=now() +WHERE id=$1::uuid AND sessao_id=$2::uuid +`, comentarioID, sessaoID, comentario) + if err != nil { + return false, err + } + return tag.RowsAffected() > 0, nil +} + +// DeletarComentarioPainel remove um comentário, mas somente se pertencer à mesma sessão. +func (s *Store) DeletarComentarioPainel(ctx context.Context, comentarioID, sessaoID string) (bool, error) { + tag, err := s.pool.Exec(ctx, ` +DELETE FROM painel_resposta_comentario +WHERE id=$1::uuid AND sessao_id=$2::uuid +`, comentarioID, sessaoID) + if err != nil { + return false, err + } + return tag.RowsAffected() > 0, nil +} + +// GetStatusAnaliseBatch retorna um mapa resposta_id -> status. +// Se não existir registro para um id, ele simplesmente não aparece no mapa. +func (s *Store) GetStatusAnaliseBatch(ctx context.Context, produto string, respostaIDs []string) (map[string]StatusAnalisePainel, error) { + out := map[string]StatusAnalisePainel{} + if len(respostaIDs) == 0 { + return out, nil + } + + rows, err := s.pool.Query(ctx, ` +SELECT resposta_id, status +FROM painel_resposta_status +WHERE produto=$1 AND resposta_id = ANY($2::text[]) +`, produto, respostaIDs) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var id, st string + if err := rows.Scan(&id, &st); err != nil { + return nil, err + } + switch st { + case string(StatusAnaliseConcluida): + out[id] = StatusAnaliseConcluida + default: + out[id] = StatusAnalisePendente + } + } + return out, rows.Err() +} + +// ListarComentariosPainelBatch retorna um mapa resposta_id -> comentários. +func (s *Store) ListarComentariosPainelBatch(ctx context.Context, produto string, respostaIDs []string) (map[string][]ComentarioPainel, error) { + out := map[string][]ComentarioPainel{} + if len(respostaIDs) == 0 { + return out, nil + } + + rows, err := s.pool.Query(ctx, ` +SELECT resposta_id, id::text, pessoa_nome, sessao_id::text, comentario, criado_em, atualizado_em +FROM painel_resposta_comentario +WHERE produto=$1 AND resposta_id = ANY($2::text[]) +ORDER BY resposta_id ASC, criado_em ASC +`, produto, respostaIDs) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var respostaID string + var c ComentarioPainel + if err := rows.Scan(&respostaID, &c.ID, &c.PessoaNome, &c.SessaoID, &c.Comentario, &c.CriadoEm, &c.AtualizadoEm); err != nil { + return nil, err + } + out[respostaID] = append(out[respostaID], c) + } + return out, rows.Err() +} + func ipReal(r *http.Request) string { // IP real do cliente. // diff --git a/migrations/002_painel_comentarios_status.sql b/migrations/002_painel_comentarios_status.sql new file mode 100644 index 0000000..81827a5 --- /dev/null +++ b/migrations/002_painel_comentarios_status.sql @@ -0,0 +1,31 @@ +-- e-li.nps: tabelas globais do painel (comentários e status de análise) +-- +-- Importante: +-- - o app também cria essas tabelas de forma defensiva em runtime (EnsurePainelTables) +-- - este arquivo é um backup/documentação para ambientes que preferem rodar SQL manual + +CREATE TABLE IF NOT EXISTS painel_resposta_status ( + produto text NOT NULL, + resposta_id text NOT NULL, + status text NOT NULL CHECK (status IN ('pendente','concluida')), + concluida_em timestamptz NULL, + atualizado_em timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (produto, resposta_id) +); + +CREATE TABLE IF NOT EXISTS painel_resposta_comentario ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + produto text NOT NULL, + resposta_id text NOT NULL, + -- Operador logado no painel no momento da criação do comentário. + pessoa_nome text NOT NULL, + -- ID de sessão do login para aplicar regra: só edita/deleta na mesma sessão. + sessao_id uuid NOT NULL, + comentario text NOT NULL, + criado_em timestamptz NOT NULL DEFAULT now(), + atualizado_em timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_painel_resp_comentario + ON painel_resposta_comentario (produto, resposta_id, criado_em ASC); +