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 }