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