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).
")
@@ -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("
| Data | Nota | Usuário | Inquilino | Email | Telefone | Comentário | Ações |
")
+ if strings.TrimSpace(d.Operador) == "" {
+ b.WriteString("Defina um operador (faça login novamente) para comentar e concluir respostas.
")
+ }
+ b.WriteString("| Data | Nota | Pendente? | Usuário | Inquilino | Email | Telefone | Justificativa | Último comentário | Açõ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("| " + template.HTMLEscapeString(data) + " | " + template.HTMLEscapeString(nota) + " | " + usuario + " | " + inquilino + " | " + emailHTML + " | " + telefoneHTML + " | " + coment + " | " + acoes + " |
")
+
+ // 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("| " + template.HTMLEscapeString(data) + " | " + template.HTMLEscapeString(nota) + " | " + statusCell + " | " + usuario + " | " + inquilino + " | " + emailHTML + " | " + telefoneHTML + " | " + justificativa + " | " + ultimo + " | " + acoes + " " + actionToggle + " " + acaoModal + " |
")
}
b.WriteString("
")
@@ -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);
+