adicionado comentários
This commit is contained in:
parent
e8ca410b94
commit
e796b29e1d
8 changed files with 875 additions and 13 deletions
21
README.md
21
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**.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
<h1>e-li.nps • Painel</h1>
|
||||
<p class="muted">Acesso protegido por senha (SENHA_PAINEL).</p>
|
||||
<form method="POST" action="/painel/login">
|
||||
<label>Operador</label>
|
||||
<input type="text" name="operador" autocomplete="name" />
|
||||
<label>Senha</label>
|
||||
<input type="password" name="senha" autocomplete="current-password" />
|
||||
<button type="submit">Entrar</button>
|
||||
|
|
@ -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("<!doctype html><html lang=\"pt-br\"><head><meta charset=\"utf-8\"/>")
|
||||
b.WriteString("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>")
|
||||
b.WriteString("<title>Comentários • Painel</title>")
|
||||
b.WriteString(`<style>
|
||||
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:rgba(0,0,0,.35);margin:0;padding:18px;}
|
||||
.modal{max-width:860px;margin:0 auto;background:#fff;border-radius:14px;border:1px solid #e5e5e5;box-shadow:0 12px 30px rgba(0,0,0,.2);padding:14px;}
|
||||
.top{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;}
|
||||
.badge{display:inline-block;padding:6px 10px;border-radius:999px;background:#f2f2f2;border:1px solid #e5e5e5;font-size:12px;}
|
||||
.muted{color:#666;font-size:12px;}
|
||||
textarea{width:100%;min-height:90px;border:1px solid #ddd;border-radius:12px;padding:10px;font-size:14px;}
|
||||
button{cursor:pointer;}
|
||||
.btn{padding:8px 12px;border-radius:10px;border:1px solid #111;background:#111;color:#fff;}
|
||||
.btn2{padding:8px 12px;border-radius:10px;border:1px solid #ddd;background:#fff;color:#111;}
|
||||
.card{border:1px solid #eee;border-radius:12px;padding:10px;margin-top:10px;}
|
||||
</style>`)
|
||||
b.WriteString("</head><body>")
|
||||
b.WriteString("<div class=\"modal\">")
|
||||
b.WriteString("<div class=\"top\">")
|
||||
b.WriteString("<div><h2 style=\"margin:0\">Comentários internos</h2>")
|
||||
b.WriteString("<div class=\"muted\">Produto: <b>" + template.HTMLEscapeString(prodNorm) + "</b> • Resposta: <b>" + template.HTMLEscapeString(respostaID) + "</b></div>")
|
||||
if operador != "" {
|
||||
b.WriteString("<div class=\"muted\">Operador: <b>" + template.HTMLEscapeString(operador) + "</b></div>")
|
||||
}
|
||||
b.WriteString("</div>")
|
||||
b.WriteString("<a class=\"btn2\" href=\"" + template.HTMLEscapeString(back) + "\">Fechar</a>")
|
||||
b.WriteString("</div>")
|
||||
|
||||
// Lista
|
||||
for _, c := range comentarios {
|
||||
b.WriteString("<div class=\"card\">")
|
||||
b.WriteString("<div class=\"muted\" style=\"margin-bottom:8px\">" + template.HTMLEscapeString(c.PessoaNome) + " • " + template.HTMLEscapeString(formatarDataPainel(c.CriadoEm)) + "</div>")
|
||||
b.WriteString("<div style=\"white-space:pre-wrap\">" + template.HTMLEscapeString(c.Comentario) + "</div>")
|
||||
if strings.TrimSpace(sessaoID) != "" && c.SessaoID == sessaoID {
|
||||
// Editar
|
||||
b.WriteString("<details style=\"margin-top:10px\"><summary class=\"muted\">Editar</summary>")
|
||||
b.WriteString("<form method=\"POST\" action=\"/painel/respostas/" + url.PathEscape(prodNorm) + "/" + url.PathEscape(respostaID) + "/comentarios/" + url.PathEscape(c.ID) + "/editar?" + r.URL.Query().Encode() + "\" style=\"margin-top:8px\">")
|
||||
b.WriteString("<textarea name=\"comentario\">" + template.HTMLEscapeString(c.Comentario) + "</textarea>")
|
||||
b.WriteString("<div style=\"display:flex;gap:8px;margin-top:8px\"><button class=\"btn\" type=\"submit\">Salvar</button></div>")
|
||||
b.WriteString("</form></details>")
|
||||
// Deletar
|
||||
b.WriteString("<form method=\"POST\" action=\"/painel/respostas/" + url.PathEscape(prodNorm) + "/" + url.PathEscape(respostaID) + "/comentarios/" + url.PathEscape(c.ID) + "/deletar?" + r.URL.Query().Encode() + "\" style=\"margin-top:8px\">")
|
||||
b.WriteString("<button class=\"btn2\" type=\"submit\">Excluir</button>")
|
||||
b.WriteString("</form>")
|
||||
}
|
||||
b.WriteString("</div>")
|
||||
}
|
||||
|
||||
// Criar
|
||||
if strings.TrimSpace(operador) != "" && strings.TrimSpace(sessaoID) != "" {
|
||||
b.WriteString("<div class=\"card\">")
|
||||
b.WriteString("<div class=\"muted\">Novo comentário</div>")
|
||||
b.WriteString("<form method=\"POST\" action=\"/painel/respostas/" + url.PathEscape(prodNorm) + "/" + url.PathEscape(respostaID) + "/comentarios?" + r.URL.Query().Encode() + "\" style=\"margin-top:8px\">")
|
||||
b.WriteString("<textarea name=\"comentario\" placeholder=\"Registrar ação/observação...\"></textarea>")
|
||||
b.WriteString("<div style=\"display:flex;gap:8px;margin-top:8px\"><button class=\"btn\" type=\"submit\">Adicionar</button><a class=\"btn2\" href=\"" + template.HTMLEscapeString(back) + "\">Voltar</a></div>")
|
||||
b.WriteString("</form>")
|
||||
b.WriteString("</div>")
|
||||
} else {
|
||||
b.WriteString("<p class=\"badge\" style=\"margin-top:12px\">Faça login novamente informando Operador para comentar.</p>")
|
||||
}
|
||||
|
||||
b.WriteString("</div></body></html>")
|
||||
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("<p class=\"badge\">" + template.HTMLEscapeString(d.MsgErro) + "</p>")
|
||||
}
|
||||
if strings.TrimSpace(d.Operador) != "" {
|
||||
b.WriteString("<p class=\"muted\" style=\"margin:0 0 8px\">Operador: <b>" + template.HTMLEscapeString(d.Operador) + "</b></p>")
|
||||
}
|
||||
b.WriteString("<form method=\"GET\" action=\"/painel\" style=\"display:flex;gap:10px;flex-wrap:wrap;align-items:center\">")
|
||||
b.WriteString("<label class=\"muted\">Produto</label>")
|
||||
b.WriteString("<select name=\"produto\">")
|
||||
|
|
@ -423,6 +888,11 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
|
|||
chk = "checked"
|
||||
}
|
||||
b.WriteString("<label class=\"muted\"><input type=\"checkbox\" name=\"baixas\" value=\"1\" " + chk + "/> notas baixas (<=6)</label>")
|
||||
chkPend := ""
|
||||
if d.SomentePendentes {
|
||||
chkPend = "checked"
|
||||
}
|
||||
b.WriteString("<label class=\"muted\"><input type=\"checkbox\" name=\"pendentes\" value=\"1\" " + chkPend + "/> somente pendentes</label>")
|
||||
b.WriteString("<button type=\"submit\" style=\"padding:10px 14px;border-radius:10px;border:1px solid #111;background:#111;color:#fff;cursor:pointer\">Aplicar</button>")
|
||||
|
||||
// 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("<a class=\"badge\" style=\"text-decoration:none;display:inline-block;padding:10px 14px\" href=\"" + exportURL + "\">Exportar CSV</a>")
|
||||
|
||||
b.WriteString("</form></div>")
|
||||
|
|
@ -446,7 +919,10 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
|
|||
|
||||
// Respostas
|
||||
b.WriteString("<div class=\"card\" style=\"margin-top:12px\"><h2 style=\"margin:0 0 8px\">Respostas</h2>")
|
||||
b.WriteString("<div class=\"tablewrap\"><table><thead><tr><th>Data</th><th>Nota</th><th>Usuário</th><th>Inquilino</th><th>Email</th><th>Telefone</th><th>Comentário</th><th>Ações</th></tr></thead><tbody>")
|
||||
if strings.TrimSpace(d.Operador) == "" {
|
||||
b.WriteString("<p class=\"badge\">Defina um operador (faça login novamente) para comentar e concluir respostas.</p>")
|
||||
}
|
||||
b.WriteString("<div class=\"tablewrap\"><table><thead><tr><th>Data</th><th>Nota</th><th>Pendente?</th><th>Usuário</th><th>Inquilino</th><th>Email</th><th>Telefone</th><th>Justificativa</th><th>Último comentário</th><th>Ações</th></tr></thead><tbody>")
|
||||
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("<tr><td>" + template.HTMLEscapeString(data) + "</td><td><b>" + template.HTMLEscapeString(nota) + "</b></td><td>" + usuario + "</td><td>" + inquilino + "</td><td>" + emailHTML + "</td><td>" + telefoneHTML + "</td><td>" + coment + "</td><td>" + acoes + "</td></tr>")
|
||||
|
||||
// Pendente?
|
||||
pendente := !isConcluida(r.ID)
|
||||
pendenteTxt := "Sim"
|
||||
if !pendente {
|
||||
pendenteTxt = "Não"
|
||||
}
|
||||
badgePendente := "<span class=\"badge\">" + template.HTMLEscapeString(pendenteTxt) + "</span>"
|
||||
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 = "<a class=\"badge\" style=\"text-decoration:none\" href=\"/painel/respostas/" + url.PathEscape(d.Produto) + "/" + url.PathEscape(r.ID) + "/comentarios" + qBase + "\">Comentários</a>"
|
||||
}
|
||||
|
||||
// Botão de ação: alternar status (concluída/pendente)
|
||||
actionToggle := ""
|
||||
if strings.TrimSpace(d.Operador) != "" {
|
||||
if pendente {
|
||||
actionToggle = "<form method=\"POST\" action=\"/painel/respostas/" + url.PathEscape(d.Produto) + "/" + url.PathEscape(r.ID) + "/concluir" + qBase + "\" style=\"display:inline\"><button class=\"badge\" type=\"submit\" style=\"cursor:pointer\">Concluir</button></form>"
|
||||
} else {
|
||||
actionToggle = "<form method=\"POST\" action=\"/painel/respostas/" + url.PathEscape(d.Produto) + "/" + url.PathEscape(r.ID) + "/reabrir" + qBase + "\" style=\"display:inline\"><button class=\"badge\" type=\"submit\" style=\"cursor:pointer\">Reabrir</button></form>"
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("<tr><td>" + template.HTMLEscapeString(data) + "</td><td><b>" + template.HTMLEscapeString(nota) + "</b></td><td>" + statusCell + "</td><td>" + usuario + "</td><td>" + inquilino + "</td><td>" + emailHTML + "</td><td>" + telefoneHTML + "</td><td>" + justificativa + "</td><td>" + ultimo + "</td><td>" + acoes + " " + actionToggle + " " + acaoModal + "</td></tr>")
|
||||
}
|
||||
b.WriteString("</tbody></table></div>")
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
31
migrations/002_painel_comentarios_status.sql
Normal file
31
migrations/002_painel_comentarios_status.sql
Normal file
|
|
@ -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);
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue