365 lines
11 KiB
Go
365 lines
11 KiB
Go
package elinps
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"time"
|
|
|
|
"e-li.nps/internal/contratos"
|
|
"e-li.nps/internal/db"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
type Store struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
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.
|
|
//
|
|
// Importante:
|
|
// - No servidor, usamos middleware.RealIP (chi) que resolve o IP considerando
|
|
// headers comuns de proxy (X-Forwarded-For / X-Real-IP).
|
|
// - Aqui usamos o r.RemoteAddr já processado e extraímos apenas o host.
|
|
// - Se não for possível parsear, retornamos vazio.
|
|
ip := r.RemoteAddr
|
|
if host, _, err := net.SplitHostPort(ip); err == nil {
|
|
ip = host
|
|
}
|
|
if net.ParseIP(ip) == nil {
|
|
return ""
|
|
}
|
|
return ip
|
|
}
|
|
|
|
func (s *Store) EnsureTableForProduto(ctx context.Context, produtoNome string) (table string, err error) {
|
|
prod, err := db.NormalizeProduto(produtoNome)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
table = db.TableNameForProduto(prod)
|
|
if err := db.EnsureNPSTable(ctx, s.pool, table); err != nil {
|
|
return "", err
|
|
}
|
|
return table, nil
|
|
}
|
|
|
|
func (s *Store) HasRespostaValidaRecente(ctx context.Context, table, inquilinoCodigo, usuarioCodigo string) (bool, error) {
|
|
// Segurança: a tabela é um identificador interpolado. Validamos sempre.
|
|
if !db.TableNameValido(table) {
|
|
return false, fmt.Errorf("tabela invalida")
|
|
}
|
|
q := fmt.Sprintf(`
|
|
SELECT 1
|
|
FROM %s
|
|
WHERE inquilino_codigo=$1 AND usuario_codigo=$2
|
|
AND status='respondido' AND valida=true
|
|
AND respondido_em >= now() - interval '45 days'
|
|
LIMIT 1`, table)
|
|
|
|
var one int
|
|
err := s.pool.QueryRow(ctx, q, inquilinoCodigo, usuarioCodigo).Scan(&one)
|
|
if err == pgx.ErrNoRows {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (s *Store) HasPedidoEmAbertoRecente(ctx context.Context, table, inquilinoCodigo, usuarioCodigo string) (bool, error) {
|
|
// Segurança: a tabela é um identificador interpolado. Validamos sempre.
|
|
if !db.TableNameValido(table) {
|
|
return false, fmt.Errorf("tabela invalida")
|
|
}
|
|
q := fmt.Sprintf(`
|
|
SELECT 1
|
|
FROM %s
|
|
WHERE inquilino_codigo=$1 AND usuario_codigo=$2
|
|
AND status='pedido'
|
|
AND pedido_criado_em >= now() - interval '10 days'
|
|
LIMIT 1`, table)
|
|
var one int
|
|
err := s.pool.QueryRow(ctx, q, inquilinoCodigo, usuarioCodigo).Scan(&one)
|
|
if err == pgx.ErrNoRows {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (s *Store) CreatePedido(ctx context.Context, table string, in contratos.PedidoInput, r *http.Request) (string, error) {
|
|
// Segurança: a tabela é um identificador interpolado. Validamos sempre.
|
|
if !db.TableNameValido(table) {
|
|
return "", fmt.Errorf("tabela invalida")
|
|
}
|
|
q := fmt.Sprintf(`
|
|
INSERT INTO %s (
|
|
produto_nome,
|
|
inquilino_codigo, inquilino_nome,
|
|
usuario_codigo, usuario_nome, usuario_email, usuario_telefone,
|
|
status, origem, user_agent, ip_real
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,'pedido','widget_iframe',$8,$9)
|
|
RETURNING id`, table)
|
|
|
|
var id string
|
|
err := s.pool.QueryRow(ctx, q,
|
|
in.ProdutoNome,
|
|
in.InquilinoCodigo, in.InquilinoNome,
|
|
in.UsuarioCodigo, in.UsuarioNome, in.UsuarioEmail, in.UsuarioTelefone,
|
|
r.UserAgent(), ipReal(r),
|
|
).Scan(&id)
|
|
return id, err
|
|
}
|
|
|
|
func (s *Store) GetRegistro(ctx context.Context, table, id string) (contratos.Registro, error) {
|
|
// Segurança: a tabela é um identificador interpolado. Validamos sempre.
|
|
if !db.TableNameValido(table) {
|
|
return contratos.Registro{}, fmt.Errorf("tabela invalida")
|
|
}
|
|
q := fmt.Sprintf(`
|
|
SELECT id, produto_nome, status, nota, justificativa, pedido_criado_em, respondido_em
|
|
FROM %s
|
|
WHERE id=$1`, table)
|
|
|
|
var reg contratos.Registro
|
|
err := s.pool.QueryRow(ctx, q, id).Scan(
|
|
®.ID, ®.ProdutoNome, ®.Status, ®.Nota, ®.Justificativa, ®.PedidoCriadoEm, ®.RespondidoEm,
|
|
)
|
|
return reg, err
|
|
}
|
|
|
|
func (s *Store) PatchRegistro(ctx context.Context, table, id string, in contratos.PatchInput) error {
|
|
// Segurança: a tabela é um identificador interpolado. Validamos sempre.
|
|
if !db.TableNameValido(table) {
|
|
return fmt.Errorf("tabela invalida")
|
|
}
|
|
// UPDATE único com campos opcionais.
|
|
q := fmt.Sprintf(`
|
|
UPDATE %s
|
|
SET
|
|
nota = COALESCE($2, nota),
|
|
justificativa = COALESCE($3, justificativa),
|
|
status = CASE WHEN $4 THEN 'respondido' ELSE status END,
|
|
respondido_em = CASE WHEN $4 THEN COALESCE(respondido_em, now()) ELSE respondido_em END,
|
|
atualizado_em = now()
|
|
WHERE id=$1`, table)
|
|
|
|
_, err := s.pool.Exec(ctx, q, id, in.Nota, in.Justificativa, in.Finalizar)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) TouchAtualizadoEm(ctx context.Context, table, id string) error {
|
|
// Segurança: a tabela é um identificador interpolado. Validamos sempre.
|
|
if !db.TableNameValido(table) {
|
|
return fmt.Errorf("tabela invalida")
|
|
}
|
|
q := fmt.Sprintf(`UPDATE %s SET atualizado_em=now() WHERE id=$1`, table)
|
|
_, err := s.pool.Exec(ctx, q, id)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) CooldownSuggested(reg contratos.Registro) time.Duration {
|
|
// Não é usado pelo servidor hoje; fica como helper se precisarmos.
|
|
if reg.Status == "respondido" {
|
|
return 45 * 24 * time.Hour
|
|
}
|
|
return 24 * time.Hour
|
|
}
|