alterações de melhorias
This commit is contained in:
@@ -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(','))
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 (?, ?, ?, ?, ?, ?)',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }); }
|
||||
}
|
||||
|
||||
@@ -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); });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 -->
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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 já 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.
|
||||
* Só 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();
|
||||
}
|
||||
})();
|
||||
@@ -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'); },
|
||||
};
|
||||
})();
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(', ')}`);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 já 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 };
|
||||
@@ -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 };
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user