소스 검색

adiciona comentarios de cancelados as avaliações

leonardo 1 개월 전
부모
커밋
28dd9410ca

+ 17 - 0
db/migrations/20260722200000_add_midia_whatsapp_mensagens.cjs

@@ -0,0 +1,17 @@
+exports.up = function (knex) {
+  return knex.schema.alterTable("whatsapp_mensagens", (t) => {
+    t.string("TipoMidia", 20).nullable(); // imagem | audio | video | sticker | documento
+    t.string("MidiaPath", 500).nullable(); // nome do arquivo salvo em data/whatsapp-media/
+    t.string("MidiaMimetype", 100).nullable();
+    t.string("MidiaNomeArquivo", 255).nullable(); // nome original, só relevante para documentMessage
+  });
+};
+
+exports.down = function (knex) {
+  return knex.schema.alterTable("whatsapp_mensagens", (t) => {
+    t.dropColumn("TipoMidia");
+    t.dropColumn("MidiaPath");
+    t.dropColumn("MidiaMimetype");
+    t.dropColumn("MidiaNomeArquivo");
+  });
+};

+ 15 - 0
db/migrations/20260723140943_add_cancelamento_to_atendimento_avaliacoes.cjs

@@ -0,0 +1,15 @@
+exports.up = function (knex) {
+  return knex.schema.alterTable("atendimento_avaliacoes", (t) => {
+    t.string("StatusCancelamento", 12).notNullable().defaultTo("nao"); // nao | ameacou | cancelou | indefinido
+    t.text("MotivoCancelamento").nullable();
+    t.index(["StatusCancelamento"]);
+  });
+};
+
+exports.down = function (knex) {
+  return knex.schema.alterTable("atendimento_avaliacoes", (t) => {
+    t.dropIndex(["StatusCancelamento"]);
+    t.dropColumn("StatusCancelamento");
+    t.dropColumn("MotivoCancelamento");
+  });
+};

+ 5 - 1
src/controllers/Atendimentos.Controller.js

@@ -133,8 +133,10 @@ export const AtendimentosController = {
       const setor = typeof req.query.setor === "string" && req.query.setor.trim() ? req.query.setor.trim() : undefined;
       const resolvido = typeof req.query.resolvido === "string" && req.query.resolvido.trim() ? req.query.resolvido.trim() : undefined;
       const sentimento = typeof req.query.sentimento === "string" && req.query.sentimento.trim() ? req.query.sentimento.trim() : undefined;
+      const statusCancelamento = typeof req.query.statusCancelamento === "string" && req.query.statusCancelamento.trim() ? req.query.statusCancelamento.trim() : undefined;
       const scoreMax = req.query.scoreMax !== undefined ? Number(req.query.scoreMax) : undefined;
       const busca = typeof req.query.busca === "string" && req.query.busca.trim() ? req.query.busca.trim() : undefined;
+      const ordenacao = req.query.ordenacao === "asc" ? "asc" : "desc";
 
       const result = await listarAvaliacoes({
         page,
@@ -142,8 +144,10 @@ export const AtendimentosController = {
         setor,
         resolvido,
         sentimento,
+        statusCancelamento,
         scoreMax: Number.isFinite(scoreMax) ? scoreMax : undefined,
-        busca
+        busca,
+        ordenacao
       });
       res.json(result);
     } catch (err) {

+ 11 - 1
src/controllers/Conversas.Controller.js

@@ -1,4 +1,4 @@
-import { listarConversas, listarMensagens, responderConversa, excluirConversa } from "../services/whatsappService.js";
+import { listarConversas, listarMensagens, responderConversa, excluirConversa, obterCaminhoMidia } from "../services/whatsappService.js";
 
 export const ConversasController = {
   Listar: async function (req, res, next) {
@@ -28,6 +28,16 @@ export const ConversasController = {
     }
   },
 
+  ObterMidia: async function (req, res, next) {
+    try {
+      const { caminho, mimetype } = await obterCaminhoMidia(req.params.id, req.params.mensagemId);
+      if (mimetype) res.type(mimetype);
+      res.sendFile(caminho);
+    } catch (err) {
+      next(err);
+    }
+  },
+
   Excluir: async function (req, res, next) {
     try {
       await excluirConversa(req.params.id);

+ 4 - 1
src/factories/Server.factory.js

@@ -19,7 +19,10 @@ export class ServerFactory {
     const app = express();
 
     app.disable("x-powered-by");
-    app.use(helmet());
+    // o front roda em origem/porta diferente do back (dev: 5173 vs 3001) e busca mídia
+    // (imagem/áudio) via fetch autenticado — o padrão "same-origin" do helmet faz o
+    // navegador bloquear essa resposta mesmo com CORS liberado, travando o fetch pra sempre
+    app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } }));
     app.use(
       cors({
         origin: config.corsOrigin,

+ 1 - 0
src/routes/Conversas.Rotas.js

@@ -10,5 +10,6 @@ export const conversasRouter = Router();
 
 conversasRouter.get("/", requireUser, ConversasController.Listar);
 conversasRouter.get("/:id/mensagens", requireUser, parseId, validate({ query: conversaMensagensQuerySchema }), ConversasController.ListarMensagens);
+conversasRouter.get("/:id/mensagens/:mensagemId/midia", requireUser, parseId, ConversasController.ObterMidia);
 conversasRouter.post("/:id/mensagens", requireUser, parseId, validate({ body: conversaMensagemBodySchema }), ConversasController.Responder);
 conversasRouter.delete("/:id", requireUser, requireAdmin, parseId, ConversasController.Excluir);

+ 66 - 24
src/services/atendimentoAvaliacaoService.js

@@ -66,6 +66,25 @@ const AVALIACAO_SYSTEM_PROMPT = [
   '  solução e insatisfeito, saindo da conversa sem retorno."',
   "→ score_atendente: 2",
   "",
+  "Exemplo 4 — cliente pede/menciona cancelar, sem confirmação de que foi concluído",
+  '(status_cancelamento = "ameacou" — este é o caso mais comum de menção a cancelamento; não espere',
+  "o cliente confirmar que já cancelou para usar esse valor):",
+  'Cliente: "Quero cancelar a internet, não estou satisfeito com o serviço."',
+  'Atendente: "Vou te transferir para o setor financeiro para dar andamento."',
+  "(conversa termina sem confirmação de que o cancelamento foi processado)",
+  '→ status_cancelamento: "ameacou" — cliente pediu para cancelar, mas nada na conversa confirma que',
+  "  o cancelamento foi de fato efetivado.",
+  '→ motivo_cancelamento: "Insatisfação com o serviço."',
+  "",
+  "Exemplo 5 — cancelamento confirmado como concluído dentro da própria conversa",
+  '(status_cancelamento = "cancelou" — só use este valor quando houver confirmação explícita):',
+  'Cliente: "Vou me mudar de cidade, preciso cancelar meu plano."',
+  'Atendente: "Sem problemas, já processei o cancelamento do seu contrato, sem multa."',
+  'Cliente: "Obrigado."',
+  '→ status_cancelamento: "cancelou" — o atendente confirma, na própria conversa, que o cancelamento',
+  "  foi processado.",
+  '→ motivo_cancelamento: "Mudança de cidade."',
+  "",
   "Campos:",
   '- resolvido: "sim" se o problema do cliente foi claramente resolvido na conversa; "parcial" se houve',
   '  encaminhamento/paliativo; "nao" se terminou sem solução; "indefinido" se não dá para concluir.',
@@ -78,6 +97,15 @@ const AVALIACAO_SYSTEM_PROMPT = [
   '- horario_visita_informado: "sim" se o atendente informou data/horário (mesmo aproximado) da visita;',
   '  "nao" se agendou visita sem informar quando; "nao_se_aplica" se não houve agendamento de visita.',
   "- horario_visita: a data/horário informado, copiado da conversa (ou null).",
+  '- status_cancelamento: "nao" (padrão) quando não há NENHUMA menção a cancelar o serviço/contrato',
+  '  na conversa; "ameacou" sempre que o cliente pede, menciona ou ameaça cancelar em QUALQUER momento',
+  '  da conversa — mesmo que a conversa não confirme se o cancelamento chegou a ser concluído (este é',
+  '  o valor mais comum quando há menção a cancelamento; na dúvida entre "ameacou" e "nao", prefira',
+  '  "ameacou"); "cancelou" apenas quando a própria conversa confirma que o cancelamento foi de fato',
+  '  processado/efetivado; "indefinido" só se não der para saber se houve menção a cancelamento.',
+  "  Não marque \"ameacou\" para reclamações genéricas que não mencionam cancelar o serviço/contrato.",
+  '- motivo_cancelamento: o motivo relatado pelo cliente para cancelar/ameaçar cancelar, resumido em',
+  '  1 frase copiada/parafraseada da conversa (ou null quando status_cancelamento for "nao").',
   "",
   "Responda somente com o JSON pedido, em português brasileiro."
 ].join("\n");
@@ -95,7 +123,9 @@ const AVALIACAO_JSON_SCHEMA = {
     atendentes: { type: "array", items: { type: "string" } },
     visita_agendada: { type: "boolean" },
     horario_visita_informado: { type: "string", enum: ["sim", "nao", "nao_se_aplica"] },
-    horario_visita: { type: ["string", "null"] }
+    horario_visita: { type: ["string", "null"] },
+    status_cancelamento: { type: "string", enum: ["nao", "ameacou", "cancelou", "indefinido"] },
+    motivo_cancelamento: { type: ["string", "null"] }
   },
   required: [
     "justificativa_score",
@@ -107,7 +137,9 @@ const AVALIACAO_JSON_SCHEMA = {
     "atendentes",
     "visita_agendada",
     "horario_visita_informado",
-    "horario_visita"
+    "horario_visita",
+    "status_cancelamento",
+    "motivo_cancelamento"
   ]
 };
 
@@ -171,6 +203,11 @@ function parseAvaliacao(content) {
     ? parsed.horario_visita.trim().slice(0, 150)
     : null;
 
+  const statusCancelamento = normalizeEnum(parsed.status_cancelamento, ["nao", "ameacou", "cancelou", "indefinido"], "nao");
+  const motivoCancelamento = statusCancelamento !== "nao" && typeof parsed.motivo_cancelamento === "string" && parsed.motivo_cancelamento.trim()
+    ? parsed.motivo_cancelamento.trim().slice(0, 500)
+    : null;
+
   return aplicarPisoScore({
     ScoreAtendente: normalizeScore(parsed.score_atendente),
     JustificativaScore: typeof parsed.justificativa_score === "string" && parsed.justificativa_score.trim()
@@ -183,7 +220,9 @@ function parseAvaliacao(content) {
     Atendentes: normalizeStringArray(parsed.atendentes, { maxItems: 10, maxLength: 100 }),
     VisitaAgendada: Boolean(parsed.visita_agendada),
     HorarioVisitaInformado: horarioInformado,
-    HorarioVisita: horarioInformado === "sim" ? horarioVisita : null
+    HorarioVisita: horarioInformado === "sim" ? horarioVisita : null,
+    StatusCancelamento: statusCancelamento,
+    MotivoCancelamento: motivoCancelamento
   });
 }
 
@@ -232,6 +271,8 @@ export async function avaliarAtendimento(atendimentoId) {
       VisitaAgendada: null,
       HorarioVisitaInformado: null,
       HorarioVisita: null,
+      StatusCancelamento: "nao",
+      MotivoCancelamento: null,
       Modelo: null,
       UltimaMensagemId: ultimaMensagemId,
       TotalMensagens: mensagens.length
@@ -348,37 +389,36 @@ function parseDataBusca(termo) {
   return null;
 }
 
-export async function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvido, sentimento, scoreMax, busca } = {}) {
+export async function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvido, sentimento, statusCancelamento, scoreMax, busca, ordenacao } = {}) {
+  // ordena por Abertura (data do atendimento, é o que aparece no card) e não por
+  // UpdatedAt (data da avaliação da IA) — os dois divergem e ordenar pelo errado
+  // deixa a lista com datas aparentemente fora de ordem
   const query = AtendimentoAvaliacao.query()
     .where("Avaliavel", true)
     .page(page - 1, pageSize)
-    .orderBy("atendimento_avaliacoes.UpdatedAt", "desc");
+    .join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId")
+    .select("atendimento_avaliacoes.*")
+    .orderBy("a.Abertura", ordenacao === "asc" ? "asc" : "desc");
 
   if (resolvido) query.where("Resolvido", resolvido);
   if (sentimento) query.where("Sentimento", sentimento);
+  if (statusCancelamento) query.where("StatusCancelamento", statusCancelamento);
   if (scoreMax !== undefined) query.where("ScoreAtendente", "<=", scoreMax);
+  if (setor) query.where("a.Setor", setor);
 
   const termo = typeof busca === "string" ? busca.trim() : "";
 
-  if (setor || termo) {
-    query
-      .join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId")
-      .select("atendimento_avaliacoes.*");
-
-    if (setor) query.where("a.Setor", setor);
-
-    if (termo) {
-      const like = `%${termo.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
-      const data = parseDataBusca(termo);
+  if (termo) {
+    const like = `%${termo.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
+    const data = parseDataBusca(termo);
 
-      query.leftJoin("atendimento_clientes as c", "c.Id", "a.ClienteId").where((qb) => {
-        qb.where("a.Codigo", "like", like)
-          .orWhere("c.Nome", "like", like)
-          // Atendentes é JSON text (["Luiz","Carla"]) — LIKE cobre busca por nome
-          .orWhere("atendimento_avaliacoes.Atendentes", "like", like);
-        if (data) qb.orWhereRaw("DATE(a.Abertura) = ?", [data]);
-      });
-    }
+    query.leftJoin("atendimento_clientes as c", "c.Id", "a.ClienteId").where((qb) => {
+      qb.where("a.Codigo", "like", like)
+        .orWhere("c.Nome", "like", like)
+        // Atendentes é JSON text (["Luiz","Carla"]) — LIKE cobre busca por nome
+        .orWhere("atendimento_avaliacoes.Atendentes", "like", like);
+      if (data) qb.orWhereRaw("DATE(a.Abertura) = ?", [data]);
+    });
   }
 
   const { results, total } = await query;
@@ -428,11 +468,12 @@ export async function estatisticasAvaliacoes({ setor } = {}) {
     return q;
   };
 
-  const [scoreRow, avaliados, porResolvido, porSentimento, visitas, pendentes, totalAtendimentos] = await Promise.all([
+  const [scoreRow, avaliados, porResolvido, porSentimento, porCancelamento, visitas, pendentes, totalAtendimentos] = await Promise.all([
     base().avg("ScoreAtendente as media").whereNotNull("ScoreAtendente").first(),
     base().resultSize(),
     base().select("Resolvido").count("* as total").groupBy("Resolvido"),
     base().select("Sentimento").count("* as total").groupBy("Sentimento"),
+    base().select("StatusCancelamento").count("* as total").groupBy("StatusCancelamento"),
     base()
       .where("VisitaAgendada", true)
       .select("HorarioVisitaInformado")
@@ -450,6 +491,7 @@ export async function estatisticasAvaliacoes({ setor } = {}) {
     avaliados: Number(avaliados ?? 0),
     resolvido: toMap(porResolvido, "Resolvido"),
     sentimento: toMap(porSentimento, "Sentimento"),
+    cancelamento: toMap(porCancelamento, "StatusCancelamento"),
     visitasAgendadas: toMap(visitas, "HorarioVisitaInformado"),
     pendentes,
     totalAtendimentos

+ 88 - 4
src/services/whatsappService.js

@@ -1,12 +1,63 @@
 import path from "node:path";
 import fs from "node:fs";
-import { makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion } from "baileys";
+import { randomUUID } from "node:crypto";
+import { makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, downloadMediaMessage } from "baileys";
 import qrcode from "qrcode";
 import { WhatsappConversa } from "../models/WhatsappConversa.model.js";
 import { WhatsappMensagem } from "../models/WhatsappMensagem.model.js";
 import { BadRequestError, NotFoundError } from "../shared/errors/index.js";
 
 const SESSION_DIR = path.resolve("data/baileys-session");
+const MEDIA_DIR = path.resolve("data/whatsapp-media");
+
+const CAMPOS_MIDIA = {
+  imageMessage: "imagem",
+  videoMessage: "video",
+  audioMessage: "audio",
+  stickerMessage: "sticker",
+  documentMessage: "documento"
+};
+
+const EXTENSOES_POR_MIMETYPE = {
+  "image/jpeg": ".jpg",
+  "image/png": ".png",
+  "image/webp": ".webp",
+  "image/gif": ".gif",
+  "video/mp4": ".mp4",
+  "video/3gpp": ".3gp",
+  "audio/ogg": ".ogg",
+  "audio/mpeg": ".mp3",
+  "audio/mp4": ".m4a"
+};
+
+function extensaoPorMimetype(mimetype) {
+  if (!mimetype) return "";
+  const base = mimetype.split(";")[0].trim();
+  return EXTENSOES_POR_MIMETYPE[base] ?? "";
+}
+
+function detectarMidia(msgContent) {
+  for (const [campo, tipo] of Object.entries(CAMPOS_MIDIA)) {
+    const parte = msgContent?.[campo];
+    if (parte) {
+      return {
+        tipo,
+        mimetype: parte.mimetype ?? null,
+        caption: typeof parte.caption === "string" ? parte.caption.trim() : "",
+        nomeArquivo: parte.fileName ?? null
+      };
+    }
+  }
+  return null;
+}
+
+async function baixarMidia(m, midiaInfo) {
+  const buffer = await downloadMediaMessage(m, "buffer", {}, { reuploadRequest: sock?.updateMediaMessage });
+  fs.mkdirSync(MEDIA_DIR, { recursive: true });
+  const nomeArquivo = `${randomUUID()}${extensaoPorMimetype(midiaInfo.mimetype)}`;
+  fs.writeFileSync(path.join(MEDIA_DIR, nomeArquivo), buffer);
+  return nomeArquivo;
+}
 
 let sock = null;
 let estado = { status: "desconectado", qrDataUrl: null, telefone: null, ultimoErro: null };
@@ -97,9 +148,33 @@ export async function iniciarConexao() {
       // quando o WhatsApp informa senderPn, ele traz o JID baseado no número de fato — preferir esse
       const jid = m.key.senderPn || m.key.remoteJid;
       const telefone = normalizarTelefone(jid);
-      const texto = m.message.conversation ?? m.message.extendedTextMessage?.text ?? `[mensagem do tipo não suportada]`;
+
+      let texto = m.message.conversation ?? m.message.extendedTextMessage?.text ?? null;
+      let midiaCampos = { TipoMidia: null, MidiaPath: null, MidiaMimetype: null, MidiaNomeArquivo: null };
+
+      if (texto === null) {
+        const midiaInfo = detectarMidia(m.message);
+        if (midiaInfo) {
+          try {
+            const nomeArquivo = await baixarMidia(m, midiaInfo);
+            midiaCampos = {
+              TipoMidia: midiaInfo.tipo,
+              MidiaPath: nomeArquivo,
+              MidiaMimetype: midiaInfo.mimetype,
+              MidiaNomeArquivo: midiaInfo.nomeArquivo
+            };
+            texto = midiaInfo.caption ?? "";
+          } catch (err) {
+            console.error("[whatsapp] falha ao baixar mídia:", err.message);
+            texto = `[mensagem do tipo não suportada]`;
+          }
+        } else {
+          texto = `[mensagem do tipo não suportada]`;
+        }
+      }
+
       const timestamp = m.messageTimestamp ? new Date(Number(m.messageTimestamp) * 1000) : new Date();
-      console.log(`[whatsapp] gravando mensagem de ${telefone}: "${texto}"`);
+      console.log(`[whatsapp] gravando mensagem de ${telefone}: "${texto}"${midiaCampos.TipoMidia ? ` (mídia: ${midiaCampos.TipoMidia})` : ""}`);
       const conversaId = await obterOuCriarConversa(telefone, m.pushName, jid);
 
       // o Baileys pode reentregar a mesma mensagem; key.id é único e o insert é ignorado nesse caso
@@ -109,7 +184,8 @@ export async function iniciarConexao() {
           Direcao: "entrada",
           Corpo: texto,
           WhatsappMessageId: m.key.id ?? null,
-          Timestamp: timestamp
+          Timestamp: timestamp,
+          ...midiaCampos
         })
         .onConflict("WhatsappMessageId")
         .ignore();
@@ -183,6 +259,14 @@ export async function responderConversa(conversaId, texto) {
   return mensagem;
 }
 
+export async function obterCaminhoMidia(conversaId, mensagemId) {
+  const mensagem = await WhatsappMensagem.query().findById(mensagemId);
+  if (!mensagem || String(mensagem.ConversaId) !== String(conversaId) || !mensagem.MidiaPath) {
+    throw new NotFoundError("midia_not_found");
+  }
+  return { caminho: path.join(MEDIA_DIR, mensagem.MidiaPath), mimetype: mensagem.MidiaMimetype };
+}
+
 export async function excluirConversa(conversaId) {
   const conversa = await WhatsappConversa.query().findById(conversaId);
   if (!conversa) throw new NotFoundError("conversa_not_found");