From 29190cd71c999ad26a4f4d787693b35c89a7293d Mon Sep 17 00:00:00 2001 From: Ayron Santoss Date: Wed, 24 Jun 2026 14:13:09 +0000 Subject: [PATCH] =?UTF-8?q?altera=C3=A7=C3=B5es=20de=20melhorias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 + Dockerfile | 78 ++++ docker-compose.yml | 59 +++ ecosystem.config.js | 50 +++ package-lock.json | 13 + package.json | 6 + scripts/migracoes.js | 488 +------------------------ scripts/migracoes.sql | 98 ++++- scripts/migrationRunner.js | 161 ++++++++ src/app.js | 8 + src/config/connections.js | 157 ++++++++ src/controllers/chatController.js | 200 ++++++---- src/controllers/clientController.js | 10 +- src/controllers/configController.js | 12 +- src/controllers/evolutionController.js | 39 +- src/controllers/genericController.js | 42 ++- src/controllers/menuController.js | 3 +- src/controllers/triageController.js | 64 ++-- src/database.js | 389 ++++---------------- src/drivers/firebird.js | 323 ++++++++++++++++ src/public/chat.html | 48 ++- src/public/js/dark-mode.js | 38 +- src/public/js/notifications.js | 94 +++++ src/public/js/toast.js | 70 ++++ src/routes/chatRoutes.js | 1 + src/routes/index.js | 18 + src/server.js | 10 + src/services/boletoService.js | 308 ++++++++++++++++ src/services/cacheService.js | 89 +++++ src/services/configService.js | 73 ++++ src/services/evolutionService.js | 206 +++++++++++ src/services/logger.js | 64 ++++ src/services/mediaService.js | 252 +++++++++++++ src/services/metrics.js | 41 +++ 34 files changed, 2570 insertions(+), 950 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 ecosystem.config.js create mode 100644 scripts/migrationRunner.js create mode 100644 src/config/connections.js create mode 100644 src/drivers/firebird.js create mode 100644 src/public/js/notifications.js create mode 100644 src/public/js/toast.js create mode 100644 src/services/boletoService.js create mode 100644 src/services/cacheService.js create mode 100644 src/services/configService.js create mode 100644 src/services/evolutionService.js create mode 100644 src/services/logger.js create mode 100644 src/services/mediaService.js create mode 100644 src/services/metrics.js diff --git a/.env.example b/.env.example index 42412e0..9215218 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c289005 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3cc4448 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000..7d49599 --- /dev/null +++ b/ecosystem.config.js @@ -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', + }, + }], +}; diff --git a/package-lock.json b/package-lock.json index 076df9d..6dacceb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 8effdd7..9a1dba0 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/scripts/migracoes.js b/scripts/migracoes.js index 743e317..ff7efab 100644 --- a/scripts/migracoes.js +++ b/scripts/migracoes.js @@ -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); }); diff --git a/scripts/migracoes.sql b/scripts/migracoes.sql index 7f393c9..08df4d7 100644 --- a/scripts/migracoes.sql +++ b/scripts/migracoes.sql @@ -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. diff --git a/scripts/migrationRunner.js b/scripts/migrationRunner.js new file mode 100644 index 0000000..6939ba4 --- /dev/null +++ b/scripts/migrationRunner.js @@ -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} 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 }; diff --git a/src/app.js b/src/app.js index 943bfea..e2d49b2 100644 --- a/src/app.js +++ b/src/app.js @@ -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(',')) diff --git a/src/config/connections.js b/src/config/connections.js new file mode 100644 index 0000000..444fb97 --- /dev/null +++ b/src/config/connections.js @@ -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, +}; diff --git a/src/controllers/chatController.js b/src/controllers/chatController.js index bb3cb29..33cd573 100644 --- a/src/controllers/chatController.js +++ b/src/controllers/chatController.js @@ -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 (?, ?, ?, ?, ?, ?)', diff --git a/src/controllers/clientController.js b/src/controllers/clientController.js index 8beb08c..053478b 100644 --- a/src/controllers/clientController.js +++ b/src/controllers/clientController.js @@ -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, diff --git a/src/controllers/configController.js b/src/controllers/configController.js index 83ce33b..540e0d6 100644 --- a/src/controllers/configController.js +++ b/src/controllers/configController.js @@ -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 }); } } diff --git a/src/controllers/evolutionController.js b/src/controllers/evolutionController.js index 5eb793f..430c0ec 100644 --- a/src/controllers/evolutionController.js +++ b/src/controllers/evolutionController.js @@ -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); }); } diff --git a/src/controllers/genericController.js b/src/controllers/genericController.js index 53dd32d..4a3317b 100644 --- a/src/controllers/genericController.js +++ b/src/controllers/genericController.js @@ -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 }); } } diff --git a/src/controllers/menuController.js b/src/controllers/menuController.js index e8d7eb6..cbfe400 100644 --- a/src/controllers/menuController.js +++ b/src/controllers/menuController.js @@ -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); diff --git a/src/controllers/triageController.js b/src/controllers/triageController.js index a73a2a5..4cc53ad 100644 --- a/src/controllers/triageController.js +++ b/src/controllers/triageController.js @@ -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); } diff --git a/src/database.js b/src/database.js index c84b601..4dc468c 100644 --- a/src/database.js +++ b/src/database.js @@ -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, }; diff --git a/src/drivers/firebird.js b/src/drivers/firebird.js new file mode 100644 index 0000000..0749624 --- /dev/null +++ b/src/drivers/firebird.js @@ -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, +}; diff --git a/src/public/chat.html b/src/public/chat.html index c7f6b39..11b2ec4 100644 --- a/src/public/chat.html +++ b/src/public/chat.html @@ -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 '' + esc(l.nome || '') + ''; }).join(''); const ultimaMsg = (c.ultimaMsg || '').substring(0, 60); + // Preview com ícone por tipo de mensagem + var previewHtml; + if (!c.ultimaMsg) { + previewHtml = 'Sem mensagens'; + } 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 '
' + '
' + iniciais + '
' + @@ -1159,7 +1176,7 @@ function renderConversas(convs, busca) { '
' + esc(nome) + '
' + '
' + tempo + '
' + '
' + - '
' + (ultimaMsg ? esc(ultimaMsg) : 'Sem mensagens') + '
' + + '
' + previewHtml + '
' + '
' + statusDot + ' ' + (c.status === 'E' ? 'Em espera' : c.status === 'A' ? 'Aberto' : 'Finalizado') + '
' + (labelsHtml ? '
' + labelsHtml + '
' : '') + '' + @@ -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() { })(); + + diff --git a/src/public/js/dark-mode.js b/src/public/js/dark-mode.js index ea95c50..15668d8 100644 --- a/src/public/js/dark-mode.js +++ b/src/public/js/dark-mode.js @@ -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); } })(); diff --git a/src/public/js/notifications.js b/src/public/js/notifications.js new file mode 100644 index 0000000..cba9165 --- /dev/null +++ b/src/public/js/notifications.js @@ -0,0 +1,94 @@ +/** + * Notificações Desktop — Chatc2 + * + * Incluir em páginas que precisam de notificações (chat, dashboard): + * + * + * 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(); + } +})(); diff --git a/src/public/js/toast.js b/src/public/js/toast.js new file mode 100644 index 0000000..44d3592 --- /dev/null +++ b/src/public/js/toast.js @@ -0,0 +1,70 @@ +/** + * Toast notifications — Chatc2 + * + * Incluir em páginas que precisam de feedback visual: + * + * + * 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'); }, + }; +})(); diff --git a/src/routes/chatRoutes.js b/src/routes/chatRoutes.js index 2512eb9..68839d3 100644 --- a/src/routes/chatRoutes.js +++ b/src/routes/chatRoutes.js @@ -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; diff --git a/src/routes/index.js b/src/routes/index.js index f5a6fe2..1a5e1a2 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -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); diff --git a/src/server.js b/src/server.js index f9beda6..f98e210 100644 --- a/src/server.js +++ b/src/server.js @@ -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(', ')}`); diff --git a/src/services/boletoService.js b/src/services/boletoService.js new file mode 100644 index 0000000..7fc5bfc --- /dev/null +++ b/src/services/boletoService.js @@ -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} 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, +}; diff --git a/src/services/cacheService.js b/src/services/cacheService.js new file mode 100644 index 0000000..61c53b4 --- /dev/null +++ b/src/services/cacheService.js @@ -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 }; diff --git a/src/services/configService.js b/src/services/configService.js new file mode 100644 index 0000000..1aac193 --- /dev/null +++ b/src/services/configService.js @@ -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} 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 }; diff --git a/src/services/evolutionService.js b/src/services/evolutionService.js new file mode 100644 index 0000000..6bf7f00 --- /dev/null +++ b/src/services/evolutionService.js @@ -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} 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} + */ +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} + */ +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} + */ +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 }; diff --git a/src/services/logger.js b/src/services/logger.js new file mode 100644 index 0000000..d42b2d7 --- /dev/null +++ b/src/services/logger.js @@ -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), +}; diff --git a/src/services/mediaService.js b/src/services/mediaService.js new file mode 100644 index 0000000..7942657 --- /dev/null +++ b/src/services/mediaService.js @@ -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 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 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, +}; diff --git a/src/services/metrics.js b/src/services/metrics.js new file mode 100644 index 0000000..76a6ec9 --- /dev/null +++ b/src/services/metrics.js @@ -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 };