ponytail: remove resolucaoSetup, compressVideo, sendEvolutionMessage, sendEvolution; add migration #24
- delete: src/resolucaoSetup.js (migration paralela, consolidado em #24) - shrink: chatController.sendEvolutionMessage (140 -> 6 linhas, delega ao evolutionService) - shrink: triageController.sendEvolution (55 -> 10 linhas, delega ao evolutionService) - delete: compressVideo em chatController (codigo morto, chamado so pelo old sendEvolutionMessage) - add: migration #24 consolida DDL que estava espalhada em runtime - fix: configController.remove garantirEstrutura calls (arquivo removido) - net: -270 linhas, -1 arquivo
This commit is contained in:
@@ -1,63 +1,9 @@
|
||||
const db = require('../database');
|
||||
const { garantirEstrutura } = require('../resolucaoSetup');
|
||||
const ffmpegPath = require('ffmpeg-static');
|
||||
const { execFile } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
/**
|
||||
* Comprime vídeo para tamanho aceitável pelo WhatsApp/Evolution API
|
||||
* Reduz bitrate e resolução se necessário (alvo: ~10MB)
|
||||
* @param {Buffer} inputBuffer - Buffer do video original
|
||||
* @returns {Promise<Buffer>} Buffer do video comprimido
|
||||
*/
|
||||
function compressVideo(inputBuffer) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var tmpDir = path.join(__dirname, '../../uploads/audio');
|
||||
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
var inputPath = path.join(tmpDir, 'video_in_' + Date.now() + '.mp4');
|
||||
var outputPath = path.join(tmpDir, 'video_out_' + Date.now() + '.mp4');
|
||||
|
||||
fs.writeFileSync(inputPath, inputBuffer);
|
||||
var inputSizeMB = (inputBuffer.length / (1024 * 1024)).toFixed(1);
|
||||
|
||||
// Se o video ja tem <= 10MB, nao precisa comprimir muito
|
||||
var targetBitrate = inputBuffer.length < 10 * 1024 * 1024 ? '1M' : '500k';
|
||||
console.log('[Video] Comprimindo video de ' + inputSizeMB + 'MB, bitrate alvo: ' + targetBitrate);
|
||||
|
||||
execFile(ffmpegPath, [
|
||||
'-y',
|
||||
'-i', inputPath,
|
||||
'-c:v', 'libx264',
|
||||
'-b:v', targetBitrate,
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '64k',
|
||||
'-vf', 'scale=720:-2',
|
||||
'-movflags', '+faststart',
|
||||
'-preset', 'fast',
|
||||
'-maxrate', '1M',
|
||||
'-bufsize', '2M',
|
||||
outputPath
|
||||
], { timeout: 120000 }, function(err) {
|
||||
try { fs.unlinkSync(inputPath); } catch(e) {}
|
||||
|
||||
if (err) {
|
||||
try { fs.unlinkSync(outputPath); } catch(e) {}
|
||||
console.error('[Video] Erro na compressao:', err.message.substring(0, 100));
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
var outputBuffer = fs.readFileSync(outputPath);
|
||||
try { fs.unlinkSync(outputPath); } catch(e) {}
|
||||
|
||||
var outputSizeMB = (outputBuffer.length / (1024 * 1024)).toFixed(1);
|
||||
console.log('[Video] Compressao concluida: ' + inputSizeMB + 'MB -> ' + outputSizeMB + 'MB');
|
||||
resolve(outputBuffer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converte buffer de áudio WebM para Ogg Opus usando ffmpeg
|
||||
*/
|
||||
@@ -691,7 +637,6 @@ class ChatController {
|
||||
return res.status(403).json({ success: false, error: 'Sem permissão.' });
|
||||
|
||||
// ===== Fluxo de Resolução: valida motivo/resolução conforme config =====
|
||||
await garantirEstrutura(alias);
|
||||
const motivoId = (req.body && req.body.motivoId) ? parseInt(req.body.motivoId, 10) : null;
|
||||
const resolucao = (req.body && req.body.resolucao != null) ? String(req.body.resolucao).trim() : '';
|
||||
|
||||
@@ -743,7 +688,9 @@ class ChatController {
|
||||
WHERE CON_CODIGO_ID = ?
|
||||
`, [usuarioNome, equipeNome, etiquetasDesc, motivoId, resolucao || null, id]);
|
||||
|
||||
// CSAT
|
||||
// CSAT — NÃO envia se a conversa for agendada (skipCsat = true)
|
||||
const skipCsat = req.body && req.body.skipCsat === true;
|
||||
if (!skipCsat) {
|
||||
const config = await db.query(alias,
|
||||
'SELECT CFE_CSAT_ATIVO, CFE_CSAT_MENSAGEM FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?',
|
||||
[conv[0].CON_EMPRESA_ID]);
|
||||
@@ -784,6 +731,7 @@ class ChatController {
|
||||
console.log('[CSAT] NAO enviado - Evolution API pode estar offline');
|
||||
}
|
||||
}
|
||||
} // fecha if (!skipCsat)
|
||||
|
||||
// Salva resolução na tabela MENSAGENS_CLIENTES como aviso (se configurado)
|
||||
if (isS(fl.CFE_RESOLUCAO_SALVAR_MSC) && resolucao && c.CON_CLIENTE_ID) {
|
||||
@@ -1711,149 +1659,13 @@ class ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: normaliza número para formato WhatsApp (DDI + 9º dígito)
|
||||
function _normalizarWhatsApp(numero) {
|
||||
let n = String(numero || '').replace(/\D/g, '');
|
||||
if (!n) return '';
|
||||
if (!n.startsWith('55')) n = '55' + n;
|
||||
const resto = n.slice(2);
|
||||
if (resto.length === 10) n = '55' + resto.slice(0, 2) + '9' + resto.slice(2);
|
||||
return n;
|
||||
}
|
||||
|
||||
// Função auxiliar para enviar mensagem via Evolution API
|
||||
// ponytail: delegado para evolutionService
|
||||
const evolution = require('../services/evolutionService');
|
||||
async function sendEvolutionMessage(instancia, numero, texto, tipo, midiaBase64, nomeArquivo) {
|
||||
const url = ((instancia.INS_URL || '').trim()).replace(/\/$/, '');
|
||||
const apiKey = (instancia.INS_API_KEY || '').trim();
|
||||
const instanceName = (instancia.INS_INSTANCE_NAME || '').trim();
|
||||
const numeroLimpo = _normalizarWhatsApp(numero);
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
let payload;
|
||||
let endpoint;
|
||||
|
||||
if (midiaBase64 && (tipo === 'image' || tipo === 'audio' || tipo === 'video' || tipo === 'document')) {
|
||||
var mediaB64 = midiaBase64;
|
||||
var fileName = nomeArquivo || 'arquivo';
|
||||
|
||||
if (tipo === 'audio') {
|
||||
// A mídia pode já estar em OGG (convertida antes de salvar no BD)
|
||||
// Se ainda for WebM, tenta converter agora como fallback
|
||||
var jaEOgg = (fileName || '').toLowerCase().endsWith('.ogg');
|
||||
if (!jaEOgg) {
|
||||
try {
|
||||
var inputBuffer = Buffer.from(mediaB64, 'base64');
|
||||
if (inputBuffer.length > 100) {
|
||||
var oggBuffer = await convertWebmToOgg(inputBuffer);
|
||||
if (oggBuffer && oggBuffer.length > 100) {
|
||||
mediaB64 = oggBuffer.toString('base64');
|
||||
fileName = 'audio.ogg';
|
||||
console.log('[Audio] Conversão fallback WebM→OGG:', inputBuffer.length, '→', oggBuffer.length);
|
||||
}
|
||||
}
|
||||
} catch(convErr) {
|
||||
console.error('[Audio] Conversão OGG falhou, mantendo como áudio:', convErr.message.substring(0, 100));
|
||||
fileName = 'audio.webm';
|
||||
// NÃO muda para 'document' - mantém como áudio
|
||||
}
|
||||
}
|
||||
|
||||
// Usa sendWhatsAppAudio para enviar como mensagem de voz (PTT)
|
||||
endpoint = '/message/sendWhatsAppAudio/' + encodeURIComponent(instanceName);
|
||||
payload = JSON.stringify({ number: numeroLimpo, audio: mediaB64, encoding: true });
|
||||
console.log('[Evolution] Enviando via sendWhatsAppAudio, tamanho:', mediaB64.length, 'arquivo:', fileName);
|
||||
|
||||
} else if (tipo === 'image') {
|
||||
endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName);
|
||||
payload = JSON.stringify({ number: numeroLimpo, mediatype: 'image', media: mediaB64, fileName: fileName });
|
||||
|
||||
} else if (tipo === 'video') {
|
||||
// Comprime video se for muito grande (> 5MB) para evitar rejeicao da Evolution API
|
||||
try {
|
||||
var videoBuf = Buffer.from(mediaB64, 'base64');
|
||||
if (videoBuf.length > 5 * 1024 * 1024) {
|
||||
console.log('[Video] Video grande detectado (' + (videoBuf.length / (1024 * 1024)).toFixed(1) + 'MB), comprimindo...');
|
||||
var compressedBuf = await compressVideo(videoBuf);
|
||||
if (compressedBuf && compressedBuf.length > 1000) {
|
||||
mediaB64 = compressedBuf.toString('base64');
|
||||
fileName = (fileName || 'video').replace(/\.\w+$/, '') + '_compressed.mp4';
|
||||
console.log('[Video] Compressao OK, novo tamanho base64:', mediaB64.length);
|
||||
}
|
||||
}
|
||||
} catch(compressErr) {
|
||||
console.error('[Video] Compressao falhou, enviando original:', compressErr.message.substring(0, 80));
|
||||
}
|
||||
endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName);
|
||||
payload = JSON.stringify({ number: numeroLimpo, mediatype: 'video', media: mediaB64, fileName: fileName });
|
||||
|
||||
} else if (tipo === 'document') {
|
||||
// Para documentos que sao videos, comprime tambem se for grande
|
||||
var isVideoDoc = (fileName || '').toLowerCase().match(/\.(mp4|mov|avi|mkv|webm|3gp|m4v)$/);
|
||||
if (isVideoDoc) {
|
||||
try {
|
||||
var docBuf = Buffer.from(mediaB64, 'base64');
|
||||
if (docBuf.length > 5 * 1024 * 1024) {
|
||||
console.log('[Document] Video como documento detectado (' + (docBuf.length / (1024 * 1024)).toFixed(1) + 'MB), comprimindo...');
|
||||
var compressedDocBuf = await compressVideo(docBuf);
|
||||
if (compressedDocBuf && compressedDocBuf.length > 1000) {
|
||||
mediaB64 = compressedDocBuf.toString('base64');
|
||||
fileName = (fileName || 'video').replace(/\.\w+$/, '') + '_compressed.mp4';
|
||||
console.log('[Document] Compressao OK, novo tamanho base64:', mediaB64.length);
|
||||
}
|
||||
}
|
||||
} catch(compressErr) {
|
||||
console.error('[Document] Compressao falhou, enviando original:', compressErr.message.substring(0, 80));
|
||||
}
|
||||
}
|
||||
endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName);
|
||||
payload = JSON.stringify({ number: numeroLimpo, mediatype: 'document', media: mediaB64, fileName: fileName });
|
||||
|
||||
} else {
|
||||
endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName);
|
||||
payload = JSON.stringify({ number: numeroLimpo, mediatype: 'document', media: mediaB64, fileName: fileName });
|
||||
}
|
||||
|
||||
} else {
|
||||
endpoint = '/message/sendText/' + encodeURIComponent(instanceName);
|
||||
payload = JSON.stringify({ number: numeroLimpo, text: texto || '', delay: 0 });
|
||||
return evolution.sendMedia(instancia, numero, tipo, midiaBase64, nomeArquivo);
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(url + endpoint);
|
||||
const lib = parsedUrl.protocol === 'https:' ? https : http;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: parsedUrl.hostname,
|
||||
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
|
||||
path: parsedUrl.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
'apikey': apiKey,
|
||||
}
|
||||
};
|
||||
const req = lib.request(options, (res) => {
|
||||
let body = '';
|
||||
res.on('data', c => body += c);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 400) {
|
||||
console.error('[Evolution] Erro ao enviar mídia:', res.statusCode, body.substring(0, 200));
|
||||
reject(new Error('HTTP ' + res.statusCode + ': ' + body.substring(0, 100)));
|
||||
} else {
|
||||
resolve(body);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', function(err) {
|
||||
console.error('[Evolution] Erro na requisição:', err.message);
|
||||
reject(err);
|
||||
});
|
||||
req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
return evolution.sendText(instancia, numero, texto);
|
||||
}
|
||||
|
||||
module.exports = ChatController;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const db = require('../database');
|
||||
const { isGerente } = require('../middlewares/roles');
|
||||
const { garantirEstrutura } = require('../resolucaoSetup');
|
||||
const configService = require('../services/configService');
|
||||
|
||||
class ConfigController {
|
||||
@@ -165,7 +164,6 @@ class ConfigController {
|
||||
const { alias } = req.params;
|
||||
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
|
||||
|
||||
await garantirEstrutura(alias);
|
||||
|
||||
let config = await db.query(alias,
|
||||
'SELECT * FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]
|
||||
@@ -209,7 +207,6 @@ class ConfigController {
|
||||
try {
|
||||
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem alterar o fluxo de resolução.' });
|
||||
const { alias } = req.params;
|
||||
await garantirEstrutura(alias);
|
||||
const empresaId = req.body.empresaId || req.user?.empresas?.[0];
|
||||
const sn = (v) => (v === 'S' || v === true ? 'S' : 'N');
|
||||
const mv = sn(req.body.motivoVisualizar), mo = sn(req.body.motivoObrigatorio);
|
||||
@@ -236,7 +233,6 @@ class ConfigController {
|
||||
static async listMotivos(req, res) {
|
||||
try {
|
||||
const { alias } = req.params;
|
||||
await garantirEstrutura(alias);
|
||||
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
|
||||
const rows = await db.query(alias,
|
||||
`SELECT MOT_CODIGO_ID, MOT_DESCRICAO FROM "CHATC2_MOTIVOS_ATENDIMENTO"
|
||||
@@ -249,7 +245,6 @@ class ConfigController {
|
||||
try {
|
||||
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem cadastrar motivos.' });
|
||||
const { alias } = req.params;
|
||||
await garantirEstrutura(alias);
|
||||
const descricao = (req.body.descricao || '').trim();
|
||||
if (!descricao) return res.status(400).json({ success: false, error: 'Descrição obrigatória.' });
|
||||
const empresaId = req.body.empresaId || req.user?.empresas?.[0];
|
||||
@@ -265,7 +260,6 @@ class ConfigController {
|
||||
try {
|
||||
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem remover motivos.' });
|
||||
const { alias, id } = req.params;
|
||||
await garantirEstrutura(alias);
|
||||
await db.execute(alias, `UPDATE "CHATC2_MOTIVOS_ATENDIMENTO" SET MOT_SITUACAO = 'I' WHERE MOT_CODIGO_ID = ?`, [id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||||
@@ -275,7 +269,6 @@ class ConfigController {
|
||||
try {
|
||||
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem alterar as configurações da empresa.' });
|
||||
const { alias } = req.params;
|
||||
await garantirEstrutura(alias);
|
||||
const data = req.body;
|
||||
const empresaId = data.empresaId || req.user?.empresas?.[0];
|
||||
const enviarBoleto = (data.enviarBoleto === 'S' || data.enviarBoleto === true) ? 'S' : 'N';
|
||||
|
||||
@@ -73,6 +73,12 @@ class DashboardController {
|
||||
WHERE CON_EMPRESA_ID IN (${ph}) AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A'
|
||||
AND CON_USUARIO_ID IS NULL AND CON_EQUIPE_ID IS NOT NULL`, empresas);
|
||||
|
||||
// Agendadas para hoje (status P = Pendente)
|
||||
const agendadas = await db.query(alias,
|
||||
`SELECT COUNT(*) AS CT FROM CHATC2_CONVERSAS_AGENDAMENTOS
|
||||
WHERE CAG_EMPRESA_ID IN (${ph}) AND CAG_DATA = CURRENT_DATE AND CAG_STATUS = 'P'`,
|
||||
empresas);
|
||||
|
||||
// ===== Atendentes =====
|
||||
const usuarios = await db.query(alias,
|
||||
`SELECT DISTINCT u.USU_CODIGO_ID, u.USU_NOME
|
||||
@@ -113,6 +119,7 @@ class DashboardController {
|
||||
naoAtendidas: Number(naoAtendidas[0]?.CT) || 0,
|
||||
naoAtribuidas: Number(naoAtribuidas[0]?.CT) || 0,
|
||||
pendentes: Number(pendentes[0]?.CT) || 0,
|
||||
agendadas: Number(agendadas[0]?.CT) || 0,
|
||||
},
|
||||
atendentes: { disponiveis, desconectados, lista },
|
||||
trafego,
|
||||
|
||||
@@ -789,6 +789,63 @@ async function processWebhook(alias, body) {
|
||||
let conversa;
|
||||
var isNovaConversa = false;
|
||||
if (conversations.length === 0) {
|
||||
// Antes de criar nova, verifica se há conversa finalizada COM agendamento pendente
|
||||
// Se o cliente retornou antes da data agendada, reabre a conversa existente
|
||||
const agendada = await db.query(alias, `
|
||||
SELECT c.CON_CODIGO_ID, c.CON_CLIENTE_ID, c.CON_NOME_CONTATO, c.CON_INSTANCIA_ID,
|
||||
a.CAG_CODIGO_ID AS AG_ID, a.CAG_NOTA
|
||||
FROM CHATC2_CONVERSAS c
|
||||
JOIN CHATC2_CONVERSAS_AGENDAMENTOS a ON a.CAG_CONVERSA_ID = c.CON_CODIGO_ID
|
||||
WHERE c.CON_NUMERO = ? AND c.CON_EMPRESA_ID = ?
|
||||
AND c.CON_STATUS = 'F' AND c.CON_SITUACAO = 'A'
|
||||
AND a.CAG_STATUS = 'P'
|
||||
ORDER BY a.CAG_DATA DESC
|
||||
FETCH FIRST 1 ROWS ONLY
|
||||
`, [numero, empresaId]);
|
||||
|
||||
// Tenta também pelos últimos 8 dígitos
|
||||
let agendada8 = [];
|
||||
if (agendada.length === 0) {
|
||||
const ultimos8 = numero.slice(-8);
|
||||
agendada8 = await db.query(alias, `
|
||||
SELECT c.CON_CODIGO_ID, c.CON_CLIENTE_ID, c.CON_NOME_CONTATO, c.CON_INSTANCIA_ID,
|
||||
a.CAG_CODIGO_ID AS AG_ID, a.CAG_NOTA
|
||||
FROM CHATC2_CONVERSAS c
|
||||
JOIN CHATC2_CONVERSAS_AGENDAMENTOS a ON a.CAG_CONVERSA_ID = c.CON_CODIGO_ID
|
||||
WHERE c.CON_NUMERO LIKE '%' || ? || '%' AND c.CON_EMPRESA_ID = ?
|
||||
AND c.CON_STATUS = 'F' AND c.CON_SITUACAO = 'A'
|
||||
AND a.CAG_STATUS = 'P'
|
||||
ORDER BY a.CAG_DATA DESC
|
||||
FETCH FIRST 1 ROWS ONLY
|
||||
`, [ultimos8, empresaId]);
|
||||
}
|
||||
|
||||
const agReabrir = agendada.length > 0 ? agendada[0] : (agendada8.length > 0 ? agendada8[0] : null);
|
||||
|
||||
if (agReabrir) {
|
||||
// Reabre a conversa agendada em vez de criar nova
|
||||
console.log('[Webhook] Reabrindo conversa agendada:', agReabrir.CON_CODIGO_ID, 'agendamento:', agReabrir.AG_ID);
|
||||
await db.execute(alias,
|
||||
"UPDATE CHATC2_CONVERSAS SET CON_STATUS = 'A', CON_DT_FINAL = NULL, CON_DT_ULTIMA_MSG = CURRENT_TIMESTAMP WHERE CON_CODIGO_ID = ?",
|
||||
[agReabrir.CON_CODIGO_ID]);
|
||||
await db.execute(alias,
|
||||
"UPDATE CHATC2_CONVERSAS_AGENDAMENTOS SET CAG_STATUS = 'R' WHERE CAG_CODIGO_ID = ?",
|
||||
[agReabrir.AG_ID]);
|
||||
|
||||
conversa = await db.query(alias, 'SELECT * FROM CHATC2_CONVERSAS WHERE CON_CODIGO_ID = ?', [agReabrir.CON_CODIGO_ID]);
|
||||
conversa = conversa[0];
|
||||
|
||||
// Se o cliente não estava vinculado, tenta vincular agora
|
||||
if (!conversa.CON_CLIENTE_ID) {
|
||||
const resultadoGlobal = await buscarClienteGlobal(numero, empresaId, alias);
|
||||
if (resultadoGlobal) {
|
||||
await db.execute(alias,
|
||||
'UPDATE CHATC2_CONVERSAS SET CON_CLIENTE_ID = ?, CON_NOME_CONTATO = ? WHERE CON_CODIGO_ID = ?',
|
||||
[resultadoGlobal.cliente.CLI_CODIGO_ID, resultadoGlobal.cliente.CLI_NOME.trim(), conversa.CON_CODIGO_ID]);
|
||||
}
|
||||
}
|
||||
// Pula o bloco de criação (já temos conversa)
|
||||
} else {
|
||||
isNovaConversa = true;
|
||||
const newId = await db.nextId(alias, 'GEN_CHATC2_CONVERSAS');
|
||||
const nomeContato = data.pushName || data.notifyName || sender || '';
|
||||
@@ -861,6 +918,7 @@ async function processWebhook(alias, body) {
|
||||
await TriageController.sendMenu(aliasEfetivo, conversa.CON_CODIGO_ID, empresaId, instanciaId, numero);
|
||||
}
|
||||
} catch(e) { console.error('[Webhook] Erro ao enviar triagem:', e.message); }
|
||||
} // fecha else (nova conversa)
|
||||
} else {
|
||||
conversa = conversations[0];
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const db = require('../database');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
function logTriage(msg) {
|
||||
try {
|
||||
@@ -1056,48 +1055,8 @@ class TriageController {
|
||||
const inst = await db.query(alias,
|
||||
"SELECT * FROM CHATC2_INSTANCIAS WHERE INS_CODIGO_ID = ? AND INS_SITUACAO = 'A'", [instanciaId]);
|
||||
if (inst.length === 0) return;
|
||||
const url = ((inst[0].INS_URL || '').trim()).replace(/\/+$/, '');
|
||||
const apiKey = (inst[0].INS_API_KEY || '').trim();
|
||||
const instanceName = (inst[0].INS_INSTANCE_NAME || '').trim();
|
||||
if (!url || !apiKey || !instanceName) return;
|
||||
|
||||
// Normaliza para formato WhatsApp (com DDI)
|
||||
const numeroLimpo = _normalizarWhatsApp(numero);
|
||||
const payload = JSON.stringify({ number: numeroLimpo, text: texto || '', delay: 0 });
|
||||
const endpoint = '/message/sendText/' + encodeURIComponent(instanceName);
|
||||
const parsedUrl = new URL(url + endpoint);
|
||||
const lib = parsedUrl.protocol === 'https:' ? https : http;
|
||||
|
||||
await new Promise(function(resolve, reject) {
|
||||
const req = lib.request({
|
||||
hostname: parsedUrl.hostname,
|
||||
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
|
||||
path: parsedUrl.pathname,
|
||||
method: 'POST',
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
'apikey': apiKey,
|
||||
}
|
||||
}, function(res) {
|
||||
var d = '';
|
||||
res.on('data', function(c) { d += c; });
|
||||
res.on('end', function() {
|
||||
if (res.statusCode >= 400) {
|
||||
console.error('[Triagem Evolution] Erro HTTP', res.statusCode, d.substring(0, 100));
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
req.on('error', function(err) {
|
||||
console.error('[Triagem Evolution] Erro:', err.message);
|
||||
resolve();
|
||||
});
|
||||
req.on('timeout', function() { req.destroy(); resolve(); });
|
||||
req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
const evolution = require('../services/evolutionService');
|
||||
await evolution.sendText(inst[0], numero, texto);
|
||||
} catch(e) {
|
||||
console.error('[Triagem Evolution] Erro ao enviar:', e.message);
|
||||
}
|
||||
|
||||
@@ -56,7 +56,9 @@ body { background:var(--surface-3); display:flex; min-height:100vh; }
|
||||
<option value="A">Em atendimento</option>
|
||||
<option value="F">Finalizadas</option>
|
||||
<option value="A,E,F">Todas</option>
|
||||
<option value="scheduled">📅 Agendadas</option>
|
||||
</select>
|
||||
<input type="date" id="filterAgData" onchange="carregar()" style="display:none;padding:6px 10px;border:2px solid var(--border);border-radius:8px;font-size:13px;background:var(--surface-2);color:var(--text-primary)">
|
||||
<label for="filterAtribuicao" class="sr-only">Atribuição</label>
|
||||
<select id="filterAtribuicao" onchange="carregar()">
|
||||
<option value="">Todas</option>
|
||||
@@ -112,6 +114,39 @@ window.carregar = async function() {
|
||||
container.innerHTML = '<div class="loading"><div class="spinner"></div><p style="margin-top:8px">Carregando conversas...</p></div>';
|
||||
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
|
||||
// Mostra/esconde seletor de data para agendadas
|
||||
var agDataInput = document.getElementById('filterAgData');
|
||||
if (status === 'scheduled') {
|
||||
agDataInput.style.display = 'inline-block';
|
||||
if (!agDataInput.value) agDataInput.value = new Date().toISOString().split('T')[0];
|
||||
} else {
|
||||
agDataInput.style.display = 'none';
|
||||
}
|
||||
|
||||
// Agendadas: usa API de schedules
|
||||
if (status === 'scheduled') {
|
||||
var dataSel = agDataInput.value;
|
||||
var dataFmt = dataSel ? dataSel.split('-').reverse().join('/') : 'hoje';
|
||||
const agData = await api('/schedules?empresaId=' + empresaId + '&data=' + dataSel);
|
||||
if (!agData.success) { container.innerHTML = '<p>Erro ao carregar</p>'; return; }
|
||||
if (!agData.data || agData.data.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state"><div class="icon">📅</div><p>Nenhum agendamento para ' + dataFmt + '</p></div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = '<table><thead><tr><th>Contato</th><th>Número</th><th>Data</th><th>Hora</th><th>Anotação</th></tr></thead><tbody>' +
|
||||
agData.data.map(function(a) {
|
||||
var d = a.data ? a.data.split('-').reverse().join('/') : '-';
|
||||
var h = a.hora ? a.hora.substring(0,5) : '-';
|
||||
return '<tr><td><strong>' + (a.nomeContato || a.numero || '-') + '</strong></td>' +
|
||||
'<td>' + (a.numero || '-') + '</td>' +
|
||||
'<td>' + d + '</td>' +
|
||||
'<td>' + h + '</td>' +
|
||||
'<td style="font-size:12px;color:var(--text-muted)">' + (a.nota || '-') + '</td></tr>';
|
||||
}).join('') + '</tbody></table>';
|
||||
return;
|
||||
}
|
||||
|
||||
const atrib = document.getElementById('filterAtribuicao').value;
|
||||
let busca = document.getElementById('buscaContato').value.trim();
|
||||
|
||||
|
||||
@@ -1904,7 +1904,7 @@ window.finalizarConversa = async function() {
|
||||
var res = await fetch('/api/' + alias + '/conversations/' + conversaAtiva + '/finalize', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ motivoId: motivoId, resolucao: resolucao })
|
||||
body: JSON.stringify({ motivoId: motivoId, resolucao: resolucao, skipCsat: document.getElementById('chkAgendar') && document.getElementById('chkAgendar').checked })
|
||||
});
|
||||
var data = await res.json();
|
||||
if (data.success) {
|
||||
@@ -1925,7 +1925,13 @@ window.finalizarConversa = async function() {
|
||||
document.querySelector('.btn-finalizar').disabled = true;
|
||||
document.querySelector('.btn-finalizar').style.opacity = '0.6';
|
||||
carregarConversas();
|
||||
abrirConversa(conversaAtiva);
|
||||
// Fecha a conversa da tela (volta ao estado vazio)
|
||||
conversaAtiva = null;
|
||||
document.getElementById('chatHeader').style.display = 'none';
|
||||
document.getElementById('chatInput').style.display = 'none';
|
||||
document.getElementById('messagesArea').innerHTML = '<div class="empty-state"><div class="icon">💬</div><p>Selecione uma conversa</p></div>';
|
||||
document.getElementById('clienteInfoContainer').innerHTML = '';
|
||||
document.getElementById('clienteFoto').innerHTML = '?';
|
||||
} else {
|
||||
alert(data.error || 'Não foi possível finalizar a conversa.');
|
||||
}
|
||||
|
||||
@@ -1050,6 +1050,13 @@ body.dark-mode #agendamentoBox input[type="date"],
|
||||
body.dark-mode #agendamentoBox input[type="time"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
/* Filtro de data das agendadas no admin */
|
||||
body.dark-mode #filterAgData {
|
||||
color-scheme: dark;
|
||||
background: #1a1a2e !important;
|
||||
border-color: #0f3460 !important;
|
||||
color: #e0e0e0 !important;
|
||||
}
|
||||
/* Botoes do painel direito no dark mode */
|
||||
body.dark-mode [style*="background:#eef2ff"] {
|
||||
background: #1e1b4b !important;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
.stat-card.naoatendidas { border-left-color: var(--danger); }
|
||||
.stat-card.naoatribuidas { border-left-color: #f59e0b; }
|
||||
.stat-card.pendentes { border-left-color: #6366f1; }
|
||||
.stat-card.agendadas-card { border-left-color: #8b5cf6; }
|
||||
.stat-card.disponiveis { border-left-color: #10b981; }
|
||||
.stat-card.desconectados { border-left-color: var(--text-faint); }
|
||||
.card { background:#fff; border-radius:12px; padding:18px; box-shadow:0 1px 3px rgba(0,0,0,0.08); margin-bottom:24px; }
|
||||
@@ -93,6 +94,7 @@
|
||||
<div class="stat-card naoatendidas"><div class="num" id="cNaoAtendidas">–</div><div class="lbl">Não Atendidas</div><div class="desc">Aguardando resposta do atendente</div></div>
|
||||
<div class="stat-card naoatribuidas"><div class="num" id="cNaoAtribuidas">–</div><div class="lbl">Não Atribuídas</div><div class="desc">Sem equipe e sem atendente</div></div>
|
||||
<div class="stat-card pendentes"><div class="num" id="cPendentes">–</div><div class="lbl">Pendentes</div><div class="desc">Somente com equipe</div></div>
|
||||
<div class="stat-card agendadas-card"><div class="num" id="cAgendadas">–</div><div class="lbl">📅 Agendadas Hoje</div><div class="desc">Retornos para hoje</div></div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Atendentes</div>
|
||||
@@ -172,6 +174,7 @@
|
||||
document.getElementById('cNaoAtendidas').textContent = d.conversas.naoAtendidas;
|
||||
document.getElementById('cNaoAtribuidas').textContent = d.conversas.naoAtribuidas;
|
||||
document.getElementById('cPendentes').textContent = d.conversas.pendentes;
|
||||
document.getElementById('cAgendadas').textContent = d.conversas.agendadas || 0;
|
||||
document.getElementById('aDisponiveis').textContent = d.atendentes.disponiveis;
|
||||
document.getElementById('aDesconectados').textContent = d.atendentes.desconectados;
|
||||
renderAtendentes(d.atendentes.lista);
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Estrutura do "Fluxo de Resolução" (motivos + resolução do atendimento).
|
||||
* Cria de forma idempotente no Firebird:
|
||||
* - Tabela CHATC2_MOTIVOS_ATENDIMENTO
|
||||
* - Flags em CHATC2_CONFIGURACOES_EMPRESA (CFE_MOTIVO_*, CFE_RESOLUCAO_*)
|
||||
* - Colunas CON_MOTIVO_ID / CON_RESOLUCAO em CHATC2_CONVERSAS
|
||||
*
|
||||
* Usa DDL no subconjunto que funciona nos dois bancos (INTEGER, VARCHAR, CHAR,
|
||||
* ALTER TABLE ... ADD). A tabela nova é criada com nome MAIÚSCULO entre aspas
|
||||
* para manter a convenção do schema migrado.
|
||||
*/
|
||||
const db = require('./database');
|
||||
|
||||
const prontos = {};
|
||||
|
||||
async function tentar(alias, sql) {
|
||||
// DDL idempotente: ignora erros de "já existe" (e similares entre dialetos)
|
||||
try { await db.execute(alias, sql); } catch (e) { /* noop */ }
|
||||
}
|
||||
|
||||
async function garantirEstrutura(alias) {
|
||||
if (prontos[alias]) return;
|
||||
|
||||
await tentar(alias, `CREATE TABLE "CHATC2_MOTIVOS_ATENDIMENTO" (
|
||||
MOT_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
|
||||
MOT_EMPRESA_ID INTEGER,
|
||||
MOT_DESCRICAO VARCHAR(150),
|
||||
MOT_SITUACAO CHAR(1) DEFAULT 'A'
|
||||
)`);
|
||||
|
||||
await tentar(alias, `ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_MOTIVO_VISUALIZAR CHAR(1) DEFAULT 'N'`);
|
||||
await tentar(alias, `ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_MOTIVO_OBRIGATORIO CHAR(1) DEFAULT 'N'`);
|
||||
await tentar(alias, `ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_RESOLUCAO_VISUALIZAR CHAR(1) DEFAULT 'N'`);
|
||||
await tentar(alias, `ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_RESOLUCAO_OBRIGATORIO CHAR(1) DEFAULT 'N'`);
|
||||
|
||||
// Envio de boleto na conversa (checkbox em Configurações > Empresa)
|
||||
await tentar(alias, `ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_ENVIAR_BOLETO CHAR(1) DEFAULT 'N'`);
|
||||
|
||||
// Salvar resolução na tabela MENSAGENS_CLIENTES como aviso (checkbox em Configurações > Empresa)
|
||||
await tentar(alias, `ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_RESOLUCAO_SALVAR_MSC CHAR(1) DEFAULT 'N'`);
|
||||
|
||||
await tentar(alias, `ALTER TABLE CHATC2_CONVERSAS ADD CON_MOTIVO_ID INTEGER`);
|
||||
await tentar(alias, `ALTER TABLE CHATC2_CONVERSAS ADD CON_RESOLUCAO VARCHAR(4000)`);
|
||||
|
||||
prontos[alias] = true;
|
||||
}
|
||||
|
||||
module.exports = { garantirEstrutura };
|
||||
Reference in New Issue
Block a user