package elinps import ( "context" "fmt" "strings" "time" "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) ([]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 := []NPSMensal{} for rows.Next() { var m 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() { if f.Pagina <= 0 { f.Pagina = 1 } if f.PorPagina <= 0 || f.PorPagina > 200 { f.PorPagina = 50 } } // ListarRespostas retorna respostas respondidas, com paginação e filtro. func (s *Store) ListarRespostas(ctx context.Context, tabela string, filtro ListarRespostasFiltro) ([]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 := []RespostaPainel{} for rows.Next() { var r 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