164 lines
4.2 KiB
Go
164 lines
4.2 KiB
Go
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 (1–10):
|
||
// - 1–6 detratores
|
||
// - 7–8 neutros
|
||
// - 9–10 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
|