e-li-nps/internal/elinps/painel_queries.go

164 lines
4.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package elinps
import (
"context"
"fmt"
"strings"
"time"
"e-li.nps/internal/contratos"
"e-li.nps/internal/db"
"github.com/jackc/pgx/v5"
)
// ListarProdutos retorna os produtos existentes a partir das tabelas `nps_*`.
//
// Importante: este painel é para exploração interna. Mesmo assim, mantemos uma
// sanitização mínima no nome (prefixo nps_ removido).
func (s *Store) ListarProdutos(ctx context.Context) ([]string, error) {
rows, err := s.pool.Query(ctx, `
SELECT tablename
FROM pg_catalog.pg_tables
WHERE schemaname='public' AND tablename LIKE 'nps_%'
ORDER BY tablename`)
if err != nil {
return nil, err
}
defer rows.Close()
produtos := []string{}
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
return nil, err
}
produtos = append(produtos, strings.TrimPrefix(t, "nps_"))
}
return produtos, rows.Err()
}
// NPSMesAMes calcula o NPS por mês para um produto (tabela `nps_{produto}`).
//
// Regra NPS (110):
// - 16 detratores
// - 78 neutros
// - 910 promotores
func (s *Store) NPSMesAMes(ctx context.Context, tabela string, meses int) ([]contratos.NPSMensal, error) {
// Segurança: a tabela é um identificador interpolado. Validamos sempre.
if !db.TableNameValido(tabela) {
return nil, fmt.Errorf("tabela invalida")
}
q := fmt.Sprintf(`
WITH base AS (
SELECT
date_trunc('month', respondido_em) AS mes,
nota
FROM %s
WHERE status='respondido'
AND valida=true
AND respondido_em IS NOT NULL
AND respondido_em >= date_trunc('month', now()) - ($1::int * interval '1 month')
)
SELECT
to_char(mes, 'YYYY-MM') AS mes,
SUM(CASE WHEN nota BETWEEN 1 AND 6 THEN 1 ELSE 0 END)::int AS detratores,
SUM(CASE WHEN nota BETWEEN 7 AND 8 THEN 1 ELSE 0 END)::int AS neutros,
SUM(CASE WHEN nota BETWEEN 9 AND 10 THEN 1 ELSE 0 END)::int AS promotores,
COUNT(*)::int AS total
FROM base
GROUP BY mes
ORDER BY mes ASC`, tabela)
rows, err := s.pool.Query(ctx, q, meses)
if err != nil {
return nil, err
}
defer rows.Close()
out := []contratos.NPSMensal{}
for rows.Next() {
var m contratos.NPSMensal
if err := rows.Scan(&m.Mes, &m.Detratores, &m.Neutros, &m.Promotores, &m.Total); err != nil {
return nil, err
}
if m.Total > 0 {
pctProm := float64(m.Promotores) / float64(m.Total) * 100
pctDet := float64(m.Detratores) / float64(m.Total) * 100
m.NPS = int((pctProm - pctDet) + 0.5) // arredonda para inteiro
}
out = append(out, m)
}
return out, rows.Err()
}
type ListarRespostasFiltro struct {
SomenteNotasBaixas bool
Pagina int
PorPagina int
}
func (f *ListarRespostasFiltro) normalizar() { (*contratos.ListarRespostasFiltro)(f).Normalizar() }
// ListarRespostas retorna respostas respondidas, com paginação e filtro.
func (s *Store) ListarRespostas(ctx context.Context, tabela string, filtro ListarRespostasFiltro) ([]contratos.RespostaPainel, error) {
// Segurança: a tabela é um identificador interpolado. Validamos sempre.
if !db.TableNameValido(tabela) {
return nil, fmt.Errorf("tabela invalida")
}
filtro.normalizar()
offset := (filtro.Pagina - 1) * filtro.PorPagina
cond := "status='respondido' AND valida=true"
if filtro.SomenteNotasBaixas {
cond += " AND nota BETWEEN 1 AND 6"
}
// Importante (segurança): apesar do cond ser construído em string, ele NÃO usa
// entrada do usuário diretamente. O filtro "SomenteNotasBaixas" é booleano.
// A tabela também é validada por regex (TableNameValido).
q := fmt.Sprintf(`
SELECT
id,
respondido_em,
pedido_criado_em,
usuario_codigo,
usuario_nome,
usuario_email,
nota,
justificativa
FROM %s
WHERE %s
ORDER BY respondido_em DESC NULLS LAST
LIMIT $1 OFFSET $2`, tabela, cond)
rows, err := s.pool.Query(ctx, q, filtro.PorPagina, offset)
if err != nil {
return nil, err
}
defer rows.Close()
respostas := []contratos.RespostaPainel{}
for rows.Next() {
var r contratos.RespostaPainel
if err := rows.Scan(
&r.ID,
&r.RespondidoEm,
&r.PedidoCriadoEm,
&r.UsuarioCodigo,
&r.UsuarioNome,
&r.UsuarioEmail,
&r.Nota,
&r.Justificativa,
); err != nil {
return nil, err
}
respostas = append(respostas, r)
}
return respostas, rows.Err()
}
// ensure interface imports
var _ = pgx.ErrNoRows
var _ = time.Second