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 *.fdb
*.rar *.rar
relatorio_migracao_*.json relatorio_migracao_*.json
relatorio_migracao_*.txt
# Dados do Postgres (cluster local)
pgdata/
# Logs / temporários # Logs / temporários
*.log *.log
_inspect*.js _inspect*.js
whisper/ whisper/
.claude .claude
.gitignore
CONTEXTO.md
+21 -41
View File
@@ -20,14 +20,13 @@ SERVER_DOMAIN="${SERVER_DOMAIN:-}" # Opcional: seu-dominio.com.br
JWT_SECRET="" JWT_SECRET=""
NGROK_URL="" 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. # Informe via variaveis de ambiente antes de rodar, ou edite o .env depois.
PG_HOST="${PG_HOST:-}" DB_HOST="${DB_HOST:-localhost}"
PG_PORT="${PG_PORT:-5432}" DB_PORT="${DB_PORT:-3050}"
PG_USER="${PG_USER:-postgres}" DB_USER="${DB_USER:-SYSDBA}"
PG_PASSWORD="${PG_PASSWORD:-}" DB_PASSWORD="${DB_PASSWORD:-masterkey}"
PG_DATABASE="${PG_DATABASE:-postgres}" DB_DATABASE="${DB_DATABASE:-}"
PG_SCHEMA="${PG_SCHEMA:-public}"
# Cores # Cores
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' 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 # 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} DB_HOST=${DB_HOST}
PG_PORT=${PG_PORT} DB_PORT=${DB_PORT}
PG_USER=${PG_USER} DB_USER=${DB_USER}
PG_PASSWORD=${PG_PASSWORD} DB_PASSWORD=${DB_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_ENCODING=UTF-8 DB_ENCODING=UTF-8
# Caminho do .FDB (vazio = usa ../NOVO.FDB na raiz do projeto) # Caminho do .FDB (vazio = usa ../NOVO.FDB na raiz do projeto)
DB_DATABASE= DB_DATABASE=${DB_DATABASE}
# Servidor # Servidor
PORT=3000 PORT=3000
@@ -289,12 +278,9 @@ EXTERNAL_URL=${EXTERNAL_URL}
EOF EOF
chown "$CHATC2_USER:$CHATC2_USER" "$CHATC2_DIR/.env" chown "$CHATC2_USER:$CHATC2_USER" "$CHATC2_DIR/.env"
log "Arquivo .env criado/atualizado com JWT_SECRET seguro" 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 " LOCAL_URL = ${LOCAL_URL}"
log " EXTERNAL_URL = ${EXTERNAL_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 # 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" warn "Apos copiar, execute: chown $CHATC2_USER:firebird $CHATC2_DIR/db/*.FDB && chmod 660 $CHATC2_DIR/db/*.FDB"
fi fi
info "Passo 9/12: Migracoes (Firebird legado)..." info "Passo 9/12: Migracoes (Firebird)..."
# O schema do PostgreSQL e gerenciado no banco EXTERNO — nao migramos aqui. # Migracoes Firebird rodam se houver um .FDB local (alias novo_local).
# Migracoes Firebird so rodam se houver um .FDB local (alias firebird_local).
if ls "$CHATC2_DIR/db/"*.FDB "$CHATC2_DIR/"*.FDB >/dev/null 2>&1; then if ls "$CHATC2_DIR/db/"*.FDB "$CHATC2_DIR/"*.FDB >/dev/null 2>&1; then
if [ -f "$CHATC2_DIR/scripts/migracoes.js" ]; 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" log "Migracoes Firebird executadas"
fi fi
else else
info "Sem .FDB local — pulando migracoes Firebird (schema do Postgres e externo)." info "Sem .FDB local — pulando migracoes Firebird."
fi fi
# ████████████████████████████████████████████████████ # ████████████████████████████████████████████████████
# PASSO 9b: HABILITAR USUARIOS ADMIN PARA WEB # PASSO 9b: HABILITAR USUARIOS ADMIN PARA WEB
# ████████████████████████████████████████████████████ # ████████████████████████████████████████████████████
info "Passo 9b/12: Habilitando usuarios admin para acesso 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' cat > /tmp/chatc2-habilitar-web.js << 'SCRIPTJS'
const db = require('DATABASE_PATH'); const db = require('DATABASE_PATH');
@@ -352,16 +337,11 @@ const db = require('DATABASE_PATH');
SCRIPTJS SCRIPTJS
sed -i "s|DATABASE_PATH|$CHATC2_DIR/src/database|" /tmp/chatc2-habilitar-web.js 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 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 rm -f /tmp/chatc2-habilitar-web.js
log "Usuarios admin verificados (Postgres + Firebird se presente)" log "Usuarios admin verificados (Firebird)"
# ████████████████████████████████████████████████████ # ████████████████████████████████████████████████████
# PASSO 10: NGINX # PASSO 10: NGINX
+5 -151
View File
@@ -7,6 +7,7 @@
"": { "": {
"name": "chatc2", "name": "chatc2",
"version": "2.0.0", "version": "2.0.0",
"hasInstallScript": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"cors": "^2.8.6", "cors": "^2.8.6",
@@ -14,8 +15,7 @@
"express": "^5.2.1", "express": "^5.2.1",
"ffmpeg-static": "^5.3.0", "ffmpeg-static": "^5.3.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"node-firebird": "^2.3.1", "node-firebird": "^2.3.1"
"pg": "^8.21.0"
} }
}, },
"node_modules/@derhuerst/http-basic": { "node_modules/@derhuerst/http-basic": {
@@ -832,134 +832,6 @@
"url": "https://opencollective.com/express" "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": { "node_modules/progress": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@@ -983,9 +855,9 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.15.1", "version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "side-channel": "^1.1.0"
@@ -1212,15 +1084,6 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "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", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "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", "name": "chatc2",
"version": "2.0.0", "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", "main": "src/server.js",
"scripts": { "scripts": {
"start": "node src/server.js", "start": "node src/server.js",
"dev": "node --watch src/server.js" "dev": "node --watch src/server.js",
"postinstall": "node scripts/postinstall.js"
}, },
"keywords": [ "keywords": [
"postgres",
"firebird", "firebird",
"api", "api",
"express" "express"
@@ -22,7 +22,6 @@
"express": "^5.2.1", "express": "^5.2.1",
"ffmpeg-static": "^5.3.0", "ffmpeg-static": "^5.3.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"node-firebird": "^2.3.1", "node-firebird": "^2.3.1"
"pg": "^8.21.0"
} }
} }
+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'); const db = require('../src/database');
// Este script aplica DDL no dialeto Firebird (BLOB SUB_TYPE, etc.). // Este script aplica DDL no dialeto Firebird (BLOB SUB_TYPE, etc.).
// O schema do PostgreSQL é gerenciado externamente (banco externo) — use este // Alias padrão: novo_local.
// script apenas para bancos Firebird. Alias padrão: firebird_local. const alias = process.argv[2] || 'novo_local';
const alias = process.argv[2] || 'firebird_local';
// ============================================================ // ============================================================
// CONTROLE DE MIGRAÇÕES // CONTROLE DE MIGRAÇÕES
@@ -428,8 +427,7 @@ async function main() {
catch (e) { console.error('❌', e.message); process.exit(1); } catch (e) { console.error('❌', e.message); process.exit(1); }
if (driver !== 'firebird') { if (driver !== 'firebird') {
console.log(`⚠️ O alias "${alias}" usa o driver "${driver}". Este script aplica DDL 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 novo_local\n');
console.log(' Para migrar um banco Firebird: node scripts/migracoes.js firebird_local\n');
process.exit(0); 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 // Rota raiz
app.get('/', (req, res) => { app.get('/', (req, res) => {
res.json({ res.json({
name: 'API Firebird - Chatc2', name: 'API Chatc2 - Firebird',
version: '2.0.0', version: '2.0.0',
description: 'API multi-banco com sistema de aliases', description: 'API Firebird com sistema de aliases',
auth: { auth: {
loginPage: 'GET /app/:alias/login', loginPage: 'GET /app/:alias/login',
login: 'POST /app/:alias/login', login: 'POST /app/:alias/login',
+2 -2
View File
@@ -75,7 +75,7 @@ class AuthController {
FROM USUARIOS FROM USUARIOS
WHERE USU_LOGIN = ? WHERE USU_LOGIN = ?
AND (USU_SENHA = ? OR USU_SENHA_WEB = ?) 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' AND USU_STATUS = 'A'
`; `;
@@ -99,7 +99,7 @@ class AuthController {
'SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA WHERE USE_USUARIO_ID = ?', 'SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA WHERE USE_USUARIO_ID = ?',
[user.USU_CODIGO_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 // Atualiza a data do último acesso
await db.execute( await db.execute(
+180 -19
View File
@@ -1,4 +1,5 @@
const db = require('../database'); const db = require('../database');
const { garantirEstrutura } = require('../resolucaoSetup');
const ffmpegPath = require('ffmpeg-static'); const ffmpegPath = require('ffmpeg-static');
const { execFile } = require('child_process'); const { execFile } = require('child_process');
const path = require('path'); const path = require('path');
@@ -147,7 +148,7 @@ class ChatController {
const statusFilter = req.query.status || 'A,E'; const statusFilter = req.query.status || 'A,E';
const filter = req.query.filter || ''; // 'mine', 'unassigned', 'all' 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.' }); return res.status(403).json({ success: false, error: 'Sem permissão.' });
// Status via parâmetros (evita SQL injection). Aceita apenas A/E/F. // Status via parâmetros (evita SQL injection). Aceita apenas A/E/F.
@@ -259,7 +260,7 @@ class ChatController {
var minhasEquipes = await db.query(alias, var minhasEquipes = await db.query(alias,
'SELECT EQU_EQUIPE_ID FROM CHATC2_USU_EQUIPES WHERE EQU_USUARIO_ID = ?', [userId]); 'SELECT EQU_EQUIPE_ID FROM CHATC2_USU_EQUIPES WHERE EQU_USUARIO_ID = ?', [userId]);
if (minhasEquipes.length > 0) { 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, 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})`, `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]); [empresaId]);
@@ -295,7 +296,7 @@ class ChatController {
const row = result[0]; const row = result[0];
const empresaId = row.CON_EMPRESA_ID; 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.' }); return res.status(403).json({ success: false, error: 'Sem permissão.' });
// Verifica se o número pertence a um dependente // Verifica se o número pertence a um dependente
@@ -446,6 +447,8 @@ class ChatController {
saudacaoEnviada: (row.CON_SAUDACAO_ENVIADA || 'N').trim(), saudacaoEnviada: (row.CON_SAUDACAO_ENVIADA || 'N').trim(),
csatEnviado: (row.CON_CSAT_ENVIADO || 'N').trim(), csatEnviado: (row.CON_CSAT_ENVIADO || 'N').trim(),
primeiraMsg: row.CON_PRIMEIRA_MSG, primeiraMsg: row.CON_PRIMEIRA_MSG,
motivoId: row.CON_MOTIVO_ID || null,
resolucao: row.CON_RESOLUCAO || null,
cliente: clienteInfo, cliente: clienteInfo,
dependente: dependenteInfo, dependente: dependenteInfo,
labels: labels.map(l => ({ id: l.ETI_CODIGO_ID, nome: (l.ETI_NOME || '').trim(), cor: (l.ETI_COR || '#667eea').trim() })), 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 }; if (r.length === 0) return { status: 404 };
const emp = r[0].CON_EMPRESA_ID; const emp = r[0].CON_EMPRESA_ID;
const minhas = (req.user && req.user.empresas) || []; 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 }; 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]); 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.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.' }); return res.status(403).json({ success: false, error: 'Sem permissão.' });
// Se a conversa estiver finalizada, reabre // Se a conversa estiver finalizada, reabre
@@ -608,7 +611,8 @@ class ChatController {
if (user.length > 0) { if (user.length > 0) {
const nomeUser = (user[0].USU_NOME || '').trim(); const nomeUser = (user[0].USU_NOME || '').trim();
if (nomeUser && textoFinal) { 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 // Insere mensagem no banco
await db.execute(alias, ` 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) 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', ?, ?, ?, ?, CURRENT_TIMESTAMP, ?) VALUES (?, ?, 'U', ?, ?, ?, ?, 'A', CURRENT_TIMESTAMP, ?)
`, [newId, id, usuarioId, textoFinal, tipo || 'text', privada === 'S' ? 'S' : 'N', midiaId]); `, [newId, id, usuarioId, textoFinal, tipo || 'text', privada === 'S' ? 'S' : 'N', midiaId]);
// Atualiza data da última mensagem // 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.' }); if (conv.length === 0) return res.status(404).json({ success: false, error: 'Conversa não encontrada.' });
const c = conv[0]; 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.' }); 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 // Busca nome do usuário que atendeu
let usuarioNome = null; let usuarioNome = null;
if (c.CON_USUARIO_ID) { if (c.CON_USUARIO_ID) {
@@ -692,9 +713,11 @@ class ChatController {
CON_DT_FINAL = CURRENT_TIMESTAMP, CON_DT_FINAL = CURRENT_TIMESTAMP,
CON_USUARIO_NOME = ?, CON_USUARIO_NOME = ?,
CON_EQUIPE_NOME = ?, CON_EQUIPE_NOME = ?,
CON_ETIQUETAS_DESC = ? CON_ETIQUETAS_DESC = ?,
CON_MOTIVO_ID = ?,
CON_RESOLUCAO = ?
WHERE CON_CODIGO_ID = ? WHERE CON_CODIGO_ID = ?
`, [usuarioNome, equipeNome, etiquetasDesc, id]); `, [usuarioNome, equipeNome, etiquetasDesc, motivoId, resolucao || null, id]);
// CSAT // CSAT
const config = await db.query(alias, const config = await db.query(alias,
@@ -758,7 +781,7 @@ class ChatController {
// Verifica permissão // Verifica permissão
const userEmpresas = req.user?.empresas || []; 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.' }); 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 maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const msgId = (maxMsgId[0]?.ID || 0) + 1; const msgId = (maxMsgId[0]?.ID || 0) + 1;
await db.execute(alias, ` 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) 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', CURRENT_TIMESTAMP) VALUES (?, ?, 'U', ?, ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [msgId, conversaId, req.user?.id, mensagem]); `, [msgId, conversaId, req.user?.id, mensagem]);
await db.execute(alias, await db.execute(alias,
@@ -837,8 +860,8 @@ class ChatController {
// Insere conversa // Insere conversa
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS (CON_CODIGO_ID, CON_EMPRESA_ID, CON_INSTANCIA_ID, CON_NUMERO, 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) CON_NOME_CONTATO, CON_CLIENTE_ID, CON_STATUS, CON_SITUACAO, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG)
VALUES (?, ?, ?, ?, ?, ?, 'A', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?, ?, ?, 'A', 'A', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`, [newId, empresaId, instId, numeroLimpo, nomeContato || numeroLimpo, clienteId || null]); `, [newId, empresaId, instId, numeroLimpo, nomeContato || numeroLimpo, clienteId || null]);
// Insere primeira mensagem // Insere primeira mensagem
@@ -846,8 +869,8 @@ class ChatController {
const msgId = (maxMsgId[0]?.ID || 0) + 1; const msgId = (maxMsgId[0]?.ID || 0) + 1;
await db.execute(alias, ` 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) 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', CURRENT_TIMESTAMP) VALUES (?, ?, 'U', ?, ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [msgId, newId, req.user?.id, mensagem]); `, [msgId, newId, req.user?.id, mensagem]);
// Envia via Evolution API // Envia via Evolution API
@@ -1035,7 +1058,7 @@ class ChatController {
mimeType = mimeType.split(';')[0].trim(); mimeType = mimeType.split(';')[0].trim();
const nomeArquivo = (m.MAT_NOME_ARQUIVO || '').trim() || 'arquivo.bin'; 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; let rawBuffer = m.MAT_ARQUIVO;
if (rawBuffer == null) { if (rawBuffer == null) {
return respond(404, { success: false, error: 'Mídia sem conteúdo.' }); 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 }); } } 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 * Recebe avaliação CSAT via formulário web
* POST /api/:alias/csat/avaliar * POST /api/:alias/csat/avaliar
+28 -3
View File
@@ -24,7 +24,7 @@ class ClientController {
); );
const empresas = result.map(row => ({ const empresas = result.map(row => ({
id: row.EMP_CODIGO_ID, id: Number(row.EMP_CODIGO_ID),
nome: (row.EMP_NOME || '').trim(), nome: (row.EMP_NOME || '').trim(),
nomeFantasia: (row.EMP_NOME_FANTASIA || '').trim(), nomeFantasia: (row.EMP_NOME_FANTASIA || '').trim(),
})); }));
@@ -74,7 +74,8 @@ class ClientController {
c.CLI_ENDERECO, c.CLI_ENDERECO,
c.CLI_NUMERO_FAT, c.CLI_NUMERO_FAT,
c.CLI_BAIRRO_FAT, c.CLI_BAIRRO_FAT,
c.CLI_CIDADES_ID c.CLI_CIDADES_ID,
c.CLI_FOTO
FROM CLIENTES c FROM CLIENTES c
WHERE c.CLI_CODIGO_ID = ? WHERE c.CLI_CODIGO_ID = ?
AND c.CLI_EMPRESA_ID = ? AND c.CLI_EMPRESA_ID = ?
@@ -133,6 +134,29 @@ class ClientController {
// Interpreta a situação // Interpreta a situação
const situacao = client.CLI_SITUACAO?.trim() === 'A' ? 'Ativo' : 'Inativo'; 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 // Monta o endereço completo de faturamento
const enderecoFaturamento = [ const enderecoFaturamento = [
client.CLI_ENDERECO_FAT?.trim(), client.CLI_ENDERECO_FAT?.trim(),
@@ -147,7 +171,8 @@ class ClientController {
clienteId: id_cliente, clienteId: id_cliente,
data: { data: {
nome: client.CLI_NOME?.trim(), nome: client.CLI_NOME?.trim(),
matricula: client.CLI_MATRICULA?.trim(), matricula: matriculaStr,
foto: fotoCliente,
situacao: { situacao: {
codigo: client.CLI_SITUACAO?.trim(), codigo: client.CLI_SITUACAO?.trim(),
descricao: situacao, descricao: situacao,
+87 -6
View File
@@ -1,5 +1,6 @@
const db = require('../database'); const db = require('../database');
const { isGerente } = require('../middlewares/roles'); const { isGerente } = require('../middlewares/roles');
const { garantirEstrutura } = require('../resolucaoSetup');
class ConfigController { class ConfigController {
// ==================== CHATC2_EQUIPES ==================== // ==================== CHATC2_EQUIPES ====================
@@ -152,12 +153,16 @@ class ConfigController {
const { alias } = req.params; const { alias } = req.params;
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0]; const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
await garantirEstrutura(alias);
let config = await db.query(alias, let config = await db.query(alias,
'SELECT * FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId] '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) { 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 }); res.json({ success: true, data: cfg });
} else { } else {
const c = config[0]; const c = config[0];
@@ -174,17 +179,93 @@ class ConfigController {
triagemMsgWelcome: c.CFE_TRIAGEM_MSG_WELCOME || '', triagemMsgWelcome: c.CFE_TRIAGEM_MSG_WELCOME || '',
triagemMsgAfter: c.CFE_TRIAGEM_MSG_AFTER || '', triagemMsgAfter: c.CFE_TRIAGEM_MSG_AFTER || '',
triagemBoletoNumero: (c.CFE_TRIAGEM_BOLETO_NUMERO || '0').trim(), 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 }); } } 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) { static async saveCompanyConfig(req, res) {
try { try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem alterar as configurações da empresa.' }); 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; const { alias } = req.params;
await garantirEstrutura(alias);
const data = req.body; const data = req.body;
const empresaId = data.empresaId || req.user?.empresas?.[0]; const empresaId = data.empresaId || req.user?.empresas?.[0];
const enviarBoleto = (data.enviarBoleto === 'S' || data.enviarBoleto === true) ? 'S' : 'N';
// Upsert // Upsert
const exists = await db.query(alias, 'SELECT COUNT(*) AS T FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]); 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_INSTANCIA_PADRAO_ID = ?, CFE_FOTO_CELULAR = ?, CFE_SAUDACAO_ATIVA = ?,
CFE_SAUDACAO_MENSAGEM = ?, CFE_CSAT_ATIVO = ?, CFE_CSAT_MENSAGEM = ?, CFE_SAUDACAO_MENSAGEM = ?, CFE_CSAT_ATIVO = ?, CFE_CSAT_MENSAGEM = ?,
CFE_ENVIAR_NOME_USUARIO = ?, CFE_TRIAGEM_ATIVA = ?, CFE_TRIAGEM_MSG_WELCOME = ?, 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 = ? 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 { } else {
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONFIGURACOES_EMPRESA (CFE_EMPRESA_ID, CFE_INSTANCIA_PADRAO_ID, CFE_FOTO_CELULAR, 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_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_ATIVA, CFE_TRIAGEM_MSG_WELCOME, CFE_TRIAGEM_MSG_AFTER, CFE_TRIAGEM_BOLETO_NUMERO, CFE_ENVIAR_BOLETO)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 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']); `, [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 }); 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.' }); return res.status(400).json({ success: false, error: 'database é obrigatório.' });
} }
var driver = (req.body.driver || DEFAULT_DRIVER).toLowerCase(); var Firebird = require('node-firebird');
var config = {
if (driver === 'postgres') { host: req.body.host || 'localhost',
var pg = require('pg'); port: req.body.port || 3050,
var schema = String(req.body.schema || 'public').trim(); database: req.body.database,
var safe = /^[A-Za-z_][A-Za-z0-9_$]*$/.test(schema) ? schema : 'public'; user: req.body.user || 'SYSDBA',
var searchPath = safe === 'public' ? 'public' : safe + ',public'; password: req.body.password || 'masterkey',
var client = new pg.Client({ encoding: 'UTF-8',
host: req.body.host || '127.0.0.1', lowercase_keys: false,
port: req.body.port || 5432, pageSize: 4096,
database: req.body.database, };
user: req.body.user || 'postgres', await new Promise(function (resolve, reject) {
password: req.body.password || 'postgres', Firebird.attach(config, function (err, conn) {
ssl: req.body.ssl ? { rejectUnauthorized: false } : false, if (err) return reject(new Error('Falha ao conectar: ' + err.message));
options: '-c search_path=' + searchPath, conn.query('SELECT 1 AS TEST FROM RDB$DATABASE', [], function (qErr, rows) {
connectionTimeoutMillis: 8000, conn.detach();
}); if (qErr) return reject(new Error('Falha na consulta: ' + qErr.message));
try { resolve(rows);
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);
});
}); });
}); });
} });
res.json({ success: true, message: '✅ Conexão (' + driver + ') estabelecida com sucesso!' }); res.json({ success: true, message: '✅ Conexão (firebird) estabelecida com sucesso!' });
} catch (err) { } catch (err) {
res.status(500).json({ success: false, error: err.message }); 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 */ } } 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; const blobData = e.EMP_FOTO;
let base64; let base64;
if (Buffer.isBuffer(blobData)) { if (Buffer.isBuffer(blobData)) {
+71 -50
View File
@@ -242,18 +242,18 @@ class EvolutionController {
}); });
instanceCreated = true; instanceCreated = true;
await db.execute(alias, ` 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) 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') VALUES (?, ?, ?, ?, ?, ?, 'C', 'A')
`, [newId, INS_EMPRESA_ID || userEmpresas[0], INS_NOME, INS_URL, INS_API_KEY, INS_INSTANCE_NAME]); `, [newId, INS_EMPRESA_ID || userEmpresas[0], INS_NOME, INS_URL, INS_API_KEY, INS_INSTANCE_NAME]);
break; break;
} catch (e) { /* Tenta próximo endpoint */ } } catch (e) { /* Tenta próximo endpoint */ }
} }
if (!instanceCreated) { if (!instanceCreated) {
// Salva mesmo sem conseguir criar na Evolution // Salva mesmo sem conseguir criar na Evolution (status 'D' = desconectado)
await db.execute(alias, ` 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) 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') VALUES (?, ?, ?, ?, ?, ?, 'D', 'A')
`, [newId, INS_EMPRESA_ID || userEmpresas[0], INS_NOME, INS_URL, INS_API_KEY, INS_INSTANCE_NAME]); `, [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) { static async listInstances(req, res) {
try { try {
const { alias } = req.params; 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, const result = await db.query(alias,
'SELECT * FROM CHATC2_INSTANCIAS WHERE INS_EMPRESA_ID = ? AND INS_SITUACAO = \'A\' ORDER BY INS_NOME', '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 // Reaproveita a funcao de atualizar foto
var numero = conv[0].CON_NUMERO || ''; var numero = conv[0].CON_NUMERO || '';
var empresaId = conv[0].CON_EMPRESA_ID; var empresaId = conv[0].CON_EMPRESA_ID;
// A funcao atualizarFotoContato esta no escopo do processWebhook, entao recriamos a logica aqui // Ação explícita do usuário: força a busca (ignora a flag CFE_FOTO_CELULAR)
await EvolutionController.atualizarFotoContato(alias, conv[0].CON_CLIENTE_ID, numero, empresaId); 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) { } catch (err) {
res.status(500).json({ success: false, error: err.message }); res.status(500).json({ success: false, error: err.message });
} }
@@ -452,12 +465,14 @@ class EvolutionController {
/** /**
* Versão estática de atualizarFotoContato para uso externo * Versão estática de atualizarFotoContato para uso externo
*/ */
static async atualizarFotoContato(alias, clienteId, numero, empresaId) { static async atualizarFotoContato(alias, clienteId, numero, empresaId, forcar) {
if (!clienteId) return; if (!clienteId) return false;
try { try {
const cfg = await db.query(alias, if (!forcar) {
"SELECT CFE_FOTO_CELULAR FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?", [empresaId]); const cfg = await db.query(alias,
if (cfg.length === 0 || cfg[0].CFE_FOTO_CELULAR !== 'S') return; "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, const inst = await db.query(alias,
"SELECT * FROM CHATC2_INSTANCIAS WHERE INS_EMPRESA_ID = ? AND INS_SITUACAO = 'A' FETCH FIRST 1 ROWS ONLY", [empresaId]); "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(); const instanceName = (inst[0].INS_INSTANCE_NAME || '').trim();
if (!url || !apiKey || !instanceName) return; if (!url || !apiKey || !instanceName) return;
// Evolution API 2.4.0 NAO possui endpoint getProfile (disponivel apenas a partir da v2.5+) // Busca a foto de perfil via Evolution: POST <URL>/chat/fetchProfile/<instancia> { number }
console.log('[Foto] Evolution API ' + url + ' - getProfile nao disponivel nesta versao. A foto do WhatsApp sera usada quando atualizar a Evolution.');
// Mesmo sem o endpoint, tentamos alguns formatos conhecidos // Normaliza o numero para o padrao WhatsApp BR: 55(DDI) + DDD + 9XXXXXXXX
var profileEndpoints = [ function normalizarNumero(n) {
'/chat/getProfile/' + encodeURIComponent(instanceName) + '?number=' + numero, var d = String(n || '').replace(/\D/g, '');
'/chat/getProfile/' + encodeURIComponent(instanceName) + '/' + numero, if (!d) return '';
'/contact/getProfile/' + encodeURIComponent(instanceName) + '?number=' + numero, 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)
var profile = null; resto = resto.slice(0, 2) + '9' + resto.slice(2);
for (var ep of profileEndpoints) {
try {
profile = await evolutionRequest(url, apiKey, ep, 'GET');
if (profile) break;
} catch(e) {
profile = null;
} }
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) { if (!profile) {
console.log('[Foto] Nenhum endpoint getProfile disponivel. Evolution API precisa ser atualizada.'); console.log('[Foto] Nenhum perfil retornado para', numeroFmt);
return; return false;
} }
let fotoUrl = null; let fotoUrl = null;
@@ -505,31 +525,32 @@ class EvolutionController {
fotoUrl = profile.response.profilePicUrl || profile.response.picUrl || null; fotoUrl = profile.response.profilePicUrl || profile.response.picUrl || null;
} }
if (fotoUrl && fotoUrl.startsWith('http')) { if (fotoUrl && String(fotoUrl).startsWith('http')) {
console.log('[Foto] Baixando foto de:', fotoUrl.substring(0, 80)); console.log('[Foto] Baixando foto de:', String(fotoUrl).substring(0, 80));
try { try {
var http = require('http');
var https = require('https');
var fotoBuffer = await new Promise(function(resolve, reject) { var fotoBuffer = await new Promise(function(resolve, reject) {
var u = new URL(fotoUrl); var u = new URL(fotoUrl);
var lib = u.protocol === 'https:' ? https : http; 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 = []; var chunks = [];
imgRes.on('data', function(c) { chunks.push(c); }); imgRes.on('data', function(c) { chunks.push(c); });
imgRes.on('end', function() { resolve(Buffer.concat(chunks)); }); imgRes.on('end', function() { resolve(Buffer.concat(chunks)); });
}).on('error', reject).on('timeout', function() { this.destroy(); reject(new Error('Timeout')); }); }).on('error', reject).on('timeout', function() { this.destroy(); reject(new Error('Timeout')); });
}); });
if (fotoBuffer && fotoBuffer.length > 100) { if (fotoBuffer && fotoBuffer.length > 100) {
var imgData = fotoBuffer.toString('base64');
await db.execute(alias, await db.execute(alias,
'UPDATE CLIENTES SET CLI_FOTO = ? WHERE CLI_CODIGO_ID = ?', 'UPDATE CLIENTES SET CLI_FOTO = ? WHERE CLI_CODIGO_ID = ?',
[imgData, clienteId]); [fotoBuffer.toString('base64'), clienteId]);
console.log('[Foto] Foto atualizada para cliente', clienteId, '- tamanho:', fotoBuffer.length); console.log('[Foto] Foto atualizada para cliente', clienteId, '- bytes:', fotoBuffer.length);
return true;
} }
} catch(imgErr) { } 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) { } catch(e) {
console.error('[Foto] Erro:', e.message); console.error('[Foto] Erro:', e.message);
} }
@@ -764,8 +785,8 @@ async function processWebhook(alias, body) {
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS (CON_CODIGO_ID, CON_EMPRESA_ID, CON_INSTANCIA_ID, CON_NUMERO, 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) CON_NOME_CONTATO, CON_CLIENTE_ID, CON_STATUS, CON_SITUACAO, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG)
VALUES (?, ?, ?, ?, ?, ?, 'E', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?, ?, ?, 'E', 'A', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`, [newId, empresaId, instanciaId, numero, nomeFinal, clienteId]); `, [newId, empresaId, instanciaId, numero, nomeFinal, clienteId]);
conversa = await db.query(alias, 'SELECT * FROM CHATC2_CONVERSAS WHERE CON_CODIGO_ID = ?', [newId]); 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) { if (msgSaudacao) {
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) 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', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversa.CON_CODIGO_ID, msgSaudacao]); `, [(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]); 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) { if (midiaId) {
await db.execute(alias, ` 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) 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', ?, ?, CURRENT_TIMESTAMP, ?) VALUES (?, ?, 'C', ?, ?, 'A', CURRENT_TIMESTAMP, ?)
`, [(maxMsgId[0]?.ID || 0) + 1, conversa.CON_CODIGO_ID, messageText, messageType, midiaId]); `, [(maxMsgId[0]?.ID || 0) + 1, conversa.CON_CODIGO_ID, messageText, messageType, midiaId]);
} else { } else {
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'C', ?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, 'C', ?, ?, 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversa.CON_CODIGO_ID, messageText, messageType]); `, [(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).' }, 'GET /api/databases': { desc: 'Lista as conexões de banco cadastradas (senhas ocultas).' },
'POST /api/databases': { 'POST /api/databases': {
desc: 'Adiciona ou atualiza uma conexão de banco (persistida em databases_custom.json).', 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'], 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).' }, 'DELETE /api/databases/{alias}': { desc: 'Remove uma conexão customizada (estáticas não podem ser removidas).' },
'POST /api/databases/test': { 'POST /api/databases/test': {
desc: 'Testa uma conexão sem cadastrá-la.', 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'], req: ['database'],
}, },
+32 -32
View File
@@ -72,8 +72,8 @@ class TriageController {
// Salva mensagem do sistema // Salva mensagem do sistema
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) 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', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'triagem', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, menuText]); `, [(maxMsgId[0]?.ID || 0) + 1, conversaId, menuText]);
// Envia via Evolution // 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'); const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) 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', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'triagem', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, menuText]); `, [(maxMsgId[0]?.ID || 0) + 1, conversaId, menuText]);
await TriageController.sendEvolution(alias, instanciaId, numero, menuText); await TriageController.sendEvolution(alias, instanciaId, numero, menuText);
@@ -246,8 +246,8 @@ class TriageController {
if (msgAfter) { if (msgAfter) {
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) 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', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, msgAfter]); `, [(maxMsgId[0]?.ID || 0) + 1, conversaId, msgAfter]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgAfter); await TriageController.sendEvolution(alias, instanciaId, numero, msgAfter);
} }
@@ -377,8 +377,8 @@ class TriageController {
if (textoMsg) { if (textoMsg) {
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) 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', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, textoMsg]); `, [(maxMsgId[0]?.ID || 0) + 1, conversaId, textoMsg]);
await TriageController.sendEvolution(alias, instanciaId, numero, 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'); const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) 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', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, msgPrompt]); `, [(maxMsgId[0]?.ID || 0) + 1, conversaId, msgPrompt]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgPrompt); await TriageController.sendEvolution(alias, instanciaId, numero, msgPrompt);
@@ -521,8 +521,8 @@ class TriageController {
if (resultado) { if (resultado) {
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) 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', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, resultado]); `, [(maxMsgId[0]?.ID || 0) + 1, conversaId, resultado]);
await TriageController.sendEvolution(alias, instanciaId, numero, 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.'; var msgNaoEncontrado = 'Este número não está associado a nenhum contrato no sistema.\n\nPor favor, selecione um setor para que um atendente possa ajudá-lo a associar seu número.';
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) 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', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, msgNaoEncontrado]); `, [(maxMsgId[0]?.ID || 0) + 1, conversaId, msgNaoEncontrado]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgNaoEncontrado); await TriageController.sendEvolution(alias, instanciaId, numero, msgNaoEncontrado);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = 'root' WHERE CON_CODIGO_ID = ?", [conversaId]); await 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'); logTriage('Boleto - NENHUM título em aberto');
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) 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', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, msg]); `, [(maxMsgId[0]?.ID || 0) + 1, conversaId, msg]);
await TriageController.sendEvolution(alias, instanciaId, numero, msg); await TriageController.sendEvolution(alias, instanciaId, numero, msg);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = 'root' WHERE CON_CODIGO_ID = ?", [conversaId]); await 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'); const maxMsgId2 = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) 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', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId2[0]?.ID || 0) + 1, conversaId, msgBoleto]); `, [(maxMsgId2[0]?.ID || 0) + 1, conversaId, msgBoleto]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgBoleto); await TriageController.sendEvolution(alias, instanciaId, numero, msgBoleto);
@@ -652,8 +652,8 @@ class TriageController {
const msg = 'Opção inválida. Tente novamente.'; const msg = 'Opção inválida. Tente novamente.';
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) 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', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxId[0]?.ID || 0) + 1, conversaId, msg]); `, [(maxId[0]?.ID || 0) + 1, conversaId, msg]);
await TriageController.sendEvolution(alias, instanciaId, numero, msg); await TriageController.sendEvolution(alias, instanciaId, numero, msg);
return; return;
@@ -838,8 +838,8 @@ class TriageController {
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) 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', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxId[0]?.ID || 0) + 1, conversaId, textoBoleto]); `, [(maxId[0]?.ID || 0) + 1, conversaId, textoBoleto]);
await TriageController.sendEvolution(alias, instanciaId, numero, 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'; var msgCont = '📄 Boleto enviado!\n\nDeseja continuar com o atendimento?\n\n1 - Sim\n2 - Não, finalizar';
const maxIdCont = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxIdCont = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) 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', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxIdCont[0]?.ID || 0) + 1, conversaId, msgCont]); `, [(maxIdCont[0]?.ID || 0) + 1, conversaId, msgCont]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgCont); await TriageController.sendEvolution(alias, instanciaId, numero, msgCont);
@@ -889,8 +889,8 @@ class TriageController {
var msgFinal = 'Atendimento finalizado. Obrigado pelo contato!'; var msgFinal = 'Atendimento finalizado. Obrigado pelo contato!';
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) 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', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxId[0]?.ID || 0) + 1, conversaId, msgFinal]); `, [(maxId[0]?.ID || 0) + 1, conversaId, msgFinal]);
await TriageController.sendEvolution(alias, instanciaId, numero, 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]); 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'; var msgInv = 'Por favor, responda:\n\n1 - Sim\n2 - Não, finalizar';
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) 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', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxId[0]?.ID || 0) + 1, conversaId, msgInv]); `, [(maxId[0]?.ID || 0) + 1, conversaId, msgInv]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgInv); await TriageController.sendEvolution(alias, instanciaId, numero, msgInv);
} }
@@ -916,8 +916,8 @@ class TriageController {
const msg = 'Opção inválida. Tente novamente.'; const msg = 'Opção inválida. Tente novamente.';
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) 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', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxId[0]?.ID || 0) + 1, conversaId, msg]); `, [(maxId[0]?.ID || 0) + 1, conversaId, msg]);
await TriageController.sendEvolution(alias, instanciaId, numero, msg); await TriageController.sendEvolution(alias, instanciaId, numero, msg);
@@ -949,8 +949,8 @@ class TriageController {
if (texto) { if (texto) {
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) 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', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxId[0]?.ID || 0) + 1, conversaId, texto]); `, [(maxId[0]?.ID || 0) + 1, conversaId, texto]);
await TriageController.sendEvolution(alias, instanciaId, numero, 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 * 1) CONFIGURAÇÃO aliases, drivers, conexões estáticas e customizadas
* 2) DRIVER FIREBIRD * 2) DRIVER FIREBIRD
* 3) DRIVER POSTGRES (pool + tradutor de SQL + schema) * 3) DISPATCHER API pública usada por todo o sistema
* 4) DISPATCHER API pública usada por todo o sistema
* *
* Suporta múltiplos drivers ('postgres' padrão, 'firebird' legado). Cada * Suporte ao banco Firebird. Cada conexão declara o campo `driver`.
* conexão declara o campo `driver`. Conexões adicionadas em runtime (via API * Conexões adicionadas em runtime (via API /api/databases) são persistidas
* /api/databases) são persistidas em databases_custom.json (ao lado deste * em databases_custom.json (ao lado deste arquivo).
* arquivo) é um arquivo de DADOS, não de código.
*/ */
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
@@ -31,22 +29,11 @@ try {
console.error('[Databases] Erro ao carregar databases_custom.json:', e.message); console.error('[Databases] Erro ao carregar databases_custom.json:', e.message);
} }
const DRIVERS = ['postgres', 'firebird']; const DRIVERS = ['firebird'];
const DEFAULT_DRIVER = (process.env.DB_DRIVER || 'postgres').toLowerCase(); const DEFAULT_DRIVER = 'firebird';
// Valores padrão por driver // Valores padrão para Firebird
const DRIVER_DEFAULTS = { 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: { firebird: {
host: 'localhost', host: 'localhost',
port: 3050, port: 3050,
@@ -61,28 +48,17 @@ const DRIVER_DEFAULTS = {
/** /**
* Conexões estáticas. * Conexões estáticas.
* - `novo_local` PostgreSQL (conexão principal) * - `novo_local` Firebird (informe o CAMINHO do arquivo .FDB em `database`)
* - `firebird_local` Firebird (informe o CAMINHO do arquivo .FDB em `database`) * Sobrescrevível pelo .env (DB_*).
* Sobrescrevíveis pelo .env (PG_* para Postgres, DB_* para Firebird).
*/ */
const databases = { const databases = {
novo_local: { 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', driver: 'firebird',
host: process.env.DB_HOST || 'localhost', host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT, 10) || 3050, port: parseInt(process.env.DB_PORT, 10) || 3050,
// CAMINHO do arquivo .FDB: defina DB_DATABASE no .env (absoluto) ou ajuste // CAMINHO do arquivo .FDB: defina DB_DATABASE no .env (absoluto) ou ajuste
// o path.resolve abaixo. Ex.: path.resolve(__dirname, '../db/NOVO.FDB'). // 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', user: process.env.DB_USER || 'SYSDBA',
password: process.env.DB_PASSWORD || 'masterkey', password: process.env.DB_PASSWORD || 'masterkey',
encoding: process.env.DB_ENCODING || 'UTF-8', encoding: process.env.DB_ENCODING || 'UTF-8',
@@ -104,30 +80,10 @@ function salvarCustomDatabases() {
/** Normaliza uma config bruta aplicando o driver e seus defaults. */ /** Normaliza uma config bruta aplicando o driver e seus defaults. */
function normalize(raw) { function normalize(raw) {
const driver = DRIVERS.includes((raw.driver || '').toLowerCase()) const d = DRIVER_DEFAULTS['firebird'];
? raw.driver.toLowerCase()
: DEFAULT_DRIVER;
const d = DRIVER_DEFAULTS[driver];
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 { return {
driver, driver: 'firebird',
host: raw.host || d.host, host: raw.host || d.host,
port: raw.port || d.port, port: raw.port || d.port,
database: raw.database, database: raw.database,
@@ -177,14 +133,10 @@ function addDatabase(alias, config) {
throw new Error('Alias e database são obrigatórios.'); throw new Error('Alias e database são obrigatórios.');
} }
var aliasLower = alias.toLowerCase().replace(/[^a-z0-9_]/g, '_'); 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 }; var stored = { driver: 'firebird' };
['host', 'port', 'database', 'schema', 'user', 'password', 'ssl', ['host', 'port', 'database', 'user', 'password',
'encoding', 'lowercase_keys', 'role', 'pageSize', 'wireCrypt', 'encoding', 'lowercase_keys', 'role', 'pageSize', 'wireCrypt']
'max', 'idleTimeoutMillis', 'connectionTimeoutMillis']
.forEach(function (k) { .forEach(function (k) {
if (config[k] !== undefined && config[k] !== null && config[k] !== '') { if (config[k] !== undefined && config[k] !== null && config[k] !== '') {
stored[k] = config[k]; stored[k] = config[k];
@@ -207,411 +159,172 @@ function removeDatabase(alias) {
// ============================================================ // ============================================================
// 2) DRIVER FIREBIRD // 2) DRIVER FIREBIRD
// ============================================================ // ============================================================
const firebirdDriver = (() => { const Firebird = require('node-firebird');
const Firebird = require('node-firebird');
function readBlob(blobFunc) { function readBlob(blobFunc) {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
if (typeof blobFunc !== 'function') return resolve(blobFunc); if (typeof blobFunc !== 'function') return resolve(blobFunc);
blobFunc(function (err, name, emitter) { blobFunc(function (err, name, emitter) {
if (err) return reject(err); if (err) return reject(err);
if (!emitter || typeof emitter.on !== 'function') return resolve(null); if (!emitter || typeof emitter.on !== 'function') return resolve(null);
var chunks = []; var chunks = [];
var total = 0; var total = 0;
emitter.on('data', function (c) { chunks.push(c); total += c.length; }); emitter.on('data', function (c) { chunks.push(c); total += c.length; });
emitter.on('end', function () { resolve(Buffer.concat(chunks, total)); }); emitter.on('end', function () { resolve(Buffer.concat(chunks, total)); });
emitter.on('error', reject); emitter.on('error', reject);
});
}); });
} });
}
function query(config, sql, params = []) { function firebirdQuery(config, sql, params = []) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
Firebird.attach(config, (err, db) => { Firebird.attach(config, (err, db) => {
if (err) return reject(new Error(`Erro ao conectar (firebird): ${err.message}`)); if (err) return reject(new Error(`Erro ao conectar (firebird): ${err.message}`));
var allRows = []; var allRows = [];
db.sequentially(sql, params, function (row) { db.sequentially(sql, params, function (row) {
var keys = Object.keys(row); var keys = Object.keys(row);
var blobPromises = keys.map(function (key) { var blobPromises = keys.map(function (key) {
var val = row[key]; var val = row[key];
if (typeof val === 'function') { if (typeof val === 'function') {
return readBlob(val).then(function (data) { row[key] = data; }); 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}`));
} }
transaction.query(sql, params, (queryErr, result) => { return Promise.resolve();
if (queryErr) { });
transaction.rollback(); return Promise.all(blobPromises).then(function () { allRows.push(row); });
db.detach(); }, function (queryErr) {
return reject(new Error(`Erro na execução (firebird): ${queryErr.message}`)); db.detach();
} if (queryErr) return reject(new Error(`Erro na consulta (firebird): ${queryErr.message}`));
transaction.commit((commitErr) => { resolve(allRows);
db.detach(); });
if (commitErr) return reject(new Error(`Erro ao commitar (firebird): ${commitErr.message}`)); });
resolve({ affectedRows: result ? result.length : 0, result }); });
}); }
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) { async function firebirdTestConnection(config) {
await query(config, 'SELECT 1 FROM RDB$DATABASE'); await firebirdQuery(config, 'SELECT 1 FROM RDB$DATABASE');
return true; return true;
} }
async function listTables(config) { async function firebirdListTables(config) {
const rows = await query(config, ` const rows = await firebirdQuery(config, `
SELECT TRIM(RDB$RELATION_NAME) AS TABLE_NAME SELECT TRIM(RDB$RELATION_NAME) AS TABLE_NAME
FROM RDB$RELATIONS FROM RDB$RELATIONS
WHERE RDB$SYSTEM_FLAG = 0 AND RDB$RELATION_TYPE = 0 WHERE RDB$SYSTEM_FLAG = 0 AND RDB$RELATION_TYPE = 0
ORDER BY RDB$RELATION_NAME ORDER BY RDB$RELATION_NAME
`); `);
return rows.map((r) => (r.TABLE_NAME || '').trim()).filter(Boolean); return rows.map((r) => (r.TABLE_NAME || '').trim()).filter(Boolean);
} }
async function tableInfo(config, tableName) { async function firebirdTableInfo(config, tableName) {
const rows = await query(config, ` const rows = await firebirdQuery(config, `
SELECT SELECT
rf.RDB$FIELD_NAME AS COLUMN_NAME, rf.RDB$FIELD_NAME AS COLUMN_NAME,
rf.RDB$FIELD_POSITION AS ORDINAL_POSITION, rf.RDB$FIELD_POSITION AS ORDINAL_POSITION,
CASE f.RDB$FIELD_TYPE CASE f.RDB$FIELD_TYPE
WHEN 7 THEN 'SMALLINT' WHEN 8 THEN 'INTEGER' WHEN 16 THEN 'BIGINT' 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 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 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 37 THEN 'VARCHAR' WHEN 40 THEN 'CSTRING' WHEN 45 THEN 'BLOB_ID'
WHEN 261 THEN 'BLOB' WHEN 14 THEN 'CHAR' WHEN 41 THEN 'NUMERIC' WHEN 261 THEN 'BLOB' WHEN 14 THEN 'CHAR' WHEN 41 THEN 'NUMERIC'
ELSE 'UNKNOWN' ELSE 'UNKNOWN'
END AS DATA_TYPE, END AS DATA_TYPE,
f.RDB$FIELD_LENGTH AS FIELD_LENGTH, f.RDB$FIELD_LENGTH AS FIELD_LENGTH,
f.RDB$FIELD_SCALE AS FIELD_SCALE, f.RDB$FIELD_SCALE AS FIELD_SCALE,
f.RDB$FIELD_PRECISION AS FIELD_PRECISION, f.RDB$FIELD_PRECISION AS FIELD_PRECISION,
rf.RDB$NULL_FLAG AS NULL_FLAG rf.RDB$NULL_FLAG AS NULL_FLAG
FROM RDB$RELATION_FIELDS rf FROM RDB$RELATION_FIELDS rf
INNER JOIN RDB$FIELDS f ON rf.RDB$FIELD_SOURCE = f.RDB$FIELD_NAME INNER JOIN RDB$FIELDS f ON rf.RDB$FIELD_SOURCE = f.RDB$FIELD_NAME
WHERE rf.RDB$RELATION_NAME = ? WHERE rf.RDB$RELATION_NAME = ?
ORDER BY rf.RDB$FIELD_POSITION ORDER BY rf.RDB$FIELD_POSITION
`, [String(tableName).toUpperCase()]); `, [String(tableName).toUpperCase()]);
return rows.map((row) => ({ return rows.map((row) => ({
name: (row.COLUMN_NAME || '').trim(), name: (row.COLUMN_NAME || '').trim(),
position: row.ORDINAL_POSITION, position: row.ORDINAL_POSITION,
type: (row.DATA_TYPE || '').trim(), type: (row.DATA_TYPE || '').trim(),
length: row.FIELD_LENGTH, length: row.FIELD_LENGTH,
precision: row.FIELD_PRECISION, precision: row.FIELD_PRECISION,
scale: row.FIELD_SCALE, scale: row.FIELD_SCALE,
nullable: row.NULL_FLAG !== 1, nullable: row.NULL_FLAG !== 1,
})); }));
} }
async function tableExists(config, tableName) { async function firebirdTableExists(config, tableName) {
const r = await query(config, const r = await firebirdQuery(config,
"SELECT COUNT(*) AS CT FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = ?", "SELECT COUNT(*) AS CT FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = ?",
[String(tableName).toUpperCase()]); [String(tableName).toUpperCase()]);
return (r[0] && r[0].CT > 0) || false; return (r[0] && r[0].CT > 0) || false;
} }
async function columnExists(config, tableName, columnName) { async function firebirdColumnExists(config, tableName, columnName) {
const r = await query(config, const r = await firebirdQuery(config,
"SELECT COUNT(*) AS CT FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = ? AND RDB$FIELD_NAME = ?", "SELECT COUNT(*) AS CT FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = ? AND RDB$FIELD_NAME = ?",
[String(tableName).toUpperCase(), String(columnName).toUpperCase()]); [String(tableName).toUpperCase(), String(columnName).toUpperCase()]);
return (r[0] && r[0].CT > 0) || false; return (r[0] && r[0].CT > 0) || false;
} }
async function close() { /* Firebird abre conexão por consulta; nada a encerrar */ } async function firebirdClose() { /* Firebird abre conexão por consulta; nada a encerrar */ }
return { query, execute, testConnection, listTables, tableInfo, tableExists, columnExists, close };
})();
// ============================================================ // ============================================================
// 3) DRIVER POSTGRES (pool + tradutor de SQL Firebird->PG + schema) // 3) DISPATCHER — API pública
// ============================================================ // ============================================================
const postgresDriver = (() => { const driver = {
const pg = require('pg'); 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. function getConn(alias) {
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) {
const config = getConfig(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 }; return { config, driver };
} }
function query(alias, sql, params = []) { function query(alias, sql, params = []) {
let d; 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}`); }); return d.driver.query(d.config, sql, params).catch((err) => { throw new Error(`[${alias}] ${err.message}`); });
} }
function execute(alias, sql, params = []) { function execute(alias, sql, params = []) {
let d; 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}`); }); return d.driver.execute(d.config, sql, params).catch((err) => { throw new Error(`[${alias}] ${err.message}`); });
} }
async function testConnection(alias) { async function testConnection(alias) {
try { try {
const { config, driver } = getDriver(alias); const { config } = getConn(alias);
await driver.testConnection(config); await driver.testConnection(config);
return true; return true;
} catch (err) { } catch (err) {
@@ -629,33 +342,33 @@ async function testAllConnections() {
} }
function listTables(alias) { function listTables(alias) {
const { config, driver } = getDriver(alias); const { config } = getConn(alias);
return driver.listTables(config); return driver.listTables(config);
} }
function tableInfo(alias, tableName) { function tableInfo(alias, tableName) {
const { config, driver } = getDriver(alias); const { config } = getConn(alias);
return driver.tableInfo(config, tableName); return driver.tableInfo(config, tableName);
} }
function tableExists(alias, tableName) { function tableExists(alias, tableName) {
const { config, driver } = getDriver(alias); const { config } = getConn(alias);
return driver.tableExists(config, tableName); return driver.tableExists(config, tableName);
} }
function columnExists(alias, tableName, columnName) { function columnExists(alias, tableName, columnName) {
const { config, driver } = getDriver(alias); const { config } = getConn(alias);
return driver.columnExists(config, tableName, columnName); 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) { 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() { async function closeAll() {
await Promise.all(Object.values(drivers).map((d) => d.close && d.close())); await driver.close();
} }
module.exports = { module.exports = {
+26 -2
View File
@@ -43,6 +43,30 @@ async function authenticateToken(req, res, next) {
if (tokenSource !== 'usu_token') { if (tokenSource !== 'usu_token') {
try { try {
const decoded = jwt.verify(token, authConfig.secret, { issuer: authConfig.issuer }); 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.user = decoded;
req.authType = 'jwt'; req.authType = 'jwt';
return next(); return next();
@@ -83,7 +107,7 @@ async function authenticateToken(req, res, next) {
FROM USUARIOS FROM USUARIOS
WHERE USU_TOKEN = ? WHERE USU_TOKEN = ?
AND USU_STATUS = 'A' AND USU_STATUS = 'A'
AND COALESCE(USU_ACESSO_WEB, 0) = 1`, AND COALESCE(USU_ACESSO_WEB, '0') IN ('1', 'S')`,
[token] [token]
); );
@@ -103,7 +127,7 @@ async function authenticateToken(req, res, next) {
'SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA WHERE USE_USUARIO_ID = ?', 'SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA WHERE USE_USUARIO_ID = ?',
[user.USU_CODIGO_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) { } catch (e) {
// Se a tabela não existir, ignora // 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 .icon { font-size: 52px; opacity: 0.45; }
.empty-state p { font-size: 15px; color: #6b7280; } .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> </style>
<link rel="stylesheet" href="/css/dark-mode.css"> <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'});} <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>
</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="sidebar-left">
<div class="header"> <div class="header">
<h2>💬 Chatc2</h2> <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> <a id="navConfigChat" class="admin-only-nav" href="#" title="Configurações">⚙️</a>
</div> </div>
</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"> <div class="nav-tabs">
<a href="#" class="active" onclick="mudarFiltro('mine',this)">Minhas (0)</a> <a href="#" class="active" onclick="mudarFiltro('mine',this)">Minhas (0)</a>
<a href="#" onclick="mudarFiltro('unassigned',this)">Sem atend. (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-center">
<div class="chat-header" id="chatHeader" style="display:none"> <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"> <div class="info">
<h3 id="chatNome"></h3> <h3 id="chatNome"></h3>
<span id="chatStatus"></span> <span id="chatStatus"></span>
</div> </div>
<div class="actions"> <div class="actions">
<button class="mobile-only" onclick="toggleInfo()" title="Informações"></button>
<button class="btn-finalizar" onclick="finalizarConversa()">✅ Finalizar</button> <button class="btn-finalizar" onclick="finalizarConversa()">✅ Finalizar</button>
</div> </div>
</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="header"><h3>📋 Informações</h3></div>
<div class="cliente-foto" id="clienteFoto">?</div> <div class="cliente-foto" id="clienteFoto">?</div>
<div class="info-section" id="clienteInfoContainer"></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="info-section">
<div class="label" style="font-size:10px;text-transform:uppercase;color:#9ca3af;margin-bottom:4px">ATENDENTE</div> <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)"> <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 token = localStorage.getItem('chatc2_token');
const pathParts = window.location.pathname.split('/'); const pathParts = window.location.pathname.split('/');
const alias = pathParts[2]; const alias = pathParts[2];
const empresaId = pathParts[4]; let empresaId = pathParts[4];
const conversaId = pathParts[6]; const conversaId = pathParts[6];
const usuarioLogado = JSON.parse(localStorage.getItem('chatc2_user') || '{}'); const usuarioLogado = JSON.parse(localStorage.getItem('chatc2_user') || '{}');
// Navegacao rapida no header do chat // 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;'); } .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 ===== // ===== NAVEGAÇÃO =====
document.querySelectorAll('.sidebar-left .nav-tabs a').forEach(function(a) { document.querySelectorAll('.sidebar-left .nav-tabs a').forEach(function(a) {
a.addEventListener('click', function(e) { e.preventDefault(); }); a.addEventListener('click', function(e) { e.preventDefault(); });
@@ -843,8 +932,11 @@ function renderConversas(convs, busca) {
} }
// ===== ABRIR CONVERSA ===== // ===== ABRIR CONVERSA =====
window.abrirConversa = async function(id) { window.abrirConversa = async function(id, skipFotoRefresh) {
conversaAtiva = id; 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'); }); document.querySelectorAll('.conv-item').forEach(function(el) { el.classList.remove('active'); });
var item = document.querySelector('.conv-item[data-id="' + id + '"]'); var item = document.querySelector('.conv-item[data-id="' + id + '"]');
if (item) item.classList.add('active'); if (item) item.classList.add('active');
@@ -864,11 +956,26 @@ window.abrirConversa = async function(id) {
var data = await res.json(); var data = await res.json();
if (!data.success) return; if (!data.success) return;
renderInfoConversa(data.data); 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) {} } catch(e) {}
carregarMensagens(); 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) { function renderInfoConversa(conv) {
document.getElementById('chatNome').textContent = conv.nomeContato || conv.numero || 'Desconhecido'; document.getElementById('chatNome').textContent = conv.nomeContato || conv.numero || 'Desconhecido';
document.getElementById('chatStatus').textContent = conv.status === 'E' ? '🟡 Em espera' : conv.status === 'A' ? '🟢 Em atendimento' : '⚫ Finalizado'; 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; window._convAtual = conv;
aplicarPainelResolucao(conv);
// Info cliente // Info cliente
var infoContainer = document.getElementById('clienteInfoContainer'); 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>'; 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) // Verifica convalescentes pendentes (situação E = entrega)
var clienteId = conv.cliente ? conv.cliente.id : (conv.dependente ? conv.dependente.titularId : null); var clienteId = conv.cliente ? conv.cliente.id : (conv.dependente ? conv.dependente.titularId : null);
if (clienteId) { if (clienteId) {
@@ -1361,11 +1476,31 @@ dropArea.addEventListener('drop', function(e) {
// ===== FINALIZAR ===== // ===== FINALIZAR =====
window.finalizarConversa = async function() { 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 { try {
var res = await fetch('/api/' + alias + '/conversations/' + conversaAtiva + '/finalize', { var res = await fetch('/api/' + alias + '/conversations/' + conversaAtiva + '/finalize', {
method: 'POST', 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(); var data = await res.json();
if (data.success) { if (data.success) {
@@ -1374,11 +1509,80 @@ window.finalizarConversa = async function() {
document.querySelector('.btn-finalizar').style.opacity = '0.6'; document.querySelector('.btn-finalizar').style.opacity = '0.6';
carregarConversas(); carregarConversas();
abrirConversa(conversaAtiva); abrirConversa(conversaAtiva);
} else {
alert(data.error || 'Não foi possível finalizar a conversa.');
} }
} catch(e) {} } catch(e) {}
}; };
// ===== PRIVADA TOGGLE ===== // ===== 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() { window.togglePrivada = function() {
document.getElementById('privadaToggle').classList.toggle('ativa'); document.getElementById('privadaToggle').classList.toggle('ativa');
var btn = document.getElementById('privadaToggle'); var btn = document.getElementById('privadaToggle');
@@ -1517,11 +1721,31 @@ window.mudarFiltro = function(filtro, el) {
// Mostra // Mostra
// ===== INICIAR ===== // ===== SELETOR DE EMPRESA (usuário com acesso a mais de uma) =====
carregarConversas(); async function carregarEmpresas() {
if (conversaId) { try {
setTimeout(function() { abrirConversa(parseInt(conversaId)); }, 300); 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 ===== // ===== LOGOUT =====
window.logout = function() { window.logout = function() {
@@ -1535,6 +1759,15 @@ setInterval(function() {
if (conversaAtiva) carregarMensagens(); if (conversaAtiva) carregarMensagens();
}, 5000); }, 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 // Auto-resize textarea
document.getElementById('msgInput').addEventListener('input', function() { document.getElementById('msgInput').addEventListener('input', function() {
this.style.height = 'auto'; 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 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 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 = container.innerHTML =
'<div class="client-header">' + '<div class="client-header">' +
'<div class="client-avatar">' + iniciais + '</div>' + '<div class="client-avatar">' + avatarConteudo + '</div>' +
'<div class="client-header-info">' + '<div class="client-header-info">' +
'<h1>' + (c.nome || '-') + '</h1>' + '<h1>' + (c.nome || '-') + '</h1>' +
'<div class="matricula">Matrícula: ' + (c.matricula || '-') + '</div>' + '<div class="matricula">Matrícula: ' + (c.matricula || '-') + '</div>' +
@@ -452,8 +459,8 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
} else { } else {
html += '<div id="conteudoCarnes_' + c.id + '" style="display:none"></div>'; html += '<div id="conteudoCarnes_' + c.id + '" style="display:none"></div>';
} }
// Conteudo Itens // Conteudo Itens (oculto por padrão quando há boletos — Boletos é a aba ativa)
html += '<div id="conteudoItens_' + c.id + '" style="' + (temCarnes && !temItens ? 'display:none' : '') + ';overflow-x:auto">'; html += '<div id="conteudoItens_' + c.id + '" style="' + (temCarnes ? 'display:none;' : '') + 'overflow-x:auto">';
if (temItens) { if (temItens) {
html += '<table style="font-size:12px"><thead><tr>' + html += '<table style="font-size:12px"><thead><tr>' +
'<th>Produto</th><th style="text-align:center">Quantidade</th>' + '<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'; if (cItens) cItens.style.display = prefixo === 'itens' ? '' : 'none';
}; };
// Edição INLINE do telefone do dependente (sem prompt/alert)
window.editarTelDep = function(id) { window.editarTelDep = function(id) {
var btn = document.querySelector('button[onclick="editarTelDep(' + id + ')"]'); var span = document.getElementById('depTel_' + id);
var telAtual = btn ? (btn.getAttribute('data-tel') || '') : ''; if (!span || span.getAttribute('data-editing') === '1') return;
var novo = prompt('Editar telefone do dependente:', telAtual || ''); var btnEdit = document.querySelector('button[onclick="editarTelDep(' + id + ')"]');
if (novo === null || novo.trim() === telAtual) return; 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', { fetch('/api/' + alias + '/dependents/' + id + '/phone', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, 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) { }).then(function(r) { return r.json(); }).then(function(d) {
if (d.success) { document.getElementById('depTel_' + id).textContent = novo.trim(); alert('Telefone atualizado!'); } if (d.success) {
else alert('Erro: ' + d.error); span.setAttribute('data-editing', '');
}).catch(function(e) { alert('Erro: ' + e.message); }); 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-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 3px; } ::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #9ca3af; } ::-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; } body { background: #f3f4f6; display: flex; min-height: 100vh; }
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; } .main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.container { flex: 1; padding: 24px; overflow-y: auto; } .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> </style>
<link rel="stylesheet" href="/css/dark-mode.css"> <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'});} <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> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<div class="nav-label">Principal</div> <div class="nav-label">Principal</div>
<a href="#" class="active" id="navDashboard"> <a href="#" class="active" id="navDashboard"><span class="icon">📊</span> Dashboard</a>
<span class="icon">📊</span> Dashboard <a href="#" id="navClients"><span class="icon">👥</span> Clientes</a>
</a> <a href="#" id="navChat"><span class="icon">💬</span> Conversas</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> <div class="nav-label" id="adminLabel" style="display:none">Administrador</div>
<a href="#" id="navConfig" style="display:none"> <a href="#" id="navConfig" style="display:none"><span class="icon">⚙️</span> Configurações</a>
<span class="icon">⚙️</span> Configurações
</a>
<a href="#" id="navAllConvs" style="display:none"><span class="icon">💬</span> Todas Conversas</a> <a href="#" id="navAllConvs" style="display:none"><span class="icon">💬</span> Todas Conversas</a>
<a href="#" id="navRoutes" style="display:none"> <a href="#" id="navRoutes" style="display:none"><span class="icon">📡</span> Rotas</a>
<span class="icon">📡</span> Rotas
</a>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button> <button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button>
<a onclick="logout()"> <a onclick="logout()"><span class="icon">🚪</span> Sair</a>
<span class="icon">🚪</span> Sair
</a>
</div> </div>
</aside> </aside>
@@ -64,123 +81,181 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
</div> </div>
<div class="container"> <div class="container">
<div class="card"> <div class="section-title">Conversas</div>
<h3>👤 Dados do Usuário</h3> <div class="stat-grid">
<div class="info-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="info-item"> <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="label">ID</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="value" id="userId">-</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="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> </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"> <div class="card">
<h3>🔑 Token de Acesso</h3> <div class="section-title" style="margin-top:0">Situação dos atendentes</div>
<button class="btn-copy" onclick="showToken()" id="btnShowToken" style="margin-bottom:8px">Mostrar Token</button> <div id="atendentesLista"><div style="color:#9ca3af;font-size:13px">Carregando...</div></div>
<div class="token-box" id="tokenDisplay" style="display:none"></div> </div>
<button class="btn-copy" onclick="copyToken()" id="btnCopyToken" style="display:none">Copiar Token</button>
<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> </div>
</div> </div>
<script> <script>
// Extrai alias da URL var pathParts = window.location.pathname.split('/');
const pathParts = window.location.pathname.split('/'); var alias = pathParts[2] || localStorage.getItem('chatc2_alias') || 'lajedo';
const alias = pathParts[2] || localStorage.getItem('chatc2_alias') || 'lajedo';
localStorage.setItem('chatc2_alias', alias); localStorage.setItem('chatc2_alias', alias);
const token = localStorage.getItem('chatc2_token'); var token = localStorage.getItem('chatc2_token');
const user = JSON.parse(localStorage.getItem('chatc2_user') || '{}'); var user = JSON.parse(localStorage.getItem('chatc2_user') || '{}');
if (!token) { if (!token) { window.location.href = '/app/' + alias + '/login'; }
window.location.href = '/app/' + alias + '/login';
}
// Sidebar - mostra opções de admin se for Gerente var tipoChat = user.tipoChat || 'A';
const tipoChat = user.tipoChat || 'A';
if (tipoChat === 'G') { if (tipoChat === 'G') {
document.getElementById('adminLabel').style.display = ''; document.getElementById('adminLabel').style.display = '';
document.getElementById('navConfig').style.display = ''; document.getElementById('navConfig').style.display = '';
document.getElementById('navAllConvs').style.display = '';
document.getElementById('navRoutes').style.display = ''; document.getElementById('navRoutes').style.display = '';
} else { } else {
// Agente não dashboard - redireciona para conversas // Agente não acessa o dashboard — vai direto para as conversas
window.location.href = '/app/' + alias + '/company/' + (user.empresas?.[0] || 1) + '/conversation/0'; window.location.href = '/app/' + alias + '/company/' + (user.empresas && user.empresas[0] || 1) + '/conversation/0';
} }
document.getElementById('sidebarAlias').textContent = alias; 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('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('navDashboard').onclick = function(e) { navClick(e, '/app/' + alias + '/dashboard'); };
document.getElementById('navClients').onclick = function(e) { navClick(e, '/app/' + alias + '/clients'); }; 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('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'); }; document.getElementById('navRoutes').onclick = function(e) { navClick(e, '/app/' + alias + '/routes'); };
function copyToken() {
navigator.clipboard.writeText(token).then(() => {
alert('Token copiado!');
});
}
function logout() { function logout() {
localStorage.removeItem('chatc2_token'); ['chatc2_token','chatc2_alias','chatc2_user'].forEach(function(k){ localStorage.removeItem(k); });
localStorage.removeItem('chatc2_alias');
localStorage.removeItem('chatc2_user');
window.location.href = '/app/' + alias + '/login'; window.location.href = '/app/' + alias + '/login';
} }
function showToken() { function esc(s){ return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
const display = document.getElementById('tokenDisplay');
const btnShow = document.getElementById('btnShowToken'); // ===== Carrega estatísticas =====
const btnCopy = document.getElementById('btnCopyToken'); async function carregarStats() {
if (display.style.display === 'none') { try {
display.textContent = token; var r = await (await fetch('/api/' + alias + '/dashboard/stats', {
display.style.display = 'block'; headers: { 'Authorization': 'Bearer ' + token }
btnShow.textContent = 'Ocultar Token'; })).json();
btnCopy.style.display = 'inline-block'; if (!r.success) return;
} else { var d = r.data;
display.style.display = 'none'; document.getElementById('cAbertas').textContent = d.conversas.abertas;
btnShow.textContent = 'Mostrar Token'; document.getElementById('cNaoAtendidas').textContent = d.conversas.naoAtendidas;
btnCopy.style.display = 'none'; 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>
<script src="/js/dark-mode.js"></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; } .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 { 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; } .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> </style>
<link rel="stylesheet" href="/css/dark-mode.css"> <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'});} <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> </p>
<div id="menusList"><p style="color:#9ca3af">Carregando...</p></div> <div id="menusList"><p style="color:#9ca3af">Carregando...</p></div>
</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> </div>
<!-- Aba Equipe --> <!-- 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"><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"><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="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"><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" 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>' + '<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', saudacaoAtiva: document.getElementById('cfgSaudacao').checked ? 'S' : 'N',
saudacaoMensagem: document.getElementById('cfgSaudacaoMsg').value, saudacaoMensagem: document.getElementById('cfgSaudacaoMsg').value,
enviarNomeUsuario: document.getElementById('cfgNomeUser').checked ? 'S' : 'N', enviarNomeUsuario: document.getElementById('cfgNomeUser').checked ? 'S' : 'N',
enviarBoleto: document.getElementById('cfgEnviarBoleto').checked ? 'S' : 'N',
triagemAtiva: document.getElementById('cfgTriagem').checked ? 'S' : 'N', triagemAtiva: document.getElementById('cfgTriagem').checked ? 'S' : 'N',
triagemMsgWelcome: document.getElementById('cfgTriagemWelcome').value, triagemMsgWelcome: document.getElementById('cfgTriagemWelcome').value,
triagemMsgAfter: document.getElementById('cfgTriagemAfter').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 // 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 ===== // ===== CONEXÕES =====
async function carregarConexoes() { async function carregarConexoes() {
var data = await api('/evolution/instances?empresaId=' + empresaId); var data = await api('/evolution/instances?empresaId=' + empresaId);
@@ -850,6 +931,7 @@ carregarMenus();
carregarEtiquetas(); carregarEtiquetas();
carregarConexoes(); carregarConexoes();
carregarConfig(); carregarConfig();
carregarResolucao();
})(); })();
</script> </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/assign-team', authenticateToken, ChatController.assignTeam);
router.post('/:alias/conversations/:id/labels', authenticateToken, ChatController.toggleLabel); router.post('/:alias/conversations/:id/labels', authenticateToken, ChatController.toggleLabel);
router.post('/:alias/conversations/:id/link-client', authenticateToken, ChatController.linkClient); 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); router.get('/:alias/media/:mediaId', ChatController.getMedia);
module.exports = router; 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.get('/:alias/company/config', ConfigController.getCompanyConfig);
router.post('/:alias/company/config', ConfigController.saveCompanyConfig); 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 // Evolution API
router.get('/:alias/evolution/instances', EvolutionController.listInstances); router.get('/:alias/evolution/instances', EvolutionController.listInstances);
router.post('/:alias/evolution/connect', EvolutionController.connect); 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 configRoutes = require('./configRoutes');
const menuRoutes = require('./menuRoutes'); const menuRoutes = require('./menuRoutes');
const databaseRoutes = require('./databaseRoutes'); const databaseRoutes = require('./databaseRoutes');
const dashboardRoutes = require('./dashboardRoutes');
const RoutesController = require('../controllers/routesController'); const RoutesController = require('../controllers/routesController');
const EvolutionController = require('../controllers/evolutionController'); const EvolutionController = require('../controllers/evolutionController');
@@ -34,6 +35,7 @@ router.use('/api', chatRoutes);
router.use('/api', configRoutes); router.use('/api', configRoutes);
router.use('/api', menuRoutes); router.use('/api', menuRoutes);
router.use('/api', databaseRoutes); router.use('/api', databaseRoutes);
router.use('/api', dashboardRoutes);
// Rotas do aplicativo (proteção aplicada internamente) // Rotas do aplicativo (proteção aplicada internamente)
router.use('/app', authRoutes); router.use('/app', authRoutes);