Compare commits

...

10 Commits

Author SHA1 Message Date
AyronSantos cf720c37a1 Ajustes 2306 2026-06-23 12:42:14 +00:00
AyronSantos e7aa5f5333 erro ao localizar tabela usuarios 2026-06-19 16:25:50 +00:00
AyronSantos 899f20eab7 Ajuste empresa usuarios 2026-06-19 13:14:59 -03:00
AyronSantos 468a8467a3 ajuste empresa usuario 2026-06-19 13:04:35 -03:00
AyronSantos 151beb10a7 Ignora pgdata/ e relatórios de migração .txt
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:45:36 -03:00
AyronSantos d9e195d924 Foto automática, boleto na conversa e novo Dashboard
- Foto do cliente atualizada automaticamente ao abrir a conversa (remove botão manual)
- Exibe CLI_FOTO na tela de Dados do Cliente; matrícula com conversão robusta
- Envio de boleto na conversa (checkbox em Configurações > Empresa): lista títulos em aberto e envia texto com linha digitável + PIX ao cliente
- Novo Dashboard (gerente): conversas (abertas/não atendidas/não atribuídas/pendentes), atendentes disponíveis/desconectados e heatmap de tráfego
- Presença de atendentes em memória + ping periódico do chat/dashboard

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:41:09 -03:00
AyronSantos aa74c984d0 Correções 2026-06-17 17:27:40 -03:00
AyronSantos 887e95c6ca Torna a interface responsiva para celular e tablet
- main.css: media queries (1024/768/480) — sidebar vira barra horizontal,
  tabelas com rolagem, busca/modais adaptados
- chat.html: layout de 3 colunas vira navegação de painel único no mobile
  (lista -> conversa com botão voltar; painel de info como overlay)
- settings.html: abas com rolagem horizontal no celular

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:28:02 -03:00
AyronSantos 144ca322f5 Adiciona Fluxo de Resolução (motivo + resolução do atendimento)
- Settings > Fluxo: cadastro de motivos e flags (visualizar/obrigatório
  para motivo e resolução)
- Tela de conversa: dropdown de motivos + campo de resolução abaixo dos
  dados do titular, exibidos conforme configuração
- Finalização valida obrigatoriedade (front e backend) e salva
  CON_MOTIVO_ID/CON_RESOLUCAO
- Estrutura criada de forma idempotente (Postgres/Firebird) em
  src/resolucaoSetup.js; CRUD de motivos restrito a gerente

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:41:35 -03:00
AyronSantos 89edb6f9a9 Configura conexões de banco e adiciona .env.example
- Alias "novo": PostgreSQL externo (db.assantos.app.br, schema atendi, SSL)
- Alias "novo_local": Firebird (NOVO.FDB) para base legada
- Adiciona .env.example como modelo de configuração
- .gitignore: ignora .gitignore e CONTEXTO.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:22:03 -03:00
32 changed files with 1737 additions and 936 deletions
+39
View File
@@ -0,0 +1,39 @@
# ============================================================
# Configuracao do Chatc2
# ============================================================
# Driver padrao (firebird)
DB_DRIVER=firebird
# ------------------------------------------------------------
# Banco Firebird (alias "novo_local")
# ------------------------------------------------------------
DB_HOST=localhost
DB_PORT=3050
DB_USER=SYSDBA
DB_PASSWORD=masterkey
DB_ENCODING=UTF-8
# Caminho do arquivo .FDB. Se vazio, usa ../NOVO.FDB (raiz do projeto).
# Ex. Windows: DB_DATABASE=C:\caminho\para\NOVO.FDB
# Ex. Linux: DB_DATABASE=/opt/chatc2/db/NOVO.FDB
DB_DATABASE=
# Servidor
PORT=3000
JWT_SECRET=CHATc2_1781527593_87c0a20ff1606d3e2aa0900eda4ecda9
JWT_EXPIRES_IN=1h
# URLs de acesso
LOCAL_URL=http://10.0.0.88:3000
EXTERNAL_URL=https://atendchat.assantos.app.br
# ------------------------------------------------------------
# Seguranca
# ------------------------------------------------------------
# CORS: origens permitidas (separadas por virgula). Se vazio, usa
# LOCAL_URL + EXTERNAL_URL. Requisicoes same-origin nao sao afetadas.
# CORS_ORIGINS=https://atendchat.assantos.app.br,http://10.0.0.88:3000
# 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=
+6
View File
@@ -8,9 +8,15 @@ src/databases_custom.json
*.fdb
*.rar
relatorio_migracao_*.json
relatorio_migracao_*.txt
# Dados do Postgres (cluster local)
pgdata/
# Logs / temporários
*.log
_inspect*.js
whisper/
.claude
.gitignore
CONTEXTO.md
+21 -41
View File
@@ -20,14 +20,13 @@ SERVER_DOMAIN="${SERVER_DOMAIN:-}" # Opcional: seu-dominio.com.br
JWT_SECRET=""
NGROK_URL=""
# PostgreSQL EXTERNO (banco principal "novo_local").
# Firebird LOCAL (banco principal).
# Informe via variaveis de ambiente antes de rodar, ou edite o .env depois.
PG_HOST="${PG_HOST:-}"
PG_PORT="${PG_PORT:-5432}"
PG_USER="${PG_USER:-postgres}"
PG_PASSWORD="${PG_PASSWORD:-}"
PG_DATABASE="${PG_DATABASE:-postgres}"
PG_SCHEMA="${PG_SCHEMA:-public}"
DB_HOST="${DB_HOST:-localhost}"
DB_PORT="${DB_PORT:-3050}"
DB_USER="${DB_USER:-SYSDBA}"
DB_PASSWORD="${DB_PASSWORD:-masterkey}"
DB_DATABASE="${DB_DATABASE:-}"
# Cores
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
@@ -247,28 +246,18 @@ cat > "$CHATC2_DIR/.env" << EOF
# ============================================================
# Driver padrao do banco principal
DB_DRIVER=postgres
DB_DRIVER=firebird
# ------------------------------------------------------------
# PostgreSQL (EXTERNO) — banco principal "novo_local"
# Firebird (alias "novo_local")
# ------------------------------------------------------------
PG_HOST=${PG_HOST}
PG_PORT=${PG_PORT}
PG_USER=${PG_USER}
PG_PASSWORD=${PG_PASSWORD}
PG_DATABASE=${PG_DATABASE}
PG_SCHEMA=${PG_SCHEMA}
# ------------------------------------------------------------
# Firebird (legado / alias "firebird_local")
# ------------------------------------------------------------
DB_HOST=localhost
DB_PORT=3050
DB_USER=SYSDBA
DB_PASSWORD=masterkey
DB_HOST=${DB_HOST}
DB_PORT=${DB_PORT}
DB_USER=${DB_USER}
DB_PASSWORD=${DB_PASSWORD}
DB_ENCODING=UTF-8
# Caminho do .FDB (vazio = usa ../NOVO.FDB na raiz do projeto)
DB_DATABASE=
DB_DATABASE=${DB_DATABASE}
# Servidor
PORT=3000
@@ -289,12 +278,9 @@ EXTERNAL_URL=${EXTERNAL_URL}
EOF
chown "$CHATC2_USER:$CHATC2_USER" "$CHATC2_DIR/.env"
log "Arquivo .env criado/atualizado com JWT_SECRET seguro"
log " PG_HOST/DB = ${PG_HOST:-(vazio)} / ${PG_DATABASE} (schema: ${PG_SCHEMA})"
log " Firebird = ${DB_HOST}:${DB_PORT} (database: ${DB_DATABASE:-(vazio)})"
log " LOCAL_URL = ${LOCAL_URL}"
log " EXTERNAL_URL = ${EXTERNAL_URL}"
if [ -z "$PG_HOST" ] || [ -z "$PG_PASSWORD" ]; then
warn "PostgreSQL externo nao configurado: edite $CHATC2_DIR/.env (PG_HOST, PG_PASSWORD, PG_DATABASE, PG_SCHEMA) antes de iniciar."
fi
# ████████████████████████████████████████████████████
# PASSO 9: MIGRACOES DO BANCO
@@ -314,23 +300,22 @@ else
warn "Apos copiar, execute: chown $CHATC2_USER:firebird $CHATC2_DIR/db/*.FDB && chmod 660 $CHATC2_DIR/db/*.FDB"
fi
info "Passo 9/12: Migracoes (Firebird legado)..."
# O schema do PostgreSQL e gerenciado no banco EXTERNO — nao migramos aqui.
# Migracoes Firebird so rodam se houver um .FDB local (alias firebird_local).
info "Passo 9/12: Migracoes (Firebird)..."
# Migracoes Firebird rodam se houver um .FDB local (alias novo_local).
if ls "$CHATC2_DIR/db/"*.FDB "$CHATC2_DIR/"*.FDB >/dev/null 2>&1; then
if [ -f "$CHATC2_DIR/scripts/migracoes.js" ]; then
su - "$CHATC2_USER" -c "cd $CHATC2_DIR && node scripts/migracoes.js firebird_local 2>&1 | tail -10" || warn "Migracoes Firebird podem ter falhado"
su - "$CHATC2_USER" -c "cd $CHATC2_DIR && node scripts/migracoes.js novo_local 2>&1 | tail -10" || warn "Migracoes Firebird podem ter falhado"
log "Migracoes Firebird executadas"
fi
else
info "Sem .FDB local — pulando migracoes Firebird (schema do Postgres e externo)."
info "Sem .FDB local — pulando migracoes Firebird."
fi
# ████████████████████████████████████████████████████
# PASSO 9b: HABILITAR USUARIOS ADMIN PARA WEB
# ████████████████████████████████████████████████████
info "Passo 9b/12: Habilitando usuarios admin para acesso web..."
# Habilita acesso web para admins (Postgres principal e Firebird se presente)
# Habilita acesso web para admins (Firebird)
cat > /tmp/chatc2-habilitar-web.js << 'SCRIPTJS'
const db = require('DATABASE_PATH');
@@ -352,16 +337,11 @@ const db = require('DATABASE_PATH');
SCRIPTJS
sed -i "s|DATABASE_PATH|$CHATC2_DIR/src/database|" /tmp/chatc2-habilitar-web.js
# Postgres externo (alias principal)
# Firebird (alias principal)
su - "$CHATC2_USER" -c "cd $CHATC2_DIR && node /tmp/chatc2-habilitar-web.js novo_local" 2>&1 || true
# Firebird local (apenas se houver .FDB)
if ls "$CHATC2_DIR/db/"*.FDB "$CHATC2_DIR/"*.FDB >/dev/null 2>&1; then
su - "$CHATC2_USER" -c "cd $CHATC2_DIR && node /tmp/chatc2-habilitar-web.js firebird_local" 2>&1 || true
fi
rm -f /tmp/chatc2-habilitar-web.js
log "Usuarios admin verificados (Postgres + Firebird se presente)"
log "Usuarios admin verificados (Firebird)"
# ████████████████████████████████████████████████████
# PASSO 10: NGINX
+5 -151
View File
@@ -7,6 +7,7 @@
"": {
"name": "chatc2",
"version": "2.0.0",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"cors": "^2.8.6",
@@ -14,8 +15,7 @@
"express": "^5.2.1",
"ffmpeg-static": "^5.3.0",
"jsonwebtoken": "^9.0.3",
"node-firebird": "^2.3.1",
"pg": "^8.21.0"
"node-firebird": "^2.3.1"
}
},
"node_modules/@derhuerst/http-basic": {
@@ -832,134 +832,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/pg": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.13.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.14.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
"integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
"integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@@ -983,9 +855,9 @@
}
},
"node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -1212,15 +1084,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1314,15 +1177,6 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}
+4 -5
View File
@@ -1,14 +1,14 @@
{
"name": "chatc2",
"version": "2.0.0",
"description": "API REST multi-banco (PostgreSQL + Firebird) para a plataforma de atendimento Chatc2",
"description": "API REST Firebird para a plataforma de atendimento Chatc2",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
"dev": "node --watch src/server.js",
"postinstall": "node scripts/postinstall.js"
},
"keywords": [
"postgres",
"firebird",
"api",
"express"
@@ -22,7 +22,6 @@
"express": "^5.2.1",
"ffmpeg-static": "^5.3.0",
"jsonwebtoken": "^9.0.3",
"node-firebird": "^2.3.1",
"pg": "^8.21.0"
"node-firebird": "^2.3.1"
}
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Script para corrigir registros existentes com SITUACAO = NULL
*
* Uso: node scripts/corrigir-situacao.js <alias> [schema]
* Ex: node scripts/corrigir-situacao.js novo
* Ex: node scripts/corrigir-situacao.js novo at
*/
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') });
const db = require('../src/database');
async function main() {
const alias = process.argv[2] || 'novo';
const schema = process.argv[3] ? process.argv[3].trim() : '';
const dot = schema ? '.' : '';
console.log(`📌 Corrigindo registros no alias "${alias}"${schema ? ', schema "' + schema + '"' : ''}...\n`);
// 1. Corrigir CHATC2_INSTANCIAS sem INS_SITUACAO
const sql1 = `UPDATE ${schema}${dot}"CHATC2_INSTANCIAS" SET "INS_SITUACAO" = 'A' WHERE "INS_SITUACAO" IS NULL OR "INS_SITUACAO" = ''`;
console.log(`📌 ${sql1}`);
try {
const r1 = await db.execute(alias, sql1);
console.log(`✅ CHATC2_INSTANCIAS: ${r1.affectedRows} registro(s) atualizado(s)`);
} catch (e) {
console.log(`⚠️ CHATC2_INSTANCIAS: ${e.message.substring(0, 100)}`);
}
// 2. Corrigir CHATC2_CONVERSAS sem CON_SITUACAO
const sql2 = `UPDATE ${schema}${dot}"CHATC2_CONVERSAS" SET "CON_SITUACAO" = 'A' WHERE "CON_SITUACAO" IS NULL OR "CON_SITUACAO" = ''`;
console.log(`📌 ${sql2}`);
try {
const r2 = await db.execute(alias, sql2);
console.log(`✅ CHATC2_CONVERSAS: ${r2.affectedRows} registro(s) atualizado(s)`);
} catch (e) {
console.log(`⚠️ CHATC2_CONVERSAS: ${e.message.substring(0, 100)}`);
}
// 3. Corrigir CHATC2_CONVERSAS_MENSAGENS sem CME_SITUACAO
const sql3 = `UPDATE ${schema}${dot}"CHATC2_CONVERSAS_MENSAGENS" SET "CME_SITUACAO" = 'A' WHERE "CME_SITUACAO" IS NULL OR "CME_SITUACAO" = ''`;
console.log(`📌 ${sql3}`);
try {
const r3 = await db.execute(alias, sql3);
console.log(`✅ CHATC2_CONVERSAS_MENSAGENS: ${r3.affectedRows} registro(s) atualizado(s)`);
} catch (e) {
console.log(`⚠️ CHATC2_CONVERSAS_MENSAGENS: ${e.message.substring(0, 100)}`);
}
// 4. Verifica conversa 61111 especificamente
const sql4 = `SELECT "CON_CODIGO_ID", "CON_SITUACAO", "CON_STATUS" FROM ${schema}${dot}"CHATC2_CONVERSAS" WHERE "CON_CODIGO_ID" = '61111'`;
console.log(`📌 ${sql4}`);
try {
const conv = await db.query(alias, sql4);
if (conv.length > 0) {
console.log(`\n📋 Conversa 61111: SITUACAO=${conv[0].CON_SITUACAO || 'NULL'}, STATUS=${conv[0].CON_STATUS}`);
// Conta mensagens
const sql5 = `SELECT COUNT(*) AS CT FROM ${schema}${dot}"CHATC2_CONVERSAS_MENSAGENS" WHERE "CME_CONVERSA_ID" = '61111' AND "CME_SITUACAO" = 'A'`;
console.log(`📌 ${sql5}`);
const msgs = await db.query(alias, sql5);
console.log(`📋 Mensagens ativas na conversa 61111: ${msgs[0]?.CT || 0}`);
} else {
console.log(`\n❌ Conversa 61111 não encontrada`);
}
} catch (e) {
console.log(`⚠️ Verificação 61111: ${e.message.substring(0, 100)}`);
}
console.log(`\n✅ Correção concluída!`);
process.exit(0);
}
main().catch(err => {
console.error(`❌ Erro: ${err.message}`);
process.exit(1);
});
+3 -5
View File
@@ -22,9 +22,8 @@
const db = require('../src/database');
// Este script aplica DDL no dialeto Firebird (BLOB SUB_TYPE, etc.).
// O schema do PostgreSQL é gerenciado externamente (banco externo) — use este
// script apenas para bancos Firebird. Alias padrão: firebird_local.
const alias = process.argv[2] || 'firebird_local';
// Alias padrão: novo_local.
const alias = process.argv[2] || 'novo_local';
// ============================================================
// CONTROLE DE MIGRAÇÕES
@@ -428,8 +427,7 @@ async function main() {
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(' O schema do PostgreSQL é gerenciado no banco externo — nada a fazer aqui.');
console.log(' Para migrar um banco Firebird: node scripts/migracoes.js firebird_local\n');
console.log(' Para migrar um banco Firebird: node scripts/migracoes.js novo_local\n');
process.exit(0);
}
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env node
/**
* postinstall.js - Aplica patches de compatibilidade no node-firebird
* após npm install, garantindo compatibilidade com Node.js 22+.
*/
const fs = require('fs');
const path = require('path');
const connPath = path.resolve(__dirname, '../node_modules/node-firebird/lib/wire/connection.js');
if (!fs.existsSync(connPath)) {
console.log('[patch] connection.js not found, skipping');
process.exit(0);
}
let content = fs.readFileSync(connPath, 'utf8');
let patched = false;
// ============================================================
// Patch 1: SRP empty buffer (Firebird 3.0 Legacy_Auth)
// ============================================================
const srpOld = (
' // TODO : Fallback Srp256 to Srp ?\n' +
' /*if (!d.buffer) {\n' +
' cnx.sendOpContAuth(\n' +
' cnx.clientKeys.public.toString(16),\n' +
' DEFAULT_ENCODING,\n' +
' accept.pluginName\n' +
' );\n' +
'\n' +
' return cb(new Error(\'login\'));\n' +
' }*/\n' +
'\n' +
' // Check buffer contains salt\n' +
' var saltLen = d.buffer.readUInt16LE(0);'
);
const srpNew = (
' // No auth data from server - server accepted the connection\n' +
' // without requiring SRP. This happens with Firebird 3.0 when\n' +
' // the server already validated the client.\n' +
' if (!d || !d.buffer) {\n' +
" accept.authData = '';\n" +
" accept.sessionKey = '';\n" +
' } else {\n' +
' // Check buffer contains salt\n' +
' var saltLen = d.buffer.readUInt16LE(0);'
);
if (content.includes(srpOld)) {
content = content.replace(srpOld, srpNew);
patched = true;
console.log('[patch] Patch 1 (SRP) applied');
}
// ============================================================
// Patch 2: Missing closing brace (Node.js 22+ syntax)
// ============================================================
const syntaxOld = (
' accept.authData = proof.authData.toString(16);\n' +
' accept.sessionKey = proof.clientSessionKey;\n' +
' } else if (accept.pluginName === Const.AUTH_PLUGIN_LEGACY) {'
);
const syntaxNew = (
' accept.authData = proof.authData.toString(16);\n' +
' accept.sessionKey = proof.clientSessionKey;\n' +
' } // fecha o else do if (!d || !d.buffer)\n' +
' } else if (accept.pluginName === Const.AUTH_PLUGIN_LEGACY) {'
);
if (content.includes(syntaxOld)) {
content = content.replace(syntaxOld, syntaxNew);
patched = true;
console.log('[patch] Patch 2 (syntax Node22) applied');
}
if (patched) {
fs.writeFileSync(connPath, content, 'utf8');
console.log('[patch] node-firebird patched successfully');
} else {
console.log('[patch] node-firebird already patched or not needed');
}
+2 -2
View File
@@ -62,9 +62,9 @@ app.use(routes);
// Rota raiz
app.get('/', (req, res) => {
res.json({
name: 'API Firebird - Chatc2',
name: 'API Chatc2 - Firebird',
version: '2.0.0',
description: 'API multi-banco com sistema de aliases',
description: 'API Firebird com sistema de aliases',
auth: {
loginPage: 'GET /app/:alias/login',
login: 'POST /app/:alias/login',
+2 -2
View File
@@ -75,7 +75,7 @@ class AuthController {
FROM USUARIOS
WHERE USU_LOGIN = ?
AND (USU_SENHA = ? OR USU_SENHA_WEB = ?)
AND COALESCE(USU_ACESSO_WEB, 0) = 1
AND COALESCE(USU_ACESSO_WEB, '0') IN ('1', 'S')
AND USU_STATUS = 'A'
`;
@@ -99,7 +99,7 @@ class AuthController {
'SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA WHERE USE_USUARIO_ID = ?',
[user.USU_CODIGO_ID]
);
const empresasIds = empresas.map(e => e.USE_EMPRESA_ID);
const empresasIds = empresas.map(e => Number(e.USE_EMPRESA_ID)).filter(n => !isNaN(n));
// Atualiza a data do último acesso
await db.execute(
+180 -19
View File
@@ -1,4 +1,5 @@
const db = require('../database');
const { garantirEstrutura } = require('../resolucaoSetup');
const ffmpegPath = require('ffmpeg-static');
const { execFile } = require('child_process');
const path = require('path');
@@ -147,7 +148,7 @@ class ChatController {
const statusFilter = req.query.status || 'A,E';
const filter = req.query.filter || ''; // 'mine', 'unassigned', 'all'
if (!req.user?.empresas?.includes(empresaId))
if (!empresaId || !req.user?.empresas?.some(function(e) { return Number(e) === Number(empresaId); }))
return res.status(403).json({ success: false, error: 'Sem permissão.' });
// Status via parâmetros (evita SQL injection). Aceita apenas A/E/F.
@@ -259,7 +260,7 @@ class ChatController {
var minhasEquipes = await db.query(alias,
'SELECT EQU_EQUIPE_ID FROM CHATC2_USU_EQUIPES WHERE EQU_USUARIO_ID = ?', [userId]);
if (minhasEquipes.length > 0) {
var eqIds = minhasEquipes.map(function(e) { return e.EQU_EQUIPE_ID; }).join(',');
var eqIds = minhasEquipes.map(function(e) { return "'" + e.EQU_EQUIPE_ID + "'"; }).join(',');
var countEq = await db.query(alias,
`SELECT COUNT(*) AS CT FROM CHATC2_CONVERSAS WHERE CON_EMPRESA_ID = ? AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A' AND CON_EQUIPE_ID IN (${eqIds})`,
[empresaId]);
@@ -295,7 +296,7 @@ class ChatController {
const row = result[0];
const empresaId = row.CON_EMPRESA_ID;
if (!req.user?.empresas?.includes(empresaId))
if (empresaId && !req.user?.empresas?.some(function(e) { return String(e) === String(empresaId); }))
return res.status(403).json({ success: false, error: 'Sem permissão.' });
// Verifica se o número pertence a um dependente
@@ -446,6 +447,8 @@ class ChatController {
saudacaoEnviada: (row.CON_SAUDACAO_ENVIADA || 'N').trim(),
csatEnviado: (row.CON_CSAT_ENVIADO || 'N').trim(),
primeiraMsg: row.CON_PRIMEIRA_MSG,
motivoId: row.CON_MOTIVO_ID || null,
resolucao: row.CON_RESOLUCAO || null,
cliente: clienteInfo,
dependente: dependenteInfo,
labels: labels.map(l => ({ id: l.ETI_CODIGO_ID, nome: (l.ETI_NOME || '').trim(), cor: (l.ETI_COR || '#667eea').trim() })),
@@ -468,7 +471,7 @@ class ChatController {
if (r.length === 0) return { status: 404 };
const emp = r[0].CON_EMPRESA_ID;
const minhas = (req.user && req.user.empresas) || [];
if (emp && minhas.indexOf(emp) === -1) return { status: 403 };
if (emp && !minhas.some(function(e) { return String(e) === String(emp); })) return { status: 403 };
return { ok: true, empresaId: emp };
}
@@ -539,7 +542,7 @@ class ChatController {
const conv = await db.query(alias, 'SELECT * FROM CHATC2_CONVERSAS WHERE CON_CODIGO_ID = ?', [id]);
if (conv.length === 0) return res.status(404).json({ success: false, error: 'Conversa não encontrada.' });
if (conv[0].CON_EMPRESA_ID && !((req.user && req.user.empresas) || []).includes(conv[0].CON_EMPRESA_ID))
if (conv[0].CON_EMPRESA_ID && !((req.user && req.user.empresas) || []).some(function(e) { return String(e) === String(conv[0].CON_EMPRESA_ID); }))
return res.status(403).json({ success: false, error: 'Sem permissão.' });
// Se a conversa estiver finalizada, reabre
@@ -608,7 +611,8 @@ class ChatController {
if (user.length > 0) {
const nomeUser = (user[0].USU_NOME || '').trim();
if (nomeUser && textoFinal) {
textoFinal = nomeUser + ': ' + textoFinal;
// Padrão WhatsApp: *Nome:* em negrito + quebra de linha + mensagem
textoFinal = '*' + nomeUser + ':*\n' + textoFinal;
}
}
}
@@ -616,8 +620,8 @@ class ChatController {
// Insere mensagem no banco
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_DT_ENVIO, CME_MIDIA_ID)
VALUES (?, ?, 'U', ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
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, CME_MIDIA_ID)
VALUES (?, ?, 'U', ?, ?, ?, ?, 'A', CURRENT_TIMESTAMP, ?)
`, [newId, id, usuarioId, textoFinal, tipo || 'text', privada === 'S' ? 'S' : 'N', midiaId]);
// Atualiza data da última mensagem
@@ -659,9 +663,26 @@ class ChatController {
if (conv.length === 0) return res.status(404).json({ success: false, error: 'Conversa não encontrada.' });
const c = conv[0];
if (c.CON_EMPRESA_ID && !((req.user && req.user.empresas) || []).includes(c.CON_EMPRESA_ID))
if (c.CON_EMPRESA_ID && !((req.user && req.user.empresas) || []).some(function(e) { return String(e) === String(c.CON_EMPRESA_ID); }))
return res.status(403).json({ success: false, error: 'Sem permissão.' });
// ===== Fluxo de Resolução: valida motivo/resolução conforme config =====
await garantirEstrutura(alias);
const motivoId = (req.body && req.body.motivoId) ? parseInt(req.body.motivoId, 10) : null;
const resolucao = (req.body && req.body.resolucao != null) ? String(req.body.resolucao).trim() : '';
const flagsRows = await db.query(alias,
'SELECT CFE_MOTIVO_VISUALIZAR, CFE_MOTIVO_OBRIGATORIO, CFE_RESOLUCAO_VISUALIZAR, CFE_RESOLUCAO_OBRIGATORIO FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?',
[c.CON_EMPRESA_ID]);
const fl = flagsRows[0] || {};
const isS = (v) => String(v || 'N').trim() === 'S';
if (isS(fl.CFE_MOTIVO_VISUALIZAR) && isS(fl.CFE_MOTIVO_OBRIGATORIO) && !motivoId) {
return res.status(400).json({ success: false, error: 'Selecione o motivo do atendimento para finalizar.' });
}
if (isS(fl.CFE_RESOLUCAO_VISUALIZAR) && isS(fl.CFE_RESOLUCAO_OBRIGATORIO) && !resolucao) {
return res.status(400).json({ success: false, error: 'Preencha a resolução do atendimento para finalizar.' });
}
// Busca nome do usuário que atendeu
let usuarioNome = null;
if (c.CON_USUARIO_ID) {
@@ -692,9 +713,11 @@ class ChatController {
CON_DT_FINAL = CURRENT_TIMESTAMP,
CON_USUARIO_NOME = ?,
CON_EQUIPE_NOME = ?,
CON_ETIQUETAS_DESC = ?
CON_ETIQUETAS_DESC = ?,
CON_MOTIVO_ID = ?,
CON_RESOLUCAO = ?
WHERE CON_CODIGO_ID = ?
`, [usuarioNome, equipeNome, etiquetasDesc, id]);
`, [usuarioNome, equipeNome, etiquetasDesc, motivoId, resolucao || null, id]);
// CSAT
const config = await db.query(alias,
@@ -758,7 +781,7 @@ class ChatController {
// Verifica permissão
const userEmpresas = req.user?.empresas || [];
if (!userEmpresas.includes(parseInt(empresaId))) {
if (!userEmpresas.some(function(e) { return String(e) === String(empresaId); })) {
return res.status(403).json({ success: false, error: 'Sem permissão para esta empresa.' });
}
@@ -804,8 +827,8 @@ class ChatController {
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const msgId = (maxMsgId[0]?.ID || 0) + 1;
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_DT_ENVIO)
VALUES (?, ?, 'U', ?, ?, 'text', CURRENT_TIMESTAMP)
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)
`, [msgId, conversaId, req.user?.id, mensagem]);
await db.execute(alias,
@@ -837,8 +860,8 @@ class ChatController {
// Insere conversa
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS (CON_CODIGO_ID, CON_EMPRESA_ID, CON_INSTANCIA_ID, CON_NUMERO,
CON_NOME_CONTATO, CON_CLIENTE_ID, CON_STATUS, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG)
VALUES (?, ?, ?, ?, ?, ?, 'A', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
CON_NOME_CONTATO, CON_CLIENTE_ID, CON_STATUS, CON_SITUACAO, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG)
VALUES (?, ?, ?, ?, ?, ?, 'A', 'A', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`, [newId, empresaId, instId, numeroLimpo, nomeContato || numeroLimpo, clienteId || null]);
// Insere primeira mensagem
@@ -846,8 +869,8 @@ class ChatController {
const msgId = (maxMsgId[0]?.ID || 0) + 1;
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_DT_ENVIO)
VALUES (?, ?, 'U', ?, ?, 'text', CURRENT_TIMESTAMP)
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)
`, [msgId, newId, req.user?.id, mensagem]);
// Envia via Evolution API
@@ -1035,7 +1058,7 @@ class ChatController {
mimeType = mimeType.split(';')[0].trim();
const nomeArquivo = (m.MAT_NOME_ARQUIVO || '').trim() || 'arquivo.bin';
// MAT_ARQUIVO chega como Buffer (postgres bytea / firebird blob) ou string
// MAT_ARQUIVO chega como Buffer (firebird blob) ou string
let rawBuffer = m.MAT_ARQUIVO;
if (rawBuffer == null) {
return respond(404, { success: false, error: 'Mídia sem conteúdo.' });
@@ -1144,6 +1167,144 @@ class ChatController {
});
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Valida que o cliente informado pertence à mesma empresa da conversa
* (e que o usuário tem acesso à conversa). Retorna { ok, clienteId } ou { status }.
*/
static async checarClienteDaConversa(alias, conversaId, clienteId, req) {
const chk = await ChatController.checarConversaEmpresa(alias, conversaId, req);
if (chk.status) return chk;
const cid = parseInt(clienteId, 10);
if (!cid) return { status: 400 };
const cli = await db.query(alias,
'SELECT CLI_EMPRESA_ID FROM CLIENTES WHERE CLI_CODIGO_ID = ?', [cid]);
if (cli.length === 0) return { status: 404 };
if (chk.empresaId && cli[0].CLI_EMPRESA_ID && String(cli[0].CLI_EMPRESA_ID) !== String(chk.empresaId))
return { status: 403 };
return { ok: true, clienteId: cid };
}
/**
* Lista títulos (boletos) em aberto do cliente vinculado à conversa,
* para o atendente escolher qual enviar.
* GET /api/:alias/conversations/:id/boletos?clienteId=
*/
static async getBoletos(req, res) {
try {
const { alias, id } = req.params;
const chk = await ChatController.checarClienteDaConversa(alias, id, req.query.clienteId, req);
if (chk.status) return res.status(chk.status).json({ success: false, error: chk.status === 403 ? 'Sem permissão.' : (chk.status === 404 ? 'Cliente não encontrado.' : 'Parâmetros inválidos.') });
const carnes = await db.query(alias,
`SELECT CAR_CODIGO_ID, CAR_DT_VENCIMENTO, CAR_VALOR_PARCELA, CAR_LINHA_DIGITAVEL,
CAR_NOSSO_NUMERO, CAR_PIX_QRCODE, CAR_NUMERO_PARCELA, CAR_NUMERO_TOTAL_PARCELAS,
CAR_CODIGO_BARRAS
FROM CARNES WHERE CAR_CLIENTE_ID = ? AND CAR_SITUACAO = 0
ORDER BY CAR_DT_VENCIMENTO`, [chk.clienteId]);
const fmtDate = (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);
};
res.json({ success: true, data: carnes.map(function(c) {
return {
id: c.CAR_CODIGO_ID,
vencimento: fmtDate(c.CAR_DT_VENCIMENTO),
valor: c.CAR_VALOR_PARCELA,
parcela: c.CAR_NUMERO_PARCELA,
totalParcelas: c.CAR_NUMERO_TOTAL_PARCELAS,
temLinhaDigitavel: !!(c.CAR_LINHA_DIGITAVEL && String(c.CAR_LINHA_DIGITAVEL).trim()),
temPix: !!(c.CAR_PIX_QRCODE && String(c.CAR_PIX_QRCODE).trim()),
};
}) });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Envia um boleto escolhido (texto: linha digitável + PIX) ao cliente no chat.
* POST /api/:alias/conversations/:id/send-boleto Body: { clienteId, carneId }
*/
static async sendBoleto(req, res) {
try {
const { alias, id } = req.params;
const { clienteId, carneId } = req.body;
const chk = await ChatController.checarClienteDaConversa(alias, id, clienteId, req);
if (chk.status) return res.status(chk.status).json({ success: false, error: chk.status === 403 ? 'Sem permissão.' : (chk.status === 404 ? 'Cliente não encontrado.' : 'Parâmetros inválidos.') });
const carneIdNum = parseInt(carneId, 10);
if (!carneIdNum) return res.status(400).json({ success: false, error: 'Título inválido.' });
// Título precisa pertencer ao cliente validado (evita IDOR)
const car = await db.query(alias,
`SELECT CAR_CODIGO_ID, CAR_DT_VENCIMENTO, CAR_VALOR_PARCELA, CAR_LINHA_DIGITAVEL,
CAR_PIX_QRCODE, CAR_NUMERO_PARCELA, CAR_NUMERO_TOTAL_PARCELAS, CAR_CODIGO_BARRAS
FROM CARNES WHERE CAR_CODIGO_ID = ? AND CAR_CLIENTE_ID = ?`, [carneIdNum, chk.clienteId]);
if (car.length === 0) return res.status(404).json({ success: false, error: 'Título não encontrado para este cliente.' });
const b = car[0];
const conv = await db.query(alias, 'SELECT * FROM CHATC2_CONVERSAS WHERE CON_CODIGO_ID = ?', [id]);
if (conv.length === 0) return res.status(404).json({ success: false, error: 'Conversa não encontrada.' });
// Monta o texto do boleto
const fmtDate = (dt) => {
if (!dt) return '';
let s = typeof dt === 'string' ? dt.split('T')[0] : (dt instanceof Date ? dt.toISOString().split('T')[0] : String(dt));
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
return m ? (m[3] + '/' + m[2] + '/' + m[1]) : s;
};
const fmtValor = (v) => {
const n = Number(v);
if (isNaN(n)) return '';
return 'R$ ' + n.toFixed(2).replace('.', ',');
};
const linha = (b.CAR_LINHA_DIGITAVEL || '').trim() || (b.CAR_CODIGO_BARRAS || '').trim();
const pix = (b.CAR_PIX_QRCODE || '').trim();
let texto = '📄 *Boleto*';
if (b.CAR_NUMERO_PARCELA) {
texto += ' - Parcela ' + b.CAR_NUMERO_PARCELA + (b.CAR_NUMERO_TOTAL_PARCELAS ? '/' + b.CAR_NUMERO_TOTAL_PARCELAS : '');
}
texto += '\n';
if (b.CAR_VALOR_PARCELA != null) texto += '\n💰 Valor: ' + fmtValor(b.CAR_VALOR_PARCELA);
if (b.CAR_DT_VENCIMENTO) texto += '\n📅 Vencimento: ' + fmtDate(b.CAR_DT_VENCIMENTO);
if (linha) texto += '\n\n*Linha digitável:*\n' + linha;
if (pix) texto += '\n\n*PIX (copia e cola):*\n' + pix;
if (!linha && !pix) {
return res.status(400).json({ success: false, error: 'Este título não possui linha digitável nem PIX disponível.' });
}
// 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;
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)
`, [newId, id, req.user?.id, texto]);
await db.execute(alias,
'UPDATE CHATC2_CONVERSAS SET CON_DT_ULTIMA_MSG = CURRENT_TIMESTAMP WHERE CON_CODIGO_ID = ?', [id]);
// Envia via Evolution
if (conv[0].CON_NUMERO) {
try {
const instancia = await db.query(alias,
'SELECT * FROM CHATC2_INSTANCIAS WHERE INS_CODIGO_ID = ?', [conv[0].CON_INSTANCIA_ID]);
if (instancia.length > 0) {
await sendEvolutionMessage(instancia[0], conv[0].CON_NUMERO, texto, 'text', null);
}
} catch (evoErr) {
console.error('[Boleto] Erro ao enviar via Evolution:', evoErr.message);
}
}
res.json({ success: true, data: { id: newId } });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Recebe avaliação CSAT via formulário web
* POST /api/:alias/csat/avaliar
+28 -3
View File
@@ -24,7 +24,7 @@ class ClientController {
);
const empresas = result.map(row => ({
id: row.EMP_CODIGO_ID,
id: Number(row.EMP_CODIGO_ID),
nome: (row.EMP_NOME || '').trim(),
nomeFantasia: (row.EMP_NOME_FANTASIA || '').trim(),
}));
@@ -74,7 +74,8 @@ class ClientController {
c.CLI_ENDERECO,
c.CLI_NUMERO_FAT,
c.CLI_BAIRRO_FAT,
c.CLI_CIDADES_ID
c.CLI_CIDADES_ID,
c.CLI_FOTO
FROM CLIENTES c
WHERE c.CLI_CODIGO_ID = ?
AND c.CLI_EMPRESA_ID = ?
@@ -133,6 +134,29 @@ class ClientController {
// Interpreta a situação
const situacao = client.CLI_SITUACAO?.trim() === 'A' ? 'Ativo' : 'Inativo';
// Foto do cliente (CLI_FOTO) — pode vir como Buffer (bytea/blob) ou string base64
let fotoCliente = null;
if (client.CLI_FOTO) {
const f = client.CLI_FOTO;
if (Buffer.isBuffer(f)) {
const s = f.toString('utf8');
if (/^[A-Za-z0-9+/]+=*$/.test(s.slice(0, 30))) {
fotoCliente = s;
} else {
let prefixo = 'data:image/jpeg;base64,';
if (f[0] === 0x89 && f[1] === 0x50) prefixo = 'data:image/png;base64,';
else if (f[0] === 0x47 && f[1] === 0x49) prefixo = 'data:image/gif;base64,';
else if (f[0] === 0x52 && f[1] === 0x49) prefixo = 'data:image/webp;base64,';
fotoCliente = prefixo + f.toString('base64');
}
} else {
fotoCliente = String(f);
}
}
// Matrícula: converte com segurança (pode ser numérica ou string no banco)
const matriculaStr = client.CLI_MATRICULA == null ? '' : String(client.CLI_MATRICULA).trim();
// Monta o endereço completo de faturamento
const enderecoFaturamento = [
client.CLI_ENDERECO_FAT?.trim(),
@@ -147,7 +171,8 @@ class ClientController {
clienteId: id_cliente,
data: {
nome: client.CLI_NOME?.trim(),
matricula: client.CLI_MATRICULA?.trim(),
matricula: matriculaStr,
foto: fotoCliente,
situacao: {
codigo: client.CLI_SITUACAO?.trim(),
descricao: situacao,
+87 -6
View File
@@ -1,5 +1,6 @@
const db = require('../database');
const { isGerente } = require('../middlewares/roles');
const { garantirEstrutura } = require('../resolucaoSetup');
class ConfigController {
// ==================== CHATC2_EQUIPES ====================
@@ -152,12 +153,16 @@ class ConfigController {
const { alias } = req.params;
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
await garantirEstrutura(alias);
let config = await db.query(alias,
'SELECT * FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]
);
const flagsPadrao = { motivoVisualizar: 'N', motivoObrigatorio: 'N', resolucaoVisualizar: 'N', resolucaoObrigatorio: 'N', enviarBoleto: 'N' };
if (config.length === 0) {
const cfg = { empresaId, fotoCelular: 'N', saudacaoAtiva: 'S', saudacaoMensagem: '', csatAtivo: 'N', csatMensagem: '', enviarNomeUsuario: 'N', triagemAtiva: 'N', triagemMsgWelcome: '', triagemMsgAfter: '', triagemBoletoNumero: '0', instanciaPadraoId: null };
const cfg = Object.assign({ empresaId, fotoCelular: 'N', saudacaoAtiva: 'S', saudacaoMensagem: '', csatAtivo: 'N', csatMensagem: '', enviarNomeUsuario: 'N', triagemAtiva: 'N', triagemMsgWelcome: '', triagemMsgAfter: '', triagemBoletoNumero: '0', instanciaPadraoId: null }, flagsPadrao);
res.json({ success: true, data: cfg });
} else {
const c = config[0];
@@ -174,17 +179,93 @@ class ConfigController {
triagemMsgWelcome: c.CFE_TRIAGEM_MSG_WELCOME || '',
triagemMsgAfter: c.CFE_TRIAGEM_MSG_AFTER || '',
triagemBoletoNumero: (c.CFE_TRIAGEM_BOLETO_NUMERO || '0').trim(),
enviarBoleto: (c.CFE_ENVIAR_BOLETO || 'N').trim(),
// Fluxo de Resolução
motivoVisualizar: (c.CFE_MOTIVO_VISUALIZAR || 'N').trim(),
motivoObrigatorio: (c.CFE_MOTIVO_OBRIGATORIO || 'N').trim(),
resolucaoVisualizar: (c.CFE_RESOLUCAO_VISUALIZAR || 'N').trim(),
resolucaoObrigatorio: (c.CFE_RESOLUCAO_OBRIGATORIO || 'N').trim(),
}});
}
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
// ==================== FLUXO DE RESOLUÇÃO ====================
/** Salva as 4 flags do Fluxo de Resolução (sem tocar nas demais configs). */
static async saveResolucaoConfig(req, res) {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem alterar o fluxo de resolução.' });
const { alias } = req.params;
await garantirEstrutura(alias);
const empresaId = req.body.empresaId || req.user?.empresas?.[0];
const sn = (v) => (v === 'S' || v === true ? 'S' : 'N');
const mv = sn(req.body.motivoVisualizar), mo = sn(req.body.motivoObrigatorio);
const rv = sn(req.body.resolucaoVisualizar), ro = sn(req.body.resolucaoObrigatorio);
const exists = await db.query(alias, 'SELECT COUNT(*) AS T FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]);
if (exists[0].T > 0) {
await db.execute(alias,
`UPDATE CHATC2_CONFIGURACOES_EMPRESA SET CFE_MOTIVO_VISUALIZAR = ?, CFE_MOTIVO_OBRIGATORIO = ?,
CFE_RESOLUCAO_VISUALIZAR = ?, CFE_RESOLUCAO_OBRIGATORIO = ? WHERE CFE_EMPRESA_ID = ?`,
[mv, mo, rv, ro, empresaId]);
} else {
await db.execute(alias,
`INSERT INTO CHATC2_CONFIGURACOES_EMPRESA (CFE_EMPRESA_ID, CFE_MOTIVO_VISUALIZAR, CFE_MOTIVO_OBRIGATORIO, CFE_RESOLUCAO_VISUALIZAR, CFE_RESOLUCAO_OBRIGATORIO)
VALUES (?, ?, ?, ?, ?)`,
[empresaId, mv, mo, rv, ro]);
}
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/** Lista os motivos de atendimento (leitura aberta — usada no chat). */
static async listMotivos(req, res) {
try {
const { alias } = req.params;
await garantirEstrutura(alias);
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
const rows = await db.query(alias,
`SELECT MOT_CODIGO_ID, MOT_DESCRICAO FROM "CHATC2_MOTIVOS_ATENDIMENTO"
WHERE MOT_EMPRESA_ID = ? AND MOT_SITUACAO = 'A' ORDER BY MOT_DESCRICAO`, [empresaId]);
res.json({ success: true, data: rows.map((m) => ({ id: m.MOT_CODIGO_ID, descricao: (m.MOT_DESCRICAO || '').trim() })) });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
static async createMotivo(req, res) {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem cadastrar motivos.' });
const { alias } = req.params;
await garantirEstrutura(alias);
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;
await db.execute(alias,
`INSERT INTO "CHATC2_MOTIVOS_ATENDIMENTO" (MOT_CODIGO_ID, MOT_EMPRESA_ID, MOT_DESCRICAO, MOT_SITUACAO)
VALUES (?, ?, ?, 'A')`, [newId, empresaId, descricao]);
res.json({ success: true, data: { id: newId } });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
static async deleteMotivo(req, res) {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem remover motivos.' });
const { alias, id } = req.params;
await garantirEstrutura(alias);
await db.execute(alias, `UPDATE "CHATC2_MOTIVOS_ATENDIMENTO" SET MOT_SITUACAO = 'I' WHERE MOT_CODIGO_ID = ?`, [id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
static async saveCompanyConfig(req, res) {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem alterar as configurações da empresa.' });
const { alias } = req.params;
await garantirEstrutura(alias);
const data = req.body;
const empresaId = data.empresaId || req.user?.empresas?.[0];
const enviarBoleto = (data.enviarBoleto === 'S' || data.enviarBoleto === true) ? 'S' : 'N';
// Upsert
const exists = await db.query(alias, 'SELECT COUNT(*) AS T FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]);
@@ -195,16 +276,16 @@ class ConfigController {
CFE_INSTANCIA_PADRAO_ID = ?, CFE_FOTO_CELULAR = ?, CFE_SAUDACAO_ATIVA = ?,
CFE_SAUDACAO_MENSAGEM = ?, CFE_CSAT_ATIVO = ?, CFE_CSAT_MENSAGEM = ?,
CFE_ENVIAR_NOME_USUARIO = ?, CFE_TRIAGEM_ATIVA = ?, CFE_TRIAGEM_MSG_WELCOME = ?,
CFE_TRIAGEM_MSG_AFTER = ?, CFE_TRIAGEM_BOLETO_NUMERO = ?
CFE_TRIAGEM_MSG_AFTER = ?, CFE_TRIAGEM_BOLETO_NUMERO = ?, CFE_ENVIAR_BOLETO = ?
WHERE CFE_EMPRESA_ID = ?
`, [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', 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, empresaId]);
} else {
await db.execute(alias, `
INSERT INTO CHATC2_CONFIGURACOES_EMPRESA (CFE_EMPRESA_ID, CFE_INSTANCIA_PADRAO_ID, CFE_FOTO_CELULAR,
CFE_SAUDACAO_ATIVA, CFE_SAUDACAO_MENSAGEM, CFE_CSAT_ATIVO, CFE_CSAT_MENSAGEM, CFE_ENVIAR_NOME_USUARIO,
CFE_TRIAGEM_ATIVA, CFE_TRIAGEM_MSG_WELCOME, CFE_TRIAGEM_MSG_AFTER, CFE_TRIAGEM_BOLETO_NUMERO)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [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']);
CFE_TRIAGEM_ATIVA, CFE_TRIAGEM_MSG_WELCOME, CFE_TRIAGEM_MSG_AFTER, CFE_TRIAGEM_BOLETO_NUMERO, CFE_ENVIAR_BOLETO)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [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]);
}
res.json({ success: true });
+126
View File
@@ -0,0 +1,126 @@
const db = require('../database');
const { isGerente } = require('../middlewares/roles');
const presence = require('../presence');
/**
* Resolve a lista de empresas para a consulta, respeitando a permissão do
* usuário. Se vier empresaId e o usuário tiver acesso, usa só ela; senão usa
* todas as empresas do usuário.
*/
function empresasPermitidas(req) {
const minhas = (req.user && req.user.empresas) || [];
const pedida = parseInt(req.query.empresaId, 10);
if (pedida && minhas.includes(pedida)) return [pedida];
return minhas;
}
class DashboardController {
/**
* Ping de presença — marca o atendente como online.
* POST /api/:alias/dashboard/ping
*/
static async ping(req, res) {
const { alias } = req.params;
if (req.user && req.user.id != null) presence.registrar(alias, req.user.id);
res.json({ success: true });
}
/**
* Estatísticas do dashboard (apenas gerente).
* GET /api/:alias/dashboard/stats?empresaId=
*/
static async stats(req, res) {
try {
if (!(await isGerente(req))) {
return res.status(403).json({ success: false, error: 'Apenas gerentes podem visualizar o dashboard.' });
}
const { alias } = req.params;
const empresas = empresasPermitidas(req);
if (empresas.length === 0) {
return res.json({ success: true, data: {
conversas: { abertas: 0, naoAtendidas: 0, naoAtribuidas: 0, pendentes: 0 },
atendentes: { disponiveis: 0, desconectados: 0, lista: [] },
trafego: [],
} });
}
const ph = empresas.map(() => '?').join(',');
// ===== Conversas =====
// Abertas: têm atendente E equipe
const abertas = await db.query(alias,
`SELECT COUNT(*) AS CT FROM CHATC2_CONVERSAS
WHERE CON_EMPRESA_ID IN (${ph}) AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A'
AND CON_USUARIO_ID IS NOT NULL AND CON_EQUIPE_ID IS NOT NULL`, empresas);
// Não atendidas: têm atendente+equipe e a última mensagem foi do cliente ('C')
const naoAtendidas = await db.query(alias,
`SELECT COUNT(*) AS CT FROM CHATC2_CONVERSAS c
WHERE c.CON_EMPRESA_ID IN (${ph}) AND c.CON_STATUS IN ('A','E') AND c.CON_SITUACAO = 'A'
AND c.CON_USUARIO_ID IS NOT NULL AND c.CON_EQUIPE_ID IS NOT NULL
AND (SELECT m.CME_REMETENTE FROM CHATC2_CONVERSAS_MENSAGENS m
WHERE m.CME_CONVERSA_ID = c.CON_CODIGO_ID
ORDER BY m.CME_DT_ENVIO DESC FETCH FIRST 1 ROWS ONLY) = 'C'`, empresas);
// Não atribuídas: sem atendente e sem equipe
const naoAtribuidas = await db.query(alias,
`SELECT COUNT(*) AS CT FROM CHATC2_CONVERSAS
WHERE CON_EMPRESA_ID IN (${ph}) AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A'
AND CON_USUARIO_ID IS NULL AND CON_EQUIPE_ID IS NULL`, empresas);
// Pendentes: têm apenas equipe (sem atendente)
const pendentes = await db.query(alias,
`SELECT COUNT(*) AS CT FROM CHATC2_CONVERSAS
WHERE CON_EMPRESA_ID IN (${ph}) AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A'
AND CON_USUARIO_ID IS NULL AND CON_EQUIPE_ID IS NOT NULL`, empresas);
// ===== Atendentes =====
const usuarios = await db.query(alias,
`SELECT DISTINCT u.USU_CODIGO_ID, u.USU_NOME
FROM USUARIOS u INNER JOIN USUARIOS_EMPRESA ue ON u.USU_CODIGO_ID = ue.USE_USUARIO_ID
WHERE ue.USE_EMPRESA_ID IN (${ph}) AND u.USU_STATUS = 'A' AND COALESCE(u.USU_ACESSO_WEB,0) = 1
ORDER BY u.USU_NOME`, empresas);
const online = presence.idsOnline(alias);
let disponiveis = 0, desconectados = 0;
const lista = usuarios.map(function (u) {
const on = online.has(Number(u.USU_CODIGO_ID));
if (on) disponiveis++; else desconectados++;
return { id: u.USU_CODIGO_ID, nome: (u.USU_NOME || '').trim(), online: on };
});
// ===== Tráfego das conversas (últimos 365 dias, por dia) =====
const desde = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
const desdeStr = desde.toISOString().split('T')[0];
const trafegoRows = await db.query(alias,
`SELECT CAST(COALESCE(CON_DT_INICIO, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG) AS DATE) AS DIA, COUNT(*) AS CT
FROM CHATC2_CONVERSAS
WHERE CON_EMPRESA_ID IN (${ph})
AND COALESCE(CON_DT_INICIO, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG) >= ?
GROUP BY CAST(COALESCE(CON_DT_INICIO, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG) AS DATE)`,
empresas.concat([desdeStr]));
const fmtDia = (d) => {
if (!d) return null;
if (typeof d === 'string') return d.split('T')[0];
if (d instanceof Date) return d.toISOString().split('T')[0];
return String(d);
};
const trafego = trafegoRows
.map(function (r) { return { dia: fmtDia(r.DIA), total: Number(r.CT) || 0 }; })
.filter(function (r) { return r.dia; });
res.json({ success: true, data: {
conversas: {
abertas: Number(abertas[0]?.CT) || 0,
naoAtendidas: Number(naoAtendidas[0]?.CT) || 0,
naoAtribuidas: Number(naoAtribuidas[0]?.CT) || 0,
pendentes: Number(pendentes[0]?.CT) || 0,
},
atendentes: { disponiveis, desconectados, lista },
trafego,
} });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
}
module.exports = DashboardController;
+20 -50
View File
@@ -89,59 +89,29 @@ class DatabaseController {
return res.status(400).json({ success: false, error: 'database é obrigatório.' });
}
var driver = (req.body.driver || DEFAULT_DRIVER).toLowerCase();
if (driver === 'postgres') {
var pg = require('pg');
var schema = String(req.body.schema || 'public').trim();
var safe = /^[A-Za-z_][A-Za-z0-9_$]*$/.test(schema) ? schema : 'public';
var searchPath = safe === 'public' ? 'public' : safe + ',public';
var client = new pg.Client({
host: req.body.host || '127.0.0.1',
port: req.body.port || 5432,
database: req.body.database,
user: req.body.user || 'postgres',
password: req.body.password || 'postgres',
ssl: req.body.ssl ? { rejectUnauthorized: false } : false,
options: '-c search_path=' + searchPath,
connectionTimeoutMillis: 8000,
});
try {
await client.connect();
await client.query('SELECT 1');
// Confirma que o schema existe
var sc = await client.query('SELECT 1 FROM information_schema.schemata WHERE schema_name = $1', [safe]);
if (sc.rowCount === 0) {
throw new Error('Schema "' + safe + '" não encontrado no banco "' + req.body.database + '".');
}
} finally {
await client.end().catch(function () {});
}
} else {
var Firebird = require('node-firebird');
var config = {
host: req.body.host || 'localhost',
port: req.body.port || 3050,
database: req.body.database,
user: req.body.user || 'SYSDBA',
password: req.body.password || 'masterkey',
encoding: 'UTF-8',
lowercase_keys: false,
pageSize: 4096,
};
await new Promise(function (resolve, reject) {
Firebird.attach(config, function (err, conn) {
if (err) return reject(new Error('Falha ao conectar: ' + err.message));
conn.query('SELECT 1 AS TEST FROM RDB$DATABASE', [], function (qErr, rows) {
conn.detach();
if (qErr) return reject(new Error('Falha na consulta: ' + qErr.message));
resolve(rows);
});
var Firebird = require('node-firebird');
var config = {
host: req.body.host || 'localhost',
port: req.body.port || 3050,
database: req.body.database,
user: req.body.user || 'SYSDBA',
password: req.body.password || 'masterkey',
encoding: 'UTF-8',
lowercase_keys: false,
pageSize: 4096,
};
await new Promise(function (resolve, reject) {
Firebird.attach(config, function (err, conn) {
if (err) return reject(new Error('Falha ao conectar: ' + err.message));
conn.query('SELECT 1 AS TEST FROM RDB$DATABASE', [], function (qErr, rows) {
conn.detach();
if (qErr) return reject(new Error('Falha na consulta: ' + qErr.message));
resolve(rows);
});
});
}
});
res.json({ success: true, message: '✅ Conexão (' + driver + ') estabelecida com sucesso!' });
res.json({ success: true, message: '✅ Conexão (firebird) estabelecida com sucesso!' });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
+1 -1
View File
@@ -38,7 +38,7 @@ class EmpresaController {
} catch (fe) { /* ignora e tenta o BLOB */ }
}
// 2. BLOB EMP_FOTO (Buffer no postgres/firebird; string base64 em alguns casos)
// 2. BLOB EMP_FOTO (Buffer no firebird; string base64 em alguns casos)
const blobData = e.EMP_FOTO;
let base64;
if (Buffer.isBuffer(blobData)) {
+71 -50
View File
@@ -242,18 +242,18 @@ class EvolutionController {
});
instanceCreated = true;
await db.execute(alias, `
INSERT INTO CHATC2_INSTANCIAS (INS_CODIGO_ID, INS_EMPRESA_ID, INS_NOME, INS_URL, INS_API_KEY, INS_INSTANCE_NAME, INS_STATUS)
VALUES (?, ?, ?, ?, ?, ?, 'C')
INSERT INTO CHATC2_INSTANCIAS (INS_CODIGO_ID, INS_EMPRESA_ID, INS_NOME, INS_URL, INS_API_KEY, INS_INSTANCE_NAME, INS_STATUS, INS_SITUACAO)
VALUES (?, ?, ?, ?, ?, ?, 'C', 'A')
`, [newId, INS_EMPRESA_ID || userEmpresas[0], INS_NOME, INS_URL, INS_API_KEY, INS_INSTANCE_NAME]);
break;
} catch (e) { /* Tenta próximo endpoint */ }
}
if (!instanceCreated) {
// Salva mesmo sem conseguir criar na Evolution
// Salva mesmo sem conseguir criar na Evolution (status 'D' = desconectado)
await db.execute(alias, `
INSERT INTO CHATC2_INSTANCIAS (INS_CODIGO_ID, INS_EMPRESA_ID, INS_NOME, INS_URL, INS_API_KEY, INS_INSTANCE_NAME, INS_STATUS)
VALUES (?, ?, ?, ?, ?, ?, 'D')
INSERT INTO CHATC2_INSTANCIAS (INS_CODIGO_ID, INS_EMPRESA_ID, INS_NOME, INS_URL, INS_API_KEY, INS_INSTANCE_NAME, INS_STATUS, INS_SITUACAO)
VALUES (?, ?, ?, ?, ?, ?, 'D', 'A')
`, [newId, INS_EMPRESA_ID || userEmpresas[0], INS_NOME, INS_URL, INS_API_KEY, INS_INSTANCE_NAME]);
}
@@ -270,8 +270,21 @@ class EvolutionController {
static async listInstances(req, res) {
try {
const { alias } = req.params;
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
if (!req.user?.empresas?.includes(empresaId)) return res.status(403).json({ success: false, error: 'Sem permissão.' });
// Garante que userEmpresas está carregado (fallback para DB se JWT não tiver)
let userEmpresas = req.user?.empresas || [];
if (userEmpresas.length === 0 && req.user?.id) {
try {
const empresasDb = await db.query(alias,
'SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA WHERE USE_USUARIO_ID = ?',
[req.user.id]
);
userEmpresas = empresasDb.map(e => e.USE_EMPRESA_ID);
} catch (e) { /* ignora falha */ }
}
const empresaId = parseInt(req.query.empresaId) || userEmpresas[0];
if (!userEmpresas.includes(empresaId)) return res.status(403).json({ success: false, error: 'Sem permissão.' });
const result = await db.query(alias,
'SELECT * FROM CHATC2_INSTANCIAS WHERE INS_EMPRESA_ID = ? AND INS_SITUACAO = \'A\' ORDER BY INS_NOME',
@@ -440,10 +453,10 @@ class EvolutionController {
// Reaproveita a funcao de atualizar foto
var numero = conv[0].CON_NUMERO || '';
var empresaId = conv[0].CON_EMPRESA_ID;
// A funcao atualizarFotoContato esta no escopo do processWebhook, entao recriamos a logica aqui
await EvolutionController.atualizarFotoContato(alias, conv[0].CON_CLIENTE_ID, numero, empresaId);
// Ação explícita do usuário: força a busca (ignora a flag CFE_FOTO_CELULAR)
const atualizou = await EvolutionController.atualizarFotoContato(alias, conv[0].CON_CLIENTE_ID, numero, empresaId, true);
res.json({ success: true, message: 'Foto atualizada.' });
res.json({ success: true, atualizada: !!atualizou, message: atualizou ? 'Foto atualizada.' : 'Nenhuma foto encontrada para este contato.' });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
@@ -452,12 +465,14 @@ class EvolutionController {
/**
* Versão estática de atualizarFotoContato para uso externo
*/
static async atualizarFotoContato(alias, clienteId, numero, empresaId) {
if (!clienteId) return;
static async atualizarFotoContato(alias, clienteId, numero, empresaId, forcar) {
if (!clienteId) return false;
try {
const cfg = await db.query(alias,
"SELECT CFE_FOTO_CELULAR FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?", [empresaId]);
if (cfg.length === 0 || cfg[0].CFE_FOTO_CELULAR !== 'S') return;
if (!forcar) {
const cfg = await db.query(alias,
"SELECT CFE_FOTO_CELULAR FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?", [empresaId]);
if (cfg.length === 0 || cfg[0].CFE_FOTO_CELULAR !== 'S') return false;
}
const inst = await db.query(alias,
"SELECT * FROM CHATC2_INSTANCIAS WHERE INS_EMPRESA_ID = ? AND INS_SITUACAO = 'A' FETCH FIRST 1 ROWS ONLY", [empresaId]);
@@ -468,29 +483,34 @@ class EvolutionController {
const instanceName = (inst[0].INS_INSTANCE_NAME || '').trim();
if (!url || !apiKey || !instanceName) return;
// Evolution API 2.4.0 NAO possui endpoint getProfile (disponivel apenas a partir da v2.5+)
console.log('[Foto] Evolution API ' + url + ' - getProfile nao disponivel nesta versao. A foto do WhatsApp sera usada quando atualizar a Evolution.');
// Busca a foto de perfil via Evolution: POST <URL>/chat/fetchProfile/<instancia> { number }
// Mesmo sem o endpoint, tentamos alguns formatos conhecidos
var profileEndpoints = [
'/chat/getProfile/' + encodeURIComponent(instanceName) + '?number=' + numero,
'/chat/getProfile/' + encodeURIComponent(instanceName) + '/' + numero,
'/contact/getProfile/' + encodeURIComponent(instanceName) + '?number=' + numero,
];
var profile = null;
for (var ep of profileEndpoints) {
try {
profile = await evolutionRequest(url, apiKey, ep, 'GET');
if (profile) break;
} catch(e) {
profile = null;
// Normaliza o numero para o padrao WhatsApp BR: 55(DDI) + DDD + 9XXXXXXXX
function normalizarNumero(n) {
var d = String(n || '').replace(/\D/g, '');
if (!d) return '';
if (!d.startsWith('55')) d = '55' + d; // garante DDI Brasil
var resto = d.slice(2); // DDD + assinante
if (resto.length === 10) { // DDD(2) + 8 digitos -> insere o 9 (celular)
resto = resto.slice(0, 2) + '9' + resto.slice(2);
}
return '55' + resto;
}
var numeroFmt = normalizarNumero(numero);
if (!numeroFmt) return false;
var profile = null;
try {
profile = await evolutionRequest(url, apiKey,
'/chat/fetchProfile/' + encodeURIComponent(instanceName), 'POST', { number: numeroFmt });
} catch (e) {
console.log('[Foto] fetchProfile falhou:', (e.message || '').substring(0, 120));
return false;
}
if (!profile) {
console.log('[Foto] Nenhum endpoint getProfile disponivel. Evolution API precisa ser atualizada.');
return;
console.log('[Foto] Nenhum perfil retornado para', numeroFmt);
return false;
}
let fotoUrl = null;
@@ -505,31 +525,32 @@ class EvolutionController {
fotoUrl = profile.response.profilePicUrl || profile.response.picUrl || null;
}
if (fotoUrl && fotoUrl.startsWith('http')) {
console.log('[Foto] Baixando foto de:', fotoUrl.substring(0, 80));
if (fotoUrl && String(fotoUrl).startsWith('http')) {
console.log('[Foto] Baixando foto de:', String(fotoUrl).substring(0, 80));
try {
var http = require('http');
var https = require('https');
var fotoBuffer = await new Promise(function(resolve, reject) {
var u = new URL(fotoUrl);
var lib = u.protocol === 'https:' ? https : http;
lib.get(fotoUrl, { timeout: 15000, headers: { 'apikey': apiKey } }, function(imgRes) {
lib.get(fotoUrl, { timeout: 15000 }, function(imgRes) {
var chunks = [];
imgRes.on('data', function(c) { chunks.push(c); });
imgRes.on('end', function() { resolve(Buffer.concat(chunks)); });
}).on('error', reject).on('timeout', function() { this.destroy(); reject(new Error('Timeout')); });
});
if (fotoBuffer && fotoBuffer.length > 100) {
var imgData = fotoBuffer.toString('base64');
await db.execute(alias,
'UPDATE CLIENTES SET CLI_FOTO = ? WHERE CLI_CODIGO_ID = ?',
[imgData, clienteId]);
console.log('[Foto] Foto atualizada para cliente', clienteId, '- tamanho:', fotoBuffer.length);
[fotoBuffer.toString('base64'), clienteId]);
console.log('[Foto] Foto atualizada para cliente', clienteId, '- bytes:', fotoBuffer.length);
return true;
}
} catch(imgErr) {
console.error('[Foto] Erro ao baixar imagem:', imgErr.message.substring(0, 100));
console.error('[Foto] Erro ao baixar imagem:', (imgErr.message || '').substring(0, 120));
}
} else {
console.log('[Foto] Perfil sem foto disponivel para', numeroFmt);
}
return false;
} catch(e) {
console.error('[Foto] Erro:', e.message);
}
@@ -764,8 +785,8 @@ async function processWebhook(alias, body) {
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS (CON_CODIGO_ID, CON_EMPRESA_ID, CON_INSTANCIA_ID, CON_NUMERO,
CON_NOME_CONTATO, CON_CLIENTE_ID, CON_STATUS, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG)
VALUES (?, ?, ?, ?, ?, ?, 'E', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
CON_NOME_CONTATO, CON_CLIENTE_ID, CON_STATUS, CON_SITUACAO, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG)
VALUES (?, ?, ?, ?, ?, ?, 'E', 'A', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`, [newId, empresaId, instanciaId, numero, nomeFinal, clienteId]);
conversa = await db.query(alias, 'SELECT * FROM CHATC2_CONVERSAS WHERE CON_CODIGO_ID = ?', [newId]);
@@ -781,8 +802,8 @@ async function processWebhook(alias, body) {
if (msgSaudacao) {
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP)
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]);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_SAUDACAO_ENVIADA = 'S' WHERE CON_CODIGO_ID = ?", [conversa.CON_CODIGO_ID]);
@@ -1197,13 +1218,13 @@ async function processWebhook(alias, body) {
if (midiaId) {
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO, CME_MIDIA_ID)
VALUES (?, ?, 'C', ?, ?, CURRENT_TIMESTAMP, ?)
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]);
} else {
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO)
VALUES (?, ?, 'C', ?, ?, CURRENT_TIMESTAMP)
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]);
}
+3 -3
View File
@@ -145,14 +145,14 @@ class RoutesController {
'GET /api/databases': { desc: 'Lista as conexões de banco cadastradas (senhas ocultas).' },
'POST /api/databases': {
desc: 'Adiciona ou atualiza uma conexão de banco (persistida em databases_custom.json).',
body: { alias: 'novo_dev', config: { driver: 'postgres', host: '127.0.0.1', port: 15433, database: 'novo_local', schema: 'dev', user: 'postgres', password: 'postgres' } },
body: { alias: 'novo_dev', config: { driver: 'firebird', host: '127.0.0.1', port: 3050, database: '/opt/chatc2/db/NOVO.FDB', user: 'SYSDBA', password: 'masterkey' } },
req: ['alias', 'config'],
note: 'driver: "postgres" (usa schema) ou "firebird" (database = caminho do .FDB).',
note: 'driver: "firebird" (database = caminho do .FDB).',
},
'DELETE /api/databases/{alias}': { desc: 'Remove uma conexão customizada (estáticas não podem ser removidas).' },
'POST /api/databases/test': {
desc: 'Testa uma conexão sem cadastrá-la.',
body: { driver: 'postgres', host: '127.0.0.1', port: 15433, database: 'novo_local', schema: 'public', user: 'postgres', password: 'postgres' },
body: { driver: 'firebird', host: '127.0.0.1', port: 3050, database: '/opt/chatc2/db/NOVO.FDB', user: 'SYSDBA', password: 'masterkey' },
req: ['database'],
},
+32 -32
View File
@@ -72,8 +72,8 @@ class TriageController {
// Salva mensagem do sistema
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'triagem', CURRENT_TIMESTAMP)
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]);
// Envia via Evolution
@@ -121,8 +121,8 @@ class TriageController {
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'triagem', CURRENT_TIMESTAMP)
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]);
await TriageController.sendEvolution(alias, instanciaId, numero, menuText);
@@ -246,8 +246,8 @@ class TriageController {
if (msgAfter) {
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP)
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]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgAfter);
}
@@ -377,8 +377,8 @@ class TriageController {
if (textoMsg) {
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP)
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]);
await TriageController.sendEvolution(alias, instanciaId, numero, textoMsg);
}
@@ -396,8 +396,8 @@ class TriageController {
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP)
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]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgPrompt);
@@ -521,8 +521,8 @@ class TriageController {
if (resultado) {
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP)
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]);
await TriageController.sendEvolution(alias, instanciaId, numero, resultado);
}
@@ -579,8 +579,8 @@ class TriageController {
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');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP)
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]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgNaoEncontrado);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = 'root' WHERE CON_CODIGO_ID = ?", [conversaId]);
@@ -605,8 +605,8 @@ class TriageController {
logTriage('Boleto - NENHUM título em aberto');
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP)
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]);
await TriageController.sendEvolution(alias, instanciaId, numero, msg);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = 'root' WHERE CON_CODIGO_ID = ?", [conversaId]);
@@ -624,8 +624,8 @@ class TriageController {
const maxMsgId2 = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP)
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]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgBoleto);
@@ -652,8 +652,8 @@ class TriageController {
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');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP)
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]);
await TriageController.sendEvolution(alias, instanciaId, numero, msg);
return;
@@ -838,8 +838,8 @@ class TriageController {
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP)
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]);
await TriageController.sendEvolution(alias, instanciaId, numero, textoBoleto);
}
@@ -848,8 +848,8 @@ class TriageController {
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');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP)
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]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgCont);
@@ -889,8 +889,8 @@ 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');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP)
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]);
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]);
@@ -898,8 +898,8 @@ class TriageController {
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');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP)
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]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgInv);
}
@@ -916,8 +916,8 @@ class TriageController {
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');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP)
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]);
await TriageController.sendEvolution(alias, instanciaId, numero, msg);
@@ -949,8 +949,8 @@ class TriageController {
if (texto) {
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP)
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]);
await TriageController.sendEvolution(alias, instanciaId, numero, texto);
}
+154 -441
View File
@@ -5,13 +5,11 @@
*
* 1) CONFIGURAÇÃO aliases, drivers, conexões estáticas e customizadas
* 2) DRIVER FIREBIRD
* 3) DRIVER POSTGRES (pool + tradutor de SQL + schema)
* 4) DISPATCHER API pública usada por todo o sistema
* 3) DISPATCHER API pública usada por todo o sistema
*
* Suporta múltiplos drivers ('postgres' padrão, 'firebird' legado). 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) é um arquivo de DADOS, não de código.
* 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).
*/
const path = require('path');
const fs = require('fs');
@@ -31,22 +29,11 @@ try {
console.error('[Databases] Erro ao carregar databases_custom.json:', e.message);
}
const DRIVERS = ['postgres', 'firebird'];
const DEFAULT_DRIVER = (process.env.DB_DRIVER || 'postgres').toLowerCase();
const DRIVERS = ['firebird'];
const DEFAULT_DRIVER = 'firebird';
// Valores padrão por driver
// Valores padrão para Firebird
const DRIVER_DEFAULTS = {
postgres: {
host: '127.0.0.1',
port: 5432,
user: 'postgres',
password: 'postgres',
schema: 'public',
ssl: false,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 10000,
},
firebird: {
host: 'localhost',
port: 3050,
@@ -61,28 +48,17 @@ const DRIVER_DEFAULTS = {
/**
* Conexões estáticas.
* - `novo_local` PostgreSQL (conexão principal)
* - `firebird_local` Firebird (informe o CAMINHO do arquivo .FDB em `database`)
* Sobrescrevíveis pelo .env (PG_* para Postgres, DB_* para Firebird).
* - `novo_local` Firebird (informe o CAMINHO do arquivo .FDB em `database`)
* Sobrescrevível pelo .env (DB_*).
*/
const databases = {
novo_local: {
driver: 'postgres',
host: process.env.PG_HOST || '127.0.0.1',
port: parseInt(process.env.PG_PORT, 10) || 15433,
user: process.env.PG_USER || 'postgres',
password: process.env.PG_PASSWORD || 'postgres',
database: process.env.PG_DATABASE || 'novo_local',
schema: process.env.PG_SCHEMA || 'public',
},
firebird_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, '../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',
@@ -104,30 +80,10 @@ function salvarCustomDatabases() {
/** Normaliza uma config bruta aplicando o driver e seus defaults. */
function normalize(raw) {
const driver = DRIVERS.includes((raw.driver || '').toLowerCase())
? raw.driver.toLowerCase()
: DEFAULT_DRIVER;
const d = DRIVER_DEFAULTS[driver];
const d = DRIVER_DEFAULTS['firebird'];
if (driver === 'postgres') {
return {
driver,
host: raw.host || d.host,
port: raw.port || d.port,
database: raw.database,
user: raw.user || d.user,
password: raw.password || d.password,
schema: (raw.schema || d.schema || 'public'),
ssl: raw.ssl !== undefined ? raw.ssl : d.ssl,
max: raw.max || d.max,
idleTimeoutMillis: raw.idleTimeoutMillis || d.idleTimeoutMillis,
connectionTimeoutMillis: raw.connectionTimeoutMillis || d.connectionTimeoutMillis,
};
}
// firebird
return {
driver,
driver: 'firebird',
host: raw.host || d.host,
port: raw.port || d.port,
database: raw.database,
@@ -177,14 +133,10 @@ function addDatabase(alias, config) {
throw new Error('Alias e database são obrigatórios.');
}
var aliasLower = alias.toLowerCase().replace(/[^a-z0-9_]/g, '_');
var driver = DRIVERS.includes((config.driver || '').toLowerCase())
? config.driver.toLowerCase()
: DEFAULT_DRIVER;
var stored = { driver: driver };
['host', 'port', 'database', 'schema', 'user', 'password', 'ssl',
'encoding', 'lowercase_keys', 'role', 'pageSize', 'wireCrypt',
'max', 'idleTimeoutMillis', 'connectionTimeoutMillis']
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];
@@ -207,411 +159,172 @@ function removeDatabase(alias) {
// ============================================================
// 2) DRIVER FIREBIRD
// ============================================================
const firebirdDriver = (() => {
const Firebird = require('node-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 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 query(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 execute(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}`));
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; });
}
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 });
});
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 testConnection(config) {
await query(config, 'SELECT 1 FROM RDB$DATABASE');
return true;
}
async function firebirdTestConnection(config) {
await firebirdQuery(config, 'SELECT 1 FROM RDB$DATABASE');
return true;
}
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 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 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()]);
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,
}));
}
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 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 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;
}
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 close() { /* Firebird abre conexão por consulta; nada a encerrar */ }
return { query, execute, testConnection, listTables, tableInfo, tableExists, columnExists, close };
})();
async function firebirdClose() { /* Firebird abre conexão por consulta; nada a encerrar */ }
// ============================================================
// 3) DRIVER POSTGRES (pool + tradutor de SQL Firebird->PG + schema)
// 3) DISPATCHER — API pública
// ============================================================
const postgresDriver = (() => {
const pg = require('pg');
const driver = {
query: firebirdQuery,
execute: firebirdExecute,
testConnection: firebirdTestConnection,
listTables: firebirdListTables,
tableInfo: firebirdTableInfo,
tableExists: firebirdTableExists,
columnExists: firebirdColumnExists,
close: firebirdClose,
};
// COUNT()/bigint chegam como string no pg; o código histórico usa números.
pg.types.setTypeParser(20, (v) => (v === null ? null : parseInt(v, 10))); // int8 / bigint
pg.types.setTypeParser(1700, (v) => (v === null ? null : parseFloat(v))); // numeric / decimal
const pools = new Map();
function safeSchema(schema) {
const s = String(schema || 'public').trim();
return /^[A-Za-z_][A-Za-z0-9_$]*$/.test(s) ? s : 'public';
}
function poolKey(config) {
return [config.host, config.port, config.database, config.user, safeSchema(config.schema)].join('|');
}
// Schemas que o pool enxerga (configurado + public como fallback)
function schemasFor(config) {
const schema = safeSchema(config.schema);
return schema === 'public' ? ['public'] : [schema, 'public'];
}
function getEntry(config) {
const key = poolKey(config);
let entry = pools.get(key);
if (!entry) {
const schemas = schemasFor(config);
const pool = new pg.Pool({
host: config.host,
port: config.port,
database: config.database,
user: config.user,
password: config.password,
ssl: config.ssl ? { rejectUnauthorized: false } : false,
options: '-c search_path=' + schemas.join(','),
max: config.max || 10,
idleTimeoutMillis: config.idleTimeoutMillis || 30000,
connectionTimeoutMillis: config.connectionTimeoutMillis || 10000,
});
pool.on('error', (err) => console.error('[postgres] Erro inesperado no pool:', err.message));
entry = { pool, schemas, tableSet: null, loadingTables: null };
pools.set(key, entry);
}
return entry;
}
// Carrega (uma vez por pool) os nomes de tabela MAIÚSCULOS p/ decidir aspas
async function ensureTableSet(entry) {
if (entry.tableSet) return entry.tableSet;
if (!entry.loadingTables) {
entry.loadingTables = entry.pool
.query('SELECT table_name FROM information_schema.tables WHERE table_schema = ANY($1)', [entry.schemas])
.then((res) => {
entry.tableSet = new Set(res.rows.map((r) => String(r.table_name).toUpperCase()));
return entry.tableSet;
})
.catch((err) => {
console.error('[postgres] Falha ao carregar nomes de tabela:', err.message);
entry.tableSet = new Set();
return entry.tableSet;
});
}
return entry.loadingTables;
}
// ? -> $1, $2, ... (ignora literais de string)
function convertPlaceholders(sql) {
let out = '', i = 0, n = 1, inStr = false;
while (i < sql.length) {
const ch = sql[i];
if (inStr) {
out += ch;
if (ch === "'") {
if (sql[i + 1] === "'") { out += sql[i + 1]; i += 2; continue; }
inStr = false;
}
i++;
continue;
}
if (ch === "'") { inStr = true; out += ch; i++; continue; }
if (ch === '?') { out += '$' + (n++); i++; continue; }
out += ch; i++;
}
return out;
}
// Aspas nos nomes de tabela conhecidos (MAIÚSCULOS) após FROM/JOIN/INTO/UPDATE/ALTER/DROP
function quoteTableNames(sql, tableSet) {
if (!tableSet || tableSet.size === 0) return sql;
return sql.replace(
/(\b(?:FROM|JOIN|INTO|UPDATE|ALTER\s+TABLE|DROP\s+TABLE)\s+)("?)([A-Za-z_][A-Za-z0-9_$]*)("?)/gi,
(match, kw, q1, name, q2) => {
if (q1 === '"' || q2 === '"') return match;
if (tableSet.has(name.toUpperCase())) return kw + '"' + name.toUpperCase() + '"';
return match;
}
);
}
// Rede de segurança: FIRST n [SKIP m] no topo -> LIMIT/OFFSET (SQL legado)
function translateFirstSkip(sql) {
return sql.replace(
/^(\s*SELECT\s+)FIRST\s+(\d+)(?:\s+SKIP\s+(\d+))?\s+/i,
(m, sel, first, skip) => sel + ' LIMITTAIL LIMIT ' + first + (skip ? ' OFFSET ' + skip : '') + ' '
);
}
function applyLimitTail(sql) {
const marker = / LIMITTAIL( LIMIT \d+(?: OFFSET \d+)?) /;
const m = sql.match(marker);
if (!m) return sql;
return sql.replace(marker, ' ').trimEnd() + m[1];
}
function translateSql(sql, tableSet) {
let out = sql;
out = out.replace(/\bCONTAINING\s+(\?|\$\d+|'(?:[^']|'')*')/gi, "ILIKE ('%' || $1 || '%')");
out = out.replace(/\bFROM\s+RDB\$DATABASE\b/gi, '');
out = quoteTableNames(out, tableSet);
out = translateFirstSkip(out);
out = applyLimitTail(out);
out = convertPlaceholders(out);
return out;
}
function upperKeys(rows) {
return rows.map((row) => {
const o = {};
for (const k in row) o[k.toUpperCase()] = row[k];
return o;
});
}
async function query(config, sql, params = []) {
const entry = getEntry(config);
await ensureTableSet(entry);
const text = translateSql(sql, entry.tableSet);
try {
const res = await entry.pool.query(text, params);
return upperKeys(res.rows);
} catch (err) {
throw new Error(`Erro na consulta (postgres): ${err.message}`);
}
}
async function execute(config, sql, params = []) {
const entry = getEntry(config);
await ensureTableSet(entry);
const text = translateSql(sql, entry.tableSet);
try {
const res = await entry.pool.query(text, params);
return { affectedRows: res.rowCount || 0, result: upperKeys(res.rows || []) };
} catch (err) {
throw new Error(`Erro na execução (postgres): ${err.message}`);
}
}
async function testConnection(config) {
const entry = getEntry(config);
await entry.pool.query('SELECT 1');
return true;
}
async function listTables(config) {
const entry = getEntry(config);
const res = await entry.pool.query(
`SELECT table_name FROM information_schema.tables
WHERE table_schema = $1 AND table_type = 'BASE TABLE'
ORDER BY table_name`,
[safeSchema(config.schema)]
);
return res.rows.map((r) => r.table_name);
}
async function tableInfo(config, tableName) {
const entry = getEntry(config);
// Resolve no primeiro schema do search_path que contém a tabela
const res = await entry.pool.query(
`SELECT column_name, ordinal_position, data_type,
character_maximum_length, numeric_precision, numeric_scale, is_nullable
FROM information_schema.columns
WHERE UPPER(table_name) = UPPER($2)
AND table_schema = (
SELECT table_schema FROM information_schema.tables
WHERE UPPER(table_name) = UPPER($2) AND table_schema = ANY($1)
ORDER BY array_position($1, table_schema) LIMIT 1
)
ORDER BY ordinal_position`,
[entry.schemas, tableName]
);
return res.rows.map((row) => ({
name: row.column_name,
position: row.ordinal_position,
type: (row.data_type || '').toUpperCase(),
length: row.character_maximum_length,
precision: row.numeric_precision,
scale: row.numeric_scale,
nullable: row.is_nullable === 'YES',
}));
}
async function tableExists(config, tableName) {
const entry = getEntry(config);
await ensureTableSet(entry);
if (entry.tableSet) return entry.tableSet.has(String(tableName).toUpperCase());
const res = await entry.pool.query(
`SELECT 1 FROM information_schema.tables
WHERE table_schema = ANY($1) AND UPPER(table_name) = UPPER($2) LIMIT 1`,
[entry.schemas, tableName]
);
return res.rowCount > 0;
}
async function columnExists(config, tableName, columnName) {
const entry = getEntry(config);
const res = await entry.pool.query(
`SELECT 1 FROM information_schema.columns
WHERE table_schema = ANY($1) AND UPPER(table_name) = UPPER($2)
AND UPPER(column_name) = UPPER($3) LIMIT 1`,
[entry.schemas, tableName, columnName]
);
return res.rowCount > 0;
}
async function close() {
const all = Array.from(pools.values()).map((e) => e.pool.end().catch(() => {}));
pools.clear();
await Promise.all(all);
}
return {
query, execute, testConnection, listTables, tableInfo, tableExists, columnExists, close,
_translateSql: translateSql, // exportado para testes
};
})();
// ============================================================
// 4) DISPATCHER — API pública
// ============================================================
const drivers = { postgres: postgresDriver, firebird: firebirdDriver };
function getDriver(alias) {
function getConn(alias) {
const config = getConfig(alias);
const driver = drivers[config.driver];
if (!driver) {
throw new Error(`Driver "${config.driver}" não suportado para o alias "${alias}".`);
}
return { config, driver };
}
function query(alias, sql, params = []) {
let d;
try { d = getDriver(alias); } catch (err) { return Promise.reject(err); }
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}`); });
}
function execute(alias, sql, params = []) {
let d;
try { d = getDriver(alias); } catch (err) { return Promise.reject(err); }
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}`); });
}
async function testConnection(alias) {
try {
const { config, driver } = getDriver(alias);
const { config } = getConn(alias);
await driver.testConnection(config);
return true;
} catch (err) {
@@ -629,33 +342,33 @@ async function testAllConnections() {
}
function listTables(alias) {
const { config, driver } = getDriver(alias);
const { config } = getConn(alias);
return driver.listTables(config);
}
function tableInfo(alias, tableName) {
const { config, driver } = getDriver(alias);
const { config } = getConn(alias);
return driver.tableInfo(config, tableName);
}
function tableExists(alias, tableName) {
const { config, driver } = getDriver(alias);
const { config } = getConn(alias);
return driver.tableExists(config, tableName);
}
function columnExists(alias, tableName, columnName) {
const { config, driver } = getDriver(alias);
const { config } = getConn(alias);
return driver.columnExists(config, tableName, columnName);
}
/** Nome do driver de um alias (ex.: 'postgres'). */
/** Nome do driver de um alias (sempre 'firebird'). */
function driverOf(alias) {
return getConfig(alias).driver;
return 'firebird';
}
/** Encerra os pools de todos os drivers (shutdown gracioso). */
/** Encerra recursos (noop para Firebird). */
async function closeAll() {
await Promise.all(Object.values(drivers).map((d) => d.close && d.close()));
await driver.close();
}
module.exports = {
+26 -2
View File
@@ -43,6 +43,30 @@ async function authenticateToken(req, res, next) {
if (tokenSource !== 'usu_token') {
try {
const decoded = jwt.verify(token, authConfig.secret, { issuer: authConfig.issuer });
// Normaliza as empresas para número (tokens antigos podem ter strings,
// o que quebra comparações do tipo empresas.includes(2))
if (Array.isArray(decoded.empresas)) {
decoded.empresas = decoded.empresas
.map(function (e) { return Number(e); })
.filter(function (n) { return !isNaN(n); });
}
// Se o JWT não tem o array empresas (tokens legados), tenta carregar do BD
if (!Array.isArray(decoded.empresas) || decoded.empresas.length === 0) {
const alias = req.params.alias;
if (alias && decoded.id) {
try {
const empresas = await db.query(alias,
'SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA WHERE USE_USUARIO_ID = ?',
[decoded.id]
);
decoded.empresas = empresas.map(e => Number(e.USE_EMPRESA_ID)).filter(n => !isNaN(n));
} catch (e) {
decoded.empresas = [];
}
} else {
decoded.empresas = [];
}
}
req.user = decoded;
req.authType = 'jwt';
return next();
@@ -83,7 +107,7 @@ async function authenticateToken(req, res, next) {
FROM USUARIOS
WHERE USU_TOKEN = ?
AND USU_STATUS = 'A'
AND COALESCE(USU_ACESSO_WEB, 0) = 1`,
AND COALESCE(USU_ACESSO_WEB, '0') IN ('1', 'S')`,
[token]
);
@@ -103,7 +127,7 @@ async function authenticateToken(req, res, next) {
'SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA WHERE USE_USUARIO_ID = ?',
[user.USU_CODIGO_ID]
);
empresasIds = empresas.map(e => e.USE_EMPRESA_ID);
empresasIds = empresas.map(e => Number(e.USE_EMPRESA_ID)).filter(n => !isNaN(n));
} catch (e) {
// Se a tabela não existir, ignora
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Presença de atendentes (em memória).
*
* Mantém um mapa simples de "última atividade" por (alias + usuário). As páginas
* autenticadas (chat, dashboard) enviam um ping periódico; consideramos
* "disponível" quem teve atividade nos últimos TTL_MS milissegundos.
*
* É em memória de propósito: não altera o schema do banco e funciona bem para
* um único processo Node. Em cluster/multi-processo seria preciso um store
* compartilhado (Redis), mas isso foge do escopo atual.
*/
const TTL_MS = 2 * 60 * 1000; // 2 minutos sem ping => desconectado
// chave: alias + '|' + usuarioId => timestamp (ms) do último ping
const ultimoPing = new Map();
function chave(alias, usuarioId) {
return String(alias) + '|' + String(usuarioId);
}
/** Registra atividade do usuário. */
function registrar(alias, usuarioId) {
if (!alias || usuarioId == null) return;
ultimoPing.set(chave(alias, usuarioId), Date.now());
}
/** Retorna true se o usuário teve atividade recente (online). */
function estaOnline(alias, usuarioId) {
const t = ultimoPing.get(chave(alias, usuarioId));
return !!t && (Date.now() - t) <= TTL_MS;
}
/** Conjunto (Set) de IDs de usuários online para um alias. */
function idsOnline(alias) {
const agora = Date.now();
const prefixo = String(alias) + '|';
const set = new Set();
for (const [k, t] of ultimoPing.entries()) {
if (k.startsWith(prefixo) && (agora - t) <= TTL_MS) {
set.add(Number(k.slice(prefixo.length)));
}
}
return set;
}
// Limpeza periódica de entradas expiradas (evita crescimento indefinido)
const intervalo = setInterval(function () {
const agora = Date.now();
for (const [k, t] of ultimoPing.entries()) {
if (agora - t > TTL_MS * 5) ultimoPing.delete(k);
}
}, TTL_MS);
if (intervalo.unref) intervalo.unref();
module.exports = { registrar, estaOnline, idsOnline, TTL_MS };
+242 -9
View File
@@ -594,6 +594,35 @@ body { background:#f3f4f6; display:flex; height:100vh; overflow:hidden; }
}
.empty-state .icon { font-size: 52px; opacity: 0.45; }
.empty-state p { font-size: 15px; color: #6b7280; }
/* Botões só do mobile (voltar / info) */
.mobile-only { display: none; }
/* ===== RESPONSIVO: navegação de painel único (lista → conversa → info) ===== */
@media (max-width: 768px) {
.sidebar-left { width: 100%; }
.chat-center { display: none; }
/* Quando uma conversa está aberta, mostra o chat e esconde a lista */
body.chat-aberto .sidebar-left { display: none; }
body.chat-aberto .chat-center { display: flex; }
/* Painel de informações vira overlay deslizante (oculto por padrão) */
.sidebar-right {
position: fixed;
top: 0; right: 0; bottom: 0;
width: 88%; max-width: 340px;
z-index: 1200;
box-shadow: -4px 0 24px rgba(0,0,0,0.28);
display: none !important;
}
body.info-aberto .sidebar-right { display: flex !important; }
.mobile-only { display: inline-flex !important; align-items: center; justify-content: center; }
.chat-header { gap: 8px; }
.chat-header .info h3 { font-size: 14px; }
}
</style>
<link rel="stylesheet" href="/css/dark-mode.css">
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?' Claro':'🌙 Escuro'});}
@@ -622,6 +651,18 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
</div>
</div>
<!-- Modal enviar boleto -->
<div class="modal-overlay" id="modalBoleto">
<div class="modal">
<h3>💳 Enviar boleto</h3>
<p style="font-size:13px;color:#6b7280;margin-bottom:12px">Selecione o título a enviar para o cliente:</p>
<div id="boletoLista" style="max-height:320px;overflow-y:auto"></div>
<div class="btn-group">
<button onclick="fecharModalBoleto()">Fechar</button>
</div>
</div>
</div>
<div class="sidebar-left">
<div class="header">
<h2>💬 Chatc2</h2>
@@ -631,6 +672,9 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<a id="navConfigChat" class="admin-only-nav" href="#" title="Configurações">⚙️</a>
</div>
</div>
<div id="empresaSwitcherWrap" style="display:none;padding:8px 12px;border-bottom:1px solid rgba(255,255,255,0.08)">
<select id="empresaSwitcher" onchange="trocarEmpresa(this.value)" style="width:100%;padding:6px 8px;border:1px solid rgba(255,255,255,0.15);border-radius:6px;font-size:12px;background:rgba(255,255,255,0.06);color:#fff;outline:none"></select>
</div>
<div class="nav-tabs">
<a href="#" class="active" onclick="mudarFiltro('mine',this)">Minhas (0)</a>
<a href="#" onclick="mudarFiltro('unassigned',this)">Sem atend. (0)</a>
@@ -652,11 +696,13 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<div class="chat-center">
<div class="chat-header" id="chatHeader" style="display:none">
<button class="mobile-only" onclick="voltarLista()" title="Voltar" style="background:none;border:none;font-size:20px;cursor:pointer;padding:4px 6px;color:#374151"></button>
<div class="info">
<h3 id="chatNome"></h3>
<span id="chatStatus"></span>
</div>
<div class="actions">
<button class="mobile-only" onclick="toggleInfo()" title="Informações"></button>
<button class="btn-finalizar" onclick="finalizarConversa()">✅ Finalizar</button>
</div>
</div>
@@ -685,7 +731,20 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<div class="header"><h3>📋 Informações</h3></div>
<div class="cliente-foto" id="clienteFoto">?</div>
<div class="info-section" id="clienteInfoContainer"></div>
<!-- Fluxo de Resolução (aparece conforme configuração da empresa) -->
<div class="info-section" id="resolucaoPanel" style="display:none">
<div class="label" style="font-size:10px;text-transform:uppercase;color:#9ca3af;margin-bottom:6px">RESOLUÇÃO DO ATENDIMENTO</div>
<div id="motivoWrap" style="display:none;margin-bottom:8px">
<select id="selectMotivo" style="width:100%;padding:6px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:12px;outline:none">
<option value="">Selecione o motivo...</option>
</select>
</div>
<div id="resolucaoWrap" style="display:none">
<textarea id="campoResolucao" rows="3" placeholder="Descreva a resolução do atendimento..." style="width:100%;padding:8px;border:1px solid #e5e7eb;border-radius:6px;font-size:12px;outline:none;resize:vertical;font-family:inherit"></textarea>
</div>
</div>
<div class="info-section">
<div class="label" style="font-size:10px;text-transform:uppercase;color:#9ca3af;margin-bottom:4px">ATENDENTE</div>
<select id="selectAtendente" style="width:100%;padding:6px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:12px;outline:none" onchange="mudarAtendente(this)">
@@ -721,7 +780,7 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
const token = localStorage.getItem('chatc2_token');
const pathParts = window.location.pathname.split('/');
const alias = pathParts[2];
const empresaId = pathParts[4];
let empresaId = pathParts[4];
const conversaId = pathParts[6];
const usuarioLogado = JSON.parse(localStorage.getItem('chatc2_user') || '{}');
// Navegacao rapida no header do chat
@@ -746,6 +805,36 @@ function esc(s){ return String(s == null ? '' : s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
// ===== FLUXO DE RESOLUÇÃO =====
var cfgResolucao = { motivoVisualizar:'N', motivoObrigatorio:'N', resolucaoVisualizar:'N', resolucaoObrigatorio:'N' };
var motivosList = [];
async function carregarConfigResolucao() {
try {
var cd = await (await fetch('/api/' + alias + '/company/config?empresaId=' + empresaId, { headers: { 'Authorization': 'Bearer ' + token } })).json();
if (cd.success && cd.data) cfgResolucao = cd.data;
var md = await (await fetch('/api/' + alias + '/motivos?empresaId=' + empresaId, { headers: { 'Authorization': 'Bearer ' + token } })).json();
if (md.success) motivosList = md.data || [];
} catch(e) {}
}
function aplicarPainelResolucao(conv) {
var panel = document.getElementById('resolucaoPanel');
if (!panel) return;
var verMot = cfgResolucao.motivoVisualizar === 'S';
var verRes = cfgResolucao.resolucaoVisualizar === 'S';
panel.style.display = (verMot || verRes) ? 'block' : 'none';
document.getElementById('motivoWrap').style.display = verMot ? 'block' : 'none';
document.getElementById('resolucaoWrap').style.display = verRes ? 'block' : 'none';
if (verMot) {
var sel = document.getElementById('selectMotivo');
sel.innerHTML = '<option value="">Selecione o motivo...</option>' +
motivosList.map(function(m){ return '<option value="' + m.id + '">' + esc(m.descricao) + '</option>'; }).join('');
sel.value = (conv && conv.motivoId) ? String(conv.motivoId) : '';
}
if (verRes) {
document.getElementById('campoResolucao').value = (conv && conv.resolucao) ? conv.resolucao : '';
}
}
// ===== NAVEGAÇÃO =====
document.querySelectorAll('.sidebar-left .nav-tabs a').forEach(function(a) {
a.addEventListener('click', function(e) { e.preventDefault(); });
@@ -843,8 +932,11 @@ function renderConversas(convs, busca) {
}
// ===== ABRIR CONVERSA =====
window.abrirConversa = async function(id) {
window.abrirConversa = async function(id, skipFotoRefresh) {
conversaAtiva = id;
// Mobile: alterna para a visão da conversa (e fecha o painel de info)
document.body.classList.add('chat-aberto');
document.body.classList.remove('info-aberto');
document.querySelectorAll('.conv-item').forEach(function(el) { el.classList.remove('active'); });
var item = document.querySelector('.conv-item[data-id="' + id + '"]');
if (item) item.classList.add('active');
@@ -864,11 +956,26 @@ window.abrirConversa = async function(id) {
var data = await res.json();
if (!data.success) return;
renderInfoConversa(data.data);
// Foto automática: se o cliente está vinculado mas sem foto, busca via Evolution
// em segundo plano e recarrega o painel quando a foto chegar.
if (!skipFotoRefresh && data.data && data.data.cliente && !data.data.cliente.foto) {
autoAtualizarFoto(id);
}
} catch(e) {}
carregarMensagens();
};
// Busca a foto do cliente via Evolution (fetchProfile) em segundo plano, sem bloquear.
function autoAtualizarFoto(id) {
fetch('/api/' + alias + '/evolution/refresh-photo/' + id, {
method: 'POST', headers: { 'Authorization': 'Bearer ' + token }
}).then(function(r){ return r.json(); }).then(function(r){
// Recarrega apenas se a foto foi atualizada e ainda estamos na mesma conversa
if (r && r.success && r.atualizada && conversaAtiva === id) abrirConversa(id, true);
}).catch(function(){});
}
function renderInfoConversa(conv) {
document.getElementById('chatNome').textContent = conv.nomeContato || conv.numero || 'Desconhecido';
document.getElementById('chatStatus').textContent = conv.status === 'E' ? '🟡 Em espera' : conv.status === 'A' ? '🟢 Em atendimento' : '⚫ Finalizado';
@@ -884,6 +991,7 @@ function renderInfoConversa(conv) {
}
window._convAtual = conv;
aplicarPainelResolucao(conv);
// Info cliente
var infoContainer = document.getElementById('clienteInfoContainer');
@@ -926,6 +1034,13 @@ function renderInfoConversa(conv) {
infoContainer.innerHTML = '<div style="color:#9ca3af;font-size:13px;text-align:center;padding:20px 0">Contato não cadastrado como cliente</div>';
}
// Botão "Enviar boleto" (aparece se a empresa habilitar e houver titular)
var boletoClienteId = conv.cliente ? conv.cliente.id : (conv.dependente ? conv.dependente.titularId : null);
if (cfgResolucao.enviarBoleto === 'S' && boletoClienteId && conv.status !== 'F') {
infoContainer.innerHTML +=
'<button id="btnEnviarBoleto" onclick="abrirModalBoleto(' + boletoClienteId + ')" style="margin-top:10px;width:100%;padding:8px 10px;border:1px solid #c7d2fe;border-radius:6px;background:#eef2ff;cursor:pointer;font-size:13px;color:#4338ca;font-weight:600">💳 Enviar boleto</button>';
}
// Verifica convalescentes pendentes (situação E = entrega)
var clienteId = conv.cliente ? conv.cliente.id : (conv.dependente ? conv.dependente.titularId : null);
if (clienteId) {
@@ -1361,11 +1476,31 @@ dropArea.addEventListener('drop', function(e) {
// ===== FINALIZAR =====
window.finalizarConversa = async function() {
if (!conversaAtiva || !confirm('Finalizar esta conversa?')) return;
if (!conversaAtiva) return;
// Coleta motivo/resolução (se visíveis) e valida obrigatoriedade
var motivoId = null, resolucao = '';
if (cfgResolucao.motivoVisualizar === 'S') {
var sel = document.getElementById('selectMotivo');
motivoId = sel && sel.value ? parseInt(sel.value, 10) : null;
if (cfgResolucao.motivoObrigatorio === 'S' && !motivoId) {
alert('Selecione o motivo do atendimento para finalizar.'); return;
}
}
if (cfgResolucao.resolucaoVisualizar === 'S') {
var txt = document.getElementById('campoResolucao');
resolucao = txt ? (txt.value || '').trim() : '';
if (cfgResolucao.resolucaoObrigatorio === 'S' && !resolucao) {
alert('Preencha a resolução do atendimento para finalizar.'); return;
}
}
if (!confirm('Finalizar esta conversa?')) return;
try {
var res = await fetch('/api/' + alias + '/conversations/' + conversaAtiva + '/finalize', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token }
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ motivoId: motivoId, resolucao: resolucao })
});
var data = await res.json();
if (data.success) {
@@ -1374,11 +1509,80 @@ window.finalizarConversa = async function() {
document.querySelector('.btn-finalizar').style.opacity = '0.6';
carregarConversas();
abrirConversa(conversaAtiva);
} else {
alert(data.error || 'Não foi possível finalizar a conversa.');
}
} catch(e) {}
};
// ===== PRIVADA TOGGLE =====
// ===== NAVEGAÇÃO MOBILE (painel único) =====
window.voltarLista = function() {
document.body.classList.remove('chat-aberto', 'info-aberto');
};
window.toggleInfo = function() {
document.body.classList.toggle('info-aberto');
};
// ===== ENVIAR BOLETO =====
var boletoClienteAtual = null;
window.abrirModalBoleto = async function(clienteId) {
if (!conversaAtiva || !clienteId) return;
boletoClienteAtual = clienteId;
var modal = document.getElementById('modalBoleto');
var lista = document.getElementById('boletoLista');
modal.classList.add('show');
lista.innerHTML = '<div style="text-align:center;color:#9ca3af;padding:20px">⏳ Carregando títulos...</div>';
try {
var r = await (await fetch('/api/' + alias + '/conversations/' + conversaAtiva + '/boletos?clienteId=' + clienteId, {
headers: { 'Authorization': 'Bearer ' + token }
})).json();
if (!r.success) { lista.innerHTML = '<div style="color:#ef4444;padding:16px">' + esc(r.error || 'Erro ao carregar títulos.') + '</div>'; return; }
var carnes = (r.data || []).filter(function(c){ return c.temLinhaDigitavel || c.temPix; });
if (carnes.length === 0) {
lista.innerHTML = '<div style="color:#9ca3af;padding:16px;text-align:center">Nenhum título em aberto com boleto/PIX disponível.</div>';
return;
}
lista.innerHTML = carnes.map(function(c){
var valor = (c.valor != null) ? ('R$ ' + Number(c.valor).toFixed(2).replace('.', ',')) : '-';
var venc = c.vencimento ? c.vencimento.split('-').reverse().join('/') : '-';
var parc = c.parcela ? (' • Parcela ' + c.parcela + (c.totalParcelas ? '/' + c.totalParcelas : '')) : '';
var tags = (c.temPix ? '<span style="font-size:10px;background:#dcfce7;color:#166534;padding:1px 6px;border-radius:8px;margin-left:6px">PIX</span>' : '') +
(c.temLinhaDigitavel ? '<span style="font-size:10px;background:#dbeafe;color:#1e40af;padding:1px 6px;border-radius:8px;margin-left:6px">Boleto</span>' : '');
return '<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;padding:10px;border:1px solid #e5e7eb;border-radius:8px;margin-bottom:8px">' +
'<div><div style="font-weight:600;color:#111827">' + valor + tags + '</div>' +
'<div style="font-size:12px;color:#6b7280">Vencimento: ' + venc + parc + '</div></div>' +
'<button onclick="enviarBoleto(' + c.id + ', this)" style="padding:6px 12px;background:#4f46e5;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;white-space:nowrap">Enviar</button>' +
'</div>';
}).join('');
} catch(e) {
lista.innerHTML = '<div style="color:#ef4444;padding:16px">Erro de conexão.</div>';
}
};
window.fecharModalBoleto = function() {
document.getElementById('modalBoleto').classList.remove('show');
};
window.enviarBoleto = async function(carneId, btn) {
if (!conversaAtiva || !boletoClienteAtual) return;
if (btn) { btn.disabled = true; btn.textContent = 'Enviando...'; }
try {
var r = await (await fetch('/api/' + alias + '/conversations/' + conversaAtiva + '/send-boleto', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ clienteId: boletoClienteAtual, carneId: carneId })
})).json();
if (r.success) {
fecharModalBoleto();
carregarMensagens();
} else if (btn) {
btn.disabled = false; btn.textContent = 'Enviar';
alert(r.error || 'Não foi possível enviar o boleto.');
}
} catch(e) {
if (btn) { btn.disabled = false; btn.textContent = 'Enviar'; }
}
};
window.togglePrivada = function() {
document.getElementById('privadaToggle').classList.toggle('ativa');
var btn = document.getElementById('privadaToggle');
@@ -1517,11 +1721,31 @@ window.mudarFiltro = function(filtro, el) {
// Mostra
// ===== INICIAR =====
carregarConversas();
if (conversaId) {
setTimeout(function() { abrirConversa(parseInt(conversaId)); }, 300);
// ===== SELETOR DE EMPRESA (usuário com acesso a mais de uma) =====
async function carregarEmpresas() {
try {
var r = await (await fetch('/api/' + alias + '/empresas', { headers: { 'Authorization': 'Bearer ' + token } })).json();
if (!r.success || !r.data || r.data.length <= 1) return; // só mostra se tiver mais de uma
var sel = document.getElementById('empresaSwitcher');
sel.innerHTML = r.data.map(function(e) {
var nome = e.nomeFantasia || e.nome || ('Empresa ' + e.id);
return '<option value="' + e.id + '"' + (String(e.id) === String(empresaId) ? ' selected' : '') + '>' + esc(nome) + '</option>';
}).join('');
document.getElementById('empresaSwitcherWrap').style.display = 'block';
} catch(e) {}
}
window.trocarEmpresa = function(novaEmpresaId) {
if (!novaEmpresaId || String(novaEmpresaId) === String(empresaId)) return;
// Recarrega a tela na empresa escolhida (estado limpo: conversas, config, etc.)
window.location.href = '/app/' + alias + '/company/' + novaEmpresaId + '/conversation/0';
};
// ===== INICIAR =====
carregarEmpresas();
carregarConversas();
carregarConfigResolucao().then(function() {
if (conversaId) abrirConversa(parseInt(conversaId));
});
// ===== LOGOUT =====
window.logout = function() {
@@ -1535,6 +1759,15 @@ setInterval(function() {
if (conversaAtiva) carregarMensagens();
}, 5000);
// Presença: marca o atendente como "online" para o dashboard
function pingPresenca() {
fetch('/api/' + alias + '/dashboard/ping', {
method: 'POST', headers: { 'Authorization': 'Bearer ' + token }
}).catch(function(){});
}
pingPresenca();
setInterval(pingPresenca, 45000);
// Auto-resize textarea
document.getElementById('msgInput').addEventListener('input', function() {
this.style.height = 'auto';
+63 -11
View File
@@ -209,9 +209,16 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
var sitLabel = c.situacao ? c.situacao.descricao : 'Desconhecido';
var iniciais = c.nome ? c.nome.split(' ').map(function(s) { return s[0]; }).slice(0,2).join('').toUpperCase() : '?';
var avatarConteudo = iniciais;
if (c.foto) {
var srcFoto = c.foto;
if (srcFoto.indexOf('data:') !== 0 && srcFoto.length > 50) srcFoto = 'data:image/jpeg;base64,' + srcFoto;
avatarConteudo = '<img src="' + srcFoto + '" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:50%">';
}
container.innerHTML =
'<div class="client-header">' +
'<div class="client-avatar">' + iniciais + '</div>' +
'<div class="client-avatar">' + avatarConteudo + '</div>' +
'<div class="client-header-info">' +
'<h1>' + (c.nome || '-') + '</h1>' +
'<div class="matricula">Matrícula: ' + (c.matricula || '-') + '</div>' +
@@ -452,8 +459,8 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
} else {
html += '<div id="conteudoCarnes_' + c.id + '" style="display:none"></div>';
}
// Conteudo Itens
html += '<div id="conteudoItens_' + c.id + '" style="' + (temCarnes && !temItens ? 'display:none' : '') + ';overflow-x:auto">';
// Conteudo Itens (oculto por padrão quando há boletos — Boletos é a aba ativa)
html += '<div id="conteudoItens_' + c.id + '" style="' + (temCarnes ? 'display:none;' : '') + 'overflow-x:auto">';
if (temItens) {
html += '<table style="font-size:12px"><thead><tr>' +
'<th>Produto</th><th style="text-align:center">Quantidade</th>' +
@@ -492,19 +499,64 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
if (cItens) cItens.style.display = prefixo === 'itens' ? '' : 'none';
};
// Edição INLINE do telefone do dependente (sem prompt/alert)
window.editarTelDep = function(id) {
var btn = document.querySelector('button[onclick="editarTelDep(' + id + ')"]');
var telAtual = btn ? (btn.getAttribute('data-tel') || '') : '';
var novo = prompt('Editar telefone do dependente:', telAtual || '');
if (novo === null || novo.trim() === telAtual) return;
var span = document.getElementById('depTel_' + id);
if (!span || span.getAttribute('data-editing') === '1') return;
var btnEdit = document.querySelector('button[onclick="editarTelDep(' + id + ')"]');
var telAtual = btnEdit ? (btnEdit.getAttribute('data-tel') || '') : '';
span.setAttribute('data-editing', '1');
span.setAttribute('data-original', span.innerHTML);
if (btnEdit) btnEdit.style.display = 'none';
span.innerHTML =
'<input type="text" id="depTelInput_' + id + '" value="' + telAtual.replace(/"/g, '&quot;') + '" style="width:120px;padding:2px 6px;border:1px solid #667eea;border-radius:4px;font-size:12px;outline:none">' +
' <button onclick="confirmarTelDep(' + id + ')" title="Confirmar" style="padding:1px 7px;border:1px solid #059669;border-radius:4px;background:#059669;color:#fff;cursor:pointer;font-size:11px"></button>' +
' <button onclick="cancelarTelDep(' + id + ')" title="Cancelar" style="padding:1px 7px;border:1px solid #d1d5db;border-radius:4px;background:#fff;cursor:pointer;font-size:11px"></button>';
var inp = document.getElementById('depTelInput_' + id);
if (inp) {
inp.focus();
inp.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); confirmarTelDep(id); }
else if (e.key === 'Escape') { cancelarTelDep(id); }
});
}
};
window.cancelarTelDep = function(id) {
var span = document.getElementById('depTel_' + id);
if (!span) return;
span.innerHTML = span.getAttribute('data-original') || '-';
span.setAttribute('data-editing', '');
var btnEdit = document.querySelector('button[onclick="editarTelDep(' + id + ')"]');
if (btnEdit) btnEdit.style.display = '';
};
window.confirmarTelDep = function(id) {
var inp = document.getElementById('depTelInput_' + id);
var span = document.getElementById('depTel_' + id);
if (!inp || !span) return;
var novo = (inp.value || '').trim();
var btnEdit = document.querySelector('button[onclick="editarTelDep(' + id + ')"]');
inp.disabled = true;
fetch('/api/' + alias + '/dependents/' + id + '/phone', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ telefone: novo.trim() })
body: JSON.stringify({ telefone: novo })
}).then(function(r) { return r.json(); }).then(function(d) {
if (d.success) { document.getElementById('depTel_' + id).textContent = novo.trim(); alert('Telefone atualizado!'); }
else alert('Erro: ' + d.error);
}).catch(function(e) { alert('Erro: ' + e.message); });
if (d.success) {
span.setAttribute('data-editing', '');
span.textContent = novo || '-';
if (btnEdit) { btnEdit.setAttribute('data-tel', novo); btnEdit.style.display = ''; }
} else {
inp.disabled = false;
inp.style.borderColor = '#ef4444';
inp.title = d.error || 'Erro ao salvar';
}
}).catch(function(e) {
inp.disabled = false;
inp.style.borderColor = '#ef4444';
inp.title = e.message;
});
};
// ============================================================
+84
View File
@@ -646,3 +646,87 @@ tr:last-child td { border-bottom: none; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #9ca3af; }
/* =====================================================
RESPONSIVO (tablet / celular)
===================================================== */
@media (max-width: 1024px) {
.container { padding: 18px; }
.search-bar select { min-width: 150px; }
}
@media (max-width: 768px) {
/* A sidebar lateral vira uma barra horizontal no topo */
body { flex-direction: column; }
.sidebar {
width: 100%;
flex-direction: row;
align-items: center;
overflow-x: auto;
}
.sidebar-brand {
border-bottom: none;
padding: 10px 14px;
flex-shrink: 0;
}
.sidebar-brand span { display: none; }
.sidebar-brand h2 { font-size: 15px; }
.sidebar-nav {
flex-direction: row;
align-items: center;
padding: 6px;
gap: 2px;
overflow-x: auto;
overflow-y: hidden;
flex: 1;
}
.sidebar-nav .nav-label { display: none; }
.sidebar-nav a {
white-space: nowrap;
padding: 8px 12px;
margin-bottom: 0;
font-size: 13px;
}
.sidebar-footer {
display: flex;
flex-direction: row;
align-items: center;
border-top: none;
padding: 6px;
flex-shrink: 0;
}
.sidebar-footer .dark-mode-toggle {
width: auto;
margin: 0 4px 0 0;
white-space: nowrap;
}
.sidebar-footer a { padding: 8px 10px; white-space: nowrap; }
/* Conteúdo principal */
.container { padding: 14px; }
.topbar { padding: 12px 16px; flex-wrap: wrap; gap: 8px; }
.topbar-title { font-size: 15px; }
.user-info { gap: 8px; }
.card { padding: 16px; border-radius: var(--radius-md); }
/* Tabelas: rolagem horizontal em vez de espremer */
.table-wrapper { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.table-wrapper table { min-width: 560px; }
/* Busca/filtros empilham e ocupam a largura */
.search-bar { gap: 8px; }
.search-bar input,
.search-bar select,
.search-bar button { width: 100%; min-width: 0; }
.search-bar .total-info { margin-left: 0; }
/* Modal quase tela cheia */
.modal-box { width: 94%; padding: 20px; max-height: 90vh; overflow-y: auto; }
}
@media (max-width: 480px) {
.container { padding: 10px; }
.card { padding: 13px; }
.btn { padding: 9px 14px; font-size: 13px; }
.info-grid { grid-template-columns: 1fr; }
}
+178 -103
View File
@@ -9,6 +9,35 @@
body { background: #f3f4f6; display: flex; min-height: 100vh; }
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.container { flex: 1; padding: 24px; overflow-y: auto; }
.section-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: #6b7280; margin: 4px 0 12px; }
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; margin-bottom: 24px; }
.stat-card { background: #fff; border-radius: 12px; padding: 18px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); border-left: 4px solid #9ca3af; }
.stat-card .num { font-size: 32px; font-weight: 800; color: #111827; line-height: 1; }
.stat-card .lbl { font-size: 13px; color: #6b7280; margin-top: 6px; font-weight: 600; }
.stat-card .desc { font-size: 11px; color: #9ca3af; margin-top: 4px; }
.stat-card.abertas { border-left-color: #10b981; }
.stat-card.naoatendidas { border-left-color: #ef4444; }
.stat-card.naoatribuidas { border-left-color: #f59e0b; }
.stat-card.pendentes { border-left-color: #6366f1; }
.stat-card.disponiveis { border-left-color: #10b981; }
.stat-card.desconectados { border-left-color: #9ca3af; }
.card { background:#fff; border-radius:12px; padding:18px; box-shadow:0 1px 3px rgba(0,0,0,0.08); margin-bottom:24px; }
.atendente-row { display:flex; align-items:center; gap:8px; padding:7px 0; border-bottom:1px solid #f3f4f6; font-size:14px; }
.atendente-row:last-child { border-bottom:none; }
.dot { width:9px; height:9px; border-radius:50%; flex-shrink:0; }
.dot.on { background:#10b981; box-shadow:0 0 0 3px rgba(16,185,129,.18); }
.dot.off { background:#cbd5e1; }
.atendente-row .st { margin-left:auto; font-size:12px; color:#6b7280; }
/* Heatmap (estilo GitHub) */
.heatmap-scroll { overflow-x:auto; padding-bottom:6px; }
.heatmap { display:flex; gap:3px; }
.hm-col { display:flex; flex-direction:column; gap:3px; }
.hm-cell { width:12px; height:12px; border-radius:2px; background:#ebedf0; }
.hm-cell.l1 { background:#9be9a8; } .hm-cell.l2 { background:#40c463; }
.hm-cell.l3 { background:#30a14e; } .hm-cell.l4 { background:#216e39; }
.hm-months { display:flex; gap:3px; font-size:10px; color:#9ca3af; margin-bottom:4px; height:12px; }
.hm-legend { display:flex; align-items:center; gap:4px; font-size:11px; color:#9ca3af; margin-top:8px; justify-content:flex-end; }
body.dark-mode .hm-cell { background:#161b22; }
</style>
<link rel="stylesheet" href="/css/dark-mode.css">
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?' Claro':'🌙 Escuro'});}
@@ -27,29 +56,17 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
</div>
<nav class="sidebar-nav">
<div class="nav-label">Principal</div>
<a href="#" class="active" id="navDashboard">
<span class="icon">📊</span> Dashboard
</a>
<a href="#" id="navClients">
<span class="icon">👥</span> Clientes
</a>
<a href="#" id="navChat">
<span class="icon">💬</span> Conversas
</a>
<a href="#" class="active" id="navDashboard"><span class="icon">📊</span> Dashboard</a>
<a href="#" id="navClients"><span class="icon">👥</span> Clientes</a>
<a href="#" id="navChat"><span class="icon">💬</span> Conversas</a>
<div class="nav-label" id="adminLabel" style="display:none">Administrador</div>
<a href="#" id="navConfig" style="display:none">
<span class="icon">⚙️</span> Configurações
</a>
<a href="#" id="navConfig" style="display:none"><span class="icon">⚙️</span> Configurações</a>
<a href="#" id="navAllConvs" style="display:none"><span class="icon">💬</span> Todas Conversas</a>
<a href="#" id="navRoutes" style="display:none">
<span class="icon">📡</span> Rotas
</a>
<a href="#" id="navRoutes" style="display:none"><span class="icon">📡</span> Rotas</a>
</nav>
<div class="sidebar-footer">
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button>
<a onclick="logout()">
<span class="icon">🚪</span> Sair
</a>
<a onclick="logout()"><span class="icon">🚪</span> Sair</a>
</div>
</aside>
@@ -64,123 +81,181 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
</div>
<div class="container">
<div class="card">
<h3>👤 Dados do Usuário</h3>
<div class="info-grid">
<div class="info-item">
<div class="label">ID</div>
<div class="value" id="userId">-</div>
</div>
<div class="info-item">
<div class="label">Nome</div>
<div class="value" id="userNameDisplay">-</div>
</div>
<div class="info-item">
<div class="label">Login</div>
<div class="value" id="userLogin">-</div>
</div>
<div class="info-item">
<div class="label">Email</div>
<div class="value" id="userEmail">-</div>
</div>
<div class="info-item">
<div class="label">Nível</div>
<div class="value" id="userNivel">-</div>
</div>
<div class="info-item">
<div class="label">Tipo</div>
<div class="value" id="userTipo">-</div>
</div>
<div class="info-item">
<div class="label">Autenticação</div>
<div class="value" id="userAuthType">-</div>
</div>
</div>
<div class="section-title">Conversas</div>
<div class="stat-grid">
<div class="stat-card abertas"><div class="num" id="cAbertas"></div><div class="lbl">Abertas</div><div class="desc">Com atendente e equipe</div></div>
<div class="stat-card naoatendidas"><div class="num" id="cNaoAtendidas"></div><div class="lbl">Não Atendidas</div><div class="desc">Aguardando resposta do atendente</div></div>
<div class="stat-card naoatribuidas"><div class="num" id="cNaoAtribuidas"></div><div class="lbl">Não Atribuídas</div><div class="desc">Sem equipe e sem atendente</div></div>
<div class="stat-card pendentes"><div class="num" id="cPendentes"></div><div class="lbl">Pendentes</div><div class="desc">Somente com equipe</div></div>
</div>
<div class="section-title">Atendentes</div>
<div class="stat-grid">
<div class="stat-card disponiveis"><div class="num" id="aDisponiveis"></div><div class="lbl">Disponíveis</div><div class="desc">Acessando a plataforma</div></div>
<div class="stat-card desconectados"><div class="num" id="aDesconectados"></div><div class="lbl">Desconectados</div><div class="desc">Fora da plataforma</div></div>
</div>
<div class="card">
<h3>🔑 Token de Acesso</h3>
<button class="btn-copy" onclick="showToken()" id="btnShowToken" style="margin-bottom:8px">Mostrar Token</button>
<div class="token-box" id="tokenDisplay" style="display:none"></div>
<button class="btn-copy" onclick="copyToken()" id="btnCopyToken" style="display:none">Copiar Token</button>
<div class="section-title" style="margin-top:0">Situação dos atendentes</div>
<div id="atendentesLista"><div style="color:#9ca3af;font-size:13px">Carregando...</div></div>
</div>
<div class="section-title">Tráfego das Conversas</div>
<div class="card">
<div style="font-size:13px;color:#6b7280;margin-bottom:12px" id="trafegoResumo">Conversas iniciadas nos últimos 12 meses</div>
<div class="heatmap-scroll">
<div class="hm-months" id="hmMonths"></div>
<div class="heatmap" id="heatmap"></div>
</div>
<div class="hm-legend">
Menos
<span class="hm-cell"></span><span class="hm-cell l1"></span><span class="hm-cell l2"></span><span class="hm-cell l3"></span><span class="hm-cell l4"></span>
Mais
</div>
</div>
</div>
</div>
<script>
// Extrai alias da URL
const pathParts = window.location.pathname.split('/');
const alias = pathParts[2] || localStorage.getItem('chatc2_alias') || 'lajedo';
var pathParts = window.location.pathname.split('/');
var alias = pathParts[2] || localStorage.getItem('chatc2_alias') || 'lajedo';
localStorage.setItem('chatc2_alias', alias);
const token = localStorage.getItem('chatc2_token');
const user = JSON.parse(localStorage.getItem('chatc2_user') || '{}');
var token = localStorage.getItem('chatc2_token');
var user = JSON.parse(localStorage.getItem('chatc2_user') || '{}');
if (!token) {
window.location.href = '/app/' + alias + '/login';
}
if (!token) { window.location.href = '/app/' + alias + '/login'; }
// Sidebar - mostra opções de admin se for Gerente
const tipoChat = user.tipoChat || 'A';
var tipoChat = user.tipoChat || 'A';
if (tipoChat === 'G') {
document.getElementById('adminLabel').style.display = '';
document.getElementById('navConfig').style.display = '';
document.getElementById('navAllConvs').style.display = '';
document.getElementById('navRoutes').style.display = '';
} else {
// Agente não dashboard - redireciona para conversas
window.location.href = '/app/' + alias + '/company/' + (user.empresas?.[0] || 1) + '/conversation/0';
// Agente não acessa o dashboard — vai direto para as conversas
window.location.href = '/app/' + alias + '/company/' + (user.empresas && user.empresas[0] || 1) + '/conversation/0';
}
document.getElementById('sidebarAlias').textContent = alias;
document.getElementById('userId').textContent = user.id || '-';
document.getElementById('userNameDisplay').textContent = user.nome || '-';
document.getElementById('userName').textContent = user.nome || '-';
document.getElementById('userLogin').textContent = user.login || '-';
document.getElementById('userEmail').textContent = user.email || '-';
document.getElementById('userNivel').textContent = user.nivelId || '-';
document.getElementById('userTipo').textContent = user.tipo || '-';
document.getElementById('userAuthType').textContent = user.authType || 'jwt';
// Token foi carregado, mas fica oculto até clicar em Mostrar Token
// Navegação sidebar
function navClick(e, url) {
e.preventDefault();
window.location.href = url;
}
function navClick(e, url) { e.preventDefault(); window.location.href = url; }
document.getElementById('navDashboard').onclick = function(e) { navClick(e, '/app/' + alias + '/dashboard'); };
document.getElementById('navClients').onclick = function(e) { navClick(e, '/app/' + alias + '/clients'); };
document.getElementById('navChat').onclick = function(e) { navClick(e, '/app/' + alias + '/company/' + (user.empresas?.[0] || 1) + '/conversation/0'); };
document.getElementById('navChat').onclick = function(e) { navClick(e, '/app/' + alias + '/company/' + (user.empresas && user.empresas[0] || 1) + '/conversation/0'); };
document.getElementById('navConfig').onclick = function(e) { navClick(e, '/app/' + alias + '/settings'); };
document.getElementById('navAllConvs').onclick = function(e) { navClick(e, '/app/' + alias + '/conversations/all'); };
document.getElementById('navRoutes').onclick = function(e) { navClick(e, '/app/' + alias + '/routes'); };
function copyToken() {
navigator.clipboard.writeText(token).then(() => {
alert('Token copiado!');
});
}
function logout() {
localStorage.removeItem('chatc2_token');
localStorage.removeItem('chatc2_alias');
localStorage.removeItem('chatc2_user');
['chatc2_token','chatc2_alias','chatc2_user'].forEach(function(k){ localStorage.removeItem(k); });
window.location.href = '/app/' + alias + '/login';
}
function showToken() {
const display = document.getElementById('tokenDisplay');
const btnShow = document.getElementById('btnShowToken');
const btnCopy = document.getElementById('btnCopyToken');
if (display.style.display === 'none') {
display.textContent = token;
display.style.display = 'block';
btnShow.textContent = 'Ocultar Token';
btnCopy.style.display = 'inline-block';
} else {
display.style.display = 'none';
btnShow.textContent = 'Mostrar Token';
btnCopy.style.display = 'none';
function esc(s){ return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// ===== Carrega estatísticas =====
async function carregarStats() {
try {
var r = await (await fetch('/api/' + alias + '/dashboard/stats', {
headers: { 'Authorization': 'Bearer ' + token }
})).json();
if (!r.success) return;
var d = r.data;
document.getElementById('cAbertas').textContent = d.conversas.abertas;
document.getElementById('cNaoAtendidas').textContent = d.conversas.naoAtendidas;
document.getElementById('cNaoAtribuidas').textContent = d.conversas.naoAtribuidas;
document.getElementById('cPendentes').textContent = d.conversas.pendentes;
document.getElementById('aDisponiveis').textContent = d.atendentes.disponiveis;
document.getElementById('aDesconectados').textContent = d.atendentes.desconectados;
renderAtendentes(d.atendentes.lista);
renderHeatmap(d.trafego);
} catch(e) {}
}
function renderAtendentes(lista) {
var el = document.getElementById('atendentesLista');
if (!lista || lista.length === 0) {
el.innerHTML = '<div style="color:#9ca3af;font-size:13px">Nenhum atendente cadastrado.</div>';
return;
}
// Online primeiro
lista.sort(function(a,b){ return (b.online?1:0) - (a.online?1:0) || a.nome.localeCompare(b.nome); });
el.innerHTML = lista.map(function(a){
return '<div class="atendente-row">' +
'<span class="dot ' + (a.online ? 'on' : 'off') + '"></span>' +
'<span>' + esc(a.nome) + '</span>' +
'<span class="st">' + (a.online ? 'Disponível' : 'Desconectado') + '</span>' +
'</div>';
}).join('');
}
// ===== Heatmap estilo GitHub =====
function nivel(n) {
if (!n) return '';
if (n <= 2) return 'l1';
if (n <= 5) return 'l2';
if (n <= 10) return 'l3';
return 'l4';
}
function renderHeatmap(trafego) {
var mapa = {}; var totalGeral = 0;
(trafego || []).forEach(function(t){ mapa[t.dia] = t.total; totalGeral += t.total; });
var hoje = new Date(); hoje.setHours(0,0,0,0);
var inicio = new Date(hoje); inicio.setDate(inicio.getDate() - 364);
inicio.setDate(inicio.getDate() - inicio.getDay()); // alinha ao domingo
var meses = ['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez'];
var grid = document.getElementById('heatmap');
var monthsBar = document.getElementById('hmMonths');
grid.innerHTML = ''; monthsBar.innerHTML = '';
var cursor = new Date(inicio);
var ultimoMes = -1;
while (cursor <= hoje) {
var col = document.createElement('div');
col.className = 'hm-col';
var mesDaColuna = cursor.getMonth();
// rótulo do mês quando muda no topo da coluna
var lbl = document.createElement('div');
lbl.style.width = '12px';
lbl.style.fontSize = '10px';
lbl.style.color = '#9ca3af';
if (mesDaColuna !== ultimoMes) { lbl.textContent = meses[mesDaColuna]; ultimoMes = mesDaColuna; }
else { lbl.innerHTML = '&nbsp;'; }
monthsBar.appendChild(lbl);
for (var dia = 0; dia < 7; dia++) {
var cell = document.createElement('div');
var iso = cursor.toISOString().split('T')[0];
if (cursor > hoje) { cell.className = 'hm-cell'; cell.style.visibility = 'hidden'; }
else {
var n = mapa[iso] || 0;
cell.className = 'hm-cell ' + nivel(n);
cell.title = iso.split('-').reverse().join('/') + ': ' + n + ' conversa' + (n === 1 ? '' : 's');
}
col.appendChild(cell);
cursor.setDate(cursor.getDate() + 1);
}
grid.appendChild(col);
}
document.getElementById('trafegoResumo').textContent =
totalGeral + ' conversa' + (totalGeral === 1 ? '' : 's') + ' iniciada' + (totalGeral === 1 ? '' : 's') + ' nos últimos 12 meses';
}
// ===== Presença + atualização periódica =====
function pingPresenca() {
fetch('/api/' + alias + '/dashboard/ping', {
method: 'POST', headers: { 'Authorization': 'Bearer ' + token }
}).catch(function(){});
}
if (tipoChat === 'G') {
pingPresenca();
carregarStats();
setInterval(pingPresenca, 45000);
setInterval(carregarStats, 30000);
}
</script>
<script src="/js/dark-mode.js"></script>
+82
View File
@@ -36,6 +36,12 @@ body { background:#f3f4f6; display:flex; min-height:100vh; }
.user-name { font-size:14px; font-weight:500; color:#374151; }
.status-badge { display:inline-flex; align-items:center; gap:5px; padding:4px 12px; border-radius:20px; font-size:12px; font-weight:600; }
.status-badge.online { background:#d1fae5; color:#065f46; }
/* Responsivo: abas rolam horizontalmente no celular */
@media (max-width: 768px) {
.tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.tabs button { flex: 0 0 auto; white-space: nowrap; padding: 12px 14px; font-size: 13px; }
}
</style>
<link rel="stylesheet" href="/css/dark-mode.css">
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?' Claro':'🌙 Escuro'});}
@@ -85,6 +91,28 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
</p>
<div id="menusList"><p style="color:#9ca3af">Carregando...</p></div>
</div>
<div class="card" id="cardResolucao">
<h3 style="margin:0 0 8px">🧩 Fluxo de Resolução</h3>
<p style="color:#6b7280;font-size:13px;margin-bottom:16px">
Defina os motivos de atendimento e como o atendente registra a resolução ao finalizar a conversa.
</p>
<h4 style="margin:8px 0">Motivo do Atendimento</h4>
<div style="display:flex;gap:8px;margin-bottom:8px">
<input type="text" id="novoMotivo" placeholder="Cadastrar novo motivo..." style="flex:1;padding:8px;border:2px solid #e5e7eb;border-radius:8px;font-size:13px" onkeydown="if(event.key==='Enter')adicionarMotivo()">
<button class="btn btn-primary btn-sm" onclick="adicionarMotivo()">+ Adicionar</button>
</div>
<div id="motivosList" style="margin-bottom:12px"><p style="color:#9ca3af;font-size:13px">Carregando...</p></div>
<div class="form-group"><div class="toggle"><input type="checkbox" id="flgMotivoVis"> <label for="flgMotivoVis">Visualizar Motivo na tela de conversa</label></div></div>
<div class="form-group"><div class="toggle"><input type="checkbox" id="flgMotivoObr"> <label for="flgMotivoObr">Obrigatório preencher (só finaliza com motivo)</label></div></div>
<h4 style="margin:16px 0 8px">Resolução do Atendimento</h4>
<div class="form-group"><div class="toggle"><input type="checkbox" id="flgResolVis"> <label for="flgResolVis">Visualizar Resolução na tela de conversa</label></div></div>
<div class="form-group"><div class="toggle"><input type="checkbox" id="flgResolObr"> <label for="flgResolObr">Obrigatório preencher (só finaliza com resolução)</label></div></div>
<button class="btn btn-primary" onclick="salvarResolucao()">💾 Salvar Fluxo de Resolução</button>
</div>
</div>
<!-- Aba Equipe -->
@@ -674,6 +702,7 @@ async function carregarConfig() {
'<div class="form-group"><div class="toggle"><input type="checkbox" id="cfgSaudacao" ' + (cfg.saudacaoAtiva === 'S' ? 'checked' : '') + '> <label for="cfgSaudacao">Ativar saudação automática</label></div></div>' +
'<div class="form-group"><label>Mensagem de Saudação</label><textarea id="cfgSaudacaoMsg">' + (cfg.saudacaoMensagem || '') + '</textarea></div>' +
'<div class="form-group"><div class="toggle"><input type="checkbox" id="cfgNomeUser" ' + (cfg.enviarNomeUsuario === 'S' ? 'checked' : '') + '> <label for="cfgNomeUser">Mostrar nome do usuário nas mensagens ("Nome: Mensagem")</label></div></div>' +
'<div class="form-group"><div class="toggle"><input type="checkbox" id="cfgEnviarBoleto" ' + (cfg.enviarBoleto === 'S' ? 'checked' : '') + '> <label for="cfgEnviarBoleto">💳 Permitir enviar boleto na conversa (botão "Enviar boleto")</label></div></div>' +
'<div class="form-group"><div class="toggle"><input type="checkbox" id="cfgTriagem" ' + (cfg.triagemAtiva === 'S' ? 'checked' : '') + '> <label for="cfgTriagem">📋 Ativar fluxo de triagem (menu de opções)</label></div></div>' +
'<div class="form-group" id="triagemConfig" style="display:' + (cfg.triagemAtiva === 'S' ? 'block' : 'none') + ';padding:12px;background:#f9fafb;border-radius:8px;margin-bottom:12px">' +
'<div class="form-group"><label>Mensagem de boas-vindas (use {EMPRESA} para o nome)</label><textarea id="cfgTriagemWelcome" rows="2" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:13px;resize:vertical">' + (cfg.triagemMsgWelcome || '') + '</textarea></div>' +
@@ -711,6 +740,7 @@ window.salvarConfig = async function() {
saudacaoAtiva: document.getElementById('cfgSaudacao').checked ? 'S' : 'N',
saudacaoMensagem: document.getElementById('cfgSaudacaoMsg').value,
enviarNomeUsuario: document.getElementById('cfgNomeUser').checked ? 'S' : 'N',
enviarBoleto: document.getElementById('cfgEnviarBoleto').checked ? 'S' : 'N',
triagemAtiva: document.getElementById('cfgTriagem').checked ? 'S' : 'N',
triagemMsgWelcome: document.getElementById('cfgTriagemWelcome').value,
triagemMsgAfter: document.getElementById('cfgTriagemAfter').value,
@@ -725,6 +755,57 @@ window.salvarConfig = async function() {
// O addEventListener do cfgTriagem é adicionado dentro do carregarConfig() após criar o HTML
// ===== FLUXO DE RESOLUÇÃO =====
function escc(s){ return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
async function carregarResolucao() {
var md = await api('/motivos?empresaId=' + empresaId);
var div = document.getElementById('motivosList');
if (div && md.success) {
div.innerHTML = (md.data && md.data.length)
? md.data.map(function(m) {
return '<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 10px;border:1px solid #eee;border-radius:6px;margin-bottom:4px">' +
'<span style="font-size:13px">' + escc(m.descricao) + '</span>' +
'<button class="btn btn-sm" style="color:#dc2626;background:none;border:none;cursor:pointer" onclick="removerMotivo(' + m.id + ')">🗑️</button></div>';
}).join('')
: '<p style="color:#9ca3af;font-size:13px">Nenhum motivo cadastrado.</p>';
}
var cd = await api('/company/config?empresaId=' + empresaId);
if (cd.success) {
var s = function(id, v) { var el = document.getElementById(id); if (el) el.checked = v === 'S'; };
s('flgMotivoVis', cd.data.motivoVisualizar);
s('flgMotivoObr', cd.data.motivoObrigatorio);
s('flgResolVis', cd.data.resolucaoVisualizar);
s('flgResolObr', cd.data.resolucaoObrigatorio);
}
}
window.adicionarMotivo = async function() {
var inp = document.getElementById('novoMotivo');
var d = (inp.value || '').trim();
if (!d) return;
var r = await api('/motivos', { method: 'POST', body: JSON.stringify({ descricao: d, empresaId: empresaId }) });
if (r.success) { inp.value = ''; carregarResolucao(); } else alert(r.error || 'Erro ao adicionar motivo');
};
window.removerMotivo = async function(id) {
if (!confirm('Remover este motivo?')) return;
var r = await api('/motivos/' + id, { method: 'DELETE' });
if (r.success) carregarResolucao(); else alert(r.error || 'Erro ao remover');
};
window.salvarResolucao = async function() {
var body = {
empresaId: empresaId,
motivoVisualizar: document.getElementById('flgMotivoVis').checked ? 'S' : 'N',
motivoObrigatorio: document.getElementById('flgMotivoObr').checked ? 'S' : 'N',
resolucaoVisualizar: document.getElementById('flgResolVis').checked ? 'S' : 'N',
resolucaoObrigatorio: document.getElementById('flgResolObr').checked ? 'S' : 'N',
};
var r = await api('/company/resolucao-config', { method: 'POST', body: JSON.stringify(body) });
if (r.success) alert('Fluxo de Resolução salvo!'); else alert(r.error || 'Erro ao salvar');
};
// ===== CONEXÕES =====
async function carregarConexoes() {
var data = await api('/evolution/instances?empresaId=' + empresaId);
@@ -850,6 +931,7 @@ carregarMenus();
carregarEtiquetas();
carregarConexoes();
carregarConfig();
carregarResolucao();
})();
</script>
+45
View File
@@ -0,0 +1,45 @@
/**
* Estrutura do "Fluxo de Resolução" (motivos + resolução do atendimento).
* Cria de forma idempotente no Firebird:
* - Tabela CHATC2_MOTIVOS_ATENDIMENTO
* - Flags em CHATC2_CONFIGURACOES_EMPRESA (CFE_MOTIVO_*, CFE_RESOLUCAO_*)
* - Colunas CON_MOTIVO_ID / CON_RESOLUCAO em CHATC2_CONVERSAS
*
* Usa DDL no subconjunto que funciona nos dois bancos (INTEGER, VARCHAR, CHAR,
* ALTER TABLE ... ADD). A tabela nova é criada com nome MAIÚSCULO entre aspas
* para manter a convenção do schema migrado.
*/
const db = require('./database');
const prontos = {};
async function tentar(alias, sql) {
// DDL idempotente: ignora erros de "já existe" (e similares entre dialetos)
try { await db.execute(alias, sql); } catch (e) { /* noop */ }
}
async function garantirEstrutura(alias) {
if (prontos[alias]) return;
await tentar(alias, `CREATE TABLE "CHATC2_MOTIVOS_ATENDIMENTO" (
MOT_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
MOT_EMPRESA_ID INTEGER,
MOT_DESCRICAO VARCHAR(150),
MOT_SITUACAO CHAR(1) DEFAULT 'A'
)`);
await tentar(alias, `ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_MOTIVO_VISUALIZAR CHAR(1) DEFAULT 'N'`);
await tentar(alias, `ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_MOTIVO_OBRIGATORIO CHAR(1) DEFAULT 'N'`);
await tentar(alias, `ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_RESOLUCAO_VISUALIZAR CHAR(1) DEFAULT 'N'`);
await tentar(alias, `ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_RESOLUCAO_OBRIGATORIO CHAR(1) DEFAULT 'N'`);
// Envio de boleto na conversa (checkbox em Configurações > Empresa)
await tentar(alias, `ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_ENVIAR_BOLETO CHAR(1) DEFAULT 'N'`);
await tentar(alias, `ALTER TABLE CHATC2_CONVERSAS ADD CON_MOTIVO_ID INTEGER`);
await tentar(alias, `ALTER TABLE CHATC2_CONVERSAS ADD CON_RESOLUCAO VARCHAR(4000)`);
prontos[alias] = true;
}
module.exports = { garantirEstrutura };
+2
View File
@@ -17,6 +17,8 @@ router.post('/:alias/conversations/:id/assign', authenticateToken, ChatControlle
router.post('/:alias/conversations/:id/assign-team', authenticateToken, ChatController.assignTeam);
router.post('/:alias/conversations/:id/labels', authenticateToken, ChatController.toggleLabel);
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/media/:mediaId', ChatController.getMedia);
module.exports = router;
+6
View File
@@ -27,6 +27,12 @@ router.delete('/:alias/labels/:id', ConfigController.deleteLabel);
router.get('/:alias/company/config', ConfigController.getCompanyConfig);
router.post('/:alias/company/config', ConfigController.saveCompanyConfig);
// Fluxo de Resolução (motivos + flags)
router.post('/:alias/company/resolucao-config', ConfigController.saveResolucaoConfig);
router.get('/:alias/motivos', ConfigController.listMotivos);
router.post('/:alias/motivos', ConfigController.createMotivo);
router.delete('/:alias/motivos/:id', ConfigController.deleteMotivo);
// Evolution API
router.get('/:alias/evolution/instances', EvolutionController.listInstances);
router.post('/:alias/evolution/connect', EvolutionController.connect);
+10
View File
@@ -0,0 +1,10 @@
const express = require('express');
const router = express.Router();
const DashboardController = require('../controllers/dashboardController');
const authenticateToken = require('../middlewares/auth');
// Presença (qualquer usuário autenticado) e estatísticas (gerente).
router.post('/:alias/dashboard/ping', authenticateToken, DashboardController.ping);
router.get('/:alias/dashboard/stats', authenticateToken, DashboardController.stats);
module.exports = router;
+2
View File
@@ -7,6 +7,7 @@ const chatRoutes = require('./chatRoutes');
const configRoutes = require('./configRoutes');
const menuRoutes = require('./menuRoutes');
const databaseRoutes = require('./databaseRoutes');
const dashboardRoutes = require('./dashboardRoutes');
const RoutesController = require('../controllers/routesController');
const EvolutionController = require('../controllers/evolutionController');
@@ -34,6 +35,7 @@ router.use('/api', chatRoutes);
router.use('/api', configRoutes);
router.use('/api', menuRoutes);
router.use('/api', databaseRoutes);
router.use('/api', dashboardRoutes);
// Rotas do aplicativo (proteção aplicada internamente)
router.use('/app', authRoutes);