alterações de melhorias

This commit is contained in:
2026-06-24 14:13:09 +00:00
parent d942694ec0
commit 29190cd71c
34 changed files with 2570 additions and 950 deletions
+8
View File
@@ -1,5 +1,6 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const path = require('path');
const routes = require('./routes');
const authenticateToken = require('./middlewares/auth');
@@ -7,6 +8,13 @@ const authenticateToken = require('./middlewares/auth');
const app = express();
// Middlewares
// Segurança: headers HTTP protetores (X-Content-Type-Options, X-Frame-Options, etc.)
// CSP configurado de forma permissiva para não quebrar inline scripts/styles existentes
app.use(helmet({
contentSecurityPolicy: false, // Desabilitado: app usa inline scripts/styles em várias páginas
crossOriginEmbedderPolicy: false, // Permite carregar recursos cross-origin (Evolution API, etc.)
}));
// CORS: restringível via CORS_ORIGINS (lista separada por vírgula no .env).
// Requisições same-origin e server-to-server (sem header Origin) sempre passam.
const corsOrigins = (process.env.CORS_ORIGINS || [process.env.LOCAL_URL, process.env.EXTERNAL_URL].filter(Boolean).join(','))
+157
View File
@@ -0,0 +1,157 @@
/**
* Configuração de conexões (aliases de banco).
*
* Gerencia aliases estáticos (definidos em código) e customizados
* (adicionados via API e persistidos em databases_custom.json).
*/
const path = require('path');
const fs = require('fs');
const CUSTOM_DB_PATH = path.resolve(__dirname, '..', 'databases_custom.json');
// Conexões dinâmicas salvas em JSON
let customDatabases = {};
try {
if (fs.existsSync(CUSTOM_DB_PATH)) {
customDatabases = JSON.parse(fs.readFileSync(CUSTOM_DB_PATH, 'utf8'));
}
} catch (e) {
console.error('[Databases] Erro ao carregar databases_custom.json:', e.message);
}
// Valores padrão para Firebird
const DRIVER_DEFAULTS = {
firebird: {
host: 'localhost',
port: 3050,
user: 'SYSDBA',
password: 'masterkey',
encoding: 'UTF-8',
lowercase_keys: false,
pageSize: 4096,
wireCrypt: 1,
},
};
/**
* Conexões estáticas.
*/
const databases = {
novo_local: {
driver: 'firebird',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT, 10) || 3050,
database: process.env.DB_DATABASE || path.resolve(__dirname, '../../db/NOVO.FDB'),
user: process.env.DB_USER || 'SYSDBA',
password: process.env.DB_PASSWORD || 'masterkey',
encoding: process.env.DB_ENCODING || 'UTF-8',
lowercase_keys: false,
pageSize: 4096,
wireCrypt: 1,
},
};
function salvarCustomDatabases() {
try {
fs.writeFileSync(CUSTOM_DB_PATH, JSON.stringify(customDatabases, null, 2), 'utf8');
return true;
} catch (e) {
console.error('[Databases] Erro ao salvar databases_custom.json:', e.message);
return false;
}
}
/** Normaliza uma config bruta aplicando defaults do driver. */
function normalize(raw) {
const d = DRIVER_DEFAULTS['firebird'];
return {
driver: 'firebird',
host: raw.host || d.host,
port: raw.port || d.port,
database: raw.database,
user: raw.user || d.user,
password: raw.password || d.password,
encoding: raw.encoding || d.encoding,
lowercase_keys: raw.lowercase_keys || d.lowercase_keys,
role: raw.role || null,
pageSize: raw.pageSize || d.pageSize,
wireCrypt: raw.wireCrypt !== undefined ? raw.wireCrypt : d.wireCrypt,
};
}
/** Configuração normalizada de um alias. */
function getConfig(alias) {
if (!alias) throw new Error('Alias do banco não informado.');
const aliasLower = alias.toLowerCase();
const raw = customDatabases[aliasLower] || databases[aliasLower];
if (!raw) {
throw new Error(`Alias "${alias}" não encontrado. Aliases disponíveis: ${listAliases().join(', ')}`);
}
return normalize(raw);
}
/** Lista de aliases disponíveis. */
function listAliases() {
return Object.keys(databases).concat(
Object.keys(customDatabases).filter((a) => !databases[a])
);
}
/** Todos os aliases com tipo (estático ou custom). */
function listAllDatabases() {
const result = {};
Object.keys(databases).forEach((alias) => {
result[alias] = Object.assign({}, databases[alias], { _tipo: 'estatico' });
});
Object.keys(customDatabases).forEach((alias) => {
result[alias] = Object.assign({}, customDatabases[alias], { _tipo: 'custom' });
});
return result;
}
/** Adiciona/atualiza conexão customizada. */
function addDatabase(alias, config) {
if (!alias || !config || !config.database) {
throw new Error('Alias e database são obrigatórios.');
}
const aliasLower = alias.toLowerCase().replace(/[^a-z0-9_]/g, '_');
const stored = { driver: 'firebird' };
['host', 'port', 'database', 'user', 'password',
'encoding', 'lowercase_keys', 'role', 'pageSize', 'wireCrypt']
.forEach((k) => {
if (config[k] !== undefined && config[k] !== null && config[k] !== '') {
stored[k] = config[k];
}
});
customDatabases[aliasLower] = stored;
return salvarCustomDatabases();
}
/** Remove uma conexão customizada. */
function removeDatabase(alias) {
if (!alias) return false;
const aliasLower = alias.toLowerCase();
if (!customDatabases[aliasLower]) return false;
delete customDatabases[aliasLower];
return salvarCustomDatabases();
}
/** Nome do driver de um alias (sempre 'firebird'). */
function driverOf(alias) {
return 'firebird';
}
module.exports = {
databases,
customDatabases,
getConfig,
listAliases,
listAllDatabases,
addDatabase,
removeDatabase,
driverOf,
DRIVER_DEFAULTS,
};
+133 -67
View File
@@ -181,70 +181,93 @@ class ChatController {
const result = await db.query(alias, `
SELECT c.*,
(SELECT CME_TEXTO FROM CHATC2_CONVERSAS_MENSAGENS WHERE CME_CONVERSA_ID = c.CON_CODIGO_ID ORDER BY CME_DT_ENVIO DESC FETCH FIRST 1 ROWS ONLY) AS ULTIMA_MSG,
(SELECT CME_TIPO FROM CHATC2_CONVERSAS_MENSAGENS WHERE CME_CONVERSA_ID = c.CON_CODIGO_ID ORDER BY CME_DT_ENVIO DESC FETCH FIRST 1 ROWS ONLY) AS ULTIMA_TIPO,
(SELECT CME_DT_ENVIO FROM CHATC2_CONVERSAS_MENSAGENS WHERE CME_CONVERSA_ID = c.CON_CODIGO_ID ORDER BY CME_DT_ENVIO DESC FETCH FIRST 1 ROWS ONLY) AS DT_ULTIMA_MSG
FROM CHATC2_CONVERSAS c
WHERE c.CON_EMPRESA_ID = ? AND c.CON_STATUS IN (${statusPh}) AND c.CON_SITUACAO = 'A' ${extraFilter}
ORDER BY c.CON_DT_ULTIMA_MSG DESC
`, params);
const conversations = await Promise.all(result.map(async (row) => {
// Busca etiquetas
const labels = await db.query(alias, `
SELECT e.ETI_CODIGO_ID, e.ETI_NOME, e.ETI_COR
FROM CHATC2_ETIQUETAS e INNER JOIN CHATC2_CONVERSAS_ETIQUETAS ce ON e.ETI_CODIGO_ID = ce.CET_ETIQUETA_ID
WHERE ce.CET_CONVERSA_ID = ? AND e.ETI_SITUACAO = 'A'
`, [row.CON_CODIGO_ID]);
// --- Busca em lote: etiquetas de todas as conversas (evita N+1 queries) ---
const conversaIds = result.map(r => r.CON_CODIGO_ID);
const labelsMap = {};
for (const cid of conversaIds) labelsMap[cid] = [];
// Busca foto do cliente se configurado
let foto = null;
if (row.CON_CLIENTE_ID) {
const config = await db.query(alias,
'SELECT CFE_FOTO_CELULAR FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]);
if (config.length > 0 && config[0].CFE_FOTO_CELULAR === 'S') {
const cli = await db.query(alias,
'SELECT CLI_FOTO, CLI_NOME_FOTO FROM CLIENTES WHERE CLI_CODIGO_ID = ?', [row.CON_CLIENTE_ID]);
if (cli.length > 0) {
var rawFoto = cli[0].CLI_FOTO;
if (rawFoto) {
if (Buffer.isBuffer(rawFoto)) {
var str = rawFoto.toString('utf8');
if (/^[A-Za-z0-9+/]+=*$/.test(str.slice(0, 30))) {
foto = str;
} else {
var prefixo = 'data:image/jpeg;base64,';
if (rawFoto[0] === 0x89 && rawFoto[1] === 0x50) prefixo = 'data:image/png;base64,';
else if (rawFoto[0] === 0x47 && rawFoto[1] === 0x49) prefixo = 'data:image/gif;base64,';
else if (rawFoto[0] === 0x52 && rawFoto[1] === 0x49) prefixo = 'data:image/webp;base64,';
foto = prefixo + rawFoto.toString('base64');
}
if (conversaIds.length > 0) {
const idsPlaceholders = conversaIds.map(() => '?').join(',');
const todasEtiquetas = await db.query(alias, `
SELECT ce.CET_CONVERSA_ID, e.ETI_CODIGO_ID, e.ETI_NOME, e.ETI_COR
FROM CHATC2_ETIQUETAS e
INNER JOIN CHATC2_CONVERSAS_ETIQUETAS ce ON e.ETI_CODIGO_ID = ce.CET_ETIQUETA_ID
WHERE ce.CET_CONVERSA_ID IN (${idsPlaceholders}) AND e.ETI_SITUACAO = 'A'
`, conversaIds);
for (const et of todasEtiquetas) {
if (!labelsMap[et.CET_CONVERSA_ID]) labelsMap[et.CET_CONVERSA_ID] = [];
labelsMap[et.CET_CONVERSA_ID].push({
id: et.ETI_CODIGO_ID, nome: (et.ETI_NOME || '').trim(), cor: (et.ETI_COR || '#667eea').trim()
});
}
}
// --- Busca em lote: fotos dos clientes (se config ativo) ---
const fotosMap = {};
let fotoCelularAtivo = false;
{
const config = await db.query(alias,
'SELECT CFE_FOTO_CELULAR FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]);
if (config.length > 0 && config[0].CFE_FOTO_CELULAR === 'S') {
fotoCelularAtivo = true;
const clienteIds = result.filter(r => r.CON_CLIENTE_ID).map(r => r.CON_CLIENTE_ID);
if (clienteIds.length > 0) {
const cliPlaceholders = clienteIds.map(() => '?').join(',');
const fotos = await db.query(alias,
'SELECT CLI_CODIGO_ID, CLI_FOTO, CLI_NOME_FOTO FROM CLIENTES WHERE CLI_CODIGO_ID IN (' + cliPlaceholders + ')',
clienteIds);
for (const f of fotos) {
const rawFoto = f.CLI_FOTO;
if (!rawFoto) continue;
let foto = null;
if (Buffer.isBuffer(rawFoto)) {
const str = rawFoto.toString('utf8');
if (/^[A-Za-z0-9+/]+=*$/.test(str.slice(0, 30))) {
foto = str;
} else {
foto = rawFoto;
let prefixo = 'data:image/jpeg;base64,';
if (rawFoto[0] === 0x89 && rawFoto[1] === 0x50) prefixo = 'data:image/png;base64,';
else if (rawFoto[0] === 0x47 && rawFoto[1] === 0x49) prefixo = 'data:image/gif;base64,';
else if (rawFoto[0] === 0x52 && rawFoto[1] === 0x49) prefixo = 'data:image/webp;base64,';
foto = prefixo + rawFoto.toString('base64');
}
} else {
foto = rawFoto;
}
fotosMap[f.CLI_CODIGO_ID] = foto;
}
}
}
}
return {
id: row.CON_CODIGO_ID,
empresaId: row.CON_EMPRESA_ID,
instanciaId: row.CON_INSTANCIA_ID,
clienteId: row.CON_CLIENTE_ID,
numero: (row.CON_NUMERO || '').trim(),
nomeContato: (row.CON_NOME_CONTATO || '').trim(),
status: (row.CON_STATUS || '').trim(),
usuarioId: row.CON_USUARIO_ID,
equipeId: row.CON_EQUIPE_ID,
dtInicio: row.CON_DT_INICIO,
dtFinal: row.CON_DT_FINAL,
ultimaMsg: row.ULTIMA_MSG || '',
dtUltimaMsg: row.DT_ULTIMA_MSG || row.CON_DT_ULTIMA_MSG,
saudacaoEnviada: (row.CON_SAUDACAO_ENVIADA || 'N').trim(),
csatEnviado: (row.CON_CSAT_ENVIADO || 'N').trim(),
primeiraMsg: row.CON_PRIMEIRA_MSG,
labels,
foto,
};
// --- Monta resultado final sem novas queries ---
const conversations = result.map((row) => ({
id: row.CON_CODIGO_ID,
empresaId: row.CON_EMPRESA_ID,
instanciaId: row.CON_INSTANCIA_ID,
clienteId: row.CON_CLIENTE_ID,
numero: (row.CON_NUMERO || '').trim(),
nomeContato: (row.CON_NOME_CONTATO || '').trim(),
status: (row.CON_STATUS || '').trim(),
usuarioId: row.CON_USUARIO_ID,
equipeId: row.CON_EQUIPE_ID,
dtInicio: row.CON_DT_INICIO,
dtFinal: row.CON_DT_FINAL,
ultimaMsg: row.ULTIMA_MSG || '',
ultimaTipo: (row.ULTIMA_TIPO || 'text').trim(),
dtUltimaMsg: row.DT_ULTIMA_MSG || row.CON_DT_ULTIMA_MSG,
saudacaoEnviada: (row.CON_SAUDACAO_ENVIADA || 'N').trim(),
csatEnviado: (row.CON_CSAT_ENVIADO || 'N').trim(),
primeiraMsg: row.CON_PRIMEIRA_MSG,
labels: labelsMap[row.CON_CODIGO_ID] || [],
foto: fotosMap[row.CON_CLIENTE_ID] || null,
}));
// Contagens para cada filtro
@@ -490,8 +513,8 @@ class ChatController {
FROM CHATC2_CONVERSAS_MENSAGENS
WHERE CME_CONVERSA_ID = ? AND CME_SITUACAO = 'A'
ORDER BY CME_DT_ENVIO DESC
OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY
`, [id]);
OFFSET ? ROWS FETCH NEXT ? ROWS ONLY
`, [id, offset, limit]);
var mapped = msgs.reverse().map(function(m) { return {
id: m.CME_CODIGO_ID,
@@ -551,14 +574,12 @@ class ChatController {
"UPDATE CHATC2_CONVERSAS SET CON_STATUS = 'A', CON_DT_FINAL = NULL WHERE CON_CODIGO_ID = ?", [id]);
}
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const newId = (maxId[0]?.ID || 0) + 1;
const newId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
let midiaId = null;
// Salva mídia se houver
if (midiaBase64) {
const maxMidia = await db.query(alias, 'SELECT MAX(MAT_CODIGO_ID) AS ID FROM CHATC2_MENSAGENS_ATENDIMENTOS');
midiaId = (maxMidia[0]?.ID || 0) + 1;
midiaId = await db.nextId(alias, 'GEN_MENSAGENS_ATENDIMENTOS');
// Para áudio: converte WebM → OGG antes de salvar no BD
// Assim o BD já tem o OGG e o envio para Evolution usa a mesma versão
@@ -824,8 +845,7 @@ class ChatController {
}
// Adiciona mensagem na conversa existente
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const msgId = (maxMsgId[0]?.ID || 0) + 1;
const msgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_USUARIO_ID, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'U', ?, ?, 'text', 'A', CURRENT_TIMESTAMP)
@@ -846,8 +866,7 @@ class ChatController {
}
// Gera ID da nova conversa
const maxId = await db.query(alias, 'SELECT MAX(CON_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS');
const newId = (maxId[0]?.ID || 0) + 1;
const newId = await db.nextId(alias, 'GEN_CHATC2_CONVERSAS');
// Se não informou instância, pega a primeira ativa
let instId = instanciaId;
@@ -865,8 +884,7 @@ class ChatController {
`, [newId, empresaId, instId, numeroLimpo, nomeContato || numeroLimpo, clienteId || null]);
// Insere primeira mensagem
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const msgId = (maxMsgId[0]?.ID || 0) + 1;
const msgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_USUARIO_ID, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
@@ -1279,8 +1297,7 @@ class ChatController {
}
// Salva mensagem do agente
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const newId = (maxId[0]?.ID || 0) + 1;
const newId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_USUARIO_ID, CME_TEXTO, CME_TIPO, CME_PRIVADA, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'U', ?, ?, 'text', 'N', 'A', CURRENT_TIMESTAMP)
@@ -1306,7 +1323,57 @@ class ChatController {
}
/**
* Recebe avaliação CSAT via formulário web
* Busca mensagens/conversas por termo de texto.
* GET /api/:alias/search?q=termo&empresaId=
*/
static async search(req, res) {
try {
const { alias } = req.params;
const q = (req.query.q || '').trim();
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
const limit = Math.min(parseInt(req.query.limit) || 30, 100);
if (!q || q.length < 2) {
return res.status(400).json({ success: false, error: 'Termo de busca muito curto (mínimo 2 caracteres).' });
}
if (!req.user?.empresas?.some(e => Number(e) === Number(empresaId))) {
return res.status(403).json({ success: false, error: 'Sem permissão.' });
}
const results = await db.query(alias, `
SELECT m.CME_CODIGO_ID, m.CME_CONVERSA_ID, m.CME_TEXTO, m.CME_TIPO,
m.CME_DT_ENVIO, m.CME_REMETENTE,
c.CON_NUMERO, c.CON_NOME_CONTATO, c.CON_STATUS
FROM CHATC2_CONVERSAS_MENSAGENS m
JOIN CHATC2_CONVERSAS c ON c.CON_CODIGO_ID = m.CME_CONVERSA_ID
WHERE c.CON_EMPRESA_ID = ?
AND c.CON_SITUACAO = 'A'
AND m.CME_SITUACAO = 'A'
AND m.CME_TEXTO CONTAINING ?
ORDER BY m.CME_DT_ENVIO DESC
FETCH FIRST ? ROWS ONLY
`, [empresaId, q, limit]);
res.json({
success: true,
data: results.map(r => ({
mensagemId: r.CME_CODIGO_ID,
conversaId: r.CME_CONVERSA_ID,
texto: (r.CME_TEXTO || '').substring(0, 120),
tipo: (r.CME_TIPO || 'text').trim(),
dtEnvio: r.CME_DT_ENVIO,
remetente: (r.CME_REMETENTE || '').trim(),
numero: (r.CON_NUMERO || '').trim(),
nomeContato: (r.CON_NOME_CONTATO || '').trim(),
status: (r.CON_STATUS || '').trim(),
})),
count: results.length,
});
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Recebe avaliação CSAT via formulário web
* POST /api/:alias/csat/avaliar
*/
static async csatAvaliar(req, res) {
@@ -1329,8 +1396,7 @@ class ChatController {
}
// Salva avaliação
const maxId = await db.query(alias, 'SELECT MAX(CSA_CODIGO_ID) AS ID FROM CHATC2_CSAT_AVALIACOES');
const newId = (maxId[0]?.ID || 0) + 1;
const newId = await db.nextId(alias, 'GEN_CHATC2_CSAT_AVALIACOES');
await db.execute(alias,
'INSERT INTO CHATC2_CSAT_AVALIACOES (CSA_CODIGO_ID, CSA_CONVERSA_ID, CSA_EMPRESA_ID, CSA_CLIENTE_ID, CSA_NOTA, CSA_COMENTARIO) VALUES (?, ?, ?, ?, ?, ?)',
+6 -4
View File
@@ -299,6 +299,7 @@ class ClientController {
const total = countResult[0].TOTAL;
// Busca paginada
const dataParams = params.concat([offset, limit]);
const dataSql = `
SELECT
c.CAR_CODIGO_ID,
@@ -315,10 +316,10 @@ class ClientController {
FROM CARNES c
${where}
ORDER BY c.CAR_DT_VENCIMENTO
OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY
OFFSET ? ROWS FETCH NEXT ? ROWS ONLY
`;
const result = await db.query(alias, dataSql, params);
const result = await db.query(alias, dataSql, dataParams);
const carnes = result.map(row => {
const situacao = row.CAR_SITUACAO;
@@ -464,6 +465,7 @@ class ClientController {
const total = countResult[0].TOTAL;
// Busca paginada
const dataParams = params.concat([offset, limit]);
const dataSql = `
SELECT
c.CLI_CODIGO_ID,
@@ -478,10 +480,10 @@ class ClientController {
FROM CLIENTES c
${where}
ORDER BY c.CLI_NOME
OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY
OFFSET ? ROWS FETCH NEXT ? ROWS ONLY
`;
const result = await db.query(alias, dataSql, params);
const result = await db.query(alias, dataSql, dataParams);
const clients = result.map(row => ({
id: row.CLI_CODIGO_ID,
+6 -6
View File
@@ -1,6 +1,7 @@
const db = require('../database');
const { isGerente } = require('../middlewares/roles');
const { garantirEstrutura } = require('../resolucaoSetup');
const configService = require('../services/configService');
class ConfigController {
// ==================== CHATC2_EQUIPES ====================
@@ -36,8 +37,7 @@ class ConfigController {
const empresaId = req.user?.empresas?.[0] || req.body.empresaId;
if (!nome) return res.status(400).json({ success: false, error: 'Nome obrigatório.' });
const maxId = await db.query(alias, 'SELECT MAX(EQU_CODIGO_ID) AS ID FROM CHATC2_EQUIPES');
const newId = (maxId[0]?.ID || 0) + 1;
const newId = await db.nextId(alias, 'GEN_CHATC2_EQUIPES');
const ordem = req.body.ordem !== undefined ? req.body.ordem : 0;
await db.execute(alias, 'INSERT INTO CHATC2_EQUIPES (EQU_CODIGO_ID, EQU_EMPRESA_ID, EQU_NOME, EQU_ORDEM) VALUES (?, ?, ?, ?)', [newId, empresaId, nome, ordem]);
@@ -119,8 +119,7 @@ class ConfigController {
const { nome, cor } = req.body;
const empresaId = req.user?.empresas?.[0];
const maxId = await db.query(alias, 'SELECT MAX(ETI_CODIGO_ID) AS ID FROM CHATC2_ETIQUETAS');
const newId = (maxId[0]?.ID || 0) + 1;
const newId = await db.nextId(alias, 'GEN_CHATC2_ETIQUETAS');
await db.execute(alias, 'INSERT INTO CHATC2_ETIQUETAS (ETI_CODIGO_ID, ETI_EMPRESA_ID, ETI_NOME, ETI_COR) VALUES (?, ?, ?, ?)', [newId, empresaId, nome, cor || '#667eea']);
res.json({ success: true, data: { id: newId } });
@@ -214,6 +213,7 @@ class ConfigController {
VALUES (?, ?, ?, ?, ?)`,
[empresaId, mv, mo, rv, ro]);
}
configService.invalidarConfig(alias, empresaId);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
@@ -239,8 +239,7 @@ class ConfigController {
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];
const maxId = await db.query(alias, 'SELECT MAX(MOT_CODIGO_ID) AS ID FROM "CHATC2_MOTIVOS_ATENDIMENTO"');
const newId = (maxId[0]?.ID || 0) + 1;
const newId = await db.nextId(alias, 'GEN_CHATC2_MOTIVOS_ATENDIMENTO');
await db.execute(alias,
`INSERT INTO "CHATC2_MOTIVOS_ATENDIMENTO" (MOT_CODIGO_ID, MOT_EMPRESA_ID, MOT_DESCRICAO, MOT_SITUACAO)
VALUES (?, ?, ?, 'A')`, [newId, empresaId, descricao]);
@@ -288,6 +287,7 @@ class ConfigController {
`, [empresaId, data.instanciaPadraoId || null, data.fotoCelular || 'N', data.saudacaoAtiva || 'S', data.saudacaoMensagem || '', data.csatAtivo || 'N', data.csatMensagem || '', data.enviarNomeUsuario || 'N', data.triagemAtiva || 'N', data.triagemMsgWelcome || '', data.triagemMsgAfter || '', data.triagemBoletoNumero || '0', enviarBoleto]);
}
configService.invalidarConfig(alias, empresaId);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
+17 -22
View File
@@ -217,8 +217,7 @@ class EvolutionController {
return res.status(403).json({ success: false, error: 'Sem permissão para esta empresa.' });
}
const maxId = await db.query(alias, 'SELECT MAX(INS_CODIGO_ID) AS ID FROM CHATC2_INSTANCIAS');
const newId = (maxId[0]?.ID || 0) + 1;
const newId = await db.nextId(alias, 'GEN_CHATC2_INSTANCIAS');
// Tenta criar a instância na Evolution
let instanceCreated = false;
@@ -789,8 +788,7 @@ async function processWebhook(alias, body) {
var isNovaConversa = false;
if (conversations.length === 0) {
isNovaConversa = true;
const maxId = await db.query(alias, 'SELECT MAX(CON_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS');
const newId = (maxId[0]?.ID || 0) + 1;
const newId = await db.nextId(alias, 'GEN_CHATC2_CONVERSAS');
const nomeContato = data.pushName || data.notifyName || sender || '';
// Busca cliente pelo número em TODOS os aliases (multi-banco)
@@ -828,11 +826,11 @@ async function processWebhook(alias, body) {
if (config.length > 0 && config[0].CFE_SAUDACAO_ATIVA === 'S') {
const msgSaudacao = (config[0].CFE_SAUDACAO_MENSAGEM || '').trim();
if (msgSaudacao) {
const maxMsgId = await db.query(aliasEfetivo, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const saudacaoMsgId = await db.nextId(aliasEfetivo, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(aliasEfetivo, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversa.CON_CODIGO_ID, msgSaudacao]);
`, [saudacaoMsgId, conversa.CON_CODIGO_ID, msgSaudacao]);
await db.execute(aliasEfetivo, "UPDATE CHATC2_CONVERSAS SET CON_SAUDACAO_ENVIADA = 'S' WHERE CON_CODIGO_ID = ?", [conversa.CON_CODIGO_ID]);
// Envia saudação via Evolution API
@@ -876,16 +874,12 @@ async function processWebhook(alias, body) {
'UPDATE CHATC2_CONVERSAS SET CON_CLIENTE_ID = ?, CON_NOME_CONTATO = ? WHERE CON_CODIGO_ID = ?',
[cliId, nomeCliente, conversa.CON_CODIGO_ID]);
conversa.CON_CLIENTE_ID = cliId;
// Atualiza foto do contato
// Atualiza foto APENAS na primeira vinculação (não a cada mensagem)
EvolutionController.atualizarFotoContato(aliasEfetivo, cliId, numero, empresaId).catch(function() {});
}
} else {
// Mesmo ja tendo cliente vinculado, tenta atualizar a foto
// (Evolution API 2.4.0 pode estar retornando foto agora)
EvolutionController.atualizarFotoContato(alias, conversa.CON_CLIENTE_ID, numero, empresaId).catch(function(e) {
console.error('[Webhook] Erro ao atualizar foto de conversa existente:', e.message);
});
}
// NOTA: foto NÃO é mais atualizada a cada mensagem para conversas já vinculadas.
// A atualização ocorre apenas na criação da conversa ou na primeira vinculação do cliente.
await db.execute(alias, 'UPDATE CHATC2_CONVERSAS SET CON_DT_ULTIMA_MSG = CURRENT_TIMESTAMP WHERE CON_CODIGO_ID = ?', [conversa.CON_CODIGO_ID]);
}
@@ -1160,8 +1154,7 @@ async function processWebhook(alias, body) {
var b64Salvar = mediaBuffer.toString('base64');
var mimeTypeSalvar = mimeType || (mediaTipo + '/' + ext);
var maxMidia = await db.query(alias, 'SELECT MAX(MAT_CODIGO_ID) AS ID FROM CHATC2_MENSAGENS_ATENDIMENTOS');
midiaId = (maxMidia[0]?.ID || 0) + 1;
midiaId = await db.nextId(alias, 'GEN_MENSAGENS_ATENDIMENTOS');
await db.execute(alias, `
INSERT INTO CHATC2_MENSAGENS_ATENDIMENTOS (MAT_CODIGO_ID, MAT_CONVERSA_ID, MAT_MENSAGEM_ID, MAT_NOME_ARQUIVO, MAT_TIPO_ARQUIVO, MAT_ARQUIVO)
@@ -1207,24 +1200,26 @@ async function processWebhook(alias, body) {
} // fim if (mediaInfo)
if (messageText || messageType !== 'conversation' || midiaId) {
var maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const webhookMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
if (midiaId) {
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO, CME_MIDIA_ID)
VALUES (?, ?, 'C', ?, ?, 'A', CURRENT_TIMESTAMP, ?)
`, [(maxMsgId[0]?.ID || 0) + 1, conversa.CON_CODIGO_ID, messageText, messageType, midiaId]);
`, [webhookMsgId, conversa.CON_CODIGO_ID, messageText, messageType, midiaId]);
} else {
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'C', ?, ?, 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversa.CON_CODIGO_ID, messageText, messageType]);
`, [webhookMsgId, conversa.CON_CODIGO_ID, messageText, messageType]);
}
// Processa resposta do cliente à triagem (se estiver em modo triagem)
// Só processa para conversas já existentes. Para conversas novas, a mensagem
// que disparou a criação foi enviada ANTES do menu, então não deve ser processada.
if (!isNovaConversa && messageText && messageType === 'conversation' && !data.key?.fromMe) {
// Processa resposta do cliente à triagem (APENAS se conversa está em modo triagem)
// Condições: conversa existente, tem texto, é mensagem de conversa, não é do agente,
// E está em triagem (CON_MENU_ESTADO preenchido) E ainda não tem atendente+equipe.
var emTriagem = conversa.CON_MENU_ESTADO &&
(!conversa.CON_USUARIO_ID || !conversa.CON_EQUIPE_ID);
if (!isNovaConversa && messageText && messageType === 'conversation' && !data.key?.fromMe && emTriagem) {
TriageController.processResponse(alias, conversa.CON_CODIGO_ID, empresaId, instanciaId, numero, messageText)
.catch(function(e) { console.error('[Triagem] Erro ao processar resposta:', e.message); });
}
+37 -5
View File
@@ -1,7 +1,23 @@
const db = require('../database');
const crypto = require('crypto');
const { listAliases } = db;
const { isGerente } = require('../middlewares/roles');
/**
* Valida o token de administração (segundo fator para SQL direto).
* Comparação em tempo constante para prevenir timing attacks.
*/
function validarAdminToken(req) {
const esperado = process.env.ADMIN_TOKEN;
if (!esperado) return false; // Se não configurado, ninguém acessa
const recebido = req.headers['x-admin-token'] || '';
try {
return crypto.timingSafeEqual(Buffer.from(recebido), Buffer.from(esperado));
} catch (_) {
return false;
}
}
class GenericController {
/**
* Executa uma consulta SQL genérica (SELECT)
@@ -15,6 +31,10 @@ class GenericController {
return res.status(403).json({ success: false, error: 'Apenas gerentes podem executar SQL direto.' });
}
if (!validarAdminToken(req)) {
return res.status(403).json({ success: false, error: 'Token de administração (X-Admin-Token) inválido ou não configurado.' });
}
if (!sql) {
return res.status(400).json({
success: false,
@@ -42,6 +62,10 @@ class GenericController {
return res.status(403).json({ success: false, error: 'Apenas gerentes podem executar SQL direto.' });
}
if (!validarAdminToken(req)) {
return res.status(403).json({ success: false, error: 'Token de administração (X-Admin-Token) inválido ou não configurado.' });
}
if (!sql) {
return res.status(400).json({
success: false,
@@ -94,23 +118,31 @@ class GenericController {
}
/**
* Health check do alias específico
* Health check completo do alias específico.
* Verifica: conectividade Firebird, uso de memória, uptime.
*/
static async healthCheck(req, res) {
try {
const { alias } = req.params;
const t0 = Date.now();
const isConnected = await db.testConnection(alias);
const pingMs = Date.now() - t0;
const mem = process.memoryUsage();
res.json({
success: true,
alias,
connected: isConnected,
pingMs,
uptimeSeconds: Math.floor(process.uptime()),
memory: {
rssMB: Math.round(mem.rss / 1024 / 1024),
heapUsedMB: Math.round(mem.heapUsed / 1024 / 1024),
heapTotalMB: Math.round(mem.heapTotal / 1024 / 1024),
},
timestamp: new Date().toISOString(),
});
} catch (err) {
res.status(500).json({
success: false,
error: err.message,
});
res.status(500).json({ success: false, error: err.message });
}
}
+1 -2
View File
@@ -172,8 +172,7 @@ class MenuController {
await MenuController.garantirTabela(alias);
const maxId = await db.query(alias, 'SELECT MAX(MNE_CODIGO_ID) AS ID FROM CHATC2_MENUS_EMPRESA');
const newId = (maxId[0]?.ID || 0) + 1;
const newId = await db.nextId(alias, 'GEN_CHATC2_MENUS_EMPRESA');
// Se tem menuPaiId, o equipeId é herdado do pai
var equipeIdFinal = menuPaiId ? null : (equipeId || null);
+32 -32
View File
@@ -70,11 +70,11 @@ class TriageController {
}
// Salva mensagem do sistema
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'triagem', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, menuText]);
`, [maxMsgId, conversaId, menuText]);
// Envia via Evolution
await TriageController.sendEvolution(alias, instanciaId, numero, menuText);
@@ -119,11 +119,11 @@ class TriageController {
menuText += '\n0 - Voltar';
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'triagem', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, menuText]);
`, [maxMsgId, conversaId, menuText]);
await TriageController.sendEvolution(alias, instanciaId, numero, menuText);
// Salva estado
@@ -244,11 +244,11 @@ class TriageController {
if (!temSubmenus) {
// Sem submenus: envia msgAfter e finaliza triagem
if (msgAfter) {
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, msgAfter]);
`, [maxMsgId, conversaId, msgAfter]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgAfter);
}
// Remove estado de triagem (conversa fica com equipe atribuída)
@@ -375,11 +375,11 @@ class TriageController {
// Texto: envia mensagem fixa e FINALIZA (não mostra menu novamente)
var textoMsg = menuEscolhido.MNE_TEXTO ? menuEscolhido.MNE_TEXTO.toString() : '';
if (textoMsg) {
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, textoMsg]);
`, [maxMsgId, conversaId, textoMsg]);
await TriageController.sendEvolution(alias, instanciaId, numero, textoMsg);
}
// Finaliza triagem (não mostra menu novamente)
@@ -394,11 +394,11 @@ class TriageController {
msgPrompt = msgPrompt.replace(/{NOME}/g, 'Cliente');
msgPrompt = msgPrompt.replace(/{EMPRESA}/g, '');
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, msgPrompt]);
`, [maxMsgId, conversaId, msgPrompt]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgPrompt);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = 'coleta_" + menuEscolhido.MNE_CODIGO_ID + "' WHERE CON_CODIGO_ID = ?", [conversaId]);
@@ -519,11 +519,11 @@ class TriageController {
}
if (resultado) {
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, resultado]);
`, [maxMsgId, conversaId, resultado]);
await TriageController.sendEvolution(alias, instanciaId, numero, resultado);
}
@@ -577,11 +577,11 @@ class TriageController {
} else {
logTriage('Boleto - NENHUM cliente ou dependente encontrado!');
var msgNaoEncontrado = 'Este número não está associado a nenhum contrato no sistema.\n\nPor favor, selecione um setor para que um atendente possa ajudá-lo a associar seu número.';
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, msgNaoEncontrado]);
`, [maxMsgId, conversaId, msgNaoEncontrado]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgNaoEncontrado);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = 'root' WHERE CON_CODIGO_ID = ?", [conversaId]);
await TriageController.sendMenu(alias, conversaId, empresaId, instanciaId, numero);
@@ -603,11 +603,11 @@ class TriageController {
if (!titulos || titulos.length === 0) {
var msg = 'Nenhum título em aberto encontrado.';
logTriage('Boleto - NENHUM título em aberto');
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, msg]);
`, [maxMsgId, conversaId, msg]);
await TriageController.sendEvolution(alias, instanciaId, numero, msg);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = 'root' WHERE CON_CODIGO_ID = ?", [conversaId]);
await TriageController.sendMenu(alias, conversaId, empresaId, instanciaId, numero);
@@ -622,11 +622,11 @@ class TriageController {
});
msgBoleto += '\nResponda com o número desejado ou 0 para voltar.';
const maxMsgId2 = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxMsgId2 = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId2[0]?.ID || 0) + 1, conversaId, msgBoleto]);
`, [maxMsgId2, conversaId, msgBoleto]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgBoleto);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = 'boleto_lista_" + titularId + "' WHERE CON_CODIGO_ID = ?", [conversaId]);
@@ -650,11 +650,11 @@ class TriageController {
if (titulos.length === 0 || opcao > titulos.length) {
const msg = 'Opção inválida. Tente novamente.';
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxId[0]?.ID || 0) + 1, conversaId, msg]);
`, [maxId, conversaId, msg]);
await TriageController.sendEvolution(alias, instanciaId, numero, msg);
return;
}
@@ -836,21 +836,21 @@ class TriageController {
textoBoleto += '\n💳 PIX disponível (cópia e cola)';
}
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxId[0]?.ID || 0) + 1, conversaId, textoBoleto]);
`, [maxId, conversaId, textoBoleto]);
await TriageController.sendEvolution(alias, instanciaId, numero, textoBoleto);
}
// Pergunta se quer continuar
var msgCont = '📄 Boleto enviado!\n\nDeseja continuar com o atendimento?\n\n1 - Sim\n2 - Não, finalizar';
const maxIdCont = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxIdCont = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxIdCont[0]?.ID || 0) + 1, conversaId, msgCont]);
`, [maxIdCont, conversaId, msgCont]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgCont);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = 'boleto_pos_" + clienteId + "' WHERE CON_CODIGO_ID = ?", [conversaId]);
@@ -887,20 +887,20 @@ class TriageController {
}
var msgFinal = 'Atendimento finalizado. Obrigado pelo contato!';
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxId[0]?.ID || 0) + 1, conversaId, msgFinal]);
`, [maxId, conversaId, msgFinal]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgFinal);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_STATUS = 'F', CON_DT_FINAL = CURRENT_TIMESTAMP, CON_MENU_ESTADO = NULL WHERE CON_CODIGO_ID = ?", [conversaId]);
} else {
var msgInv = 'Por favor, responda:\n\n1 - Sim\n2 - Não, finalizar';
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxId[0]?.ID || 0) + 1, conversaId, msgInv]);
`, [maxId, conversaId, msgInv]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgInv);
}
} catch(e) {
@@ -914,11 +914,11 @@ class TriageController {
static async enviarMsgInvalida(alias, conversaId, empresaId, instanciaId, numero) {
try {
const msg = 'Opção inválida. Tente novamente.';
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxId[0]?.ID || 0) + 1, conversaId, msg]);
`, [maxId, conversaId, msg]);
await TriageController.sendEvolution(alias, instanciaId, numero, msg);
// Reenvia o menu atual
@@ -947,11 +947,11 @@ class TriageController {
static async enviarMsgTexto(alias, conversaId, empresaId, instanciaId, numero, texto, menuPaiId, equipeId) {
try {
if (texto) {
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxId[0]?.ID || 0) + 1, conversaId, texto]);
`, [maxId, conversaId, texto]);
await TriageController.sendEvolution(alias, instanciaId, numero, texto);
}
+67 -322
View File
@@ -1,331 +1,45 @@
/**
* CAMADA DE BANCO DE DADOS (arquivo único)
* CAMADA DE BANCO DE DADOS (dispatcher)
* ========================================
* Concentra tudo que diz respeito a banco de dados em UM lugar:
*
* 1) CONFIGURAÇÃO aliases, drivers, conexões estáticas e customizadas
* 2) DRIVER FIREBIRD
* 3) DISPATCHER API pública usada por todo o sistema
* API pública usada por todo o sistema. Delega para:
* - config/connections.js aliases, configuração
* - drivers/firebird.js operações Firebird puras
*
* Suporte ao banco Firebird. Cada conexão declara o campo `driver`.
* Conexões adicionadas em runtime (via API /api/databases) são persistidas
* em databases_custom.json (ao lado deste arquivo).
* Todos os controllers importam deste arquivo. A API permanece
* idêntica apenas a implementação foi modularizada.
*/
const path = require('path');
const fs = require('fs');
const connections = require('./config/connections');
const firebird = require('./drivers/firebird');
// ============================================================
// 1) CONFIGURAÇÃO
// 1) DISPATCHER — API pública
// ============================================================
const CUSTOM_DB_PATH = path.resolve(__dirname, 'databases_custom.json');
// Conexões dinâmicas salvas em arquivo JSON
var customDatabases = {};
try {
if (fs.existsSync(CUSTOM_DB_PATH)) {
customDatabases = JSON.parse(fs.readFileSync(CUSTOM_DB_PATH, 'utf8'));
}
} catch (e) {
console.error('[Databases] Erro ao carregar databases_custom.json:', e.message);
}
const DRIVERS = ['firebird'];
const DEFAULT_DRIVER = 'firebird';
// Valores padrão para Firebird
const DRIVER_DEFAULTS = {
firebird: {
host: 'localhost',
port: 3050,
user: 'SYSDBA',
password: 'masterkey',
encoding: 'UTF-8',
lowercase_keys: false,
pageSize: 4096,
wireCrypt: 1, // WIRE_CRYPT_ENABLE
},
};
/**
* Conexões estáticas.
* - `novo_local` Firebird (informe o CAMINHO do arquivo .FDB em `database`)
* Sobrescrevível pelo .env (DB_*).
*/
const databases = {
novo_local: {
driver: 'firebird',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT, 10) || 3050,
// CAMINHO do arquivo .FDB: defina DB_DATABASE no .env (absoluto) ou ajuste
// o path.resolve abaixo. Ex.: path.resolve(__dirname, '../db/NOVO.FDB').
database: process.env.DB_DATABASE || path.resolve(__dirname, '../db/NOVO.FDB'),
user: process.env.DB_USER || 'SYSDBA',
password: process.env.DB_PASSWORD || 'masterkey',
encoding: process.env.DB_ENCODING || 'UTF-8',
lowercase_keys: false,
pageSize: 4096,
wireCrypt: 1, // WIRE_CRYPT_ENABLE
},
};
function salvarCustomDatabases() {
try {
fs.writeFileSync(CUSTOM_DB_PATH, JSON.stringify(customDatabases, null, 2), 'utf8');
return true;
} catch (e) {
console.error('[Databases] Erro ao salvar databases_custom.json:', e.message);
return false;
}
}
/** Normaliza uma config bruta aplicando o driver e seus defaults. */
function normalize(raw) {
const d = DRIVER_DEFAULTS['firebird'];
return {
driver: 'firebird',
host: raw.host || d.host,
port: raw.port || d.port,
database: raw.database,
user: raw.user || d.user,
password: raw.password || d.password,
encoding: raw.encoding || d.encoding,
lowercase_keys: raw.lowercase_keys || d.lowercase_keys,
role: raw.role || null,
pageSize: raw.pageSize || d.pageSize,
wireCrypt: raw.wireCrypt !== undefined ? raw.wireCrypt : d.wireCrypt,
};
}
/** Configuração normalizada (inclui `driver`) de um alias. */
function getConfig(alias) {
if (!alias) throw new Error('Alias do banco não informado.');
const aliasLower = alias.toLowerCase();
const raw = customDatabases[aliasLower] || databases[aliasLower];
if (!raw) {
throw new Error(`Alias "${alias}" não encontrado. Aliases disponíveis: ${listAliases().join(', ')}`);
}
return normalize(raw);
}
/** Lista de aliases disponíveis. */
function listAliases() {
return Object.keys(databases).concat(
Object.keys(customDatabases).filter((a) => !databases[a])
);
}
/** Todos os aliases com suas configs (custom + estáticas). */
function listAllDatabases() {
var result = {};
Object.keys(databases).forEach(function (alias) {
result[alias] = Object.assign({}, databases[alias], { _tipo: 'estatico' });
});
Object.keys(customDatabases).forEach(function (alias) {
result[alias] = Object.assign({}, customDatabases[alias], { _tipo: 'custom' });
});
return result;
}
/** Adiciona/atualiza uma conexão customizada. */
function addDatabase(alias, config) {
if (!alias || !config || !config.database) {
throw new Error('Alias e database são obrigatórios.');
}
var aliasLower = alias.toLowerCase().replace(/[^a-z0-9_]/g, '_');
var stored = { driver: 'firebird' };
['host', 'port', 'database', 'user', 'password',
'encoding', 'lowercase_keys', 'role', 'pageSize', 'wireCrypt']
.forEach(function (k) {
if (config[k] !== undefined && config[k] !== null && config[k] !== '') {
stored[k] = config[k];
}
});
customDatabases[aliasLower] = stored;
return salvarCustomDatabases();
}
/** Remove uma conexão customizada. */
function removeDatabase(alias) {
if (!alias) return false;
var aliasLower = alias.toLowerCase();
if (!customDatabases[aliasLower]) return false;
delete customDatabases[aliasLower];
return salvarCustomDatabases();
}
// ============================================================
// 2) DRIVER FIREBIRD
// ============================================================
const Firebird = require('node-firebird');
function readBlob(blobFunc) {
return new Promise(function (resolve, reject) {
if (typeof blobFunc !== 'function') return resolve(blobFunc);
blobFunc(function (err, name, emitter) {
if (err) return reject(err);
if (!emitter || typeof emitter.on !== 'function') return resolve(null);
var chunks = [];
var total = 0;
emitter.on('data', function (c) { chunks.push(c); total += c.length; });
emitter.on('end', function () { resolve(Buffer.concat(chunks, total)); });
emitter.on('error', reject);
});
});
}
function firebirdQuery(config, sql, params = []) {
return new Promise((resolve, reject) => {
Firebird.attach(config, (err, db) => {
if (err) return reject(new Error(`Erro ao conectar (firebird): ${err.message}`));
var allRows = [];
db.sequentially(sql, params, function (row) {
var keys = Object.keys(row);
var blobPromises = keys.map(function (key) {
var val = row[key];
if (typeof val === 'function') {
return readBlob(val).then(function (data) { row[key] = data; });
}
return Promise.resolve();
});
return Promise.all(blobPromises).then(function () { allRows.push(row); });
}, function (queryErr) {
db.detach();
if (queryErr) return reject(new Error(`Erro na consulta (firebird): ${queryErr.message}`));
resolve(allRows);
});
});
});
}
function firebirdExecute(config, sql, params = []) {
return new Promise((resolve, reject) => {
Firebird.attach(config, (err, db) => {
if (err) return reject(new Error(`Erro ao conectar (firebird): ${err.message}`));
db.transaction(Firebird.ISOLATION_READ_COMMITTED, (transErr, transaction) => {
if (transErr) {
db.detach();
return reject(new Error(`Erro ao iniciar transação (firebird): ${transErr.message}`));
}
transaction.query(sql, params, (queryErr, result) => {
if (queryErr) {
transaction.rollback();
db.detach();
return reject(new Error(`Erro na execução (firebird): ${queryErr.message}`));
}
transaction.commit((commitErr) => {
db.detach();
if (commitErr) return reject(new Error(`Erro ao commitar (firebird): ${commitErr.message}`));
resolve({ affectedRows: result ? result.length : 0, result });
});
});
});
});
});
}
async function firebirdTestConnection(config) {
await firebirdQuery(config, 'SELECT 1 FROM RDB$DATABASE');
return true;
}
async function firebirdListTables(config) {
const rows = await firebirdQuery(config, `
SELECT TRIM(RDB$RELATION_NAME) AS TABLE_NAME
FROM RDB$RELATIONS
WHERE RDB$SYSTEM_FLAG = 0 AND RDB$RELATION_TYPE = 0
ORDER BY RDB$RELATION_NAME
`);
return rows.map((r) => (r.TABLE_NAME || '').trim()).filter(Boolean);
}
async function firebirdTableInfo(config, tableName) {
const rows = await firebirdQuery(config, `
SELECT
rf.RDB$FIELD_NAME AS COLUMN_NAME,
rf.RDB$FIELD_POSITION AS ORDINAL_POSITION,
CASE f.RDB$FIELD_TYPE
WHEN 7 THEN 'SMALLINT' WHEN 8 THEN 'INTEGER' WHEN 16 THEN 'BIGINT'
WHEN 9 THEN 'QUAD' WHEN 10 THEN 'FLOAT' WHEN 27 THEN 'DOUBLE PRECISION'
WHEN 12 THEN 'DATE' WHEN 13 THEN 'TIME' WHEN 35 THEN 'TIMESTAMP'
WHEN 37 THEN 'VARCHAR' WHEN 40 THEN 'CSTRING' WHEN 45 THEN 'BLOB_ID'
WHEN 261 THEN 'BLOB' WHEN 14 THEN 'CHAR' WHEN 41 THEN 'NUMERIC'
ELSE 'UNKNOWN'
END AS DATA_TYPE,
f.RDB$FIELD_LENGTH AS FIELD_LENGTH,
f.RDB$FIELD_SCALE AS FIELD_SCALE,
f.RDB$FIELD_PRECISION AS FIELD_PRECISION,
rf.RDB$NULL_FLAG AS NULL_FLAG
FROM RDB$RELATION_FIELDS rf
INNER JOIN RDB$FIELDS f ON rf.RDB$FIELD_SOURCE = f.RDB$FIELD_NAME
WHERE rf.RDB$RELATION_NAME = ?
ORDER BY rf.RDB$FIELD_POSITION
`, [String(tableName).toUpperCase()]);
return rows.map((row) => ({
name: (row.COLUMN_NAME || '').trim(),
position: row.ORDINAL_POSITION,
type: (row.DATA_TYPE || '').trim(),
length: row.FIELD_LENGTH,
precision: row.FIELD_PRECISION,
scale: row.FIELD_SCALE,
nullable: row.NULL_FLAG !== 1,
}));
}
async function firebirdTableExists(config, tableName) {
const r = await firebirdQuery(config,
"SELECT COUNT(*) AS CT FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = ?",
[String(tableName).toUpperCase()]);
return (r[0] && r[0].CT > 0) || false;
}
async function firebirdColumnExists(config, tableName, columnName) {
const r = await firebirdQuery(config,
"SELECT COUNT(*) AS CT FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = ? AND RDB$FIELD_NAME = ?",
[String(tableName).toUpperCase(), String(columnName).toUpperCase()]);
return (r[0] && r[0].CT > 0) || false;
}
async function firebirdClose() { /* Firebird abre conexão por consulta; nada a encerrar */ }
// ============================================================
// 3) DISPATCHER — API pública
// ============================================================
const driver = {
query: firebirdQuery,
execute: firebirdExecute,
testConnection: firebirdTestConnection,
listTables: firebirdListTables,
tableInfo: firebirdTableInfo,
tableExists: firebirdTableExists,
columnExists: firebirdColumnExists,
close: firebirdClose,
};
function getConn(alias) {
const config = getConfig(alias);
return { config, driver };
const config = connections.getConfig(alias);
return { config, driver: firebird };
}
function query(alias, sql, params = []) {
let d;
try { d = getConn(alias); } catch (err) { return Promise.reject(err); }
return d.driver.query(d.config, sql, params).catch((err) => { throw new Error(`[${alias}] ${err.message}`); });
return d.driver.query(d.config, sql, params)
.catch((err) => { throw new Error(`[${alias}] ${err.message}`); });
}
function execute(alias, sql, params = []) {
let d;
try { d = getConn(alias); } catch (err) { return Promise.reject(err); }
return d.driver.execute(d.config, sql, params).catch((err) => { throw new Error(`[${alias}] ${err.message}`); });
return d.driver.execute(d.config, sql, params)
.catch((err) => { throw new Error(`[${alias}] ${err.message}`); });
}
async function testConnection(alias) {
try {
const { config } = getConn(alias);
await driver.testConnection(config);
await firebird.testConnection(config);
return true;
} catch (err) {
console.error(`[${alias}] Falha na conexão:`, err.message);
@@ -335,7 +49,7 @@ async function testConnection(alias) {
async function testAllConnections() {
const results = {};
for (const alias of listAliases()) {
for (const alias of connections.listAliases()) {
results[alias] = await testConnection(alias);
}
return results;
@@ -343,36 +57,63 @@ async function testAllConnections() {
function listTables(alias) {
const { config } = getConn(alias);
return driver.listTables(config);
return firebird.listTables(config);
}
function tableInfo(alias, tableName) {
const { config } = getConn(alias);
return driver.tableInfo(config, tableName);
return firebird.tableInfo(config, tableName);
}
function tableExists(alias, tableName) {
const { config } = getConn(alias);
return driver.tableExists(config, tableName);
return firebird.tableExists(config, tableName);
}
function columnExists(alias, tableName, columnName) {
const { config } = getConn(alias);
return driver.columnExists(config, tableName, columnName);
return firebird.columnExists(config, tableName, columnName);
}
/** Nome do driver de um alias (sempre 'firebird'). */
function driverOf(alias) {
return 'firebird';
return connections.driverOf(alias);
}
/** Encerra recursos (noop para Firebird). */
async function closeAll() {
await driver.close();
await firebird.close();
}
// ============================================================
// 2) GERADORES DE ID (GEN_ID) — thread-safe
// ============================================================
const GENERATORS = [
'GEN_CHATC2_CONVERSAS',
'GEN_CONVERSAS_MENSAGENS',
'GEN_MENSAGENS_ATENDIMENTOS',
'GEN_CHATC2_EQUIPES',
'GEN_CHATC2_ETIQUETAS',
'GEN_CHATC2_INSTANCIAS',
'GEN_CHATC2_MENUS_EMPRESA',
'GEN_CHATC2_CSAT_AVALIACOES',
'GEN_CHATC2_MOTIVOS_ATENDIMENTO',
];
async function nextId(alias, generatorName) {
if (!GENERATORS.includes(generatorName)) {
throw new Error('Generator inválido: ' + generatorName);
}
const result = await query(alias,
'SELECT GEN_ID(' + generatorName + ', 1) AS NEW_ID FROM RDB$DATABASE');
return result[0].NEW_ID;
}
// ============================================================
// 3) EXPORTS — API completa (compatível com versão anterior)
// ============================================================
module.exports = {
// Acesso a dados
// Operações de dados
query,
execute,
testConnection,
@@ -383,13 +124,17 @@ module.exports = {
columnExists,
driverOf,
closeAll,
// Configuração / aliases
databases,
DRIVERS,
DEFAULT_DRIVER,
getConfig,
listAliases,
listAllDatabases,
addDatabase,
removeDatabase,
nextId,
GENERATORS,
poolStats: firebird.poolStats,
// Configuração / aliases (delegados)
databases: connections.databases,
customDatabases: connections.customDatabases,
DRIVERS: ['firebird'],
DEFAULT_DRIVER: 'firebird',
getConfig: connections.getConfig,
listAliases: connections.listAliases,
listAllDatabases: connections.listAllDatabases,
addDatabase: connections.addDatabase,
removeDatabase: connections.removeDatabase,
};
+323
View File
@@ -0,0 +1,323 @@
/**
* Driver Firebird com connection pooling.
*
* Mantém um pool de conexões pré-abertas por configuração de banco.
* Elimina o overhead de attach/detach a cada query (~18ms por query).
*
* API pública idêntica à versão anterior transparente para os controllers.
*/
const Firebird = require('node-firebird');
// ============================================================
// Configuração do Pool
// ============================================================
const POOL_SIZE = parseInt(process.env.FB_POOL_SIZE, 10) || 5;
const POOL_MAX = POOL_SIZE * 3; // máximo de conexões simultâneas
// Um pool por config (key = host:port:database)
const pools = new Map();
function configKey(config) {
return `${config.host}:${config.port}:${config.database}`;
}
// ============================================================
// Pool de Conexões
// ============================================================
class Pool {
constructor(config, size) {
this.config = config;
this.size = size;
this.maxSize = POOL_MAX;
this.available = [];
this.busy = 0;
this.waiting = [];
this.ready = false;
this.initPromise = null;
}
async _init() {
if (this.ready) return;
if (this.initPromise) return this.initPromise;
this.initPromise = (async () => {
const errors = [];
for (let i = 0; i < this.size; i++) {
try {
const db = await this._attach();
this.available.push(db);
} catch (e) {
errors.push(e.message);
}
}
if (this.available.length === 0) {
throw new Error(`Pool: falha ao abrir conexões: ${errors.join('; ')}`);
}
this.ready = true;
console.log(`[Pool] ${this.available.length} conexões abertas para ${configKey(this.config)}`);
})();
return this.initPromise;
}
_attach() {
return new Promise((resolve, reject) => {
Firebird.attach(this.config, (err, db) => {
if (err) return reject(err);
resolve(db);
});
});
}
async acquire() {
if (!this.ready) await this._init();
// Conexão disponível no pool
if (this.available.length > 0) {
this.busy++;
return this.available.pop();
}
// Pool esgotado mas dentro do limite máximo: abre nova
if (this.busy < this.maxSize) {
try {
const db = await this._attach();
this.busy++;
return db;
} catch (e) {
console.error('[Pool] Falha ao abrir conexão extra:', e.message);
throw e;
}
}
// Limite máximo atingido: entra na fila de espera
return new Promise((resolve) => {
this.waiting.push(resolve);
});
}
release(db) {
if (this.waiting.length > 0) {
// Entrega para o próximo da fila
const resolve = this.waiting.shift();
resolve(db);
} else {
// Devolve ao pool se ainda há espaço
if (this.available.length + this.busy <= this.maxSize) {
this.available.push(db);
} else {
try { db.detach(); } catch (_) {}
}
this.busy--;
}
}
async close() {
this.ready = false;
const all = [...this.available];
this.available = [];
for (const db of all) {
try { db.detach(); } catch (_) {}
}
}
}
function getPool(config) {
const key = configKey(config);
if (!pools.has(key)) {
pools.set(key, new Pool(config, POOL_SIZE));
}
return pools.get(key);
}
// ============================================================
// Leitura de BLOBs
// ============================================================
function readBlob(blobFunc) {
return new Promise((resolve, reject) => {
if (typeof blobFunc !== 'function') return resolve(blobFunc);
blobFunc((err, name, emitter) => {
if (err) return reject(err);
if (!emitter || typeof emitter.on !== 'function') return resolve(null);
const chunks = [];
let total = 0;
emitter.on('data', (c) => { chunks.push(c); total += c.length; });
emitter.on('end', () => resolve(Buffer.concat(chunks, total)));
emitter.on('error', reject);
});
});
}
// ============================================================
// Query (SELECT) — via pool
// ============================================================
function query(config, sql, params = []) {
return new Promise((resolve, reject) => {
getPool(config).acquire().then((db) => {
const allRows = [];
db.sequentially(sql, params, (row) => {
const keys = Object.keys(row);
const blobPromises = keys.map((key) => {
const val = row[key];
if (typeof val === 'function') {
return readBlob(val).then((data) => { row[key] = data; });
}
return Promise.resolve();
});
return Promise.all(blobPromises).then(() => { allRows.push(row); });
}, (queryErr) => {
getPool(config).release(db);
if (queryErr) return reject(new Error(`Erro na consulta (firebird): ${queryErr.message}`));
resolve(allRows);
});
}).catch((err) => {
reject(new Error(`Erro ao conectar (firebird): ${err.message}`));
});
});
}
// ============================================================
// Execute (INSERT/UPDATE/DELETE/DDL) — via pool
// ============================================================
function execute(config, sql, params = []) {
return new Promise((resolve, reject) => {
getPool(config).acquire().then((db) => {
const pool = getPool(config);
db.transaction(Firebird.ISOLATION_READ_COMMITTED, (transErr, transaction) => {
if (transErr) {
pool.release(db);
return reject(new Error(`Erro ao iniciar transação (firebird): ${transErr.message}`));
}
transaction.query(sql, params, (queryErr, result) => {
if (queryErr) {
try { transaction.rollback(); } catch (_) {}
pool.release(db);
return reject(new Error(`Erro na execução (firebird): ${queryErr.message}`));
}
transaction.commit((commitErr) => {
pool.release(db);
if (commitErr) return reject(new Error(`Erro ao commitar (firebird): ${commitErr.message}`));
resolve({ affectedRows: result ? result.length : 0, result });
});
});
});
}).catch((err) => {
reject(new Error(`Erro ao conectar (firebird): ${err.message}`));
});
});
}
// ============================================================
// Teste de conexão (usa pool)
// ============================================================
async function testConnection(config) {
await query(config, 'SELECT 1 FROM RDB$DATABASE');
return true;
}
// ============================================================
// Introspecção
// ============================================================
async function listTables(config) {
const rows = await query(config, `
SELECT TRIM(RDB$RELATION_NAME) AS TABLE_NAME
FROM RDB$RELATIONS
WHERE RDB$SYSTEM_FLAG = 0 AND RDB$RELATION_TYPE = 0
ORDER BY RDB$RELATION_NAME
`);
return rows.map((r) => (r.TABLE_NAME || '').trim()).filter(Boolean);
}
async function tableInfo(config, tableName) {
const rows = await query(config, `
SELECT
rf.RDB$FIELD_NAME AS COLUMN_NAME,
rf.RDB$FIELD_POSITION AS ORDINAL_POSITION,
CASE f.RDB$FIELD_TYPE
WHEN 7 THEN 'SMALLINT' WHEN 8 THEN 'INTEGER' WHEN 16 THEN 'BIGINT'
WHEN 9 THEN 'QUAD' WHEN 10 THEN 'FLOAT' WHEN 27 THEN 'DOUBLE PRECISION'
WHEN 12 THEN 'DATE' WHEN 13 THEN 'TIME' WHEN 35 THEN 'TIMESTAMP'
WHEN 37 THEN 'VARCHAR' WHEN 40 THEN 'CSTRING' WHEN 45 THEN 'BLOB_ID'
WHEN 261 THEN 'BLOB' WHEN 14 THEN 'CHAR' WHEN 41 THEN 'NUMERIC'
ELSE 'UNKNOWN'
END AS DATA_TYPE,
f.RDB$FIELD_LENGTH AS FIELD_LENGTH,
f.RDB$FIELD_SCALE AS FIELD_SCALE,
f.RDB$FIELD_PRECISION AS FIELD_PRECISION,
rf.RDB$NULL_FLAG AS NULL_FLAG
FROM RDB$RELATION_FIELDS rf
INNER JOIN RDB$FIELDS f ON rf.RDB$FIELD_SOURCE = f.RDB$FIELD_NAME
WHERE rf.RDB$RELATION_NAME = ?
ORDER BY rf.RDB$FIELD_POSITION
`, [String(tableName).toUpperCase()]);
return rows.map((row) => ({
name: (row.COLUMN_NAME || '').trim(),
position: row.ORDINAL_POSITION,
type: (row.DATA_TYPE || '').trim(),
length: row.FIELD_LENGTH,
precision: row.FIELD_PRECISION,
scale: row.FIELD_SCALE,
nullable: row.NULL_FLAG !== 1,
}));
}
async function tableExists(config, tableName) {
const r = await query(config,
"SELECT COUNT(*) AS CT FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = ?",
[String(tableName).toUpperCase()]);
return (r[0] && r[0].CT > 0) || false;
}
async function columnExists(config, tableName, columnName) {
const r = await query(config,
"SELECT COUNT(*) AS CT FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = ? AND RDB$FIELD_NAME = ?",
[String(tableName).toUpperCase(), String(columnName).toUpperCase()]);
return (r[0] && r[0].CT > 0) || false;
}
/**
* Encerra todos os pools de conexão.
*/
async function close() {
const promises = [];
for (const pool of pools.values()) {
promises.push(pool.close().catch(() => {}));
}
await Promise.all(promises);
pools.clear();
}
/**
* Retorna estatísticas dos pools para debug.
*/
function poolStats() {
const result = {};
for (const [key, pool] of pools) {
result[key] = {
available: pool.available.length,
busy: pool.busy,
waiting: pool.waiting.length,
maxSize: pool.maxSize,
};
}
return result;
}
module.exports = {
query,
execute,
testConnection,
listTables,
tableInfo,
tableExists,
columnExists,
close,
poolStats,
};
+44 -4
View File
@@ -1101,7 +1101,7 @@ async function carregarConversas() {
});
}
renderConversas(data.data, busca);
} catch(e) {}
} catch(e) { if (Chatc2Toast) Chatc2Toast.error('Erro ao carregar conversas'); }
}
var AVATAR_COLORS = ['#667eea','#764ba2','#f093fb','#4facfe','#43e97b','#fa709a','#fee140','#30cfd0','#a18cd1','#fda085'];
@@ -1151,6 +1151,23 @@ function renderConversas(convs, busca) {
return '<span class="conv-badge" style="background:' + esc(l.cor || '#667eea') + '">' + esc(l.nome || '') + '</span>';
}).join('');
const ultimaMsg = (c.ultimaMsg || '').substring(0, 60);
// Preview com ícone por tipo de mensagem
var previewHtml;
if (!c.ultimaMsg) {
previewHtml = '<em>Sem mensagens</em>';
} else if (c.ultimaTipo === 'audio' || c.ultimaTipo === 'ptt') {
previewHtml = '🎤 Mensagem de voz';
} else if (c.ultimaTipo === 'image') {
previewHtml = '🖼️ Imagem';
} else if (c.ultimaTipo === 'video') {
previewHtml = '🎥 Vídeo';
} else if (c.ultimaTipo === 'document') {
previewHtml = '📎 Documento';
} else if (c.ultimaTipo === 'triagem') {
previewHtml = '🤖 Menu de triagem';
} else {
previewHtml = esc(ultimaMsg);
}
const tempo = formatTime(c.dtUltimaMsg);
return '<div class="conv-item ' + isActive + '" onclick="abrirConversa(' + c.id + ')" data-id="' + c.id + '">' +
'<div class="conv-avatar" style="background:' + cor + '">' + iniciais + '</div>' +
@@ -1159,7 +1176,7 @@ function renderConversas(convs, busca) {
'<div class="conv-nome">' + esc(nome) + '</div>' +
'<div style="font-size:10px;color:rgba(255,255,255,0.35);flex-shrink:0">' + tempo + '</div>' +
'</div>' +
'<div class="conv-ultima">' + (ultimaMsg ? esc(ultimaMsg) : '<em>Sem mensagens</em>') + '</div>' +
'<div class="conv-ultima">' + previewHtml + '</div>' +
'<div class="conv-status">' + statusDot + ' ' + (c.status === 'E' ? 'Em espera' : c.status === 'A' ? 'Aberto' : 'Finalizado') + '</div>' +
(labelsHtml ? '<div class="conv-badges">' + labelsHtml + '</div>' : '') +
'</div>' +
@@ -1491,8 +1508,20 @@ async function carregarMensagens() {
headers: { 'Authorization': 'Bearer ' + token }
});
var data = await res.json();
if (data.success) renderMensagens(data.data);
} catch(e) {}
if (data.success) {
// Notificação desktop: detecta novas mensagens de cliente
var novasDoCliente = data.data.filter(function(m) {
return m.remetente === 'C' && m.id > ultimaMsgIdGlobal && m.privada !== 'S';
});
if (novasDoCliente.length > 0 && Chatc2Notify) {
var ultima = novasDoCliente[novasDoCliente.length - 1];
Chatc2Notify.show('Nova mensagem', (ultima.mensagem || 'Mídia recebida').substring(0, 100));
}
// Atualiza ID máximo global
data.data.forEach(function(m) { if (m.id > ultimaMsgIdGlobal) ultimaMsgIdGlobal = m.id; });
renderMensagens(data.data);
}
} catch(e) { if (Chatc2Toast) Chatc2Toast.error('Erro ao carregar mensagens'); }
carregandoMsgs = false;
}
@@ -2110,11 +2139,20 @@ window.logout = function() {
};
// Auto-refresh a cada 5 segundos
let ultimaMsgIdGlobal = 0;
setInterval(function() {
carregarConversas();
if (conversaAtiva) carregarMensagens();
}, 5000);
// Notificação desktop: pede permissão na primeira interação
document.addEventListener('click', function pedirPermissao() {
document.removeEventListener('click', pedirPermissao);
if (Chatc2Notify && Chatc2Notify.isSupported()) {
Chatc2Notify.requestPermission();
}
}, { once: true });
// Presença: marca o atendente como "online" para o dashboard
function pingPresenca() {
fetch('/api/' + alias + '/dashboard/ping', {
@@ -2133,6 +2171,8 @@ document.getElementById('msgInput').addEventListener('input', function() {
})();
</script>
<script src="/js/dark-mode.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/toast.js"></script>
<!-- impeccable-live-start -->
<script src="http://localhost:8400/live.js"></script>
<!-- impeccable-live-end -->
+30 -8
View File
@@ -1,14 +1,29 @@
// Dark Mode Controller — Unified for all Chatc2 pages
(function() {
// Detecta preferência do sistema
var osPrefersDark = window.matchMedia('(prefers-color-scheme: dark)');
var manualOverride = localStorage.getItem('chatc2_dark_mode_manual');
// Decide tema inicial: manual > preferência OS > claro
function getInitialTheme() {
if (manualOverride !== null) return manualOverride === 'true';
return osPrefersDark.matches;
}
// Apply on load immediately (FOUC prevention)
if (localStorage.getItem('chatc2_dark_mode') === 'true') {
if (getInitialTheme()) {
document.documentElement.classList.add('dark-mode-pending');
}
function applyTheme(enable) {
function applyTheme(enable, isManual) {
document.documentElement.classList.remove('dark-mode-pending');
document.body && document.body.classList.toggle('dark-mode', enable);
if (document.body) {
document.body.classList.toggle('dark-mode', enable);
}
localStorage.setItem('chatc2_dark_mode', enable ? 'true' : 'false');
if (isManual) {
localStorage.setItem('chatc2_dark_mode_manual', enable ? 'true' : 'false');
}
document.querySelectorAll('.dark-mode-toggle').forEach(function(btn) {
btn.innerHTML = enable ? '☀️ Claro' : '🌙 Escuro';
});
@@ -16,21 +31,28 @@
window.darkModeToggle = function() {
var isDark = localStorage.getItem('chatc2_dark_mode') === 'true';
applyTheme(!isDark);
applyTheme(!isDark, true); // toggle manual
};
window.darkModeApply = function(enable) { applyTheme(enable); };
window.darkModeApply = function(enable) { applyTheme(enable, false); };
window.darkModeIsDark = function() {
return localStorage.getItem('chatc2_dark_mode') === 'true';
};
// Apply stored preference after DOM ready
// Reage a mudanças no tema do sistema (só quando não há override manual)
osPrefersDark.addEventListener('change', function(e) {
if (localStorage.getItem('chatc2_dark_mode_manual') === null) {
applyTheme(e.matches, false);
}
});
// Apply after DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
applyTheme(localStorage.getItem('chatc2_dark_mode') === 'true');
applyTheme(getInitialTheme(), false);
});
} else {
applyTheme(localStorage.getItem('chatc2_dark_mode') === 'true');
applyTheme(getInitialTheme(), false);
}
})();
+94
View File
@@ -0,0 +1,94 @@
/**
* Notificações Desktop Chatc2
*
* Incluir em páginas que precisam de notificações (chat, dashboard):
* <script src="/js/notifications.js"></script>
*
* Uso:
* Chatc2Notify.requestPermission(); // pede permissão ao usuário
* Chatc2Notify.show('Nova mensagem', 'Cliente enviou uma mensagem');
*
* Respeita: permissão do navegador, foco da janela (não notifica se está focada).
*/
(function() {
var permitido = false;
var inicializado = false;
function init() {
if (inicializado) return;
inicializado = true;
// Verifica suporte
if (!('Notification' in window)) return;
// Verifica permissão atual
if (Notification.permission === 'granted') {
permitido = true;
} else if (Notification.permission === 'denied') {
permitido = false;
}
}
window.Chatc2Notify = {
/** Pede permissão ao usuário (deve ser chamado após interação do usuário). */
requestPermission: function() {
init();
if (!('Notification' in window)) return;
if (Notification.permission === 'granted') {
permitido = true;
return;
}
if (Notification.permission === 'denied') return;
Notification.requestPermission().then(function(permission) {
permitido = permission === 'granted';
});
},
/**
* Exibe uma notificação.
* dispara se: permissão concedida E janela não está focada.
*/
show: function(title, body, icon) {
init();
if (!permitido) return;
if (document.hasFocus && document.hasFocus()) return; // já está olhando
try {
var n = new Notification(title, {
body: body || '',
icon: icon || '/favicon.ico',
tag: 'chatc2',
});
// Clica na notificação → foca a janela
n.onclick = function() {
window.focus();
n.close();
};
// Auto-fecha após 5s
setTimeout(function() { n.close(); }, 5000);
} catch(e) {
// Ignora erros (ex: Service Worker não registrado)
}
},
/** Retorna true se notificações estão disponíveis. */
isSupported: function() {
init();
return 'Notification' in window;
},
/** Retorna true se permissão está concedida. */
isPermitted: function() {
init();
return permitido;
},
};
// Inicializa ao carregar
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+70
View File
@@ -0,0 +1,70 @@
/**
* Toast notifications Chatc2
*
* Incluir em páginas que precisam de feedback visual:
* <script src="/js/toast.js"></script>
*
* Uso:
* Chatc2Toast.success('Mensagem enviada!');
* Chatc2Toast.error('Erro ao carregar conversas');
* Chatc2Toast.warn('Conexão lenta...');
*/
(function() {
var container = null;
function ensureContainer() {
if (container) return container;
container = document.createElement('div');
container.id = 'chatc2-toast-container';
container.style.cssText = 'position:fixed;top:20px;right:20px;z-index:99999;display:flex;flex-direction:column;gap:8px;pointer-events:none;';
document.body.appendChild(container);
return container;
}
function show(message, type) {
var c = ensureContainer();
var toast = document.createElement('div');
var colors = {
success: { bg: '#d1fae5', color: '#065f46', border: '#6ee7b7' },
error: { bg: '#fef2f2', color: '#991b1b', border: '#fca5a5' },
warn: { bg: '#fef3c7', color: '#92400e', border: '#fcd34d' },
info: { bg: '#ede9fe', color: '#5b21b6', border: '#c4b5fd' },
};
var col = colors[type] || colors.info;
toast.style.cssText = 'pointer-events:auto;padding:10px 16px;border-radius:8px;font-size:13px;font-weight:500;' +
'max-width:380px;box-shadow:0 4px 16px rgba(0,0,0,0.12);animation:chatc2ToastIn 0.25s ease;' +
'background:' + col.bg + ';color:' + col.color + ';border-left:4px solid ' + col.border + ';';
toast.textContent = message;
c.appendChild(toast);
// Auto-remove
var duration = type === 'error' ? 6000 : 3500;
setTimeout(function() {
toast.style.opacity = '0';
toast.style.transform = 'translateX(20px)';
toast.style.transition = 'all 0.2s ease';
setTimeout(function() {
if (toast.parentNode) toast.parentNode.removeChild(toast);
}, 200);
}, duration);
}
// Inject keyframe
if (!document.getElementById('chatc2-toast-style')) {
var style = document.createElement('style');
style.id = 'chatc2-toast-style';
style.textContent = '@keyframes chatc2ToastIn{from{opacity:0;transform:translateX(20px)}to{opacity:1;transform:translateX(0)}}';
document.head.appendChild(style);
}
window.Chatc2Toast = {
success: function(msg) { show(msg, 'success'); },
error: function(msg) { show(msg, 'error'); },
warn: function(msg) { show(msg, 'warn'); },
info: function(msg) { show(msg, 'info'); },
};
})();
+1
View File
@@ -19,6 +19,7 @@ router.post('/:alias/conversations/:id/labels', authenticateToken, ChatControlle
router.post('/:alias/conversations/:id/link-client', authenticateToken, ChatController.linkClient);
router.get('/:alias/conversations/:id/boletos', authenticateToken, ChatController.getBoletos);
router.post('/:alias/conversations/:id/send-boleto', authenticateToken, ChatController.sendBoleto);
router.get('/:alias/search', authenticateToken, ChatController.search);
router.get('/:alias/media/:mediaId', ChatController.getMedia);
module.exports = router;
+18
View File
@@ -10,6 +10,8 @@ const databaseRoutes = require('./databaseRoutes');
const dashboardRoutes = require('./dashboardRoutes');
const RoutesController = require('../controllers/routesController');
const EvolutionController = require('../controllers/evolutionController');
const authenticateToken = require('../middlewares/auth');
const metrics = require('../services/metrics');
// Webhook Evolution (sem auth - DEVE vir antes de qualquer middleware de auth)
// Suporta URL com alias (ex: /api/lajedo/webhook/evolution) ou sem alias
@@ -26,6 +28,22 @@ router.get('/api/:alias/media/:mediaId', require('../controllers/chatController'
// CSAT (sem auth - formulário público de avaliação)
router.post('/api/:alias/csat/avaliar', require('../controllers/chatController').csatAvaliar);
// Métricas (apenas gerentes)
router.get('/api/:alias/metrics', authenticateToken, (req, res) => {
if (String(req.user?.tipoChat || req.user?.tipo || '').trim() !== 'G') {
return res.status(403).json({ success: false, error: 'Apenas gerentes.' });
}
res.json({
success: true,
uptimeSeconds: Math.floor(process.uptime()),
memory: process.memoryUsage(),
metrics: metrics.getAll(),
cache: require('../services/cacheService').stats(),
pool: require('../database').poolStats(),
timestamp: new Date().toISOString(),
});
});
// Rota de descoberta de rotas (sem auth)
router.get('/api/routes', RoutesController.list);
+10
View File
@@ -24,6 +24,16 @@ async function start() {
console.log('\n⚠️ Alguns bancos não puderam ser conectados. Verifique as configurações.');
}
// Executa migrações pendentes automaticamente (idempotente, seguro)
console.log('');
const migrationRunner = require('../scripts/migrationRunner');
for (const alias of listAliases()) {
try { await migrationRunner.executarMigracoes(alias); } catch (e) {
console.log(` ⚠️ Migrações para "${alias}" ignoradas: ${e.message.substring(0, 80)}`);
}
}
console.log('');
const server = app.listen(PORT, () => {
console.log(`\n🚀 API Chatc2 rodando em http://localhost:${PORT}`);
console.log(`📋 Aliases disponíveis: ${listAliases().join(', ')}`);
+308
View File
@@ -0,0 +1,308 @@
/**
* Serviço de geração e envio de boletos.
*
* Centraliza a montagem do payload, chamada à API externa (cobpagweb.com.br)
* e envio do PDF via Evolution API. Antes duplicado entre triageController
* e chatController.
*/
const db = require('../database');
const evolution = require('./evolutionService');
const https = require('https');
// ============================================================
// Helpers de formatação
// ============================================================
function fmtCpf(cpf) {
if (!cpf) return '';
const d = String(cpf).replace(/\D/g, '');
return d.length === 11 ? d.slice(0, 3) + '.' + d.slice(3, 6) + '.' + d.slice(6, 9) + '-' + d.slice(9) : cpf;
}
function fmtData(dt) {
if (!dt) return null;
if (typeof dt === 'string') return dt.split('T')[0];
if (dt instanceof Date) return dt.toISOString().split('T')[0];
return String(dt);
}
function fmtDataBR(dt) {
if (!dt) return '-';
try {
let s = fmtData(dt);
if (s) {
const partes = s.split('-');
if (partes.length === 3) return partes[2] + '/' + partes[1] + '/' + partes[0];
}
} catch (_) {}
return '-';
}
function fmtValor(v) {
const n = Number(v);
if (isNaN(n)) return '';
return 'R$ ' + n.toFixed(2).replace('.', ',');
}
// ============================================================
// Montagem do payload para API de boleto
// ============================================================
/**
* Monta o payload completo para envio à API de geração de boleto (cobpagweb.com.br).
* Busca dados do cliente, carnê e empresa no banco.
*
* @param {string} alias - Alias do banco
* @param {number} clienteId - ID do cliente
* @param {number} carneId - ID do carnê (título)
* @returns {Promise<{payload: object, matricula: string, vencimento: string}|null>}
*/
async function montarPayloadBoleto(alias, clienteId, carneId) {
// Busca cliente
const cli = await db.query(alias, `
SELECT CLI_CODIGO_ID, CLI_NOME, CLI_NOME_FANTASIA, CLI_CPF, CLI_ENDERECO,
CLI_BAIRRO, CLI_CEP, CLI_CIDADES_ID, CLI_MATRICULA, CLI_EMPRESA_ID
FROM CLIENTES WHERE CLI_CODIGO_ID = ?
`, [clienteId]);
if (cli.length === 0) return null;
const matricula = (cli[0].CLI_MATRICULA || '').trim() || '000000';
// Busca carnê
const car = await db.query(alias, `
SELECT CAR_CODIGO_ID, CAR_DT_VENCIMENTO, CAR_VALOR_PARCELA
FROM CARNES WHERE CAR_CODIGO_ID = ? AND CAR_CLIENTE_ID = ?
`, [carneId, clienteId]);
if (car.length === 0) return null;
// Busca detalhes do carnê (código de barras, linha digitável, etc.)
const det = await db.query(alias, `
SELECT CAR_CODIGO_BARRAS, CAR_AGEN_COD_CEDENTE, CAR_NUM_BANCARIO,
CAR_DT_CADASTRO, CAR_DT_PROCESSAMENTO, CAR_NUMERO_DOCUMENTO,
CAR_LINHA_DIGITAVEL, CAR_NOSSO_NUMERO, CAR_PIX_QRCODE
FROM CARNES WHERE CAR_CODIGO_ID = ?
`, [carneId]);
// Busca cidade
let cidadeNome = '', cidadeUf = '';
if (cli[0].CLI_CIDADES_ID) {
const cid = await db.query(alias,
'SELECT CID_NOME, CID_UF FROM CIDADES WHERE CID_CODIGO_ID = ?',
[cli[0].CLI_CIDADES_ID]);
if (cid.length > 0) {
cidadeNome = (cid[0].CID_NOME || '').trim();
cidadeUf = (cid[0].CID_UF || '').trim();
}
}
// Busca dados da empresa
let empresaBoleto = null;
if (cli[0].CLI_EMPRESA_ID) {
const empData = await db.query(alias, `
SELECT EMP_NOME, EMP_CNPJ, EMP_FOTO
FROM EMPRESAS WHERE EMP_CODIGO_ID = ?
`, [cli[0].CLI_EMPRESA_ID]);
if (empData.length > 0) {
let fotoBase64 = null;
if (empData[0].EMP_FOTO) {
try {
const buf = empData[0].EMP_FOTO;
if (Buffer.isBuffer(buf)) fotoBase64 = buf.toString('base64');
else if (typeof buf === 'string') fotoBase64 = buf;
} catch (_) {}
}
empresaBoleto = {
EMP_NOME: (empData[0].EMP_NOME || '').trim(),
EMP_CNPJ: (empData[0].EMP_CNPJ || '').trim(),
EMP_FOTO: fotoBase64,
};
}
}
const payload = {
empresa: empresaBoleto,
Cliente: {
CLI_NOME_FANTASIA: (cli[0].CLI_NOME_FANTASIA || cli[0].CLI_NOME || '').trim(),
CLI_CPF_CNPJ: fmtCpf(cli[0].CLI_CPF),
CLI_ENDERECO: (cli[0].CLI_ENDERECO || '').trim(),
CLI_BAIRRO: (cli[0].CLI_BAIRRO || '').trim(),
CLI_CEP: (cli[0].CLI_CEP || '').trim(),
CLI_CIDADE: cidadeNome,
CLI_UF: cidadeUf,
},
Carnes: [{
CAR_CODIGO_BARRAS: (det[0]?.CAR_CODIGO_BARRAS || '').trim(),
CAR_DT_VENCIMENTO: fmtData(car[0].CAR_DT_VENCIMENTO),
CAR_AGEN_COD_CEDENTE: (det[0]?.CAR_AGEN_COD_CEDENTE || '').trim(),
CAR_NUM_BANCARIO: (det[0]?.CAR_NUM_BANCARIO || '').trim(),
CAR_DT_CADASTRO: fmtData(det[0]?.CAR_DT_CADASTRO),
CAR_DT_PROCESSAMENTO: fmtData(det[0]?.CAR_DT_PROCESSAMENTO),
CAR_NUMERO_DOCUMENTO: (det[0]?.CAR_NUMERO_DOCUMENTO || '').trim(),
CAR_VALOR_PARCELA: car[0].CAR_VALOR_PARCELA,
CAR_LINHA_DIGITAVEL: (det[0]?.CAR_LINHA_DIGITAVEL || '').trim(),
CAR_NOSSO_NUMERO: (det[0]?.CAR_NOSSO_NUMERO || '').trim(),
CAR_PIX_QRCODE: (det[0]?.CAR_PIX_QRCODE || '').trim(),
}],
};
return {
payload,
matricula,
vencimento: fmtData(car[0].CAR_DT_VENCIMENTO),
valor: car[0].CAR_VALOR_PARCELA,
linhaDigitavel: (det[0]?.CAR_LINHA_DIGITAVEL || '').trim(),
pixQrcode: (det[0]?.CAR_PIX_QRCODE || '').trim(),
codigoBarras: (det[0]?.CAR_CODIGO_BARRAS || '').trim(),
};
}
// ============================================================
// Geração do PDF via API externa
// ============================================================
/**
* Gera o PDF do boleto via API externa (cobpagweb.com.br).
*
* @param {object} payload - Payload montado por montarPayloadBoleto()
* @returns {Promise<string|null>} PDF em base64 ou null se falhar
*/
function gerarPDF(payload) {
return new Promise((resolve) => {
const body = JSON.stringify(payload);
const url = new URL('https://cobpagweb.com.br/boleto/cliente/index.php?base64');
const req = https.request({
hostname: url.hostname,
port: 443,
path: url.pathname + url.search,
method: 'POST',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
}
}, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const buf = Buffer.concat(chunks);
try {
const parsed = JSON.parse(buf.toString());
const pdf = parsed.base64 || parsed.pdf || parsed.data;
if (pdf && pdf.length > 100) {
resolve(pdf);
} else {
console.log('[Boleto] API retornou resposta sem PDF válido');
resolve(null);
}
} catch (_) {
resolve(null);
}
});
});
req.on('error', (err) => {
console.error('[Boleto] Erro na API:', err.message);
resolve(null);
});
req.on('timeout', () => { req.destroy(); resolve(null); });
req.write(body);
req.end();
});
}
// ============================================================
// Envio do boleto (PDF ou texto)
// ============================================================
/**
* Gera e envia boleto em PDF para o cliente via Evolution API.
*
* @param {string} alias - Alias do banco
* @param {object} instancia - Objeto da instância Evolution
* @param {string} numero - Número do destinatário
* @param {number} clienteId - ID do cliente
* @param {number} carneId - ID do carnê (título)
* @returns {Promise<{sucesso: boolean, pdfEnviado: boolean}>}
*/
async function gerarEnviarBoletoPDF(alias, instancia, numero, clienteId, carneId) {
const dados = await montarPayloadBoleto(alias, clienteId, carneId);
if (!dados) return { sucesso: false, pdfEnviado: false };
const pdfBase64 = await gerarPDF(dados.payload);
if (pdfBase64) {
const fileName = dados.matricula + ' - ' + dados.vencimento + '.pdf';
try {
await evolution.sendMedia(instancia, numero, 'document', pdfBase64, fileName);
return { sucesso: true, pdfEnviado: true };
} catch (err) {
console.error('[Boleto] Erro ao enviar PDF:', err.message);
}
}
// Fallback: envia como texto
const texto = montarTextoBoleto(dados);
if (texto) {
await evolution.sendText(instancia, numero, texto);
return { sucesso: true, pdfEnviado: false };
}
return { sucesso: false, pdfEnviado: false };
}
/**
* Monta o texto do boleto (usado como fallback quando PDF não é gerado).
*
* @param {object} dados - Dados retornados por montarPayloadBoleto()
* @returns {string}
*/
function montarTextoBoleto(dados) {
if (!dados) return '';
let texto = '📄 *Boleto - Dados para pagamento*\n\n';
texto += '💵 Valor: ' + fmtValor(dados.valor) + '\n';
texto += '📅 Vencimento: ' + fmtDataBR(dados.vencimento) + '\n';
if (dados.codigoBarras) {
texto += '\n🔢 *Código de Barras:*\n' + dados.codigoBarras + '\n';
}
if (dados.linhaDigitavel) {
texto += '\n📝 *Linha Digitável:*\n' + dados.linhaDigitavel + '\n';
}
if (dados.pixQrcode) {
texto += '\n💳 PIX disponível (cópia e cola)';
}
return texto;
}
/**
* Monta texto resumido do boleto para envio manual pelo atendente.
* Usa formato mais enxuto (usado em chatController.sendBoleto).
*
* @param {object} carne - Linha da tabela CARNES (pelo menos: CAR_VALOR_PARCELA, CAR_DT_VENCIMENTO, CAR_NUMERO_PARCELA, CAR_NUMERO_TOTAL_PARCELAS)
* @param {string} linha - Linha digitável ou código de barras
* @param {string} pix - PIX copia-e-cola
* @returns {string}
*/
function montarTextoBoletoResumido(carne, linha, pix) {
let texto = '📄 *Boleto*';
if (carne.CAR_NUMERO_PARCELA) {
texto += ' - Parcela ' + carne.CAR_NUMERO_PARCELA +
(carne.CAR_NUMERO_TOTAL_PARCELAS ? '/' + carne.CAR_NUMERO_TOTAL_PARCELAS : '');
}
texto += '\n';
if (carne.CAR_VALOR_PARCELA != null) texto += '\n💰 Valor: ' + fmtValor(carne.CAR_VALOR_PARCELA);
if (carne.CAR_DT_VENCIMENTO) texto += '\n📅 Vencimento: ' + fmtDataBR(carne.CAR_DT_VENCIMENTO);
if (linha) texto += '\n\n*Linha digitável:*\n' + linha;
if (pix) texto += '\n\n*PIX (copia e cola):*\n' + pix;
return texto;
}
module.exports = {
montarPayloadBoleto,
gerarPDF,
gerarEnviarBoletoPDF,
montarTextoBoleto,
montarTextoBoletoResumido,
fmtDataBR,
fmtValor,
};
+89
View File
@@ -0,0 +1,89 @@
/**
* Cache em memória com TTL.
*
* Simples, sem dependências externas. Usado para evitar consultas repetidas
* ao banco para dados que mudam raramente (ex: configurações da empresa).
*
* Uso:
* const cache = require('../services/cacheService');
* let valor = cache.get('chave');
* if (!valor) {
* valor = await db.query(...);
* cache.set('chave', valor, 60000); // TTL 60s
* }
*/
const store = new Map();
/**
* Armazena um valor no cache.
* @param {string} key
* @param {*} value
* @param {number} [ttlMs=60000] - Tempo de vida em ms (padrão: 1 minuto)
*/
function set(key, value, ttlMs = 60000) {
store.set(key, {
value,
expiresAt: Date.now() + ttlMs,
});
}
/**
* Recupera um valor do cache. Retorna undefined se expirado ou inexistente.
* @param {string} key
* @returns {*|undefined}
*/
function get(key) {
const entry = store.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
store.delete(key);
return undefined;
}
return entry.value;
}
/**
* Invalida uma chave específica.
* @param {string} key
*/
function del(key) {
store.delete(key);
}
/**
* Invalida todas as chaves que começam com o prefixo.
* Útil para invalidar todo o cache de uma empresa (ex: 'config:5:').
* @param {string} prefix
*/
function delByPrefix(prefix) {
for (const key of store.keys()) {
if (key.startsWith(prefix)) {
store.delete(key);
}
}
}
/**
* Retorna estatísticas do cache para debug.
*/
function stats() {
let total = 0, valid = 0;
const now = Date.now();
for (const [, entry] of store) {
total++;
if (entry.expiresAt > now) valid++;
}
return { total, valid, expired: total - valid };
}
// Limpeza periódica de entradas expiradas (evita vazamento de memória)
const intervalo = setInterval(() => {
const now = Date.now();
for (const [key, entry] of store) {
if (entry.expiresAt <= now) store.delete(key);
}
}, 60000);
if (intervalo.unref) intervalo.unref();
module.exports = { set, get, del, delByPrefix, stats };
+73
View File
@@ -0,0 +1,73 @@
/**
* Serviço de configuração da empresa com cache.
*
* As flags CFE_* (triagem, saudação, CSAT, etc.) são lidas em toda operação
* de chat/triagem/webhook. Este serviço armazena em cache (TTL 2min) e
* invalida automaticamente quando as configurações são alteradas.
*/
const db = require('../database');
const cache = require('./cacheService');
const TTL = 2 * 60 * 1000; // 2 minutos
/**
* Retorna a configuração completa de uma empresa (cacheada).
* Invalidação: chamar invalidarConfig(alias, empresaId) após salvar settings.
*
* @param {string} alias
* @param {number} empresaId
* @returns {Promise<object>} Linha da tabela CHATC2_CONFIGURACOES_EMPRESA
*/
async function getConfig(alias, empresaId) {
const key = 'cfg:' + alias + ':' + empresaId;
const cached = cache.get(key);
if (cached) return cached;
// Garante que o registro existe (insere se não existir)
await garantirRegistro(alias, empresaId);
const result = await db.query(alias,
'SELECT * FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?',
[empresaId]);
const config = result[0] || {};
cache.set(key, config, TTL);
return config;
}
/**
* Invalida o cache de configuração de uma empresa.
* Deve ser chamado após salvar alterações nas configurações.
*
* @param {string} alias
* @param {number} empresaId
*/
function invalidarConfig(alias, empresaId) {
cache.del('cfg:' + alias + ':' + empresaId);
}
/**
* Garante que existe um registro de configuração para a empresa.
* Idempotente se existe, não faz nada.
*/
async function garantirRegistro(alias, empresaId) {
try {
const existe = await db.query(alias,
'SELECT COUNT(*) AS CT FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?',
[empresaId]);
if (existe[0]?.CT === 0) {
await db.execute(alias, `
INSERT INTO CHATC2_CONFIGURACOES_EMPRESA (CFE_EMPRESA_ID)
VALUES (?)
`, [empresaId]);
}
} catch (e) {
// Tabela pode não existir ainda — ignora
if (!e.message.includes('unknown') && !e.message.includes('não encontrado')) {
console.error('[ConfigService] Erro ao garantir registro:', e.message.substring(0, 100));
}
}
}
module.exports = { getConfig, invalidarConfig };
+206
View File
@@ -0,0 +1,206 @@
/**
* Serviço unificado de comunicação com Evolution API.
*
* Centraliza TODAS as chamadas HTTP para a Evolution API que antes estavam
* duplicadas em chatController, triageController e evolutionController.
*
* Uso:
* const evolution = require('../services/evolutionService');
* await evolution.sendText(instancia, numero, texto);
* await evolution.sendMedia(instancia, numero, tipo, base64, nome);
* const result = await evolution.request(url, apiKey, endpoint, 'POST', body);
*/
const https = require('https');
const http = require('http');
// ============================================================
// Função interna: requisição HTTP de baixo nível
// ============================================================
function _request(baseUrl, apiKey, endpoint, method, body) {
const urlClean = (baseUrl || '').replace(/\/+$/, '');
if (!urlClean || !apiKey) return Promise.reject(new Error('URL ou API Key não configurados'));
const parsedUrl = new URL(urlClean + endpoint);
const lib = parsedUrl.protocol === 'https:' ? https : http;
const data = body ? JSON.stringify(body) : '';
return new Promise((resolve, reject) => {
const options = {
hostname: parsedUrl.hostname,
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
path: parsedUrl.pathname + parsedUrl.search,
method: method || 'GET',
timeout: 30000,
headers: { 'Content-Type': 'application/json', 'apikey': apiKey },
};
if (data) options.headers['Content-Length'] = Buffer.byteLength(data);
const req = lib.request(options, (resHttp) => {
let respBody = '';
resHttp.on('data', chunk => respBody += chunk);
resHttp.on('end', () => {
try {
const parsed = JSON.parse(respBody);
parsed._httpStatus = resHttp.statusCode;
if (resHttp.statusCode >= 400) {
reject(Object.assign(
new Error(parsed.error || parsed.response?.message?.[0] || 'HTTP ' + resHttp.statusCode),
{ _httpStatus: resHttp.statusCode, response: parsed }
));
} else {
resolve(parsed);
}
} catch (e) {
if (resHttp.statusCode >= 400) {
reject(Object.assign(new Error('HTTP ' + resHttp.statusCode), { _httpStatus: resHttp.statusCode, raw: respBody }));
} else {
resolve({ raw: respBody, _httpStatus: resHttp.statusCode });
}
}
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
if (data) req.write(data);
req.end();
});
}
// ============================================================
// Função auxiliar: extrai credenciais da instância
// ============================================================
function _extrairCredenciais(instancia) {
return {
url: ((instancia.INS_URL || '').trim()).replace(/\/+$/, ''),
apiKey: (instancia.INS_API_KEY || '').trim(),
instanceName: (instancia.INS_INSTANCE_NAME || '').trim(),
};
}
// ============================================================
// API PÚBLICA
// ============================================================
/**
* Requisição HTTP genérica para a Evolution API.
* Usada para operações avançadas: QR Code, fetchInstances, restart, fetchProfile, etc.
*
* @param {string} baseUrl - URL base da Evolution (ex: https://evo.empresa.com)
* @param {string} apiKey - API Key da Evolution
* @param {string} endpoint - Caminho (ex: /instance/fetchInstances)
* @param {string} method - GET, POST, PUT, DELETE
* @param {object} [body] - Corpo da requisição (opcional)
* @returns {Promise<object>} Resposta parseada (JSON) ou { raw, _httpStatus }
*/
async function request(baseUrl, apiKey, endpoint, method, body) {
return _request(baseUrl, apiKey, endpoint, method, body);
}
/**
* Envia mensagem de texto via Evolution API.
*
* @param {object} instancia - Objeto da instância (linha do banco: INS_URL, INS_API_KEY, INS_INSTANCE_NAME)
* @param {string} numero - Número do destinatário (com ou sem formatação)
* @param {string} texto - Texto da mensagem
* @returns {Promise<void>}
*/
async function sendText(instancia, numero, texto) {
const { url, apiKey, instanceName } = _extrairCredenciais(instancia);
if (!url || !apiKey || !instanceName || !numero) return;
const numeroLimpo = numero.replace(/\D/g, '');
const endpoint = '/message/sendText/' + encodeURIComponent(instanceName);
const payload = { number: numeroLimpo, text: texto || '', delay: 0 };
try {
await _request(url, apiKey, endpoint, 'POST', payload);
} catch (err) {
console.error('[Evolution] Erro ao enviar texto:', err.message?.substring(0, 100));
}
}
/**
* Envia mídia (imagem, áudio, vídeo, documento) via Evolution API.
*
* @param {object} instancia - Objeto da instância
* @param {string} numero - Número do destinatário
* @param {string} tipo - 'image', 'audio', 'video', 'document'
* @param {string} midiaBase64 - Conteúdo em base64
* @param {string} [nomeArquivo] - Nome do arquivo
* @returns {Promise<void>}
*/
async function sendMedia(instancia, numero, tipo, midiaBase64, nomeArquivo) {
const { url, apiKey, instanceName } = _extrairCredenciais(instancia);
if (!url || !apiKey || !instanceName || !numero || !midiaBase64) return;
const numeroLimpo = numero.replace(/\D/g, '');
const fileName = nomeArquivo || 'arquivo';
let endpoint, payload;
if (tipo === 'audio') {
// Envia como mensagem de voz (PTT) via sendWhatsAppAudio
endpoint = '/message/sendWhatsAppAudio/' + encodeURIComponent(instanceName);
payload = { number: numeroLimpo, audio: midiaBase64, encoding: true };
console.log('[Evolution] Enviando áudio PTT, tamanho base64:', midiaBase64.length);
} else if (tipo === 'image') {
endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName);
payload = { number: numeroLimpo, mediatype: 'image', media: midiaBase64, fileName };
} else if (tipo === 'video') {
endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName);
payload = { number: numeroLimpo, mediatype: 'video', media: midiaBase64, fileName };
} else {
// document e outros tipos
endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName);
payload = { number: numeroLimpo, mediatype: 'document', media: midiaBase64, fileName };
}
try {
const result = await _request(url, apiKey, endpoint, 'POST', payload);
console.log('[Evolution] Mídia enviada, status:', result._httpStatus);
return result;
} catch (err) {
console.error('[Evolution] Erro ao enviar mídia:', err.message?.substring(0, 100));
throw err;
}
}
/**
* Baixa mídia da Evolution API via GET /chat/getMedia/.
* Usado no webhook para buscar áudio/imagem/vídeo/documento recebidos.
*
* @param {string} baseUrl - URL base da Evolution
* @param {string} apiKey - API Key
* @param {string} instanceName - Nome da instância
* @param {string} messageKey - ID da mensagem (data.key.id)
* @returns {Promise<Buffer|null>}
*/
async function downloadMedia(baseUrl, apiKey, instanceName, messageKey) {
if (!baseUrl || !apiKey || !instanceName || !messageKey) return null;
const urlClean = baseUrl.replace(/\/+$/, '');
const getUrl = urlClean + '/chat/getMedia/' + encodeURIComponent(instanceName) + '/' + encodeURIComponent(messageKey);
return new Promise((resolve) => {
const u = new URL(getUrl);
const lib = u.protocol === 'https:' ? https : http;
const req = lib.get(getUrl, { timeout: 20000, headers: { 'apikey': apiKey } }, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const full = Buffer.concat(chunks);
if (res.statusCode !== 200) {
console.log('[Evolution] downloadMedia HTTP ' + res.statusCode + ': ' + full.toString('utf8').substring(0, 100));
resolve(null);
} else {
resolve(full);
}
});
});
req.on('error', () => resolve(null));
req.on('timeout', () => { req.destroy(); resolve(null); });
});
}
module.exports = { request, sendText, sendMedia, downloadMedia };
+64
View File
@@ -0,0 +1,64 @@
/**
* Serviço de logging estruturado.
*
* Substitui console.log por logs com níveis, timestamps ISO e saída
* simultânea para stdout (desenvolvimento) e arquivo (produção).
*
* Uso:
* const log = require('../services/logger');
* log.info('chat', 'Mensagem enviada', { conversaId: 123 });
* log.warn('webhook', 'Falha ao baixar mídia', { erro: err.message });
* log.error('evolution', 'Timeout', err);
*/
const fs = require('fs');
const path = require('path');
const LOG_DIR = path.join(__dirname, '../../logs');
if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
const currentLevel = process.env.LOG_LEVEL ? (LEVELS[process.env.LOG_LEVEL] ?? 1) : 1;
function formatar(level, modulo, mensagem, extra) {
const ts = new Date().toISOString();
let linha = `[${ts}] [${level.toUpperCase()}] [${modulo}] ${mensagem}`;
if (extra !== undefined) {
if (extra instanceof Error) {
linha += ' | ' + extra.message;
if (extra.stack && level === 'error') linha += '\n' + extra.stack;
} else if (typeof extra === 'object') {
linha += ' | ' + JSON.stringify(extra);
} else {
linha += ' | ' + String(extra);
}
}
return linha;
}
function escrever(level, modulo, mensagem, extra) {
if (LEVELS[level] < currentLevel) return;
const linha = formatar(level, modulo, mensagem, extra);
// Stdout (colorido se terminal interativo)
const cores = { error: '\x1b[31m', warn: '\x1b[33m', info: '\x1b[36m', debug: '\x1b[90m' };
const reset = '\x1b[0m';
if (process.stdout.isTTY) {
console.log(cores[level] + linha + reset);
} else {
console.log(linha);
}
// Arquivo (sempre, sem cores)
try {
const hoje = new Date().toISOString().slice(0, 10);
fs.appendFileSync(path.join(LOG_DIR, `app_${hoje}.log`), linha + '\n', 'utf8');
} catch (_) {}
}
module.exports = {
debug: (mod, msg, extra) => escrever('debug', mod, msg, extra),
info: (mod, msg, extra) => escrever('info', mod, msg, extra),
warn: (mod, msg, extra) => escrever('warn', mod, msg, extra),
error: (mod, msg, extra) => escrever('error', mod, msg, extra),
};
+252
View File
@@ -0,0 +1,252 @@
/**
* Serviço de manipulação de mídia.
*
* Centraliza compressão de vídeo, conversão de áudio, validação de headers
* e descriptografia de mídia do WhatsApp. Antes espalhado entre
* chatController e evolutionController.
*/
const { execFile } = require('child_process');
const fs = require('fs');
const path = require('path');
const ffmpegPath = require('ffmpeg-static');
const crypto = require('crypto');
const UPLOAD_DIR = path.join(__dirname, '../../uploads/audio');
// Garante que o diretório de uploads existe
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
// ============================================================
// Compressão de Vídeo
// ============================================================
/**
* 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 vídeo original
* @returns {Promise<Buffer>} Buffer do vídeo comprimido
*/
function compressVideo(inputBuffer) {
return new Promise((resolve, reject) => {
const inputPath = path.join(UPLOAD_DIR, 'video_in_' + Date.now() + '.mp4');
const outputPath = path.join(UPLOAD_DIR, 'video_out_' + Date.now() + '.mp4');
fs.writeFileSync(inputPath, inputBuffer);
const inputSizeMB = (inputBuffer.length / (1024 * 1024)).toFixed(1);
const targetBitrate = inputBuffer.length < 10 * 1024 * 1024 ? '1M' : '500k';
console.log('[Video] Comprimindo 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 }, (err) => {
try { fs.unlinkSync(inputPath); } catch (_) {}
if (err) {
try { fs.unlinkSync(outputPath); } catch (_) {}
console.error('[Video] Erro na compressão:', err.message.substring(0, 100));
return reject(err);
}
const outputBuffer = fs.readFileSync(outputPath);
try { fs.unlinkSync(outputPath); } catch (_) {}
const outputSizeMB = (outputBuffer.length / (1024 * 1024)).toFixed(1);
console.log('[Video] Compressão concluída: ' + inputSizeMB + 'MB -> ' + outputSizeMB + 'MB');
resolve(outputBuffer);
});
});
}
// ============================================================
// Conversão de Áudio
// ============================================================
/**
* Converte buffer de áudio WebM para Ogg Opus usando ffmpeg.
*
* @param {Buffer} inputBuffer - Buffer do áudio WebM
* @returns {Promise<Buffer>} Buffer Ogg Opus
*/
function convertWebmToOgg(inputBuffer) {
return new Promise((resolve, reject) => {
const inputPath = path.join(UPLOAD_DIR, 'input_' + Date.now() + '.webm');
const outputPath = path.join(UPLOAD_DIR, 'output_' + Date.now() + '.ogg');
fs.writeFileSync(inputPath, inputBuffer);
execFile(ffmpegPath, [
'-y',
'-i', inputPath,
'-c:a', 'libopus',
'-b:a', '16k',
'-ar', '16000',
'-ac', '1',
outputPath
], { timeout: 30000 }, (err) => {
try { fs.unlinkSync(inputPath); } catch (_) {}
if (err) {
try { fs.unlinkSync(outputPath); } catch (_) {}
return reject(err);
}
const outputBuffer = fs.readFileSync(outputPath);
try { fs.unlinkSync(outputPath); } catch (_) {}
resolve(outputBuffer);
});
});
}
// ============================================================
// Validação de Headers de Mídia
// ============================================================
/**
* Valida se um buffer tem cabeçalho de arquivo de mídia válido.
* Defesa: rejeita JSON, HTML ou dados criptografados salvos por engano.
*
* @param {Buffer} buf
* @param {string} [mimeType] - MIME type opcional para validação extra
* @returns {boolean}
*/
function validarHeaderMidia(buf, mimeType) {
if (!buf || buf.length < 4) return false;
const headerHex = buf.slice(0, 4).toString('hex');
// Headers de áudio conhecidos
const audioHeaders = ['4f676753', // OggS (ogg/opus)
'52494646', // RIFF (wav)
'66747970', // ftyp (mp4/m4a)
'494433', // ID3 (mp3)
'1a45dfa3', // WebM/Matroska EBML
'fffb', 'fff3', 'fffa', 'fff2']; // MP3 frames
for (const vh of audioHeaders) {
if (headerHex.startsWith(vh)) return true;
}
// Imagens
if (mimeType && mimeType.startsWith('image/')) {
if (headerHex.startsWith('ffd8ff') || // JPEG
headerHex.startsWith('89504e47') || // PNG
headerHex.startsWith('47494638')) // GIF
return true;
}
// Documentos (ZIP/Office)
if (mimeType && mimeType.startsWith('application/')) {
if (headerHex.startsWith('504b')) return true; // PK (ZIP)
}
return false;
}
// ============================================================
// Descriptografia de Mídia WhatsApp
// ============================================================
/**
* Descriptografa mídia do WhatsApp usando a mediaKey do webhook.
* Algoritmo: AES-256-CBC + HMAC-SHA256 (padrão Signal/WhatsApp).
*
* @param {Buffer} encryptedBuffer - Dados criptografados (inclui 10 bytes HMAC no final)
* @param {string} mediaKeyBase64 - Chave em base64 (do webhook)
* @param {string} mediaType - 'audio', 'image', 'video', 'document'
* @returns {Buffer|null} Dados descriptografados ou null se falhar
*/
function decryptWhatsAppMedia(encryptedBuffer, mediaKeyBase64, mediaType) {
try {
const mediaKey = Buffer.from(mediaKeyBase64, 'base64');
if (mediaKey.length !== 32) {
console.log('[Decrypt] mediaKey tamanho inválido:', mediaKey.length);
return null;
}
// Tipo -> info string para HKDF
const infoMap = {
'image': 'WhatsApp Image Keys',
'video': 'WhatsApp Video Keys',
'audio': 'WhatsApp Audio Keys',
'document': 'WhatsApp Document Keys'
};
const info = Buffer.from(infoMap[mediaType] || 'WhatsApp Audio Keys');
// Passo 1: HKDF Extract
const salt = Buffer.alloc(32, 0);
const prk = crypto.createHmac('sha256', salt).update(mediaKey).digest();
// Passo 2: HKDF Expand para 112 bytes
const hashLen = 32;
const numBlocks = Math.ceil(112 / hashLen);
const blocks = [];
let prev = Buffer.alloc(0);
for (let i = 0; i < numBlocks; i++) {
const hmac = crypto.createHmac('sha256', prk);
hmac.update(prev);
hmac.update(info);
hmac.update(Buffer.from([i + 1]));
prev = hmac.digest();
blocks.push(prev);
}
const expanded = Buffer.concat(blocks).slice(0, 112);
if (!expanded || expanded.length < 80) {
console.log('[Decrypt] HKDF produziu buffer muito curto:', expanded ? expanded.length : 0);
return null;
}
const cipherKey = expanded.slice(0, 32);
const macKey = expanded.slice(32, 64);
const iv = expanded.slice(64, 80);
// Passo 3: Arquivo = dados criptografados (n-10 bytes) + HMAC (últimos 10 bytes)
if (encryptedBuffer.length < 10) {
console.log('[Decrypt] Buffer muito curto:', encryptedBuffer.length);
return null;
}
const fileData = encryptedBuffer.slice(0, -10);
const expectedMac = encryptedBuffer.slice(-10);
// Passo 4: Verifica HMAC
const hmacVerify = crypto.createHmac('sha256', macKey);
hmacVerify.update(fileData);
const computedMac = hmacVerify.digest().slice(0, 10);
if (Buffer.compare(computedMac, expectedMac) !== 0) {
console.log('[Decrypt] HMAC inválido - dados corrompidos, chave errada, ou mídia expirada');
return null;
}
// Passo 5: Descriptografa AES-256-CBC
const decipher = crypto.createDecipheriv('aes-256-cbc', cipherKey, iv);
decipher.setAutoPadding(true);
const decrypted = Buffer.concat([decipher.update(fileData), decipher.final()]);
console.log('[Decrypt] Sucesso!', encryptedBuffer.length, 'bytes ->', decrypted.length, 'bytes');
return decrypted;
} catch (e) {
console.log('[Decrypt] Erro:', e.message);
return null;
}
}
module.exports = {
compressVideo,
convertWebmToOgg,
validarHeaderMidia,
decryptWhatsAppMedia,
};
+41
View File
@@ -0,0 +1,41 @@
/**
* Coletor de métricas em memória.
*
* Contadores simples para monitorar saúde da aplicação.
* Exposto via GET /api/:alias/metrics (apenas gerentes).
*
* Uso:
* const metrics = require('../services/metrics');
* metrics.incr('webhook.mensagens_recebidas');
* metrics.incr('chat.mensagens_enviadas');
*/
const counters = new Map();
function incr(name, delta = 1) {
const current = counters.get(name) || 0;
counters.set(name, current + delta);
}
function get(name) {
return counters.get(name) || 0;
}
function getAll() {
const result = {};
for (const [k, v] of counters) result[k] = v;
return result;
}
function reset() {
counters.clear();
}
// Limpa contadores de taxa a cada minuto (opcional)
setInterval(() => {
for (const [k] of counters) {
if (k.startsWith('rate.')) counters.delete(k);
}
}, 60000).unref();
module.exports = { incr, get, getAll, reset };