alterações de melhorias

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