Migração para PostgreSQL multi-driver + correções de segurança

- Camada de banco unificada (src/database.js): drivers Postgres/Firebird,
  tradutor de SQL, suporte a schema e pool de conexões
- Conexões: novo_local (Postgres externo) e firebird_local (legado)
- Tela de rotas da API redesenhada (auth, params, exemplos de body)
- Correções de segurança (críticos/altos/médios/baixos): XSS no chat,
  escalonamento de privilégio, mídia autenticada, SQL restrito a gerente,
  JWT sem fallback + issuer, IDOR em conversas, CORS por allowlist,
  rate-limit no login, limites de corpo por rota
- Deploy alinhado: install.sh grava .env com PG_*, migracoes.js driver-aware

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 10:02:59 -03:00
commit ae629d1dc2
50 changed files with 17137 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
node_modules/
.env
.env.bak.*
src/databases_custom.json
# Bancos / dumps
*.FDB
*.fdb
*.rar
relatorio_migracao_*.json
# Logs / temporários
*.log
_inspect*.js
whisper/
.claude
+176
View File
@@ -0,0 +1,176 @@
a # Chatc2 - Contexto do Projeto
_Arquivo gerado em 05/06/2026 para retomada em qualquer computador._
---
## 📋 Missão Atual
Plataforma multi-empresa de atendimento com WhatsApp (Evolution API) + Firebase Firebird, incluindo:
- Chat com atendimento humano e triagem automatizada
- Geração de boletos em PDF via API externa
- Transcrição de áudio com Whisper.cpp (local)
- Fluxo de atendimento personalizado por empresa (submenus, etiquetas)
- Breve: integração com Asterisk para ligações telefônicas
---
## 🗂️ Estrutura de Arquivos (49 arquivos)
```
src/
├── app.js # Express app setup
├── server.js # Entry point (porta 3000)
├── database.js # Conexão Firebird (query, execute, readBlob)
├── config/
│ ├── databases.js # Conexões estáticas + custom (JSON)
│ ├── databases_custom.json # Conexões adicionadas via API
│ ├── database.js # Config padrão do banco
│ └── auth.js # JWT secret/expires
├── middlewares/
│ └── auth.js # JWT + USU_TOKEN dual auth
├── controllers/
│ ├── authController.js # Login, /me, dashboard
│ ├── chatController.js # Conversas, mensagens, mídia, Evolution send
│ ├── clientController.js # CRUD clientes, carnês, dependentes, convalescentes, listcarne
│ ├── configController.js # Config empresa, equipes, etiquetas, instâncias
│ ├── evolutionController.js # Webhook Evolution, QR Code, instâncias
│ ├── genericController.js # Health, tables, query, aliases
│ ├── menuController.js # CRUD CHATC2_MENUS_EMPRESA (submenus do fluxo)
│ ├── triageController.js # Fluxo de triagem (sendMenu, processResponse, boleto)
│ ├── databaseController.js # CRUD de conexões de banco (custom JSON)
│ ├── routesController.js # Descoberta automática de rotas
│ ├── empresaController.js # Logo da empresa
│ └── transcriber.js # Whisper.cpp para transcrição de áudio
├── routes/
│ ├── index.js # Agregador de rotas
│ ├── authRoutes.js, chatRoutes.js, configRoutes.js
│ ├── genericRoutes.js, menuRoutes.js, databaseRoutes.js
└── public/
├── login.html, dashboard.html, chat.html, client-list.html
├── client-detail.html, settings.html, routes.html
├── admin-conversations.html, csat.html
├── css/dark-mode.css, css/main.css
└── js/dark-mode.js
scripts/
├── migracoes.js # 19 migrações de banco (controle via CHATC2_CONTROLE_MIGRACOES)
├── criar_tabelas_chat.js # Script original de criação de tabelas
├── gerar-token-jwt.js, gerar-token-usuario.js
├── habilitar-acesso-web.js, definir-senha-web.js
└── listar-tokens.js
deploy/
├── cloud-init.yml # Deploy automático Ubuntu 22.04
├── setup.sh # Instalação manual
└── README_INSTALACAO.md # Guia completo
```
---
## 🔥 Principais Funcionalidades Implementadas
### 1. Autenticação
- JWT + USU_TOKEN (dual auth)
- Login com USU_LOGIN + USU_SENHA (ou USU_SENHA_WEB)
- Roles: Agente (A) e Gerente/Admin (G) via USU_TIPO_CHAT
- `/app/:alias/me` retorna dados do usuário
### 2. Webhook Evolution API (v2.3.7)
- URL: `POST /api/webhook/evolution` (sem alias na URL)
- Detecta alias automaticamente pelo nome da instância
- Cria conversas novas ou reusa existentes (últimos 8 dígitos)
- Busca cliente por telefone (CLI_CELULAR, CLI_FONE1, CLI_FONE2, DEPENDENTES_CLI)
- Sempre corrige CON_CLIENTE_ID pelo telefone (novas + existentes)
- Mídia: tenta download via URL, depois /chat/getMedia/, depois base64 embutido
### 3. Chat (Tempo Real)
- Sidebar: Minhas / Sem Atendimento / Equipe / Todas (com contagens)
- Painel central com mensagens
- Painel direito: atendente, equipe, etiquetas
- Envio de texto, imagem, áudio (MediaRecorder → base64), documento
- Prévia do áudio antes de enviar
- Transcrição de áudio via Whisper.cpp (exibida abaixo do player)
### 4. Triagem / Fluxo Automático
- Menu principal = Equipes (ordenadas por EQU_ORDEM) + Boleto (fixo)
- **Ao escolher equipe: CON_EQUIPE_ID atribuído IMEDIATAMENTE**
- Submenus via CHATC2_MENUS_EMPRESA (M=submenu, T=texto, R=rota)
- Navegação multi-nível com "0 - Voltar"
- Etiquetas associadas a cada submenu (adicionadas automaticamente)
- Boleto: lista títulos → seleciona → gera PDF via API externa → envia via Evolution → pergunta "continuar?"
### 5. Boleto
- Geração: `POST https://cobpagweb.com.br/boleto/cliente/index.php?base64`
- Payload: `{ empresa: {EMP_NOME, EMP_CNPJ, EMP_FOTO}, Cliente: {...}, Carnes: [...] }`
- Envio Evolution: `/message/sendMedia/` com `mediatype: 'document'`
- Fallback: texto com dados do boleto quando PDF não gerado
- Nome do arquivo: `<MATRICULA> - <VENCIMENTO>.pdf`
### 6. Whisper.cpp (Transcrição de Áudio)
- Instalado em `whisper/` (main.exe/main + modelo tiny)
- Transcreve áudios recebidos via webhook
- Salva transcrição em `MAT_TRANSCRICAO` (BLOB SUB_TYPE TEXT)
- Exibe no chat: player + transcrição abaixo
- **Limitação: Evolution API v2.3.7 não fornece áudio válido para download**
- Funcionará quando atualizar a Evolution API (> v2.5.0)
### 7. Settings (Configurações)
- Abas: Equipe, Fluxo, Empresa, Etiquetas, Conexão
- Fluxo: CRUD de submenus por equipe (árvore), com tipos e etiquetas
- Empresa: saudação, triagem, CSAT, foto, nome do usuário
- Etiquetas: nome + cor
- Conexão: instâncias WhatsApp (criar, editar, QR Code)
### 8. Banco de Dados (Migrações)
- Script: `scripts/migracoes.js` — 19 migrações
- Tabelas: CHATC2_INSTANCIAS, CHATC2_EQUIPES, CHATC2_USU_EQUIPES, CHATC2_ETIQUETAS
- CHATC2_CONVERSAS, CHATC2_CONVERSAS_MENSAGENS, CHATC2_MENSAGENS_ATENDIMENTOS
- CHATC2_CONVERSAS_ETIQUETAS, CHATC2_CSAT_AVALIACOES, CHATC2_CONFIGURACOES_EMPRESA
- CHATC2_MENUS_EMPRESA (com MNE_ACAO_ROTA, MNE_ETIQUETA_IDS)
- Controle via `CHATC2_CONTROLE_MIGRACOES` (cada migração roda uma vez)
---
## 🎯 Próximos Passos (quando retomar)
1. **Aguardar**: Usuário vai contratar SIP Trunk e instalar Asterisk
2. **Implementar**: Módulo Voice (AMI) para fazer ligações + transcrição
3. **Atualizar**: Evolution API (> v2.5.0) para download de mídia funcionar
4. **Corrigir pendências da conversa**:
- Etiquetas em submenus (não estava persistindo - adicionar logs `[Menu]` no console)
- Transcrição só funciona para áudio do agente (aguardando Evolution atualizar)
---
## 🔗 URLs Importantes
| Recurso | URL |
|---------|-----|
| Login | `/app/:alias/login` |
| Chat | `/app/:alias/company/:empresaId/conversation/:conversaId` |
| Clientes | `/app/:alias/clients` |
| Settings | `/app/:alias/settings` |
| Rotas | `/app/:alias/routes` |
| CSAT | `/app/:alias/csat` |
| Webhook | `POST /api/webhook/evolution` |
| Rotas API | `GET /api/routes` |
| Bancos | `GET /api/:alias/databases` |
| Carnê | `GET /api/:alias/clients/:id/listcarne` |
| Convalescentes | `GET /api/:alias/clients/:id/convalescentes` |
## 🔑 Testes
| Usuário | Senha | Base | Tipo |
|---------|-------|------|------|
| SUPORTE | 123456 | lajedo | Admin (G) |
| SUPORTE | 123456 | novo_local | Admin (G) |
## ☁️ Deploy
Arquivos em `deploy/`:
- `cloud-init.yml` — cloud-init (DigitalOcean, Vultr, etc.)
- `setup.sh` — instalação manual
- `README_INSTALACAO.md` — guia completo
## 📦 Backup
`ponto_1/` contém snapshot completo do código fonte.
+81
View File
@@ -0,0 +1,81 @@
# =============================================================
# Chatc2 - Script de deploy Windows -> Linux
# Uso: .\deploy.ps1 -IP 192.168.1.100
# =============================================================
param(
[Parameter(Mandatory=$true)]
[string]$IP,
[string]$User = "root",
[string]$ProjectPath = (Split-Path -Parent $PSScriptRoot),
[string]$RemotePath = "/home/chatc2/chatc2"
)
Write-Host "╔════════════════════════════════════════════╗" -ForegroundColor Cyan
Write-Host "║ Chatc2 - Deploy Windows -> Linux ║" -ForegroundColor Cyan
Write-Host "╚════════════════════════════════════════════╝" -ForegroundColor Cyan
Write-Host ""
# 1. Testar conexao SSH
Write-Host "[1/5] Testando conexao SSH..." -ForegroundColor Yellow
try {
$result = ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 "$User@$IP" "echo OK" 2>&1
if ($LASTEXITCODE -ne 0) { throw "SSH falhou" }
Write-Host " ✓ Conectado a $IP" -ForegroundColor Green
} catch {
Write-Host " ✘ Erro: $_" -ForegroundColor Red
exit 1
}
# 2. Criar diretorios no servidor
Write-Host "[2/5] Criando diretorios no servidor..." -ForegroundColor Yellow
ssh "$User@$IP" "mkdir -p $RemotePath/{db,logs,tmp,uploads/audio,public,whisper/models} 2>/dev/null; chown -R chatc2:chatc2 $RemotePath 2>/dev/null; echo 'OK'" 2>$null
Write-Host " ✓ Diretorios criados" -ForegroundColor Green
# 3. Enviar arquivos via SCP
Write-Host "[3/5] Enviando arquivos do projeto..." -ForegroundColor Yellow
Write-Host " (isso pode levar alguns minutos...)" -ForegroundColor Gray
$exclude = @('node_modules', '.git', 'db/*.FDB', 'logs', 'tmp', 'Nova pasta', 'chatc2-py', 'ponto_1', 'whisper')
$excludeArgs = ($exclude | ForEach-Object { "--exclude=$_" }) -join ' '
$scpCommand = "scp -r $excludeArgs `"$ProjectPath\*`" $User@`"$IP`":$RemotePath/"
Write-Host " Executando: scp -r (arquivos do projeto) para $IP..." -ForegroundColor Gray
try {
$result = scp -r `
--exclude='node_modules' `
--exclude='.git' `
--exclude='db/*.FDB' `
--exclude='logs' `
--exclude='tmp' `
--exclude='Nova pasta' `
--exclude='chatc2-py' `
--exclude='ponto_1' `
--exclude='whisper' `
"$ProjectPath\*" "$User@$IP`:$RemotePath/" 2>&1
if ($LASTEXITCODE -ne 0) { throw "SCP falhou" }
Write-Host " ✓ Arquivos enviados" -ForegroundColor Green
} catch {
Write-Host " ✘ Erro no SCP: $_" -ForegroundColor Red
Write-Host " Tente enviar manualmente via FileZilla ou WinSCP" -ForegroundColor Yellow
}
# 4. Ajustar permissoes
Write-Host "[4/5] Ajustando permissoes..." -ForegroundColor Yellow
ssh "$User@$IP" "chown -R chatc2:chatc2 $RemotePath 2>/dev/null; chmod -R 755 $RemotePath 2>/dev/null; echo OK" 2>$null
Write-Host " ✓ Permissoes ajustadas" -ForegroundColor Green
# 5. Instalar dependencias e rodar script de instalacao
Write-Host "[5/5] Instalando dependencias e configurando servico..." -ForegroundColor Yellow
ssh "$User@$IP" "cd $RemotePath/deploy-linux && sudo bash install.sh --continue" 2>&1 | Write-Host
Write-Host ""
Write-Host "╔════════════════════════════════════════════╗" -ForegroundColor Cyan
Write-Host "║ Deploy Concluido! ║" -ForegroundColor Cyan
Write-Host "╚════════════════════════════════════════════╝" -ForegroundColor Cyan
Write-Host ""
Write-Host " Acesse: http://$IP" -ForegroundColor Green
Write-Host " SSH: ssh $User@$IP" -ForegroundColor Gray
Write-Host " Logs: ssh $User@$IP 'pm2 logs chatc2'" -ForegroundColor Gray
+482
View File
@@ -0,0 +1,482 @@
#!/bin/bash
# =============================================================
# Chatc2 - Instalacao automatizada para Ubuntu 22.04 LTS
# Uso: curl -sL https://bit.ly/chatc2-install | bash
# ou: wget -qO- https://bit.ly/chatc2-install | bash
# =============================================================
set -euo pipefail
# ████████████████████████████████████████████████████
# VARIAVEIS
# ████████████████████████████████████████████████████
CHATC2_USER="chatc2"
CHATC2_DIR="/home/$CHATC2_USER/chatc2"
CHATC2_REPO="https://github.com/seu-usuario/chatc2/archive/refs/heads/main.tar.gz"
NODE_VERSION="22"
FIREBIRD_VERSION="3.0"
WHISPER_VERSION="1.5.4"
WHISPER_MODEL="ggml-tiny.bin" # 75MB - troque para ggml-base.bin (142MB) para mais precisao
SERVER_DOMAIN="${SERVER_DOMAIN:-}" # Opcional: seu-dominio.com.br
JWT_SECRET=""
NGROK_URL=""
# PostgreSQL EXTERNO (banco principal "novo_local").
# Informe via variaveis de ambiente antes de rodar, ou edite o .env depois.
PG_HOST="${PG_HOST:-}"
PG_PORT="${PG_PORT:-5432}"
PG_USER="${PG_USER:-postgres}"
PG_PASSWORD="${PG_PASSWORD:-}"
PG_DATABASE="${PG_DATABASE:-postgres}"
PG_SCHEMA="${PG_SCHEMA:-public}"
# Cores
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
log() { echo -e "${GREEN}[✓]${NC} $1"; }
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
err() { echo -e "${RED}[✘]${NC} $1"; exit 1; }
info() { echo -e "${CYAN}[i]${NC} $1"; }
# ████████████████████████████████████████████████████
# VERIFICACAO INICIAL
# ████████████████████████████████████████████████████
if [ "$EUID" -ne 0 ]; then
err "Execute como root: sudo bash install.sh"
fi
echo ""
echo "╔══════════════════════════════════════════════════╗"
echo "║ Chatc2 - Instalacao Automatica ║"
echo "║ Ubuntu 22.04 LTS ║"
echo "╚══════════════════════════════════════════════════╝"
echo ""
# ████████████████████████████████████████████████████
# PASSO 1: SYSTEMA BASE
# ████████████████████████████████████████████████████
info "Passo 1/12: Atualizando sistema..."
apt-get update -y
apt-get upgrade -y
apt-get install -y curl wget git unzip tar gzip \
build-essential python3 python3-pip \
ffmpeg nginx ufw certbot python3-certbot-nginx \
firebird3.0-server firebird3.0-utils
# ████████████████████████████████████████████████████
# PASSO 2: NODE.JS
# ████████████████████████████████████████████████████
info "Passo 2/12: Instalando Node.js $NODE_VERSION..."
curl -fsSL "https://deb.nodesource.com/setup_${NODE_VERSION}.x" | bash -
apt-get install -y nodejs
npm install -g pm2
log "Node.js $(node -v) instalado"
# ████████████████████████████████████████████████████
# PASSO 3: FIREBIRD
# ████████████████████████████████████████████████████
info "Passo 3/12: Configurando Firebird..."
systemctl enable firebird3.0
systemctl start firebird3.0 2>/dev/null || true
sleep 2
sed -i 's/^isc_password =.*/isc_password = masterkey/' /etc/firebird/3.0/firebird.conf 2>/dev/null || true
sed -i 's/^RemoteBindAddress = localhost/RemoteBindAddress = 0.0.0.0/' /etc/firebird/3.0/firebird.conf 2>/dev/null || true
# ── Correção de compatibilidade node-firebird + Firebird 3.0 ──
# Usa Legacy_Auth (mais compatível) em vez de Srp256 (não suportado pelo node-firebird)
sed -i 's/^#AuthServer = Srp$/AuthServer = Legacy_Auth/' /etc/firebird/3.0/firebird.conf 2>/dev/null || true
sed -i 's/^#AuthClient = Srp, Srp256, Legacy_Auth #Non Windows clients$/AuthClient = Legacy_Auth/' /etc/firebird/3.0/firebird.conf 2>/dev/null || true
# WireCrypt = Enabled (compatível com Legacy_Auth — não exige criptografia)
sed -i 's/^#WireCrypt = Enabled (for client) \/ Required (for server)$/WireCrypt = Enabled/' /etc/firebird/3.0/firebird.conf 2>/dev/null || true
systemctl restart firebird3.0
log "Firebird 3.0 configurado (senha: masterkey, auth: Legacy_Auth, WireCrypt: Enabled)"
# Garante que o Firebird esta rodando antes de continuar
systemctl restart firebird3.0 2>/dev/null || systemctl start firebird3.0 2>/dev/null || true
sleep 1
# ████████████████████████████████████████████████████
# PASSO 4: USUARIO E DIRETORIOS
# ████████████████████████████████████████████████████
info "Passo 4/12: Criando usuario e diretorios..."
id -u "$CHATC2_USER" &>/dev/null || useradd -m -s /bin/bash "$CHATC2_USER"
mkdir -p "$CHATC2_DIR"/{db,logs,tmp,uploads/audio,public,whisper/models,scripts,app/config,app/controllers,app/routes,app/middlewares}
chown -R "$CHATC2_USER:$CHATC2_USER" "$CHATC2_DIR"
# ████████████████████████████████████████████████████
# PASSO 5: WHISPER.CPP (Transcricao de Audio)
# ████████████████████████████████████████████████████
info "Passo 5/12: Instalando Whisper.cpp (compilacao nativa Linux)..."
mkdir -p "$CHATC2_DIR/whisper/models"
# Compila whisper.cpp do source (Linux) - binario nativo
if [ ! -f "$CHATC2_DIR/whisper/main" ]; then
info "Compilando whisper.cpp v${WHISPER_VERSION}..."
cd /tmp
rm -rf whisper-build 2>/dev/null || true
git clone --depth 1 --branch v${WHISPER_VERSION} https://github.com/ggerganov/whisper.cpp.git whisper-build 2>&1 | tail -1
cd whisper-build
make -j$(nproc) main 2>&1 | tail -3
cp -f main "$CHATC2_DIR/whisper/main"
chmod +x "$CHATC2_DIR/whisper/main"
cd /tmp && rm -rf whisper-build
log "Whisper.cpp compilado e instalado em $CHATC2_DIR/whisper/main"
else
log "Whisper.cpp binario Linux ja existe, pulando compilacao"
fi
info "Baixando modelo $WHISPER_MODEL (pode levar alguns minutos)..."
if [ ! -f "$CHATC2_DIR/whisper/models/$WHISPER_MODEL" ]; then
su - "$CHATC2_USER" -c "cd $CHATC2_DIR/whisper && \
curl -sL -o models/$WHISPER_MODEL \
https://huggingface.co/ggerganov/whisper.cpp/resolve/main/$WHISPER_MODEL"
log "Modelo $WHISPER_MODEL baixado"
else
log "Modelo $WHISPER_MODEL ja existe"
fi
log "Whisper.cpp instalado em $CHATC2_DIR/whisper/"
# ████████████████████████████████████████████████████
# PASSO 6: ARQUIVOS DO PROJETO
# ████████████████████████████████████████████████████
info "Passo 6/12: Preparando para receber arquivos..."
info "Transfira os arquivos do projeto para $CHATC2_DIR via SCP/SFTP"
info "Depois execute manualmente: cd $CHATC2_DIR && npm install"
echo ""
echo " ╔══════════════════════════════════════════════════════════╗"
echo " ║ Comando para enviar os arquivos (do seu computador): ║"
echo " ║ ║"
echo " ║ cd C:/projects/Chatc2 ║"
echo " ║ scp -r src/ scripts/ package.json package-lock.json ║"
echo " ║ .env.example deploy-linux/ ║"
echo " ║ root@<IP>:$CHATC2_DIR/ ║"
echo " ║ ║"
echo " ║ E depois rode novamente este script: ║"
echo " ║ bash $0 --continue ║"
echo " ╚══════════════════════════════════════════════════════════╝"
echo ""
# Se o script foi chamado com --continue, pula a espera
if [ "${1:-}" != "--continue" ]; then
info "Aguardando transferencia dos arquivos..."
info "Apos transferir, execute: bash $0 --continue"
exit 0
fi
# ████████████████████████████████████████████████████
# PASSO 7: NPM INSTALL
# ████████████████████████████████████████████████████
if [ ! -f "$CHATC2_DIR/package.json" ]; then
err "Arquivos do projeto nao encontrados em $CHATC2_DIR. Transfira via SCP primeiro."
fi
info "Passo 7/12: Instalando dependencias Node..."
chown -R "$CHATC2_USER:$CHATC2_USER" "$CHATC2_DIR"
# Remove node_modules anterior (se existir) para garantir instalação limpa
if [ -d "$CHATC2_DIR/node_modules" ]; then
info "Removendo node_modules anterior para instalacao limpa..."
rm -rf "$CHATC2_DIR/node_modules"
fi
# Executa npm install com saida completa (sem suprimir erros)
if ! su - "$CHATC2_USER" -c "cd $CHATC2_DIR && npm install"; then
err "Falha ao instalar dependencias Node. Verifique a conexao e o package.json"
fi
# Verifica se modulos criticos foram instalados
if [ ! -d "$CHATC2_DIR/node_modules/express" ]; then
err "Modulo 'express' nao encontrado apos npm install. Verifique o package.json"
fi
if [ ! -d "$CHATC2_DIR/node_modules/dotenv" ]; then
info "Instalando dotenv (carregamento de .env)..."
su - "$CHATC2_USER" -c "cd $CHATC2_DIR && npm install dotenv" || warn "Falha ao instalar dotenv"
fi
# Garante que o server.js carrega dotenv (variaveis do .env)
SERVER_JS="$CHATC2_DIR/src/server.js"
if [ -f "$SERVER_JS" ] && ! grep -q "require('dotenv').config()" "$SERVER_JS" 2>/dev/null; then
sed -i "1s/^/require('dotenv').config();\n/" "$SERVER_JS"
log "dotenv configurado no server.js"
fi
log "Dependencias instaladas e verificadas"
# ── Patch node-firebird: compatibilidade com Firebird 3.0 + sintaxe Node 22 ──
info "Passo 7b/12: Aplicando patches node-firebird..."
if [ -f "$(dirname "$0")/patch_node_firebird.py" ]; then
python3 "$(dirname "$0")/patch_node_firebird.py" "$CHATC2_DIR" 2>&1
log "Patches node-firebird aplicados"
else
warn "patch_node_firebird.py nao encontrado"
fi
# ── wireCrypt (Firebird 3.0) ──
# A partir da v2.0 a camada de banco foi unificada em src/database.js e o
# wireCrypt já vem embutido nos defaults do driver Firebird — nada a patchar.
info "Passo 7c/12: wireCrypt (Firebird) já embutido em src/database.js"
log "Nenhum patch de wireCrypt necessário"
# ████████████████████████████████████████████████████
# PASSO 8: ARQUIVO .ENV
# ████████████████████████████████████████████████████
info "Passo 8/12: Configurando .env..."
JWT_SECRET="CHATc2_$(date +%s)_$(openssl rand -hex 16)"
# Detecta IPs da maquina
LOCAL_IP=$(ip -4 addr show scope global | grep -oP 'inet \K[\d.]+' | head -1)
[ -z "$LOCAL_IP" ] && LOCAL_IP="127.0.0.1"
PUBLIC_IP=$(curl -s ifconfig.me 2>/dev/null || echo "")
# Monta URLs
LOCAL_URL="http://${LOCAL_IP}:3000"
if [ -n "$SERVER_DOMAIN" ]; then
EXTERNAL_URL="https://${SERVER_DOMAIN}"
elif [ -n "$PUBLIC_IP" ]; then
EXTERNAL_URL="http://${PUBLIC_IP}"
else
EXTERNAL_URL="http://${LOCAL_IP}:3000"
fi
# Sempre gera um .env limpo (remove paths Windows, comentarios invalidos, etc.)
if [ -f "$CHATC2_DIR/.env" ]; then
info ".env existente encontrado — fazendo backup e recriando..."
cp "$CHATC2_DIR/.env" "$CHATC2_DIR/.env.bak.$(date +%Y%m%d%H%M%S)" 2>/dev/null || true
fi
cat > "$CHATC2_DIR/.env" << EOF
# ============================================================
# Configuracao do Chatc2
# ============================================================
# Driver padrao do banco principal
DB_DRIVER=postgres
# ------------------------------------------------------------
# PostgreSQL (EXTERNO) — banco principal "novo_local"
# ------------------------------------------------------------
PG_HOST=${PG_HOST}
PG_PORT=${PG_PORT}
PG_USER=${PG_USER}
PG_PASSWORD=${PG_PASSWORD}
PG_DATABASE=${PG_DATABASE}
PG_SCHEMA=${PG_SCHEMA}
# ------------------------------------------------------------
# Firebird (legado / alias "firebird_local")
# ------------------------------------------------------------
DB_HOST=localhost
DB_PORT=3050
DB_USER=SYSDBA
DB_PASSWORD=masterkey
DB_ENCODING=UTF-8
# Caminho do .FDB (vazio = usa ../NOVO.FDB na raiz do projeto)
DB_DATABASE=
# Servidor
PORT=3000
JWT_SECRET=$JWT_SECRET
JWT_EXPIRES_IN=1h
# URLs de acesso
LOCAL_URL=${LOCAL_URL}
EXTERNAL_URL=${EXTERNAL_URL}
# ------------------------------------------------------------
# Seguranca (opcionais)
# ------------------------------------------------------------
# Origens permitidas no CORS (vazio = usa LOCAL_URL + EXTERNAL_URL)
# CORS_ORIGINS=${EXTERNAL_URL}
# Token de verificacao do webhook Evolution (header apikey)
# WEBHOOK_TOKEN=
EOF
chown "$CHATC2_USER:$CHATC2_USER" "$CHATC2_DIR/.env"
log "Arquivo .env criado/atualizado com JWT_SECRET seguro"
log " PG_HOST/DB = ${PG_HOST:-(vazio)} / ${PG_DATABASE} (schema: ${PG_SCHEMA})"
log " LOCAL_URL = ${LOCAL_URL}"
log " EXTERNAL_URL = ${EXTERNAL_URL}"
if [ -z "$PG_HOST" ] || [ -z "$PG_PASSWORD" ]; then
warn "PostgreSQL externo nao configurado: edite $CHATC2_DIR/.env (PG_HOST, PG_PASSWORD, PG_DATABASE, PG_SCHEMA) antes de iniciar."
fi
# ████████████████████████████████████████████████████
# PASSO 9: MIGRACOES DO BANCO
# ████████████████████████████████████████████████████
# ── Permissões dos arquivos .FDB ──
info "Passo 8b/12: Ajustando permissoes dos arquivos .FDB..."
# O Firebird roda como usuario 'firebird'. Os arquivos .FDB precisam
# pertencer ao grupo firebird com permissao de leitura+escrita (660).
if ls "$CHATC2_DIR/db/"*.FDB 2>/dev/null; then
chown "$CHATC2_USER:firebird" "$CHATC2_DIR/db/"*.FDB 2>/dev/null || true
chmod 660 "$CHATC2_DIR/db/"*.FDB 2>/dev/null || true
# Garante que o diretório db/ também seja acessível
chmod 750 "$CHATC2_DIR/db/" 2>/dev/null || true
log "Permissoes ajustadas: $CHATC2_USER:firebird 660"
else
warn "Nenhum arquivo .FDB encontrado em $CHATC2_DIR/db/ — copie os bancos manualmente"
warn "Apos copiar, execute: chown $CHATC2_USER:firebird $CHATC2_DIR/db/*.FDB && chmod 660 $CHATC2_DIR/db/*.FDB"
fi
info "Passo 9/12: Migracoes (Firebird legado)..."
# O schema do PostgreSQL e gerenciado no banco EXTERNO — nao migramos aqui.
# Migracoes Firebird so rodam se houver um .FDB local (alias firebird_local).
if ls "$CHATC2_DIR/db/"*.FDB "$CHATC2_DIR/"*.FDB >/dev/null 2>&1; then
if [ -f "$CHATC2_DIR/scripts/migracoes.js" ]; then
su - "$CHATC2_USER" -c "cd $CHATC2_DIR && node scripts/migracoes.js firebird_local 2>&1 | tail -10" || warn "Migracoes Firebird podem ter falhado"
log "Migracoes Firebird executadas"
fi
else
info "Sem .FDB local — pulando migracoes Firebird (schema do Postgres e externo)."
fi
# ████████████████████████████████████████████████████
# PASSO 9b: HABILITAR USUARIOS ADMIN PARA WEB
# ████████████████████████████████████████████████████
info "Passo 9b/12: Habilitando usuarios admin para acesso web..."
# Habilita acesso web para admins (Postgres principal e Firebird se presente)
cat > /tmp/chatc2-habilitar-web.js << 'SCRIPTJS'
const db = require('DATABASE_PATH');
(async () => {
const alias = process.argv[2];
if (!alias) { console.log('Uso: node script <alias>'); process.exit(1); }
try {
// Habilita USU_ACESSO_WEB = 1 para usuarios ativos com perfil admin (USU_TIPO = A)
const result = await db.query(alias,
"SELECT USU_CODIGO_ID, USU_NOME, USU_LOGIN FROM USUARIOS WHERE USU_STATUS = 'A' AND USU_TIPO = 'A' AND COALESCE(USU_ACESSO_WEB, 0) = 0");
for (const u of result) {
await db.execute(alias, 'UPDATE USUARIOS SET USU_ACESSO_WEB = 1 WHERE USU_CODIGO_ID = ?', [u.USU_CODIGO_ID]);
console.log(' ✅ ' + u.USU_NOME.trim() + ' (' + u.USU_LOGIN.trim() + ') — acesso web habilitado');
}
if (result.length === 0) console.log(' Nenhum usuario admin pendente.');
process.exit(0);
} catch(e) { console.error('Erro:', e.message); process.exit(1); }
})();
SCRIPTJS
sed -i "s|DATABASE_PATH|$CHATC2_DIR/src/database|" /tmp/chatc2-habilitar-web.js
# Postgres externo (alias principal)
su - "$CHATC2_USER" -c "cd $CHATC2_DIR && node /tmp/chatc2-habilitar-web.js novo_local" 2>&1 || true
# Firebird local (apenas se houver .FDB)
if ls "$CHATC2_DIR/db/"*.FDB "$CHATC2_DIR/"*.FDB >/dev/null 2>&1; then
su - "$CHATC2_USER" -c "cd $CHATC2_DIR && node /tmp/chatc2-habilitar-web.js firebird_local" 2>&1 || true
fi
rm -f /tmp/chatc2-habilitar-web.js
log "Usuarios admin verificados (Postgres + Firebird se presente)"
# ████████████████████████████████████████████████████
# PASSO 10: NGINX
# ████████████████████████████████████████████████████
info "Passo 10/12: Configurando Nginx..."
cat > /etc/nginx/sites-available/chatc2 << 'NGINX'
server {
listen 80;
server_name _;
client_max_body_size 100M;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}
NGINX
# Remove sites padrao que podem conflitar
rm -f /etc/nginx/sites-enabled/default 2>/dev/null || true
rm -f /etc/nginx/sites-enabled/example.com 2>/dev/null || true
# Habilita o site do chatc2
if [ ! -L /etc/nginx/sites-enabled/chatc2 ]; then
ln -sf /etc/nginx/sites-available/chatc2 /etc/nginx/sites-enabled/
fi
# Testa configuracao antes de aplicar
if nginx -t 2>&1; then
systemctl enable nginx 2>/dev/null || true
systemctl restart nginx 2>/dev/null || systemctl start nginx 2>/dev/null || true
log "Nginx configurado e iniciado"
else
warn "Erro na configuracao do nginx. Verifique manualmente: nginx -t"
fi
# SSL com Let's Encrypt (se dominio foi informado)
if [ -n "${SERVER_DOMAIN:-}" ]; then
info "Configurando SSL para $SERVER_DOMAIN..."
certbot --nginx -d "$SERVER_DOMAIN" --non-interactive --agree-tos \
--email "admin@${SERVER_DOMAIN}" || warn "SSL falhou (verifique DNS)"
log "SSL configurado para $SERVER_DOMAIN"
fi
# ████████████████████████████████████████████████████
# PASSO 11: PM2 (INICIO AUTOMATICO)
# ████████████████████████████████████████████████████
info "Passo 11/12: Configurando PM2..."
# Para processos anteriores (se existirem)
pm2 delete chatc2 2>/dev/null || true
# Inicia a aplicacao com PM2 como root (script ja exige root)
cd "$CHATC2_DIR"
if ! pm2 start src/server.js --name chatc2 --max-memory-restart 512M --time 2>&1; then
err "Falha ao iniciar a aplicacao com PM2. Verifique os logs."
fi
# Aguarda o servidor iniciar e verifica se esta ouvindo na porta 3000
sleep 2
if ss -tlnp | grep -q ':3000'; then
log "Servidor Node.js ouvindo na porta 3000"
else
warn "Servidor pode nao estar ouvindo na porta 3000. Verifique: pm2 logs chatc2"
fi
# Salva a lista de processos e configura inicio automatico no boot
pm2 save 2>/dev/null || true
pm2 startup systemd -u root --hp /root 2>/dev/null || true
log "PM2 configurado - aplicacao iniciara automaticamente no boot"
# ████████████████████████████████████████████████████
# PASSO 12: FIREWALL
# ████████████████████████████████████████████████████
info "Passo 12/12: Configurando firewall..."
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 3000/tcp
ufw --force enable
log "Firewall configurado (portas: 22, 80, 443, 3000)"
# ████████████████████████████████████████████████████
# FINAL
# ████████████████████████████████████████████████████
PUBLIC_IP=$(curl -s ifconfig.me 2>/dev/null || echo "<IP>")
echo ""
echo "╔══════════════════════════════════════════════════╗"
echo "║ Chatc2 - Instalacao Concluida! ║"
echo "╚══════════════════════════════════════════════════╝"
echo ""
echo " 📍 Acessar: http://$PUBLIC_IP"
echo " 📁 Diretorio: $CHATC2_DIR"
echo " 👤 Usuario: $CHATC2_USER"
echo " 🗄️ Firebird: senha: masterkey"
echo " 🎤 Whisper: modelo $WHISPER_MODEL"
echo ""
echo " 📋 Proximos passos:"
echo " 1. Copie seus bancos .FDB para $CHATC2_DIR/db/"
echo " 2. Edite $CHATC2_DIR/.env se necessario"
echo " 3. Crie usuario admin: node $CHATC2_DIR/scripts/gerar-token-usuario.js"
echo " 4. Configure o webhook na Evolution API:"
echo " POST http://$PUBLIC_IP/api/webhook/evolution"
echo " 5. Logs: pm2 logs chatc2"
echo " 6. Parar: pm2 stop chatc2"
echo " 7. Reset: pm2 restart chatc2"
echo ""
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
patch_node_firebird.py - Aplica patches de compatibilidade node-firebird
Patches aplicados:
1. SRP empty buffer (Firebird 3.0 Legacy_Auth)
2. Correcao de sintaxe para Node.js v22+ (colchete faltante)
Uso: python3 patch_node_firebird.py <caminho_do_projeto>
Ex: python3 patch_node_firebird.py /home/chatc2/chatc2
"""
import sys
import os
def patch_srp(project_dir):
"""Patch 1: compatibilidade SRP com Firebird 3.0 Legacy_Auth"""
filepath = os.path.join(project_dir, 'node_modules',
'node-firebird', 'lib', 'wire', 'connection.js')
if not os.path.isfile(filepath):
print(" [ERRO] Arquivo connection.js nao encontrado")
return False
with open(filepath, 'r') as f:
content = f.read()
if "No auth data from server" in content:
print(" [OK] Patch 1 (SRP) ja aplicado")
return True
old = (
' // 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);'
)
new = (
' // 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 old in content:
content = content.replace(old, new, 1)
with open(filepath, 'w') as f:
f.write(content)
print(" [OK] Patch 1 (SRP) aplicado")
return True
else:
print(" [AVISO] Patch 1 (SRP) nao encontrado - codigo ja modificado")
return True # nao e erro
def patch_syntax(project_dir):
"""Patch 2: colchete faltante (Node.js v22 nao tolera essa sintaxe)"""
filepath = os.path.join(project_dir, 'node_modules',
'node-firebird', 'lib', 'wire', 'connection.js')
if not os.path.isfile(filepath):
return False
with open(filepath, 'r') as f:
content = f.read()
# Verifica se ja foi corrigido
if "fecha o else do if" in content:
print(" [OK] Patch 2 (sintaxe) ja aplicado")
return True
# O erro: else block do if(!d||!d.buffer) nao tem chave de fechamento
# Linha original termina com: accept.sessionKey = proof.clientSessionKey;
# Linha seguinte: } else if (accept.pluginName === Const.AUTH_PLUGIN_LEGACY)
# Precisa de um } extra entre elas para fechar o else block
old = (
' accept.authData = proof.authData.toString(16);\n'
' accept.sessionKey = proof.clientSessionKey;\n'
' } else if (accept.pluginName === Const.AUTH_PLUGIN_LEGACY) {'
)
new = (
' 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 old in content:
content = content.replace(old, new, 1)
with open(filepath, 'w') as f:
f.write(content)
print(" [OK] Patch 2 (sintaxe Node22) aplicado")
return True
else:
print(" [AVISO] Patch 2 (sintaxe) - padrao nao encontrado")
return True
if __name__ == '__main__':
if len(sys.argv) < 2:
print("Uso: python3 patch_node_firebird.py <caminho_do_projeto>")
sys.exit(1)
project_dir = sys.argv[1]
print(f" Aplicando patches em: {project_dir}")
r1 = patch_srp(project_dir)
r2 = patch_syntax(project_dir)
if r1 and r2:
print(" Patches aplicados com sucesso")
sys.exit(0)
else:
print(" Alguns patches falharam")
sys.exit(1)
+1328
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "chatc2",
"version": "2.0.0",
"description": "API REST multi-banco (PostgreSQL + Firebird) para a plataforma de atendimento Chatc2",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
},
"keywords": [
"postgres",
"firebird",
"api",
"express"
],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"cors": "^2.8.6",
"dotenv": "^16.4.7",
"express": "^5.2.1",
"ffmpeg-static": "^5.3.0",
"jsonwebtoken": "^9.0.3",
"node-firebird": "^2.3.1",
"pg": "^8.21.0"
}
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Script para definir/alterar a senha web de um usuário.
* Uso: node scripts/definir-senha-web.js <LOGIN> <SENHA> [alias]
*
* @param {string} LOGIN - Login do usuário
* @param {string} SENHA - Nova senha (máx. 30 caracteres)
* @param {string} [alias=lajedo] - Alias do banco de dados
*/
const db = require('../src/database');
async function main() {
const login = process.argv[2];
const senha = process.argv[3];
const alias = process.argv[4] || 'lajedo';
if (!login || !senha) {
console.log('Uso: node scripts/definir-senha-web.js <LOGIN> <SENHA> [alias]');
console.log('Ex: node scripts/definir-senha-web.js SUPORTE 123456 lajedo');
process.exit(1);
}
if (senha.length > 30) {
console.log('❌ A senha deve ter no máximo 30 caracteres.');
process.exit(1);
}
// Verifica se o usuário existe
const users = await db.query(alias,
'SELECT USU_CODIGO_ID, USU_NOME, USU_LOGIN, USU_ACESSO_WEB FROM USUARIOS WHERE USU_LOGIN = ?',
[login]
);
if (users.length === 0) {
console.log(`❌ Usuário "${login}" não encontrado no alias "${alias}".`);
process.exit(1);
}
const user = users[0];
// Atualiza a senha web
await db.execute(alias,
'UPDATE USUARIOS SET USU_SENHA_WEB = ? WHERE USU_CODIGO_ID = ?',
[senha, user.USU_CODIGO_ID]
);
console.log(`✅ Senha web do usuário "${login}" definida com sucesso! (alias: ${alias})`);
if (user.USU_ACESSO_WEB !== 1) {
console.log('⚠️ O usuário ainda não tem acesso WEB habilitado.');
console.log(` Execute: node scripts/habilitar-acesso-web.js ${login} ${alias}`);
}
process.exit(0);
}
main().catch(err => {
console.error('Erro:', err.message);
process.exit(1);
});
+130
View File
@@ -0,0 +1,130 @@
/**
* Gera um token JWT para um usuário em uma empresa específica.
* O token é armazenado no campo USU_TOKEN da tabela USUARIOS.
*
* Uso: node scripts/gerar-token-jwt.js <LOGIN> <ID_EMPRESA> [alias]
*
* Exemplos:
* node scripts/gerar-token-jwt.js SUPORTE 1
* node scripts/gerar-token-jwt.js SUPORTE 1 lajedo
*/
const jwt = require('jsonwebtoken');
const db = require('../src/database');
const authConfig = require('../src/config/auth');
async function main() {
const login = process.argv[2];
const empresaId = parseInt(process.argv[3], 10);
const alias = process.argv[4] || 'lajedo';
if (!login || !empresaId) {
console.log('Uso: node scripts/gerar-token-jwt.js <LOGIN> <ID_EMPRESA> [alias]');
console.log('Ex: node scripts/gerar-token-jwt.js SUPORTE 1 lajedo');
process.exit(1);
}
// 1. Busca o usuário pelo login
const users = await db.query(alias,
`SELECT USU_CODIGO_ID, USU_NOME, USU_LOGIN, USU_EMAIL, USU_STATUS,
USU_ACESSO_WEB, USU_TIPO, USU_TOKEN
FROM USUARIOS WHERE USU_LOGIN = ?`,
[login]
);
if (users.length === 0) {
console.log(`❌ Usuário "${login}" não encontrado no alias "${alias}".`);
process.exit(1);
}
const user = users[0];
// 2. Verifica se o usuário tem acesso à empresa
const empresas = await db.query(alias,
`SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA
WHERE USE_USUARIO_ID = ? AND USE_EMPRESA_ID = ?`,
[user.USU_CODIGO_ID, empresaId]
);
if (empresas.length === 0) {
// Lista as empresas que o usuário tem acesso
const allEmpresas = await db.query(alias,
`SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA
WHERE USE_USUARIO_ID = ?`,
[user.USU_CODIGO_ID]
);
const empresasList = allEmpresas.map(e => e.USE_EMPRESA_ID).join(', ');
console.log(`❌ Usuário "${login}" não tem acesso à empresa ${empresaId}.`);
console.log(` Empresas disponíveis para este usuário: ${empresasList || 'nenhuma'}`);
process.exit(1);
}
// 3. Verifica status e permissão web
const statusOk = user.USU_STATUS === 'A';
const webOk = user.USU_ACESSO_WEB === 1;
if (!statusOk) {
console.log(`❌ Usuário "${login}" não está ativo (status: ${user.USU_STATUS}).`);
process.exit(1);
}
if (!webOk) {
console.log(`⚠️ Usuário "${login}" não tem acesso WEB habilitado.`);
console.log(` Execute: node scripts/habilitar-acesso-web.js ${login} ${alias}`);
// Não impede de gerar o token, mas avisa
}
// 4. Gera o payload do token
const payload = {
id: user.USU_CODIGO_ID,
empresaId: empresaId,
nome: user.USU_NOME.trim(),
login: user.USU_LOGIN.trim(),
email: user.USU_EMAIL ? user.USU_EMAIL.trim() : null,
tipo: user.USU_TIPO ? user.USU_TIPO.trim() : null,
alias: alias,
};
// 5. Gera o token JWT
const token = jwt.sign(payload, authConfig.secret, {
expiresIn: authConfig.expiresIn,
issuer: authConfig.issuer,
});
// 6. Armazena no campo USU_TOKEN
await db.execute(alias,
'UPDATE USUARIOS SET USU_TOKEN = ? WHERE USU_CODIGO_ID = ?',
[token, user.USU_CODIGO_ID]
);
// 7. Exibe o resultado
console.log('');
console.log('='.repeat(70));
console.log(' ✅ TOKEN JWT GERADO COM SUCESSO');
console.log('='.repeat(70));
console.log('');
console.log(` Alias: ${alias}`);
console.log(` Usuário: ${user.USU_NOME.trim()} (${user.USU_LOGIN.trim()})`);
console.log(` ID Usuário: ${user.USU_CODIGO_ID}`);
console.log(` Empresa: ${empresaId}`);
console.log(` Status: ${statusOk ? '✅ Ativo' : '❌ Inativo'}`);
console.log(` Acesso Web: ${webOk ? '✅ Sim' : '❌ Não'}`);
console.log('');
console.log(' ┌─ TOKEN ─────────────────────────────────────────────────┐');
console.log(`${token}`);
console.log(' └─────────────────────────────────────────────────────────┘');
console.log('');
console.log(' Expira em: ' + authConfig.expiresIn);
console.log('');
console.log(' 📌 Headers para usar nas requisições:');
console.log(' Authorization: Bearer ' + token.substring(0, 50) + '...');
console.log(' X-Usu-Token: ' + token.substring(0, 50) + '...');
console.log('');
process.exit(0);
}
main().catch(err => {
console.error('Erro:', err.message);
process.exit(1);
});
+67
View File
@@ -0,0 +1,67 @@
/**
* Script para gerar/renovar o USU_TOKEN de um usuário.
* Uso: node scripts/gerar-token-usuario.js <LOGIN> [alias]
*
* Cada usuário recebe um token único (48 caracteres hex) armazenado
* no campo USU_TOKEN, que pode ser usado para autenticação na API
* via header X-Usu-Token.
*
* @param {string} LOGIN - Login do usuário
* @param {string} [alias=lajedo] - Alias do banco de dados
*/
const crypto = require('crypto');
const db = require('../src/database');
async function main() {
const login = process.argv[2];
const alias = process.argv[3] || 'lajedo';
if (!login) {
console.log('Uso: node scripts/gerar-token-usuario.js <LOGIN> [alias]');
console.log('Ex: node scripts/gerar-token-usuario.js SUPORTE lajedo');
process.exit(1);
}
// Verifica se o usuário existe
const users = await db.query(alias,
`SELECT USU_CODIGO_ID, USU_NOME, USU_LOGIN, USU_STATUS, USU_ACESSO_WEB
FROM USUARIOS WHERE USU_LOGIN = ?`,
[login]
);
if (users.length === 0) {
console.log(`❌ Usuário "${login}" não encontrado no alias "${alias}".`);
process.exit(1);
}
const user = users[0];
// Gera um token único (48 caracteres hex)
const token = crypto.randomBytes(24).toString('hex');
// Armazena no banco
await db.execute(alias,
'UPDATE USUARIOS SET USU_TOKEN = ? WHERE USU_CODIGO_ID = ?',
[token, user.USU_CODIGO_ID]
);
console.log('=== Token gerado com sucesso! ===');
console.log(`Alias: ${alias}`);
console.log(`Usuário: ${user.USU_NOME.trim()} (${user.USU_LOGIN.trim()})`);
console.log(`ID: ${user.USU_CODIGO_ID}`);
console.log(`Status: ${user.USU_STATUS === 'A' ? '✅ Ativo' : '❌ Inativo'}`);
console.log(`Acesso Web: ${user.USU_ACESSO_WEB === 1 ? '✅ Sim' : '❌ Não'}`);
console.log(`\n🔑 USU_TOKEN:`);
console.log(`${token}`);
console.log(`\n📌 Use no header das requisições:`);
console.log(`X-Usu-Token: ${token}`);
console.log(`ou`);
console.log(`Authorization: Bearer ${token}`);
process.exit(0);
}
main().catch(err => {
console.error('Erro:', err.message);
process.exit(1);
});
+63
View File
@@ -0,0 +1,63 @@
/**
* Script para habilitar acesso WEB a um usuário existente.
* Uso: node scripts/habilitar-acesso-web.js <LOGIN> [alias]
*
* @param {string} LOGIN - Login do usuário
* @param {string} [alias=lajedo] - Alias do banco de dados
*/
const db = require('../src/database');
async function main() {
const login = process.argv[2];
const alias = process.argv[3] || 'lajedo';
if (!login) {
console.log('Uso: node scripts/habilitar-acesso-web.js <LOGIN> [alias]');
console.log('Ex: node scripts/habilitar-acesso-web.js SUPORTE lajedo');
process.exit(1);
}
// Verifica se o usuário existe
const users = await db.query(alias,
'SELECT USU_CODIGO_ID, USU_NOME, USU_LOGIN, USU_ACESSO_WEB, USU_STATUS, USU_SENHA_WEB FROM USUARIOS WHERE USU_LOGIN = ?',
[login]
);
if (users.length === 0) {
console.log(`❌ Usuário "${login}" não encontrado no alias "${alias}".`);
process.exit(1);
}
const user = users[0];
console.log('=== Usuário encontrado ===');
console.log(`Alias: ${alias}`);
console.log(`ID: ${user.USU_CODIGO_ID}`);
console.log(`Nome: ${user.USU_NOME.trim()}`);
console.log(`Login: ${user.USU_LOGIN.trim()}`);
console.log(`Acesso Web: ${user.USU_ACESSO_WEB === 1 ? '✅ Sim' : '❌ Não'}`);
console.log(`Status: ${user.USU_STATUS === 'A' ? '✅ Ativo' : '❌ Inativo'}`);
console.log(`Senha Web: ${user.USU_SENHA_WEB || '(vazia)'}`);
// Habilita acesso web
await db.execute(alias,
'UPDATE USUARIOS SET USU_ACESSO_WEB = 1 WHERE USU_CODIGO_ID = ?',
[user.USU_CODIGO_ID]
);
console.log('\n✅ Acesso WEB habilitado com sucesso!');
console.log(` Agora o usuário "${login}" pode usar o app.`);
if (!user.USU_SENHA_WEB) {
console.log('\n⚠️ ATENÇÃO: O usuário não possui senha web (USU_SENHA_WEB).');
console.log(' Defina uma senha para que o login funcione.');
console.log(' Ex: node scripts/definir-senha-web.js SUPORTE 123456 ' + alias);
}
process.exit(0);
}
main().catch(err => {
console.error('Erro:', err.message);
process.exit(1);
});
+55
View File
@@ -0,0 +1,55 @@
/**
* Lista todos os usuários com seus respectivos USU_TOKEN.
* Uso: node scripts/listar-tokens.js [LOGIN] [alias]
*
* @param {string} [LOGIN] - Filtrar por login (opcional)
* @param {string} [alias=lajedo] - Alias do banco de dados
*/
const db = require('../src/database');
async function main() {
const filtro = process.argv[2];
const alias = process.argv[3] || 'lajedo';
const sql = filtro
? `SELECT USU_CODIGO_ID, USU_NOME, USU_LOGIN, USU_STATUS,
USU_ACESSO_WEB, USU_TOKEN
FROM USUARIOS WHERE USU_LOGIN = ?`
: `SELECT USU_CODIGO_ID, USU_NOME, USU_LOGIN, USU_STATUS,
USU_ACESSO_WEB, USU_TOKEN
FROM USUARIOS`;
const users = await db.query(alias, sql, filtro ? [filtro] : []);
if (users.length === 0) {
console.log(`Nenhum usuário encontrado no alias "${alias}".`);
process.exit(0);
}
console.log('='.repeat(80));
console.log(` USUÁRIOS E TOKENS (alias: ${alias})`);
console.log('='.repeat(80));
console.log('');
users.forEach((u, i) => {
const status = u.USU_STATUS?.trim() === 'A' ? '✅' : '❌';
const web = u.USU_ACESSO_WEB === 1 ? '✅' : '❌';
const token = u.USU_TOKEN?.trim();
console.log(`${i + 1}. ${u.USU_NOME.trim()} (${u.USU_LOGIN.trim()})`);
console.log(` ID: ${u.USU_CODIGO_ID} | Status: ${status} | Acesso Web: ${web}`);
console.log(` USU_TOKEN: ${token || '(vazio)'}`);
console.log('');
});
console.log('='.repeat(80));
console.log(`Total: ${users.length} usuário(s)`);
console.log('='.repeat(80));
process.exit(0);
}
main().catch(err => {
console.error('Erro:', err.message);
process.exit(1);
});
+490
View File
@@ -0,0 +1,490 @@
/**
* ARQUIVO DE MIGRAÇÕES DO BANCO DE DADOS
* ========================================
*
* Este script contém TODAS as alterações estruturais feitas no banco de dados
* desde a criação inicial do projeto.
*
* USO:
* node scripts/migracoes.js [alias]
*
* Exemplo:
* node scripts/migracoes.js lajedo
* node scripts/migracoes.js novo
*
* IMPORTANTE:
* - Cada migração tem um ID único e só executa uma vez
* - O controle é feito via tabela CHATC2_CONTROLE_MIGRACOES
* - Para adicionar NOVA migração, adicione um novo objeto no array MIGRACOES
* - Nunca remova ou altere migrações já existentes (apenas adicione novas)
*/
const db = require('../src/database');
// Este script aplica DDL no dialeto Firebird (BLOB SUB_TYPE, etc.).
// O schema do PostgreSQL é gerenciado externamente (banco externo) — use este
// script apenas para bancos Firebird. Alias padrão: firebird_local.
const alias = process.argv[2] || 'firebird_local';
// ============================================================
// CONTROLE DE MIGRAÇÕES
// ============================================================
async function garantirControle() {
try {
await db.execute(alias, `
CREATE TABLE CHATC2_CONTROLE_MIGRACOES (
MIG_ID INTEGER NOT NULL PRIMARY KEY,
MIG_DESCRICAO VARCHAR(200),
MIG_DATA_EXECUCAO TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
MIG_STATUS CHAR(1) DEFAULT 'A'
)
`);
console.log('✅ Tabela CHATC2_CONTROLE_MIGRACOES criada');
} catch(e) {
if (!e.message.includes('already exists')) {
console.log('⚠️ CHATC2_CONTROLE_MIGRACOES:', e.message.substring(0, 100));
}
}
}
async function migracaoJaExecutada(id) {
try {
var r = await db.query(alias,
'SELECT COUNT(*) AS CT FROM CHATC2_CONTROLE_MIGRACOES WHERE MIG_ID = ?', [id]);
return r[0]?.CT > 0;
} catch(e) {
return false;
}
}
async function registrarMigracao(id, descricao) {
try {
await db.execute(alias,
'INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (?, ?)',
[id, descricao]);
} catch(e) {
console.log('⚠️ Erro ao registrar migração:', e.message.substring(0, 80));
}
}
// ============================================================
// LISTA DE MIGRAÇÕES
// ============================================================
// SEMPRE adicione NOVAS migrações no FINAL do array, com ID sequencial.
// NUNCA remova ou modifique migrações já existentes.
const MIGRACOES = [
// ----------------------------------------------------------
// MIGRAÇÃO 1: Campo USU_TIPO_CHAT em USUARIOS
// ----------------------------------------------------------
{
id: 1,
descricao: 'Adicionar USU_TIPO_CHAT em USUARIOS (A=Atendente, G=Gerente)',
sql: [
`ALTER TABLE USUARIOS ADD USU_TIPO_CHAT CHAR(1) DEFAULT 'A'`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 2: Tabela CHATC2_INSTANCIAS (Evolution API)
// ----------------------------------------------------------
{
id: 2,
descricao: 'Criar tabela CHATC2_INSTANCIAS para conexões WhatsApp',
sql: [
`CREATE TABLE CHATC2_INSTANCIAS (
INS_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
INS_EMPRESA_ID INTEGER NOT NULL,
INS_NOME VARCHAR(100),
INS_URL VARCHAR(500),
INS_API_KEY VARCHAR(500),
INS_INSTANCE_NAME VARCHAR(100),
INS_STATUS CHAR(1) DEFAULT 'D',
INS_QR_CODE BLOB SUB_TYPE 0,
INS_DT_CONEXAO TIMESTAMP,
INS_DT_CADASTRO TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INS_SITUACAO CHAR(1) DEFAULT 'A'
)`,
`CREATE SEQUENCE GEN_INSTANCIAS`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 3: Tabela CHATC2_EQUIPES
// ----------------------------------------------------------
{
id: 3,
descricao: 'Criar tabela CHATC2_EQUIPES',
sql: [
`CREATE TABLE CHATC2_EQUIPES (
EQU_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
EQU_EMPRESA_ID INTEGER NOT NULL,
EQU_NOME VARCHAR(100),
EQU_ORDEM INTEGER DEFAULT 0,
EQU_SITUACAO CHAR(1) DEFAULT 'A'
)`,
`CREATE SEQUENCE GEN_EQUIPES`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 4: Tabela CHATC2_USU_EQUIPES
// ----------------------------------------------------------
{
id: 4,
descricao: 'Criar tabela CHATC2_USU_EQUIPES (relação N:N)',
sql: [
`CREATE TABLE CHATC2_USU_EQUIPES (
EQU_EQUIPE_ID INTEGER NOT NULL,
EQU_USUARIO_ID INTEGER NOT NULL,
PRIMARY KEY (EQU_EQUIPE_ID, EQU_USUARIO_ID)
)`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 5: Tabela CHATC2_ETIQUETAS
// ----------------------------------------------------------
{
id: 5,
descricao: 'Criar tabela CHATC2_ETIQUETAS',
sql: [
`CREATE TABLE CHATC2_ETIQUETAS (
ETI_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
ETI_EMPRESA_ID INTEGER NOT NULL,
ETI_NOME VARCHAR(50),
ETI_COR VARCHAR(7) DEFAULT '#667eea',
ETI_SITUACAO CHAR(1) DEFAULT 'A'
)`,
`CREATE SEQUENCE GEN_ETIQUETAS`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 6: Tabela CHATC2_CONVERSAS
// ----------------------------------------------------------
{
id: 6,
descricao: 'Criar tabela CHATC2_CONVERSAS',
sql: [
`CREATE TABLE CHATC2_CONVERSAS (
CON_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
CON_EMPRESA_ID INTEGER NOT NULL,
CON_INSTANCIA_ID INTEGER,
CON_CLIENTE_ID INTEGER,
CON_NUMERO VARCHAR(20),
CON_NOME_CONTATO VARCHAR(100),
CON_STATUS CHAR(1) DEFAULT 'E',
CON_USUARIO_ID INTEGER,
CON_EQUIPE_ID INTEGER,
CON_DT_INICIO TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CON_DT_FINAL TIMESTAMP,
CON_DT_ULTIMA_MSG TIMESTAMP,
CON_SAUDACAO_ENVIADA CHAR(1) DEFAULT 'N',
CON_CSAT_ENVIADO CHAR(1) DEFAULT 'N',
CON_PRIMEIRA_MSG TIMESTAMP,
CON_ORIGEM VARCHAR(20) DEFAULT 'whatsapp',
CON_SITUACAO CHAR(1) DEFAULT 'A'
)`,
`CREATE SEQUENCE GEN_CONVERSAS`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 7: Tabela CHATC2_CONVERSAS_MENSAGENS
// ----------------------------------------------------------
{
id: 7,
descricao: 'Criar tabela CHATC2_CONVERSAS_MENSAGENS',
sql: [
`CREATE TABLE CHATC2_CONVERSAS_MENSAGENS (
CME_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
CME_CONVERSA_ID INTEGER NOT NULL,
CME_REMETENTE CHAR(1) DEFAULT 'C',
CME_USUARIO_ID INTEGER,
CME_MENSAGEM BLOB SUB_TYPE 1,
CME_TEXTO VARCHAR(4000),
CME_TIPO VARCHAR(20) DEFAULT 'text',
CME_DT_ENVIO TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CME_PRIVADA CHAR(1) DEFAULT 'N',
CME_LIDA CHAR(1) DEFAULT 'N',
CME_MIDIA_ID INTEGER,
CME_SITUACAO CHAR(1) DEFAULT 'A'
)`,
`CREATE SEQUENCE GEN_CONVERSAS_MENSAGENS`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 8: Tabela CHATC2_MENSAGENS_ATENDIMENTOS (blob mídias)
// ----------------------------------------------------------
{
id: 8,
descricao: 'Criar tabela CHATC2_MENSAGENS_ATENDIMENTOS para mídias',
sql: [
`CREATE TABLE CHATC2_MENSAGENS_ATENDIMENTOS (
MAT_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
MAT_CONVERSA_ID INTEGER,
MAT_MENSAGEM_ID INTEGER,
MAT_NOME_ARQUIVO VARCHAR(255),
MAT_TIPO_ARQUIVO VARCHAR(50),
MAT_ARQUIVO BLOB SUB_TYPE 0,
MAT_DT_CADASTRO TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
MAT_SITUACAO CHAR(1) DEFAULT 'A'
)`,
`CREATE SEQUENCE GEN_MENSAGENS_ATENDIMENTO`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 9: Tabela CHATC2_CONVERSAS_ETIQUETAS
// ----------------------------------------------------------
{
id: 9,
descricao: 'Criar tabela CHATC2_CONVERSAS_ETIQUETAS',
sql: [
`CREATE TABLE CHATC2_CONVERSAS_ETIQUETAS (
CET_CONVERSA_ID INTEGER NOT NULL,
CET_ETIQUETA_ID INTEGER NOT NULL,
PRIMARY KEY (CET_CONVERSA_ID, CET_ETIQUETA_ID)
)`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 10: Tabela CHATC2_CSAT_AVALIACOES
// ----------------------------------------------------------
{
id: 10,
descricao: 'Criar tabela CHATC2_CSAT_AVALIACOES',
sql: [
`CREATE TABLE CHATC2_CSAT_AVALIACOES (
CSA_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
CSA_CONVERSA_ID INTEGER,
CSA_EMPRESA_ID INTEGER,
CSA_CLIENTE_ID INTEGER,
CSA_NOTA INTEGER,
CSA_COMENTARIO VARCHAR(500),
CSA_DT_AVALIACAO TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE SEQUENCE GEN_CSAT_AVALIACOES`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 11: Tabela CHATC2_CONFIGURACOES_EMPRESA (base)
// ----------------------------------------------------------
{
id: 11,
descricao: 'Criar tabela CHATC2_CONFIGURACOES_EMPRESA (base)',
sql: [
`CREATE TABLE CHATC2_CONFIGURACOES_EMPRESA (
CFE_EMPRESA_ID INTEGER NOT NULL PRIMARY KEY,
CFE_INSTANCIA_PADRAO_ID INTEGER,
CFE_FOTO_CELULAR CHAR(1) DEFAULT 'N',
CFE_SAUDACAO_ATIVA CHAR(1) DEFAULT 'S',
CFE_SAUDACAO_MENSAGEM VARCHAR(500),
CFE_CSAT_ATIVO CHAR(1) DEFAULT 'N',
CFE_CSAT_MENSAGEM VARCHAR(500) DEFAULT 'Avalie seu atendimento de 1 a 5 estrelas'
)`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 12: EQU_ORDEM em CHATC2_EQUIPES
// ----------------------------------------------------------
{
id: 12,
descricao: 'Adicionar EQU_ORDEM em CHATC2_EQUIPES (ordenação)',
sql: [
`ALTER TABLE CHATC2_EQUIPES ADD EQU_ORDEM INTEGER DEFAULT 0`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 13: Campos de triagem em CHATC2_CONFIGURACOES_EMPRESA
// ----------------------------------------------------------
{
id: 13,
descricao: 'Adicionar campos de triagem em CHATC2_CONFIGURACOES_EMPRESA',
sql: [
`ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_ENVIAR_NOME_USUARIO CHAR(1) DEFAULT 'N'`,
`ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_TRIAGEM_ATIVA CHAR(1) DEFAULT 'N'`,
`ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_TRIAGEM_MSG_WELCOME VARCHAR(500)`,
`ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_TRIAGEM_MSG_AFTER VARCHAR(500)`,
`ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_TRIAGEM_BOLETO_NUMERO VARCHAR(10) DEFAULT '0'`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 14: CON_MENU_ESTADO em CHATC2_CONVERSAS
// ----------------------------------------------------------
{
id: 14,
descricao: 'Adicionar CON_MENU_ESTADO em CHATC2_CONVERSAS (navegação triagem)',
sql: [
`ALTER TABLE CHATC2_CONVERSAS ADD CON_MENU_ESTADO VARCHAR(100)`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 15: CON_ETIQUETAS_DESC em CHATC2_CONVERSAS
// ----------------------------------------------------------
{
id: 15,
descricao: 'Adicionar CON_ETIQUETAS_DESC em CHATC2_CONVERSAS (estado legado)',
sql: [
`ALTER TABLE CHATC2_CONVERSAS ADD CON_ETIQUETAS_DESC VARCHAR(500)`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 16: Tabela CHATC2_MENUS_EMPRESA (fluxo personalizado)
// ----------------------------------------------------------
{
id: 16,
descricao: 'Criar tabela CHATC2_MENUS_EMPRESA para fluxo de atendimento personalizado',
sql: [
`CREATE TABLE CHATC2_MENUS_EMPRESA (
MNE_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
MNE_EMPRESA_ID INTEGER,
MNE_EQUIPE_ID INTEGER,
MNE_MENU_PAI_ID INTEGER,
MNE_ORDEM INTEGER DEFAULT 0,
MNE_TITULO VARCHAR(100),
MNE_TIPO CHAR(1) DEFAULT 'M',
MNE_TEXTO BLOB SUB_TYPE TEXT,
MNE_ACAO_ROTA VARCHAR(100),
MNE_ACAO_METODO VARCHAR(10),
MNE_ACAO_PROMPT VARCHAR(300),
MNE_SITUACAO CHAR(1) DEFAULT 'A'
)`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 17: CON_USUARIO_NOME, CON_EQUIPE_NOME, CON_ETIQUETAS_DESC em CHATC2_CONVERSAS
// ----------------------------------------------------------
{
id: 17,
descricao: 'Adicionar CON_USUARIO_NOME, CON_EQUIPE_NOME, CON_ETIQUETAS_DESC em CHATC2_CONVERSAS',
sql: [
`ALTER TABLE CHATC2_CONVERSAS ADD CON_USUARIO_NOME VARCHAR(100)`,
`ALTER TABLE CHATC2_CONVERSAS ADD CON_EQUIPE_NOME VARCHAR(100)`,
`ALTER TABLE CHATC2_CONVERSAS ADD CON_ETIQUETAS_DESC VARCHAR(500)`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 18: Etiquetas nos menus de fluxo
// ----------------------------------------------------------
{
id: 18,
descricao: 'Adicionar MNE_ETIQUETA_IDS em CHATC2_MENUS_EMPRESA (etiquetas por etapa do fluxo)',
sql: [
`ALTER TABLE CHATC2_MENUS_EMPRESA ADD MNE_ETIQUETA_IDS VARCHAR(200)`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 19: Transcrição de áudio em CHATC2_MENSAGENS_ATENDIMENTOS
// ----------------------------------------------------------
{
id: 19,
descricao: 'Adicionar MAT_TRANSCRICAO em CHATC2_MENSAGENS_ATENDIMENTOS (transcricao de audio)',
sql: [
`ALTER TABLE CHATC2_MENSAGENS_ATENDIMENTOS ADD MAT_TRANSCRICAO BLOB SUB_TYPE TEXT`,
],
},
// ============================================================
// >>> ADICIONE NOVAS MIGRAÇÕES AQUI <<<
// ============================================================
//
// Exemplo:
// {
// id: 19,
// descricao: 'Descrição clara do que esta migração faz',
// sql: [
// `ALTER TABLE EXEMPLO ADD NOVO_CAMPO VARCHAR(100)`,
// `CREATE TABLE NOVA_TABELA ( ... )`,
// ],
// },
//
];
// ============================================================
// EXECUTOR DE MIGRAÇÕES
// ============================================================
async function main() {
console.log(`\n=== MIGRAÇÕES DE BANCO DE DADOS ===`);
console.log(`Alias: ${alias}\n`);
// Blindagem: a DDL aqui é Firebird. Se o alias for Postgres (banco externo,
// gerenciado fora), não há o que aplicar — sai sem erro.
let driver;
try { driver = db.driverOf(alias); }
catch (e) { console.error('❌', e.message); process.exit(1); }
if (driver !== 'firebird') {
console.log(`⚠️ O alias "${alias}" usa o driver "${driver}". Este script aplica DDL Firebird.`);
console.log(' O schema do PostgreSQL é gerenciado no banco externo — nada a fazer aqui.');
console.log(' Para migrar um banco Firebird: node scripts/migracoes.js firebird_local\n');
process.exit(0);
}
await garantirControle();
var executadas = 0;
var pendentes = 0;
for (var mig of MIGRACOES) {
var jaExecutou = await migracaoJaExecutada(mig.id);
if (jaExecutou) {
console.log(`⏭️ [${mig.id}] ${mig.descricao} — já executada`);
continue;
}
pendentes++;
console.log(`▶️ [${mig.id}] ${mig.descricao}...`);
var erros = 0;
for (var cmd of mig.sql) {
try {
await db.execute(alias, cmd);
console.log(`${cmd.substring(0, 80)}...`);
} catch(e) {
var msg = e.message;
// Firebird pode reportar "already exists" de várias formas
if (msg.includes('already exists') || msg.includes('Violation') || msg.includes('duplicate') || msg.includes('Unknow')) {
console.log(` ️ Já existe: ${cmd.substring(0, 60)}...`);
} else {
console.log(`${msg.substring(0, 120)}`);
erros++;
}
}
}
if (erros === 0) {
await registrarMigracao(mig.id, mig.descricao);
executadas++;
console.log(` ✅ Migração ${mig.id} concluída\n`);
} else {
console.log(` ⚠️ Migração ${mig.id} concluída com ${erros} erro(s)\n`);
}
}
console.log(`=== RESUMO ===`);
console.log(`Total de migrações: ${MIGRACOES.length}`);
console.log(`Executadas agora: ${executadas}`);
console.log(`Já executadas antes: ${MIGRACOES.length - pendentes}`);
console.log(`Pendentes (com erro): ${pendentes - executadas}`);
console.log(`\n✅ Finalizado!`);
process.exit(0);
}
main().catch(function(err) {
console.error('❌ Erro fatal:', err.message);
process.exit(1);
});
+109
View File
@@ -0,0 +1,109 @@
const express = require('express');
const cors = require('cors');
const path = require('path');
const routes = require('./routes');
const authenticateToken = require('./middlewares/auth');
const app = express();
// Middlewares
// CORS: restringível via CORS_ORIGINS (lista separada por vírgula no .env).
// Requisições same-origin e server-to-server (sem header Origin) sempre passam.
const corsOrigins = (process.env.CORS_ORIGINS || [process.env.LOCAL_URL, process.env.EXTERNAL_URL].filter(Boolean).join(','))
.split(',').map(s => s.trim()).filter(Boolean);
if (corsOrigins.length) {
app.use(cors({
origin: function (origin, cb) {
if (!origin || corsOrigins.indexOf(origin) !== -1) return cb(null, true);
return cb(null, false);
},
}));
console.log('🔒 CORS restrito a:', corsOrigins.join(', '));
} else {
app.use(cors());
console.log('⚠️ CORS aberto — defina CORS_ORIGINS no .env para restringir.');
}
// Limite de corpo por rota (anti-DoS):
// - rotas de mídia (envio de mensagem, webhook, evolution) → MAX_FILE_SIZE (grande)
// - demais rotas → JSON_LIMIT (pequeno), reduz superfície de ataque
const maxFileSize = process.env.MAX_FILE_SIZE || '50mb';
const jsonPadrao = process.env.JSON_LIMIT || '2mb';
console.log('📦 Limite de upload (mídia):', maxFileSize, '| padrão:', jsonPadrao);
const bigJson = express.json({ limit: maxFileSize });
const smallJson = express.json({ limit: jsonPadrao });
const bigUrl = express.urlencoded({ extended: true, limit: maxFileSize });
const smallUrl = express.urlencoded({ extended: true, limit: jsonPadrao });
function rotaComMidia(req) {
const p = req.path || '';
return p.endsWith('/messages') || p.indexOf('/webhook/') !== -1 || p.indexOf('/evolution/') !== -1;
}
app.use((req, res, next) => (rotaComMidia(req) ? bigJson : smallJson)(req, res, next));
app.use((req, res, next) => (rotaComMidia(req) ? bigUrl : smallUrl)(req, res, next));
// Static files
app.use(express.static(path.join(__dirname, 'public')));
// Log de requisições
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
console.log(`${req.method} ${req.originalUrl} - ${res.statusCode} (${duration}ms)`);
});
next();
});
// Rotas (a autenticação é aplicada internamente em cada grupo de rotas)
app.use(routes);
// Rota raiz
app.get('/', (req, res) => {
res.json({
name: 'API Firebird - Chatc2',
version: '2.0.0',
description: 'API multi-banco com sistema de aliases',
auth: {
loginPage: 'GET /app/:alias/login',
login: 'POST /app/:alias/login',
myData: 'GET /app/:alias/me',
dashboard: 'GET /app/:alias/dashboard',
},
api: {
aliases: 'GET /api/aliases',
health: 'GET /api/:alias/health',
tables: 'GET /api/:alias/tables',
tableInfo: 'GET /api/:alias/tables/:tableName',
query: 'POST /api/:alias/query',
execute: 'POST /api/:alias/execute',
},
authentication: {
jwt: 'Authorization: Bearer <jwt_token>',
usu_token: 'X-Usu-Token: <usu_token>',
},
routes: 'GET /app/:alias/routes',
obs: 'Substitua :alias pelo nome do banco (ex: lajedo, alfenas, ...)',
});
});
// Middleware de erro global — loga o detalhe internamente e responde genérico
// (evita vazar mensagens de erro do banco/SQL para o cliente)
app.use((err, req, res, next) => {
console.error('Erro não tratado:', err);
res.status(err.status || 500).json({
success: false,
error: 'Erro interno do servidor.',
});
});
// Middleware para rotas não encontradas
app.use((req, res) => {
res.status(404).json({
success: false,
error: `Rota ${req.method} ${req.originalUrl} não encontrada`,
});
});
module.exports = app;
+19
View File
@@ -0,0 +1,19 @@
/**
* Configuração de autenticação JWT
*/
const secret = process.env.JWT_SECRET;
// Segurança: sem segredo padrão. Se a variável não estiver definida, o boot
// falha de forma explícita — evita assinar tokens com um segredo previsível.
if (!secret) {
throw new Error(
'JWT_SECRET não definido. Configure JWT_SECRET no .env antes de iniciar o servidor ' +
'(não há segredo padrão por motivos de segurança).'
);
}
module.exports = {
secret: secret,
expiresIn: process.env.JWT_EXPIRES_IN || '8h',
issuer: 'chatc2-api',
};
+185
View File
@@ -0,0 +1,185 @@
const jwt = require('jsonwebtoken');
const path = require('path');
const db = require('../database');
const authConfig = require('../config/auth');
// ── Rate limit de login (em memória) — proteção anti força bruta ──
const LOGIN_MAX = 10; // tentativas falhas permitidas
const LOGIN_JANELA_MS = 15 * 60 * 1000; // por janela de 15 minutos
const loginTentativas = new Map();
function chaveLogin(req, login) { return (req.ip || '') + '|' + (login || ''); }
function loginBloqueado(req, login) {
const e = loginTentativas.get(chaveLogin(req, login));
if (!e) return false;
if (Date.now() - e.inicio > LOGIN_JANELA_MS) { loginTentativas.delete(chaveLogin(req, login)); return false; }
return e.count >= LOGIN_MAX;
}
function registrarFalhaLogin(req, login) {
const k = chaveLogin(req, login);
const agora = Date.now();
const e = loginTentativas.get(k);
if (!e || agora - e.inicio > LOGIN_JANELA_MS) loginTentativas.set(k, { count: 1, inicio: agora });
else e.count++;
}
function limparTentativasLogin(req, login) { loginTentativas.delete(chaveLogin(req, login)); }
class AuthController {
/**
* Exibe a página de login
* Endpoint: GET /app/:alias/login
*/
static async loginPage(req, res) {
const loginPath = path.resolve(__dirname, '../public/login.html');
res.sendFile(loginPath);
}
/**
* Login do usuário no aplicativo
* Endpoint: POST /app/:alias/login
* Body: { USU_LOGIN, USU_SENHA }
*/
static async login(req, res) {
try {
const { alias } = req.params;
const { USU_LOGIN, USU_SENHA } = req.body;
// Validação dos campos obrigatórios
if (!USU_LOGIN || !USU_SENHA) {
return res.status(400).json({
success: false,
error: 'Os campos USU_LOGIN e USU_SENHA são obrigatórios.',
});
}
if (loginBloqueado(req, USU_LOGIN)) {
return res.status(429).json({
success: false,
error: 'Muitas tentativas de login. Aguarde alguns minutos e tente novamente.',
});
}
// Busca o usuário pelo login
const sql = `
SELECT
USU_CODIGO_ID,
USU_NIVEL_ID,
USU_NOME,
USU_LOGIN,
USU_EMAIL,
USU_STATUS,
USU_ACESSO_WEB,
USU_CAMINHO_FOTO,
USU_DT_ULTIMO_ACESSO,
USU_TIPO,
USU_TIPO_CHAT
FROM USUARIOS
WHERE USU_LOGIN = ?
AND (USU_SENHA = ? OR USU_SENHA_WEB = ?)
AND COALESCE(USU_ACESSO_WEB, 0) = 1
AND USU_STATUS = 'A'
`;
const result = await db.query(alias, sql, [USU_LOGIN, USU_SENHA, USU_SENHA]);
if (result.length === 0) {
registrarFalhaLogin(req, USU_LOGIN);
return res.status(401).json({
success: false,
error: 'Credenciais inválidas ou usuário sem permissão de acesso.',
});
}
// Login bem-sucedido: zera o contador de tentativas
limparTentativasLogin(req, USU_LOGIN);
const user = result[0];
// Busca as empresas que o usuário tem acesso
const empresas = await db.query(alias,
'SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA WHERE USE_USUARIO_ID = ?',
[user.USU_CODIGO_ID]
);
const empresasIds = empresas.map(e => e.USE_EMPRESA_ID);
// Atualiza a data do último acesso
await db.execute(
alias,
`UPDATE USUARIOS SET USU_DT_ULTIMO_ACESSO = CURRENT_DATE WHERE USU_CODIGO_ID = ?`,
[user.USU_CODIGO_ID]
);
// Dados do usuário para o token
const payload = {
id: user.USU_CODIGO_ID,
nivelId: user.USU_NIVEL_ID,
nome: user.USU_NOME.trim(),
login: user.USU_LOGIN.trim(),
email: user.USU_EMAIL ? user.USU_EMAIL.trim() : null,
tipo: user.USU_TIPO ? user.USU_TIPO.trim() : null,
tipoChat: user.USU_TIPO_CHAT ? user.USU_TIPO_CHAT.trim() : null,
alias,
empresas: empresasIds, // empresas que o usuário pode acessar
};
// Gera o token JWT
const token = jwt.sign(payload, authConfig.secret, {
expiresIn: authConfig.expiresIn,
issuer: authConfig.issuer,
});
// Retorna o token e dados do usuário
return res.json({
success: true,
message: 'Login realizado com sucesso.',
alias,
token,
expiresIn: authConfig.expiresIn,
user: {
...payload,
foto: user.USU_CAMINHO_FOTO ? user.USU_CAMINHO_FOTO.trim() : null,
ultimoAcesso: user.USU_DT_ULTIMO_ACESSO,
},
});
} catch (err) {
console.error('Erro no login:', err);
return res.status(500).json({
success: false,
error: `Erro interno ao realizar login: ${err.message}`,
});
}
}
/**
* Valida o token atual e retorna dados atualizados do usuário
* Endpoint: GET /app/:alias/me
*/
static async me(req, res) {
try {
return res.json({
success: true,
alias: req.params.alias,
user: {
...req.user,
authType: req.authType || 'jwt',
},
});
} catch (err) {
console.error('Erro ao buscar usuário:', err);
return res.status(500).json({
success: false,
error: `Erro ao buscar dados do usuário: ${err.message}`,
});
}
}
/**
* Dashboard do app
* Endpoint: GET /app/:alias/dashboard
*/
static async dashboard(req, res) {
const dashboardPath = path.resolve(__dirname, '../public/dashboard.html');
res.sendFile(dashboardPath);
}
}
module.exports = AuthController;
File diff suppressed because it is too large Load Diff
+987
View File
@@ -0,0 +1,987 @@
const db = require('../database');
class ClientController {
/**
* Lista as empresas que o usuário logado tem acesso
* Endpoint: GET /api/:alias/empresas
*/
static async listUserEmpresas(req, res) {
try {
const { alias } = req.params;
const empresasIds = req.user?.empresas || [];
if (empresasIds.length === 0) {
return res.json({ success: true, alias, data: [] });
}
const placeholders = empresasIds.map(() => '?').join(',');
const result = await db.query(alias,
`SELECT EMP_CODIGO_ID, EMP_NOME, EMP_NOME_FANTASIA
FROM EMPRESAS
WHERE EMP_CODIGO_ID IN (${placeholders})
ORDER BY EMP_NOME`,
empresasIds
);
const empresas = result.map(row => ({
id: row.EMP_CODIGO_ID,
nome: (row.EMP_NOME || '').trim(),
nomeFantasia: (row.EMP_NOME_FANTASIA || '').trim(),
}));
return res.json({ success: true, alias, data: empresas });
} catch (err) {
console.error('Erro ao listar empresas:', err);
return res.status(500).json({
success: false,
error: `Erro ao listar empresas: ${err.message}`,
});
}
}
/**
* Retorna os dados completos de um cliente para o app
* Endpoint: GET /app/:alias/company/:id_empresa/client/:id_cliente
*/
static async clientDetails(req, res) {
try {
const { alias, id_empresa, id_cliente } = req.params;
if (!id_empresa || !id_cliente) {
return res.status(400).json({
success: false,
error: 'Os parâmetros id_empresa e id_cliente são obrigatórios.',
});
}
const sql = `
SELECT
c.CLI_NOME,
c.CLI_MATRICULA,
c.CLI_SITUACAO,
c.CLI_EMAIL,
c.CLI_CELULAR,
c.CLI_ENDERECO_FAT,
c.CLI_NUMERO,
c.CLI_CIDADES_FAT_ID,
c.CLI_BAIRROS_FAT_ID,
c.CLI_COBRADOR_ID,
c.CLI_DIA_COBRANCA,
c.CLI_EMPRESA_ID,
c.CLI_COMPLEMENTO_FAT,
c.CLI_CEP_FAT,
c.CLI_FONE1,
c.CLI_ENDERECO,
c.CLI_NUMERO_FAT,
c.CLI_BAIRRO_FAT,
c.CLI_CIDADES_ID
FROM CLIENTES c
WHERE c.CLI_CODIGO_ID = ?
AND c.CLI_EMPRESA_ID = ?
`;
const result = await db.query(alias, sql, [id_cliente, id_empresa]);
if (result.length === 0) {
return res.status(404).json({
success: false,
error: 'Cliente não encontrado para esta empresa.',
});
}
const client = result[0];
// Busca nome da cidade de faturamento
let cidadeNome = null;
if (client.CLI_CIDADES_FAT_ID) {
const cidade = await db.query(alias,
'SELECT CID_NOME FROM CIDADES WHERE CID_CODIGO_ID = ?',
[client.CLI_CIDADES_FAT_ID]
);
if (cidade.length > 0) {
cidadeNome = cidade[0].CID_NOME.trim();
}
}
// Busca nome do bairro de faturamento
let bairroNome = null;
if (client.CLI_BAIRROS_FAT_ID) {
const bairro = await db.query(alias,
'SELECT BAR_DESCRICAO FROM BAIRROS WHERE BAR_CODIGO_ID = ?',
[client.CLI_BAIRROS_FAT_ID]
);
if (bairro.length > 0) {
bairroNome = bairro[0].BAR_DESCRICAO.trim();
}
}
// Busca nome do cobrador
let cobradorNome = null;
if (client.CLI_COBRADOR_ID) {
const cobrador = await db.query(alias,
'SELECT COB_NOME FROM COBRADORES WHERE COB_CODIGO_ID = ?',
[client.CLI_COBRADOR_ID]
);
if (cobrador.length > 0) {
cobradorNome = cobrador[0].COB_NOME.trim();
}
}
// Formata o celular com máscara (XX) XXXXX-XXXX
const celularFormatado = ClientController.formatCelular(client.CLI_CELULAR);
// Interpreta a situação
const situacao = client.CLI_SITUACAO?.trim() === 'A' ? 'Ativo' : 'Inativo';
// Monta o endereço completo de faturamento
const enderecoFaturamento = [
client.CLI_ENDERECO_FAT?.trim(),
client.CLI_NUMERO_FAT?.trim(),
client.CLI_COMPLEMENTO_FAT?.trim(),
].filter(Boolean).join(', ');
return res.json({
success: true,
alias,
empresaId: client.CLI_EMPRESA_ID,
clienteId: id_cliente,
data: {
nome: client.CLI_NOME?.trim(),
matricula: client.CLI_MATRICULA?.trim(),
situacao: {
codigo: client.CLI_SITUACAO?.trim(),
descricao: situacao,
},
email: client.CLI_EMAIL?.trim(),
celular: {
original: client.CLI_CELULAR?.trim(),
formatado: celularFormatado,
},
telefone: client.CLI_FONE1?.trim(),
enderecoFaturamento: {
completo: enderecoFaturamento || null,
logradouro: client.CLI_ENDERECO_FAT?.trim(),
numero: client.CLI_NUMERO_FAT?.trim(),
complemento: client.CLI_COMPLEMENTO_FAT?.trim(),
cep: client.CLI_CEP_FAT?.trim(),
},
cidade: {
id: client.CLI_CIDADES_FAT_ID,
nome: cidadeNome,
},
bairro: {
id: client.CLI_BAIRROS_FAT_ID,
nome: bairroNome,
},
cobrador: {
id: client.CLI_COBRADOR_ID,
nome: cobradorNome,
},
diaCobranca: client.CLI_DIA_COBRANCA,
},
});
} catch (err) {
console.error('Erro ao buscar dados do cliente:', err);
return res.status(500).json({
success: false,
error: `Erro ao buscar dados do cliente: ${err.message}`,
});
}
}
/**
* Retorna os títulos (carnes) de um cliente
* Endpoint: GET /api/:alias/clients/:id_cliente/carnes?tipo[]=abertos&tipo[]=vencidos&page=1&limit=20
*
* Tipos de filtro:
* abertos → CAR_SITUACAO = 0
* baixados → CAR_SITUACAO = 1
* parcial → CAR_SITUACAO = 2
* vencidos → CAR_SITUACAO = 0 AND CAR_DT_VENCIMENTO < CURRENT_DATE
*/
static async clientCarnes(req, res) {
try {
const { alias, id_cliente } = req.params;
const page = parseInt(req.query.page, 10) || 1;
const limit = parseInt(req.query.limit, 10) || 20;
const offset = (page - 1) * limit;
// Filtros: pode vir como query string repetida (tipo[]=abertos&tipo[]=baixados)
// ou como string única separada por vírgula
let tiposFiltro = [];
if (req.query.tipo) {
if (Array.isArray(req.query.tipo)) {
tiposFiltro = req.query.tipo;
} else {
tiposFiltro = req.query.tipo.split(',');
}
}
// Verifica empresa do cliente (para respeitar permissão)
const empresaCheck = await db.query(alias,
'SELECT CLI_EMPRESA_ID FROM CLIENTES WHERE CLI_CODIGO_ID = ?',
[id_cliente]
);
if (empresaCheck.length === 0) {
return res.status(404).json({ success: false, error: 'Cliente não encontrado.' });
}
const empresaId = empresaCheck[0].CLI_EMPRESA_ID;
const userEmpresas = req.user?.empresas || [];
if (!userEmpresas.includes(empresaId)) {
return res.status(403).json({
success: false,
error: 'Você não tem permissão para acessar este cliente.',
});
}
// Monta o WHERE com OR entre cada filtro
const params = [id_cliente];
let where = 'WHERE c.CAR_CLIENTE_ID = ?';
if (tiposFiltro.length > 0) {
const conditions = [];
if (tiposFiltro.includes('abertos')) {
conditions.push('c.CAR_SITUACAO = 0');
}
if (tiposFiltro.includes('baixados')) {
conditions.push('c.CAR_SITUACAO = 1');
}
if (tiposFiltro.includes('parcial')) {
conditions.push('c.CAR_SITUACAO = 2');
}
if (tiposFiltro.includes('vencidos')) {
conditions.push('(c.CAR_SITUACAO = 0 AND c.CAR_DT_VENCIMENTO < CURRENT_DATE)');
}
if (conditions.length > 0) {
where += ' AND (' + conditions.join(' OR ') + ')';
}
}
// Contagens por situacao
const countAbertos = await db.query(alias,'SELECT COUNT(*) AS CT FROM CARNES WHERE CAR_CLIENTE_ID = ? AND CAR_SITUACAO = 0 AND (CAR_DT_VENCIMENTO >= CURRENT_DATE OR CAR_DT_VENCIMENTO IS NULL)',[id_cliente]);
const countBaixados = await db.query(alias,'SELECT COUNT(*) AS CT FROM CARNES WHERE CAR_CLIENTE_ID = ? AND CAR_SITUACAO = 1',[id_cliente]);
const countParcial = await db.query(alias,'SELECT COUNT(*) AS CT FROM CARNES WHERE CAR_CLIENTE_ID = ? AND CAR_SITUACAO = 2',[id_cliente]);
const countVencidos = await db.query(alias,'SELECT COUNT(*) AS CT FROM CARNES WHERE CAR_CLIENTE_ID = ? AND CAR_SITUACAO = 0 AND CAR_DT_VENCIMENTO < CURRENT_DATE',[id_cliente]);
// Total
const countSql = `SELECT COUNT(*) AS TOTAL FROM CARNES c ${where}`;
const countResult = await db.query(alias, countSql, params);
const total = countResult[0].TOTAL;
// Busca paginada
const dataSql = `
SELECT
c.CAR_CODIGO_ID,
c.CAR_SITUACAO,
c.CAR_DT_VENCIMENTO,
c.CAR_VALOR_PARCELA,
c.CAR_DT_PAGAMENTO,
c.CAR_NOSSO_NUMERO,
c.CAR_NUMERO_PARCELA,
c.CAR_NUMERO_TOTAL_PARCELAS,
c.CAR_VALOR_BAIXADO,
c.CAR_DT_CANCELAMENTO,
c.CAR_DT_AGENDAMENTO_COBRANCA
FROM CARNES c
${where}
ORDER BY c.CAR_DT_VENCIMENTO
OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY
`;
const result = await db.query(alias, dataSql, params);
const carnes = result.map(row => {
const situacao = row.CAR_SITUACAO;
const situacaoLabel =
situacao === 0 ? 'Aberto' :
situacao === 1 ? 'Baixado' :
situacao === 2 ? 'Parcial' :
situacao === 3 ? 'Cancelado' : 'Desconhecido';
const situacaoColor =
situacao === 0 ? '#dbeafe' :
situacao === 1 ? '#d1fae5' :
situacao === 2 ? '#fef3c7' :
situacao === 3 ? '#f3f4f6' : '#f3f4f6';
const situacaoTextColor =
situacao === 0 ? '#1e40af' :
situacao === 1 ? '#065f46' :
situacao === 2 ? '#92400e' :
situacao === 3 ? '#6b7280' : '#6b7280';
// Verifica se está vencido (aberto e vencido)
const vencido = situacao === 0 && row.CAR_DT_VENCIMENTO &&
new Date(row.CAR_DT_VENCIMENTO) < new Date(new Date().toDateString());
return {
id: row.CAR_CODIGO_ID,
situacao: {
codigo: situacao,
descricao: situacaoLabel,
color: situacaoColor,
textColor: situacaoTextColor,
},
vencimento: row.CAR_DT_VENCIMENTO,
valorParcela: row.CAR_VALOR_PARCELA,
dataPagamento: row.CAR_DT_PAGAMENTO,
nossoNumero: (row.CAR_NOSSO_NUMERO || '').trim(),
parcela: row.CAR_NUMERO_PARCELA,
totalParcelas: row.CAR_NUMERO_TOTAL_PARCELAS,
valorBaixado: row.CAR_VALOR_BAIXADO,
agendamentoCobranca: row.CAR_DT_AGENDAMENTO_COBRANCA,
vencido,
};
});
const totalPages = Math.ceil(total / limit);
return res.json({
success: true,
alias,
clienteId: id_cliente,
data: carnes,
page,
limit,
total,
totalPages,
contagens: {
abertos: countAbertos[0]?.CT || 0,
baixados: countBaixados[0]?.CT || 0,
parcial: countParcial[0]?.CT || 0,
vencidos: countVencidos[0]?.CT || 0,
},
});
} catch (err) {
console.error('Erro ao buscar títulos:', err);
return res.status(500).json({
success: false,
error: `Erro ao buscar títulos: ${err.message}`,
});
}
}
/**
* Formata número de celular no padrão (XX) XXXXX-XXXX
* @param {string} celular
* @returns {string|null}
*/
/**
* Busca clientes com paginação e filtro por nome, matrícula ou CPF.
* Endpoint: GET /api/:alias/clients/search?q=&page=&limit=
*/
static async searchClients(req, res) {
try {
const { alias } = req.params;
const q = (req.query.q || '').trim();
const page = parseInt(req.query.page, 10) || 1;
const limit = parseInt(req.query.limit, 10) || 20;
const offset = (page - 1) * limit;
const filterEmpresa = parseInt(req.query.empresaId, 10) || null;
// Empresas que o usuário pode acessar (vem do token JWT ou USU_TOKEN)
const userEmpresas = req.user?.empresas || [];
if (userEmpresas.length === 0) {
return res.json({
success: true,
alias,
data: [],
page: 1,
limit,
total: 0,
totalPages: 0,
empresasPermitidas: [],
});
}
// Determina qual empresa filtra:
// - Se o usuário informou ?empresaId=, usa esse (desde que ele tenha permissão)
// - Se não, mostra de TODAS as empresas que ele tem acesso
let empresasFiltro;
if (filterEmpresa) {
if (!userEmpresas.includes(filterEmpresa)) {
return res.status(403).json({
success: false,
error: `Você não tem permissão para acessar a empresa ${filterEmpresa}.`,
});
}
empresasFiltro = [filterEmpresa];
} else {
empresasFiltro = userEmpresas;
}
// Monta a lista de placeholders para o IN
const placeholders = empresasFiltro.map(() => '?').join(',');
// Monta o filtro WHERE
let where = '';
const params = [];
// Filtro por empresa
where = `WHERE c.CLI_EMPRESA_ID IN (${placeholders})`;
params.push(...empresasFiltro);
// Filtro por busca (nome, matrícula, CPF)
if (q) {
where += ' AND (c.CLI_NOME CONTAINING ? OR c.CLI_MATRICULA CONTAINING ? OR c.CLI_CPF CONTAINING ?)';
params.push(q, q, q);
}
// Total de registros
const countSql = `SELECT COUNT(*) AS TOTAL FROM CLIENTES c ${where}`;
const countResult = await db.query(alias, countSql, params);
const total = countResult[0].TOTAL;
// Busca paginada
const dataSql = `
SELECT
c.CLI_CODIGO_ID,
c.CLI_EMPRESA_ID,
c.CLI_NOME,
c.CLI_MATRICULA,
c.CLI_CPF,
c.CLI_SITUACAO,
c.CLI_CELULAR,
c.CLI_FONE1,
c.CLI_EMAIL
FROM CLIENTES c
${where}
ORDER BY c.CLI_NOME
OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY
`;
const result = await db.query(alias, dataSql, params);
const clients = result.map(row => ({
id: row.CLI_CODIGO_ID,
empresaId: row.CLI_EMPRESA_ID,
nome: (row.CLI_NOME || '').trim(),
matricula: (row.CLI_MATRICULA || '').trim(),
cpf: (row.CLI_CPF || '').trim(),
situacao: (row.CLI_SITUACAO || '').trim(),
celular: ClientController.formatCelular(row.CLI_CELULAR),
telefone: (row.CLI_FONE1 || '').trim(),
email: (row.CLI_EMAIL || '').trim(),
}));
const totalPages = Math.ceil(total / limit);
return res.json({
success: true,
alias,
data: clients,
page,
limit,
total,
totalPages,
empresasPermitidas: empresasFiltro,
});
} catch (err) {
console.error('Erro ao buscar clientes:', err);
return res.status(500).json({
success: false,
error: `Erro ao buscar clientes: ${err.message}`,
});
}
}
/**
* Listar dependentes de um cliente
* GET /api/:alias/clients/:id/dependents
*/
static async listDependents(req, res) {
try {
const { alias, id } = req.params;
const result = await db.query(alias,
`SELECT DEPC_CODIGO_ID, DEPC_CLIENTE_ID, DEPC_NOME, DEPC_SITUACAO, DEPC_PARENTESCO,
DEPC_TELEFONE, DEPC_ADICIONAL, DEPC_VALOR_CONTRIBUICAO, DEPC_DT_FALECIMENTO,
DEPC_DT_NASCIMENTO, DEPC_CPF, DEPC_EMAIL
FROM DEPENDENTES_CLI
WHERE DEPC_CLIENTE_ID = ? AND DEPC_SITUACAO = 'A'
ORDER BY DEPC_NOME`,
[id]
);
const dependents = result.map(d => ({
id: d.DEPC_CODIGO_ID,
clienteId: d.DEPC_CLIENTE_ID,
nome: (d.DEPC_NOME || '').trim(),
situacao: (d.DEPC_SITUACAO || '').trim(),
parentesco: (d.DEPC_PARENTESCO || '').trim(),
telefone: (d.DEPC_TELEFONE || '').trim(),
adicional: (d.DEPC_ADICIONAL || 'N').trim(),
valorContribuicao: d.DEPC_VALOR_CONTRIBUICAO,
dtFalecimento: d.DEPC_DT_FALECIMENTO,
dtNascimento: d.DEPC_DT_NASCIMENTO,
cpf: (d.DEPC_CPF || '').trim(),
email: (d.DEPC_EMAIL || '').trim(),
}));
res.json({ success: true, data: dependents, count: dependents.length });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Atualizar telefone do dependente
* PUT /api/:alias/dependents/:id/phone
*/
static async updateDependentPhone(req, res) {
try {
const { alias, id } = req.params;
const { telefone } = req.body;
await db.execute(alias,
'UPDATE DEPENDENTES_CLI SET DEPC_TELEFONE = ? WHERE DEPC_CODIGO_ID = ?',
[telefone, id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Buscar dependentes para associar (por nome, telefone ou matricula do titular)
* GET /api/:alias/dependents/search?q=&empresaId=
*/
static async searchDependents(req, res) {
try {
const { alias } = req.params;
const q = (req.query.q || '').trim();
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
if (!q) return res.json({ success: true, data: [] });
// Trunca para evitar erro de string truncation no VARCHAR(15) do telefone
const qTel = q.length > 15 ? q.substring(0, 15) : q;
const result = await db.query(alias,
`SELECT d.DEPC_CODIGO_ID, d.DEPC_NOME, d.DEPC_TELEFONE, d.DEPC_PARENTESCO,
d.DEPC_CLIENTE_ID, c.CLI_NOME AS TITULAR_NOME, c.CLI_MATRICULA AS TITULAR_MATRICULA
FROM DEPENDENTES_CLI d
LEFT JOIN CLIENTES c ON d.DEPC_CLIENTE_ID = c.CLI_CODIGO_ID
WHERE d.DEPC_EMPRESA_ID = ? AND d.DEPC_SITUACAO = 'A'
AND (d.DEPC_NOME CONTAINING ? OR d.DEPC_TELEFONE LIKE '%' || ? || '%' OR c.CLI_MATRICULA CONTAINING ? OR c.CLI_NOME CONTAINING ?)
ORDER BY d.DEPC_NOME`,
[empresaId, q, qTel, q, q]
);
const data = result.map(d => ({
id: d.DEPC_CODIGO_ID,
nome: (d.DEPC_NOME || '').trim(),
telefone: (d.DEPC_TELEFONE || '').trim(),
parentesco: (d.DEPC_PARENTESCO || '').trim(),
clienteId: d.DEPC_CLIENTE_ID,
titularNome: (d.TITULAR_NOME || '').trim(),
titularMatricula: (d.TITULAR_MATRICULA || '').trim(),
}));
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Atualizar dados do cliente (telefone e email)
* PUT /api/:alias/clients/:id
*/
static async updateClient(req, res) {
try {
const { alias, id } = req.params;
const { telefone, email, celular } = req.body;
// Verifica se pode acessar este cliente
const cli = await db.query(alias, 'SELECT CLI_EMPRESA_ID FROM CLIENTES WHERE CLI_CODIGO_ID = ?', [id]);
if (cli.length === 0) return res.status(404).json({ success: false, error: 'Cliente não encontrado.' });
const empresaId = cli[0].CLI_EMPRESA_ID;
const userEmpresas = req.user?.empresas || [];
if (!userEmpresas.includes(empresaId))
return res.status(403).json({ success: false, error: 'Sem permissão.' });
const updates = [];
const params = [];
if (telefone !== undefined) {
updates.push('CLI_FONE1 = ?');
params.push(String(telefone).substring(0, 15));
}
if (email !== undefined) {
updates.push('CLI_EMAIL = ?');
params.push(String(email).substring(0, 100));
}
if (celular !== undefined) {
updates.push('CLI_CELULAR = ?');
params.push(String(celular).substring(0, 15));
}
if (updates.length === 0)
return res.status(400).json({ success: false, error: 'Nenhum campo para atualizar.' });
params.push(id);
await db.execute(alias,
'UPDATE CLIENTES SET ' + updates.join(', ') + ' WHERE CLI_CODIGO_ID = ?', params);
res.json({ success: true, message: 'Cliente atualizado.' });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
}
static formatCelular(celular) {
if (!celular) return null;
// Remove tudo que não é dígito
const digits = celular.replace(/\D/g, '');
if (digits.length === 11) {
// (XX) XXXXX-XXXX
return `(${digits.slice(0, 2)}) ${digits.slice(2, 7)}-${digits.slice(7)}`;
} else if (digits.length === 10) {
// (XX) XXXX-XXXX
return `(${digits.slice(0, 2)}) ${digits.slice(2, 6)}-${digits.slice(6)}`;
} else if (digits.length > 0) {
// Retorna como está se não conseguir formatar
return celular.trim();
}
return null;
}
/**
* Retorna dados do cliente + carnes no formato para geracao de boleto
* GET /api/:alias/clients/:id_cliente/listcarne
*/
static async clientListCarne(req, res) {
try {
const { alias, id_cliente } = req.params;
const clientes = await db.query(alias,
'SELECT CLI_CODIGO_ID, CLI_NOME, CLI_NOME_FANTASIA, CLI_CPF, CLI_ENDERECO, CLI_BAIRRO, CLI_CEP, CLI_CIDADES_ID, CLI_EMPRESA_ID FROM CLIENTES WHERE CLI_CODIGO_ID = ?',
[id_cliente]
);
if (clientes.length === 0) {
return res.status(404).json({ success: false, error: 'Cliente nao encontrado.' });
}
const c = clientes[0];
// Busca dados da empresa
var empresaData = null;
if (c.CLI_EMPRESA_ID) {
var emp = await db.query(alias,
'SELECT EMP_NOME, EMP_CNPJ, EMP_FOTO FROM EMPRESAS WHERE EMP_CODIGO_ID = ?',
[c.CLI_EMPRESA_ID]
);
if (emp.length > 0) {
var fotoBase64 = null;
if (emp[0].EMP_FOTO) {
try {
var buf = emp[0].EMP_FOTO;
if (Buffer.isBuffer(buf)) fotoBase64 = buf.toString('base64');
else if (typeof buf === 'string') fotoBase64 = buf;
} catch(e) { /* ignora erro de foto */ }
}
empresaData = {
EMP_NOME: (emp[0].EMP_NOME || '').trim(),
EMP_CNPJ: (emp[0].EMP_CNPJ || '').trim(),
EMP_FOTO: fotoBase64,
};
}
}
let cidadeNome = '';
let cidadeUf = '';
if (c.CLI_CIDADES_ID) {
const cidade = await db.query(alias,
'SELECT CID_NOME, CID_UF FROM CIDADES WHERE CID_CODIGO_ID = ?',
[c.CLI_CIDADES_ID]
);
if (cidade.length > 0) {
cidadeNome = (cidade[0].CID_NOME || '').trim();
cidadeUf = (cidade[0].CID_UF || '').trim();
}
}
const carnes = await db.query(alias,
'SELECT CAR_CODIGO_ID, CAR_CODIGO_BARRAS, CAR_DT_VENCIMENTO, CAR_AGEN_COD_CEDENTE, CAR_NUM_BANCARIO, CAR_DT_CADASTRO, CAR_DT_PROCESSAMENTO, CAR_NUMERO_DOCUMENTO, CAR_VALOR_PARCELA, CAR_LINHA_DIGITAVEL, CAR_NOSSO_NUMERO, CAR_PIX_QRCODE FROM CARNES WHERE CAR_CLIENTE_ID = ? AND CAR_SITUACAO = 0 ORDER BY CAR_DT_VENCIMENTO',
[id_cliente]
);
function formatCpf(cpf) {
if (!cpf) return '';
var d = cpf.replace(/\D/g, '');
if (d.length === 11) return d.slice(0,3) + '.' + d.slice(3,6) + '.' + d.slice(6,9) + '-' + d.slice(9);
return cpf.trim();
}
function 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);
}
const resultado = {
empresa: empresaData,
Cliente: {
CLI_NOME_FANTASIA: (c.CLI_NOME_FANTASIA || c.CLI_NOME || '').trim(),
CLI_CPF_CNPJ: formatCpf(c.CLI_CPF),
CLI_ENDERECO: (c.CLI_ENDERECO || '').trim(),
CLI_BAIRRO: (c.CLI_BAIRRO || '').trim(),
CLI_CEP: (c.CLI_CEP || '').trim(),
CLI_CIDADE: cidadeNome,
CLI_UF: cidadeUf,
},
Carnes: carnes.map(function(carne) {
return {
CAR_CODIGO_BARRAS: (carne.CAR_CODIGO_BARRAS || '').trim(),
CAR_DT_VENCIMENTO: fmtDate(carne.CAR_DT_VENCIMENTO),
CAR_AGEN_COD_CEDENTE: (carne.CAR_AGEN_COD_CEDENTE || '').trim(),
CAR_NUM_BANCARIO: (carne.CAR_NUM_BANCARIO || '').trim(),
CAR_DT_CADASTRO: fmtDate(carne.CAR_DT_CADASTRO),
CAR_DT_PROCESSAMENTO: fmtDate(carne.CAR_DT_PROCESSAMENTO),
CAR_NUMERO_DOCUMENTO: (carne.CAR_NUMERO_DOCUMENTO || '').trim(),
CAR_VALOR_PARCELA: carne.CAR_VALOR_PARCELA,
CAR_LINHA_DIGITAVEL: (carne.CAR_LINHA_DIGITAVEL || '').trim(),
CAR_NOSSO_NUMERO: (carne.CAR_NOSSO_NUMERO || '').trim(),
CAR_PIX_QRCODE: (carne.CAR_PIX_QRCODE || '').trim(),
};
}),
};
res.json(resultado);
} catch (err) {
console.error('[ListCarne] Erro:', err.message);
res.status(500).json({ success: false, error: err.message });
}
}
/**
* Retorna os convalescentes de um cliente
* GET /api/:alias/clients/:id/convalescentes
*/
static async clientConvalescentes(req, res) {
try {
const { alias, id } = req.params;
// Verifica se o modulo estoque esta ativo para a empresa do cliente
var cliCheck = await db.query(alias,
'SELECT CLI_EMPRESA_ID FROM CLIENTES WHERE CLI_CODIGO_ID = ?', [id]);
if (cliCheck.length > 0) {
var empMod = await db.query(alias,
"SELECT COALESCE(EMP_MODULO_ESTOQUE, 'N') AS MOD FROM EMPRESAS WHERE EMP_CODIGO_ID = ?",
[cliCheck[0].CLI_EMPRESA_ID]);
if (empMod.length > 0 && (empMod[0].MOD || 'N').trim() !== 'S') {
return res.json({ success: true, data: [], moduloInativo: true });
}
}
const covs = await db.query(alias,
`SELECT
c.COV_CODIGO_ID,
c.COV_SITUACAO,
c.COV_VALOR_TOTAL,
c.COV_DT_SAIDA,
c.COV_DT_PREVISAO_RETORNO,
c.COV_DT_CANCELOU,
c.COV_USUARIO_ID,
u.USU_NOME AS USUARIO_NOME
FROM COVALESCENTES c
LEFT JOIN USUARIOS u ON c.COV_USUARIO_ID = u.USU_CODIGO_ID
WHERE c.COV_CLIENTE_ID = ?
ORDER BY c.COV_DT_SAIDA DESC`,
[id]
);
function fmtSituacaoCarne(sit) {
if (sit === 0) return { label: 'Aberto', color: '#dbeafe', textColor: '#1e40af' };
if (sit === 1) return { label: 'Pago', color: '#d1fae5', textColor: '#065f46' };
if (sit === 2) return { label: 'Baixado Parcial', color: '#fef3c7', textColor: '#92400e' };
if (sit === 3) return { label: 'Cancelado', color: '#fef2f2', textColor: '#991b1b' };
return { label: 'Desconhecido', color: '#f3f4f6', textColor: '#6b7280' };
}
// Busca carnês vinculados a cada convalescente
var resultado = [];
for (var i = 0; i < covs.length; i++) {
var cov = covs[i];
var carnes = [];
try {
carnes = await db.query(alias,
`SELECT CAR_DT_VENCIMENTO, CAR_VALOR_PARCELA, CAR_SITUACAO
FROM CARNES_COVALESCENTES WHERE CAR_COVALESCENTES_ID = ?
ORDER BY CAR_DT_VENCIMENTO`,
[cov.COV_CODIGO_ID]
);
} catch(e) {
carnes = [];
}
var itens = [];
try {
itens = await db.query(alias,
`SELECT i.ITC_QUANTIDADE, p.PRD_DESCRICAO
FROM ITENS_COVALESCENTES i
LEFT JOIN PRODUTOS p ON i.ITC_PRODUTOS_ID = p.PRD_CODIGO_ID
WHERE i.ITC_COVALESCENTES_ID = ?`,
[cov.COV_CODIGO_ID]
);
} catch(e) {
itens = [];
}
resultado.push({
id: cov.COV_CODIGO_ID,
situacao: (cov.COV_SITUACAO || '').trim(),
valorTotal: cov.COV_VALOR_TOTAL,
dtSaida: cov.COV_DT_SAIDA,
dtPrevisaoRetorno: cov.COV_DT_PREVISAO_RETORNO,
dtCancelou: cov.COV_DT_CANCELOU,
usuarioNome: (cov.USUARIO_NOME || '').trim(),
carnes: carnes.map(function(c) {
return {
vencimento: c.CAR_DT_VENCIMENTO,
valorParcela: c.CAR_VALOR_PARCELA,
situacao: fmtSituacaoCarne(c.CAR_SITUACAO),
};
}),
itens: itens.map(function(item) {
return {
quantidade: item.ITC_QUANTIDADE,
produto: (item.PRD_DESCRICAO || '').trim(),
};
}),
});
}
res.json({ success: true, data: resultado });
} catch (err) {
console.error('[Convalescentes] Erro:', err.message);
res.status(500).json({ success: false, error: err.message });
}
}
/**
* Historico de conversas do cliente (inclui dependentes)
* GET /api/:alias/clients/:id/conversations
*/
static async clientConversations(req, res) {
try {
const { alias, id } = req.params;
const clienteId = parseInt(id);
// Busca dados do cliente: telefones e nome
const cliente = await db.query(alias,
'SELECT CLI_CODIGO_ID, CLI_NOME, CLI_CELULAR, CLI_FONE1 FROM CLIENTES WHERE CLI_CODIGO_ID = ?',
[clienteId]);
if (cliente.length === 0) {
return res.json({ success: true, data: [] });
}
var cli = cliente[0];
var nomeTitular = (cli.CLI_NOME || '').trim();
var celular = (cli.CLI_CELULAR || '').replace(/\D/g, '');
var telefone = (cli.CLI_FONE1 || '').replace(/\D/g, '');
var numerosTitular = [celular, telefone].filter(function(n) { return n.length >= 8; });
// Busca dependentes ativos com telefone
var dependentes = await db.query(alias,
"SELECT DEPC_NOME, DEPC_TELEFONE, DEPC_PARENTESCO FROM DEPENDENTES_CLI WHERE DEPC_CLIENTE_ID = ? AND DEPC_SITUACAO = 'A'",
[clienteId]);
// Mapeia dependentes com telefones limpos
var depsMap = {};
dependentes.forEach(function(d) {
var tel = (d.DEPC_TELEFONE || '').replace(/\D/g, '');
if (tel.length >= 8) {
if (!depsMap[tel]) depsMap[tel] = [];
depsMap[tel].push({ nome: (d.DEPC_NOME || '').trim(), parentesco: (d.DEPC_PARENTESCO || '').trim() });
}
});
var numerosDeps = Object.keys(depsMap);
// Busca conversas: pelo cliente_id OU pelo numero (titular ou dependente)
var allConversas = [];
// 1. Conversas vinculadas ao cliente_id
var convsCliente = await db.query(alias,
"SELECT * FROM CHATC2_CONVERSAS WHERE CON_CLIENTE_ID = ? AND CON_SITUACAO = 'A' ORDER BY CON_DT_ULTIMA_MSG DESC",
[clienteId]);
allConversas = allConversas.concat(convsCliente);
// 2. Conversas pelo numero (titular ou dependentes)
var numerosBuscar = numerosTitular.concat(numerosDeps);
var idsJaTem = convsCliente.map(function(c) { return c.CON_CODIGO_ID; });
for (var n = 0; n < numerosBuscar.length; n++) {
var num = numerosBuscar[n];
var ultimos8 = num.slice(-8);
try {
var convsNum = await db.query(alias,
"SELECT * FROM CHATC2_CONVERSAS WHERE (CON_NUMERO = ? OR CON_NUMERO LIKE '%' || ? || '%') AND CON_SITUACAO = 'A' ORDER BY CON_DT_ULTIMA_MSG DESC",
[num, ultimos8]);
convsNum.forEach(function(cn) {
if (idsJaTem.indexOf(cn.CON_CODIGO_ID) === -1) {
idsJaTem.push(cn.CON_CODIGO_ID);
allConversas.push(cn);
}
});
} catch(e) {}
}
// Ordena por data mais recente e remove duplicatas
allConversas.sort(function(a, b) {
var da = a.CON_DT_ULTIMA_MSG || a.CON_DT_INICIO || '';
var db2 = b.CON_DT_ULTIMA_MSG || b.CON_DT_INICIO || '';
return db2 > da ? 1 : db2 < da ? -1 : 0;
});
// Limita a 50
allConversas = allConversas.slice(0, 50);
// Mapeia resultado
var resultado = allConversas.map(function(c) {
var numConv = (c.CON_NUMERO || '').trim();
var isTitular = numerosTitular.some(function(tn) {
return numConv.indexOf(tn.slice(-8)) !== -1;
});
if (c.CON_CLIENTE_ID === clienteId) isTitular = true;
var quemFalou = isTitular ? nomeTitular : '';
var parentesco = '';
if (!isTitular && numConv) {
for (var dt in depsMap) {
if (numConv.indexOf(dt.slice(-8)) !== -1) {
quemFalou = depsMap[dt][0].nome;
parentesco = depsMap[dt][0].parentesco;
break;
}
}
}
return {
id: c.CON_CODIGO_ID,
empresaId: c.CON_EMPRESA_ID,
numero: numConv,
nomeContato: (c.CON_NOME_CONTATO || '').trim(),
status: (c.CON_STATUS || '').trim(),
dtUltimaMsg: c.CON_DT_ULTIMA_MSG || c.CON_DT_INICIO,
usuarioId: c.CON_USUARIO_ID,
usuarioNome: (c.CON_USUARIO_NOME || '').trim(),
equipeId: c.CON_EQUIPE_ID,
equipeNome: (c.CON_EQUIPE_NOME || '').trim(),
isTitular: isTitular,
quemFalou: quemFalou || nomeTitular,
parentesco: parentesco,
nomeTitular: nomeTitular,
};
});
res.json({ success: true, data: resultado });
} catch (err) {
console.error('[Conversations] Erro:', err.message);
res.status(500).json({ success: false, error: err.message });
}
}
}
module.exports = ClientController;
+246
View File
@@ -0,0 +1,246 @@
const db = require('../database');
const { isGerente } = require('../middlewares/roles');
class ConfigController {
// ==================== CHATC2_EQUIPES ====================
static async listTeams(req, res) {
try {
const { alias } = req.params;
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
if (!req.user?.empresas?.includes(empresaId)) return res.status(403).json({ success: false, error: 'Sem permissão.' });
const teams = await db.query(alias,
'SELECT * FROM CHATC2_EQUIPES WHERE EQU_EMPRESA_ID = ? AND EQU_SITUACAO = \'A\' ORDER BY EQU_ORDEM, EQU_NOME', [empresaId]
);
// Busca membros de cada equipe
for (const t of teams) {
const members = await db.query(alias, `
SELECT u.USU_CODIGO_ID, u.USU_NOME, u.USU_LOGIN
FROM USUARIOS u INNER JOIN CHATC2_USU_EQUIPES eu ON u.USU_CODIGO_ID = eu.EQU_USUARIO_ID
WHERE eu.EQU_EQUIPE_ID = ? AND u.USU_STATUS = 'A'
`, [t.EQU_CODIGO_ID]);
t.members = members.map(m => ({ id: m.USU_CODIGO_ID, nome: (m.USU_NOME || '').trim(), login: (m.USU_LOGIN || '').trim() }));
}
res.json({ success: true, data: teams.map(t => ({ id: t.EQU_CODIGO_ID, nome: (t.EQU_NOME || '').trim(), ordem: t.EQU_ORDEM || 0, membros: t.members })) });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
static async createTeam(req, res) {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem gerenciar equipes.' });
const { alias } = req.params;
const { nome, membros } = req.body;
const empresaId = req.user?.empresas?.[0] || req.body.empresaId;
if (!nome) return res.status(400).json({ success: false, error: 'Nome obrigatório.' });
const maxId = await db.query(alias, 'SELECT MAX(EQU_CODIGO_ID) AS ID FROM CHATC2_EQUIPES');
const newId = (maxId[0]?.ID || 0) + 1;
const ordem = req.body.ordem !== undefined ? req.body.ordem : 0;
await db.execute(alias, 'INSERT INTO CHATC2_EQUIPES (EQU_CODIGO_ID, EQU_EMPRESA_ID, EQU_NOME, EQU_ORDEM) VALUES (?, ?, ?, ?)', [newId, empresaId, nome, ordem]);
if (membros && membros.length > 0) {
for (const userId of membros) {
await db.execute(alias, 'INSERT INTO CHATC2_USU_EQUIPES (EQU_EQUIPE_ID, EQU_USUARIO_ID) VALUES (?, ?)', [newId, userId]);
}
}
res.json({ success: true, data: { id: newId } });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
static async updateTeam(req, res) {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem gerenciar equipes.' });
const { alias, id } = req.params;
const { nome, membros } = req.body;
if (nome) await db.execute(alias, 'UPDATE CHATC2_EQUIPES SET EQU_NOME = ? WHERE EQU_CODIGO_ID = ?', [nome, id]);
if (req.body.ordem !== undefined) await db.execute(alias, 'UPDATE CHATC2_EQUIPES SET EQU_ORDEM = ? WHERE EQU_CODIGO_ID = ?', [req.body.ordem, id]);
if (membros) {
await db.execute(alias, 'DELETE FROM CHATC2_USU_EQUIPES WHERE EQU_EQUIPE_ID = ?', [id]);
for (const userId of membros) {
await db.execute(alias, 'INSERT INTO CHATC2_USU_EQUIPES (EQU_EQUIPE_ID, EQU_USUARIO_ID) VALUES (?, ?)', [id, userId]);
}
}
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
static async deleteTeam(req, res) {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem gerenciar equipes.' });
const { alias, id } = req.params;
await db.execute(alias, 'UPDATE CHATC2_EQUIPES SET EQU_SITUACAO = \'I\' WHERE EQU_CODIGO_ID = ?', [id]);
await db.execute(alias, 'DELETE FROM CHATC2_USU_EQUIPES WHERE EQU_EQUIPE_ID = ?', [id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
// ==================== USUÁRIOS DA EMPRESA ====================
static async listCompanyUsers(req, res) {
try {
const { alias } = req.params;
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
const users = await db.query(alias, `
SELECT u.USU_CODIGO_ID, u.USU_NOME, u.USU_LOGIN, u.USU_STATUS, u.USU_TIPO_CHAT
FROM USUARIOS u INNER JOIN USUARIOS_EMPRESA ue ON u.USU_CODIGO_ID = ue.USE_USUARIO_ID
WHERE ue.USE_EMPRESA_ID = ? AND u.USU_STATUS = 'A'
ORDER BY u.USU_NOME
`, [empresaId]);
res.json({ success: true, data: users.map(u => ({ id: u.USU_CODIGO_ID, nome: (u.USU_NOME || '').trim(), login: (u.USU_LOGIN || '').trim(), tipoChat: (u.USU_TIPO_CHAT || 'A').trim() })) });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
// ==================== CHATC2_ETIQUETAS ====================
static async listLabels(req, res) {
try {
const { alias } = req.params;
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
const labels = await db.query(alias,
'SELECT * FROM CHATC2_ETIQUETAS WHERE ETI_EMPRESA_ID = ? AND ETI_SITUACAO = \'A\' ORDER BY ETI_NOME', [empresaId]
);
res.json({ success: true, data: labels.map(l => ({ id: l.ETI_CODIGO_ID, nome: (l.ETI_NOME || '').trim(), cor: (l.ETI_COR || '#667eea').trim() })) });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
static async createLabel(req, res) {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem gerenciar etiquetas.' });
const { alias } = req.params;
const { nome, cor } = req.body;
const empresaId = req.user?.empresas?.[0];
const maxId = await db.query(alias, 'SELECT MAX(ETI_CODIGO_ID) AS ID FROM CHATC2_ETIQUETAS');
const newId = (maxId[0]?.ID || 0) + 1;
await db.execute(alias, 'INSERT INTO CHATC2_ETIQUETAS (ETI_CODIGO_ID, ETI_EMPRESA_ID, ETI_NOME, ETI_COR) VALUES (?, ?, ?, ?)', [newId, empresaId, nome, cor || '#667eea']);
res.json({ success: true, data: { id: newId } });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
static async updateLabel(req, res) {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem gerenciar etiquetas.' });
const { alias, id } = req.params;
const { nome, cor } = req.body;
if (nome) await db.execute(alias, 'UPDATE CHATC2_ETIQUETAS SET ETI_NOME = ? WHERE ETI_CODIGO_ID = ?', [nome, id]);
if (cor) await db.execute(alias, 'UPDATE CHATC2_ETIQUETAS SET ETI_COR = ? WHERE ETI_CODIGO_ID = ?', [cor, id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
static async deleteLabel(req, res) {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem gerenciar etiquetas.' });
const { alias, id } = req.params;
await db.execute(alias, 'UPDATE CHATC2_ETIQUETAS SET ETI_SITUACAO = \'I\' WHERE ETI_CODIGO_ID = ?', [id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
// ==================== CONFIGURAÇÕES EMPRESA ====================
static async getCompanyConfig(req, res) {
try {
const { alias } = req.params;
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
let config = await db.query(alias,
'SELECT * FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]
);
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 };
res.json({ success: true, data: cfg });
} else {
const c = config[0];
res.json({ success: true, data: {
empresaId: c.CFE_EMPRESA_ID,
instanciaPadraoId: c.CFE_INSTANCIA_PADRAO_ID,
fotoCelular: (c.CFE_FOTO_CELULAR || 'N').trim(),
saudacaoAtiva: (c.CFE_SAUDACAO_ATIVA || 'S').trim(),
saudacaoMensagem: c.CFE_SAUDACAO_MENSAGEM || '',
csatAtivo: (c.CFE_CSAT_ATIVO || 'N').trim(),
csatMensagem: c.CFE_CSAT_MENSAGEM || '',
enviarNomeUsuario: (c.CFE_ENVIAR_NOME_USUARIO || 'N').trim(),
triagemAtiva: (c.CFE_TRIAGEM_ATIVA || 'N').trim(),
triagemMsgWelcome: c.CFE_TRIAGEM_MSG_WELCOME || '',
triagemMsgAfter: c.CFE_TRIAGEM_MSG_AFTER || '',
triagemBoletoNumero: (c.CFE_TRIAGEM_BOLETO_NUMERO || '0').trim(),
}});
}
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
static async saveCompanyConfig(req, res) {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem alterar as configurações da empresa.' });
const { alias } = req.params;
const data = req.body;
const empresaId = data.empresaId || req.user?.empresas?.[0];
// Upsert
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_INSTANCIA_PADRAO_ID = ?, CFE_FOTO_CELULAR = ?, CFE_SAUDACAO_ATIVA = ?,
CFE_SAUDACAO_MENSAGEM = ?, CFE_CSAT_ATIVO = ?, CFE_CSAT_MENSAGEM = ?,
CFE_ENVIAR_NOME_USUARIO = ?, CFE_TRIAGEM_ATIVA = ?, CFE_TRIAGEM_MSG_WELCOME = ?,
CFE_TRIAGEM_MSG_AFTER = ?, CFE_TRIAGEM_BOLETO_NUMERO = ?
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]);
} else {
await db.execute(alias, `
INSERT INTO CHATC2_CONFIGURACOES_EMPRESA (CFE_EMPRESA_ID, CFE_INSTANCIA_PADRAO_ID, CFE_FOTO_CELULAR,
CFE_SAUDACAO_ATIVA, CFE_SAUDACAO_MENSAGEM, CFE_CSAT_ATIVO, CFE_CSAT_MENSAGEM, CFE_ENVIAR_NOME_USUARIO,
CFE_TRIAGEM_ATIVA, CFE_TRIAGEM_MSG_WELCOME, CFE_TRIAGEM_MSG_AFTER, CFE_TRIAGEM_BOLETO_NUMERO)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [empresaId, data.instanciaPadraoId || null, data.fotoCelular || 'N', data.saudacaoAtiva || 'S', data.saudacaoMensagem || '', data.csatAtivo || 'N', data.csatMensagem || '', data.enviarNomeUsuario || 'N', data.triagemAtiva || 'N', data.triagemMsgWelcome || '', data.triagemMsgAfter || '', data.triagemBoletoNumero || '0']);
}
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
// ==================== USUÁRIOS TIPO CHAT ====================
static async updateUserChatType(req, res) {
try {
const { alias, id } = req.params;
const { tipoChat } = req.body;
// Somente Gerente pode alterar o tipo de usuário
if (!(await isGerente(req))) {
return res.status(403).json({ success: false, error: 'Apenas gerentes podem alterar o tipo de usuário.' });
}
// Valida o valor (evita gravar tipos arbitrários)
const t = String(tipoChat || 'A').trim().toUpperCase();
if (t !== 'A' && t !== 'G') {
return res.status(400).json({ success: false, error: 'tipoChat inválido (use "A" ou "G").' });
}
// O usuário-alvo precisa pertencer a uma empresa do gerente
const alvo = await db.query(alias,
'SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA WHERE USE_USUARIO_ID = ?', [id]);
const empresasAlvo = alvo.map(function (e) { return e.USE_EMPRESA_ID; });
const minhas = req.user && req.user.empresas ? req.user.empresas : [];
if (empresasAlvo.length > 0 && !empresasAlvo.some(function (e) { return minhas.includes(e); })) {
return res.status(403).json({ success: false, error: 'Sem permissão sobre este usuário.' });
}
await db.execute(alias, 'UPDATE USUARIOS SET USU_TIPO_CHAT = ? WHERE USU_CODIGO_ID = ?', [t, id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
}
module.exports = ConfigController;
+151
View File
@@ -0,0 +1,151 @@
const { addDatabase, removeDatabase, listAllDatabases, DEFAULT_DRIVER } = require('../database');
const { isGerente } = require('../middlewares/roles');
class DatabaseController {
/**
* Lista todas as conexões disponíveis
* GET /api/databases
*/
static async list(req, res) {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem gerenciar conexões de banco.' });
var all = listAllDatabases();
// Oculta senhas na resposta
var sanitized = {};
Object.keys(all).forEach(function(alias) {
sanitized[alias] = Object.assign({}, all[alias]);
if (sanitized[alias].password) {
sanitized[alias].password = '******';
}
});
res.json({ success: true, data: sanitized });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
}
/**
* Adiciona ou atualiza uma conexão
* POST /api/databases
* Body: { alias: 'meu-banco', config: { host, port, database, user, password, ... } }
* Ou: { 'meu-banco': { host, port, database, ... } }
*/
static async save(req, res) {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem gerenciar conexões de banco.' });
var body = req.body;
var alias, config;
if (body.alias && body.config) {
alias = body.alias;
config = body.config;
} else {
// Aceita no formato: { 'meu-alias': { host, port, database, ... } }
var keys = Object.keys(body);
if (keys.length === 0) {
return res.status(400).json({ success: false, error: 'Envie { alias, config } ou { "meu-alias": { ... } }' });
}
alias = keys[0];
config = body[alias];
}
addDatabase(alias, config);
res.json({ success: true, data: { alias: alias.toLowerCase().replace(/[^a-z0-9_]/g, '_') } });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
}
/**
* Remove uma conexão
* DELETE /api/databases/:alias
*/
static async remove(req, res) {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem gerenciar conexões de banco.' });
var alias = req.params.alias;
if (!alias) {
return res.status(400).json({ success: false, error: 'Alias obrigatório.' });
}
var removed = removeDatabase(alias);
if (!removed) {
return res.status(404).json({ success: false, error: 'Alias não encontrado ou é estático.' });
}
res.json({ success: true });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
}
/**
* Testa uma conexão
* POST /api/databases/test
* Body: { host, port, database, user, password }
*/
static async test(req, res) {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem testar conexões de banco.' });
if (!req.body.database) {
return res.status(400).json({ success: false, error: 'database é obrigatório.' });
}
var driver = (req.body.driver || DEFAULT_DRIVER).toLowerCase();
if (driver === 'postgres') {
var pg = require('pg');
var schema = String(req.body.schema || 'public').trim();
var safe = /^[A-Za-z_][A-Za-z0-9_$]*$/.test(schema) ? schema : 'public';
var searchPath = safe === 'public' ? 'public' : safe + ',public';
var client = new pg.Client({
host: req.body.host || '127.0.0.1',
port: req.body.port || 5432,
database: req.body.database,
user: req.body.user || 'postgres',
password: req.body.password || 'postgres',
ssl: req.body.ssl ? { rejectUnauthorized: false } : false,
options: '-c search_path=' + searchPath,
connectionTimeoutMillis: 8000,
});
try {
await client.connect();
await client.query('SELECT 1');
// Confirma que o schema existe
var sc = await client.query('SELECT 1 FROM information_schema.schemata WHERE schema_name = $1', [safe]);
if (sc.rowCount === 0) {
throw new Error('Schema "' + safe + '" não encontrado no banco "' + req.body.database + '".');
}
} finally {
await client.end().catch(function () {});
}
} else {
var Firebird = require('node-firebird');
var config = {
host: req.body.host || 'localhost',
port: req.body.port || 3050,
database: req.body.database,
user: req.body.user || 'SYSDBA',
password: req.body.password || 'masterkey',
encoding: 'UTF-8',
lowercase_keys: false,
pageSize: 4096,
};
await new Promise(function (resolve, reject) {
Firebird.attach(config, function (err, conn) {
if (err) return reject(new Error('Falha ao conectar: ' + err.message));
conn.query('SELECT 1 AS TEST FROM RDB$DATABASE', [], function (qErr, rows) {
conn.detach();
if (qErr) return reject(new Error('Falha na consulta: ' + qErr.message));
resolve(rows);
});
});
});
}
res.json({ success: true, message: '✅ Conexão (' + driver + ') estabelecida com sucesso!' });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
}
}
module.exports = DatabaseController;
+74
View File
@@ -0,0 +1,74 @@
const fs = require('fs');
const db = require('../database');
class EmpresaController {
static async getLogo(req, res) {
const { alias } = req.params;
const respond = (data) => { if (!res.headersSent) res.json(data); };
try {
const result = await db.query(alias,
'SELECT EMP_CODIGO_ID, EMP_NOME_FANTASIA, EMP_NOME, EMP_NOME_FOTO, EMP_FOTO, EMP_COR_STRING_HEXA FROM EMPRESAS ORDER BY EMP_CODIGO_ID FETCH FIRST 1 ROWS ONLY');
if (result.length === 0) {
return respond({ success: true, fotoUrl: null, iniciais: 'EM' });
}
const e = result[0];
const nomeFantasia = (e.EMP_NOME_FANTASIA || e.EMP_NOME || '').trim();
const nomeFoto = (e.EMP_NOME_FOTO || '').trim();
const corHexa = (e.EMP_COR_STRING_HEXA || '#667eea').trim();
const iniciais = getIniciais(nomeFantasia);
// 1. EMP_NOME_FOTO como URL ou caminho de arquivo
if (nomeFoto) {
if (nomeFoto.startsWith('http://') || nomeFoto.startsWith('https://')) {
return respond({ success: true, fotoUrl: nomeFoto, nomeFantasia, iniciais, cor: corHexa });
}
try {
if (fs.existsSync(nomeFoto)) {
const data = fs.readFileSync(nomeFoto).toString('base64');
const ext = nomeFoto.split('.').pop().toLowerCase();
const mime = ext === 'png' ? 'image/png'
: ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg'
: ext === 'gif' ? 'image/gif'
: ext === 'webp' ? 'image/webp' : 'image/png';
return respond({ success: true, fotoUrl: 'data:' + mime + ';base64,' + data, nomeFantasia, iniciais, cor: corHexa });
}
} catch (fe) { /* ignora e tenta o BLOB */ }
}
// 2. BLOB EMP_FOTO (Buffer no postgres/firebird; string base64 em alguns casos)
const blobData = e.EMP_FOTO;
let base64;
if (Buffer.isBuffer(blobData)) {
base64 = blobData.toString('base64');
} else if (typeof blobData === 'string' && blobData.length > 0) {
base64 = /^[A-Za-z0-9+/=]+$/.test(blobData.substring(0, 100))
? blobData
: Buffer.from(blobData).toString('base64');
} else if (blobData && blobData.type === 'Buffer') {
base64 = Buffer.from(blobData.data || blobData).toString('base64');
}
if (base64 && base64.length > 10) {
return respond({ success: true, fotoUrl: 'data:image/png;base64,' + base64, nomeFantasia, iniciais, cor: corHexa });
}
return respond({ success: true, fotoUrl: null, nomeFantasia, iniciais, cor: corHexa });
} catch (err) {
console.error('[EmpresaController.getLogo]', err.message);
return respond({ success: true, fotoUrl: null, iniciais: 'EM' });
}
}
}
function getIniciais(nome) {
if (!nome) return 'EM';
const partes = nome.split(' ').filter(p => p.length > 0 && !['DE', 'DA', 'DO', 'DAS', 'DOS', 'E'].includes(p.toUpperCase()));
if (partes.length === 0) return nome.substring(0, 2).toUpperCase();
if (partes.length === 1) return partes[0].substring(0, 2).toUpperCase();
return (partes[0][0] + partes[partes.length - 1][0]).toUpperCase();
}
module.exports = EmpresaController;
File diff suppressed because it is too large Load Diff
+136
View File
@@ -0,0 +1,136 @@
const db = require('../database');
const { listAliases } = db;
const { isGerente } = require('../middlewares/roles');
class GenericController {
/**
* Executa uma consulta SQL genérica (SELECT)
*/
static async query(req, res) {
try {
const { alias } = req.params;
const { sql, params } = req.body;
if (!(await isGerente(req))) {
return res.status(403).json({ success: false, error: 'Apenas gerentes podem executar SQL direto.' });
}
if (!sql) {
return res.status(400).json({
success: false,
error: 'O campo "sql" é obrigatório.',
});
}
const result = await db.query(alias, sql, params || []);
res.json({ success: true, alias, data: result, count: result.length });
} catch (err) {
console.error('Erro no controller query:', err);
res.status(500).json({ success: false, error: err.message });
}
}
/**
* Executa um comando SQL (INSERT, UPDATE, DELETE)
*/
static async execute(req, res) {
try {
const { alias } = req.params;
const { sql, params } = req.body;
if (!(await isGerente(req))) {
return res.status(403).json({ success: false, error: 'Apenas gerentes podem executar SQL direto.' });
}
if (!sql) {
return res.status(400).json({
success: false,
error: 'O campo "sql" é obrigatório.',
});
}
const result = await db.execute(alias, sql, params || []);
res.json({ success: true, alias, ...result });
} catch (err) {
console.error('Erro no controller execute:', err);
res.status(500).json({ success: false, error: err.message });
}
}
/**
* Lista todas as tabelas do banco
*/
static async listTables(req, res) {
try {
const { alias } = req.params;
const tables = await db.listTables(alias);
res.json({ success: true, alias, data: tables, count: tables.length });
} catch (err) {
console.error('Erro ao listar tabelas:', err);
res.status(500).json({ success: false, error: err.message });
}
}
/**
* Retorna a estrutura de uma tabela (colunas e tipos)
*/
static async tableInfo(req, res) {
try {
const { alias, tableName } = req.params;
if (!tableName) {
return res.status(400).json({
success: false,
error: 'O parâmetro "tableName" é obrigatório.',
});
}
const columns = await db.tableInfo(alias, tableName);
res.json({ success: true, alias, tableName, columns, count: columns.length });
} catch (err) {
console.error('Erro ao obter info da tabela:', err);
res.status(500).json({ success: false, error: err.message });
}
}
/**
* Health check do alias específico
*/
static async healthCheck(req, res) {
try {
const { alias } = req.params;
const isConnected = await db.testConnection(alias);
res.json({
success: true,
alias,
connected: isConnected,
timestamp: new Date().toISOString(),
});
} catch (err) {
res.status(500).json({
success: false,
error: err.message,
});
}
}
/**
* Lista todos os aliases disponíveis
*/
static async listAliases(req, res) {
try {
const aliases = listAliases();
const statuses = {};
for (const a of aliases) {
statuses[a] = await db.testConnection(a);
}
res.json({ success: true, aliases, status: statuses });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
}
}
module.exports = GenericController;
+250
View File
@@ -0,0 +1,250 @@
const db = require('../database');
const { isGerente } = require('../middlewares/roles');
class MenuController {
/**
* Garante que a tabela CHATC2_MENUS_EMPRESA existe
*/
static async garantirTabela(alias) {
var tabelaExiste = false;
try {
tabelaExiste = await db.tableExists(alias, 'CHATC2_MENUS_EMPRESA');
} catch(e) { /* ignora */ }
if (!tabelaExiste) {
try {
await db.execute(alias, `
CREATE TABLE CHATC2_MENUS_EMPRESA (
MNE_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
MNE_EMPRESA_ID INTEGER,
MNE_EQUIPE_ID INTEGER,
MNE_MENU_PAI_ID INTEGER,
MNE_ORDEM INTEGER DEFAULT 0,
MNE_TITULO VARCHAR(100),
MNE_TIPO CHAR(1) DEFAULT 'M',
MNE_TEXTO BLOB SUB_TYPE TEXT,
MNE_ACAO_ROTA VARCHAR(100),
MNE_ACAO_METODO VARCHAR(10),
MNE_ACAO_PROMPT VARCHAR(300),
MNE_SITUACAO CHAR(1) DEFAULT 'A'
)
`);
console.log('[MenuController] Tabela CHATC2_MENUS_EMPRESA criada com sucesso');
} catch(e) {
if (!e.message.includes('already exists')) {
console.error('[MenuController] Erro ao criar tabela:', e.message);
}
}
}
// Tenta adicionar colunas que podem estar faltando
// Primeiro verifica se a coluna já existe para evitar erros
var colunas = [
{ nome: 'MNE_ACAO_ROTA', sql: 'ALTER TABLE CHATC2_MENUS_EMPRESA ADD MNE_ACAO_ROTA VARCHAR(100)' },
{ nome: 'MNE_ACAO_METODO', sql: 'ALTER TABLE CHATC2_MENUS_EMPRESA ADD MNE_ACAO_METODO VARCHAR(10)' },
{ nome: 'MNE_ACAO_PROMPT', sql: 'ALTER TABLE CHATC2_MENUS_EMPRESA ADD MNE_ACAO_PROMPT VARCHAR(300)' },
{ nome: 'MNE_ETIQUETA_IDS', sql: 'ALTER TABLE CHATC2_MENUS_EMPRESA ADD MNE_ETIQUETA_IDS VARCHAR(200)' },
];
for (var c of colunas) {
try {
// Verifica se coluna já existe
var colExiste = await db.columnExists(alias, 'CHATC2_MENUS_EMPRESA', c.nome);
if (colExiste) continue; // já existe, pula
await db.execute(alias, c.sql);
console.log('[MenuController] Coluna adicionada:', c.nome);
} catch(e) {
// Ignora qualquer erro (coluna pode ter sido adicionada por outro processo)
if (!e.message.includes('already exists') && !e.message.includes('duplicate')) {
console.log('[MenuController] Aviso ao adicionar coluna ' + c.nome + ': ' + e.message.substring(0, 80));
}
}
}
}
/**
* Lista menus organizados por equipe
* GET /api/:alias/menus?empresaId=X
*/
static async listMenus(req, res) {
try {
const { alias } = req.params;
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
if (!req.user?.empresas?.includes(empresaId))
return res.status(403).json({ success: false, error: 'Sem permissão.' });
await MenuController.garantirTabela(alias);
const equipes = await db.query(alias,
"SELECT EQU_CODIGO_ID, EQU_NOME FROM CHATC2_EQUIPES WHERE EQU_EMPRESA_ID = ? AND EQU_SITUACAO = 'A' ORDER BY EQU_ORDEM, EQU_NOME",
[empresaId]
);
const todos = await db.query(alias,
"SELECT * FROM CHATC2_MENUS_EMPRESA WHERE MNE_EMPRESA_ID = ? AND MNE_SITUACAO = 'A' ORDER BY MNE_ORDEM, MNE_CODIGO_ID",
[empresaId]
);
function montarArvore(paiId) {
return todos
.filter(function(m) { return (m.MNE_MENU_PAI_ID || null) === (paiId || null); })
.map(function(m) {
return {
id: m.MNE_CODIGO_ID,
titulo: (m.MNE_TITULO || '').trim(),
ordem: m.MNE_ORDEM || 0,
tipo: (m.MNE_TIPO || 'M').trim(),
texto: m.MNE_TEXTO ? m.MNE_TEXTO.toString() : '',
equipeId: m.MNE_EQUIPE_ID,
acaoRota: (m.MNE_ACAO_ROTA || '').trim(),
acaoMetodo: (m.MNE_ACAO_METODO || '').trim(),
acaoPrompt: (m.MNE_ACAO_PROMPT || '').trim(),
etiquetaIds: (m.MNE_ETIQUETA_IDS || '').trim(),
filhos: montarArvore(m.MNE_CODIGO_ID),
};
});
}
var resultado = equipes.map(function(eq) {
return {
equipeId: eq.EQU_CODIGO_ID,
equipeNome: (eq.EQU_NOME || '').trim(),
menus: montarArvore(null).filter(function(m) { return m.equipeId === eq.EQU_CODIGO_ID; }),
};
}).filter(function(g) { return g.menus.length > 0; });
res.json({ success: true, data: resultado });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
}
/**
* Lista menus em formato plano (para selects/combos)
* GET /api/:alias/menus/flat?empresaId=X&equipeId=Y
*/
static async listMenusFlat(req, res) {
try {
const { alias } = req.params;
const empresaId = parseInt(req.query.empresaId);
const equipeId = req.query.equipeId ? parseInt(req.query.equipeId) : null;
if (!req.user?.empresas?.includes(empresaId))
return res.status(403).json({ success: false, error: 'Sem permissão.' });
await MenuController.garantirTabela(alias);
let sql = "SELECT * FROM CHATC2_MENUS_EMPRESA WHERE MNE_EMPRESA_ID = ? AND MNE_SITUACAO = 'A'";
let params = [empresaId];
if (equipeId) {
sql += ' AND MNE_EQUIPE_ID = ?';
params.push(equipeId);
}
sql += ' ORDER BY MNE_ORDEM, MNE_CODIGO_ID';
const menus = await db.query(alias, sql, params);
res.json({ success: true, data: menus.map(function(m) {
return {
id: m.MNE_CODIGO_ID,
titulo: (m.MNE_TITULO || '').trim(),
paiId: m.MNE_MENU_PAI_ID,
equipeId: m.MNE_EQUIPE_ID,
tipo: (m.MNE_TIPO || 'M').trim(),
};
})});
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
}
/**
* Cria um item de menu
* POST /api/:alias/menus
*/
static async createMenu(req, res) {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem gerenciar menus.' });
const { alias } = req.params;
const { empresaId, equipeId, titulo, tipo, texto, acaoRota, acaoMetodo, acaoPrompt, menuPaiId, ordem, etiquetaIds } = req.body;
if (!empresaId || !titulo)
return res.status(400).json({ success: false, error: 'empresaId e titulo obrigatórios.' });
await MenuController.garantirTabela(alias);
const maxId = await db.query(alias, 'SELECT MAX(MNE_CODIGO_ID) AS ID FROM CHATC2_MENUS_EMPRESA');
const newId = (maxId[0]?.ID || 0) + 1;
// Se tem menuPaiId, o equipeId é herdado do pai
var equipeIdFinal = menuPaiId ? null : (equipeId || null);
await db.execute(alias,
`INSERT INTO CHATC2_MENUS_EMPRESA (MNE_CODIGO_ID, MNE_EMPRESA_ID, MNE_EQUIPE_ID, MNE_MENU_PAI_ID,
MNE_ORDEM, MNE_TITULO, MNE_TIPO, MNE_TEXTO, MNE_ACAO_ROTA, MNE_ACAO_METODO, MNE_ACAO_PROMPT, MNE_ETIQUETA_IDS)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[newId, empresaId, equipeIdFinal, menuPaiId || null,
ordem || 0, titulo, tipo || 'M', texto || null,
acaoRota || null, acaoMetodo || null, acaoPrompt || null, etiquetaIds || null]
);
res.json({ success: true, data: { id: newId } });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
}
/**
* Atualiza um item de menu
* PUT /api/:alias/menus/:id
*/
static async updateMenu(req, res) {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem gerenciar menus.' });
const { alias, id } = req.params;
const { titulo, tipo, texto, acaoRota, acaoMetodo, acaoPrompt, menuPaiId, ordem, equipeId, etiquetaIds } = req.body;
await MenuController.garantirTabela(alias);
if (titulo !== undefined) await db.execute(alias, 'UPDATE CHATC2_MENUS_EMPRESA SET MNE_TITULO = ? WHERE MNE_CODIGO_ID = ?', [titulo, id]);
if (tipo !== undefined) await db.execute(alias, "UPDATE CHATC2_MENUS_EMPRESA SET MNE_TIPO = ? WHERE MNE_CODIGO_ID = ?", [tipo, id]);
if (ordem !== undefined) await db.execute(alias, 'UPDATE CHATC2_MENUS_EMPRESA SET MNE_ORDEM = ? WHERE MNE_CODIGO_ID = ?', [ordem, id]);
if (texto !== undefined) await db.execute(alias, 'UPDATE CHATC2_MENUS_EMPRESA SET MNE_TEXTO = ? WHERE MNE_CODIGO_ID = ?', [texto, id]);
if (acaoRota !== undefined) await db.execute(alias, 'UPDATE CHATC2_MENUS_EMPRESA SET MNE_ACAO_ROTA = ? WHERE MNE_CODIGO_ID = ?', [acaoRota, id]);
if (acaoMetodo !== undefined) await db.execute(alias, 'UPDATE CHATC2_MENUS_EMPRESA SET MNE_ACAO_METODO = ? WHERE MNE_CODIGO_ID = ?', [acaoMetodo, id]);
if (acaoPrompt !== undefined) await db.execute(alias, 'UPDATE CHATC2_MENUS_EMPRESA SET MNE_ACAO_PROMPT = ? WHERE MNE_CODIGO_ID = ?', [acaoPrompt, id]);
if (menuPaiId !== undefined) await db.execute(alias, 'UPDATE CHATC2_MENUS_EMPRESA SET MNE_MENU_PAI_ID = ? WHERE MNE_CODIGO_ID = ?', [menuPaiId || null, id]);
if (equipeId !== undefined) await db.execute(alias, 'UPDATE CHATC2_MENUS_EMPRESA SET MNE_EQUIPE_ID = ? WHERE MNE_CODIGO_ID = ?', [equipeId, id]);
if (etiquetaIds !== undefined) await db.execute(alias, 'UPDATE CHATC2_MENUS_EMPRESA SET MNE_ETIQUETA_IDS = ? WHERE MNE_CODIGO_ID = ?', [etiquetaIds || null, id]);
res.json({ success: true });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
}
/**
* Exclui um item de menu (e seus filhos recursivamente)
* DELETE /api/:alias/menus/:id
*/
static async deleteMenu(req, res) {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem gerenciar menus.' });
const { alias, id } = req.params;
await MenuController.garantirTabela(alias);
async function excluirFilhos(paiId) {
var filhos = await db.query(alias, 'SELECT MNE_CODIGO_ID FROM CHATC2_MENUS_EMPRESA WHERE MNE_MENU_PAI_ID = ?', [paiId]);
for (var f of filhos) await excluirFilhos(f.MNE_CODIGO_ID);
await db.execute(alias, "UPDATE CHATC2_MENUS_EMPRESA SET MNE_SITUACAO = 'I' WHERE MNE_CODIGO_ID = ?", [paiId]);
}
await excluirFilhos(parseInt(id));
res.json({ success: true });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
}
}
module.exports = MenuController;
+401
View File
@@ -0,0 +1,401 @@
/**
* Controller que descobre automaticamente as rotas registradas no Express e
* enriquece cada uma com metadados de documentação:
* - descrição
* - autenticação (pública x requer token)
* - parâmetros de path (todos obrigatórios)
* - parâmetros de query (com indicação de obrigatório)
* - exemplo de body (JSON) + campos obrigatórios
*/
class RoutesController {
// ============================================================
// Descoberta de rotas no Express
// ============================================================
static listarRotas() {
var routesModule = require('../routes');
var seen = {};
var rotas = [];
function addRota(method, path) {
var key = method + '|' + path;
if (!seen[key]) {
seen[key] = true;
rotas.push({ method: method, path: path });
}
}
function percorrer(stack, prefixo) {
if (!stack) return;
stack.forEach(function (layer) {
if (layer.route) {
var methods = Object.keys(layer.route.methods);
var path = prefixo + layer.route.path;
path = path.replace(/:([a-zA-Z_]+)/g, '{$1}');
methods.forEach(function (m) { addRota(m.toUpperCase(), path); });
} else if (layer.handle && layer.handle.stack) {
var subPrefixo = RoutesController.extrairPrefixo(layer.regexp, layer.name);
percorrer(layer.handle.stack, prefixo + subPrefixo);
}
});
}
percorrer(routesModule.stack, '');
return rotas;
}
static extrairPrefixo(regexp) {
try {
if (!regexp) return '';
var source = regexp.source;
if (source.includes('\\/api')) return '/api';
if (source.includes('\\/app')) return '/app';
return '';
} catch (e) {
return '';
}
}
/** Garante que o path tenha o prefixo /api ou /app. */
static normalizar(path) {
if (path.startsWith('/api/') || path.startsWith('/app/') ||
path === '/api' || path === '/app') {
return path;
}
var paginas = ['/login', '/dashboard', '/clients', '/settings', '/routes',
'/csat', '/conversation/', '/conversations/all'];
var isPagina = paginas.some(function (p) { return path.endsWith(p) || path.includes(p); });
return (isPagina ? '/app' : '/api') + path;
}
// ============================================================
// Autenticação
// ============================================================
static requerAuth(method, path) {
var key = method + ' ' + path;
var publicas = {
'GET /api/aliases': 1,
'GET /api/routes': 1,
'GET /api/{alias}/empresa/logo': 1,
'GET /api/{alias}/webhook/ping': 1,
'POST /api/webhook/evolution': 1,
'POST /api/webhook/evolution/{evento}': 1,
'POST /api/{alias}/webhook/evolution': 1,
'POST /api/{alias}/webhook/evolution/{evento}': 1,
'GET /api/{alias}/media/{mediaId}': 1,
'POST /api/{alias}/csat/avaliar': 1,
'POST /app/{alias}/login': 1,
};
if (publicas[key]) return false;
// Páginas HTML servidas em /app (proteção feita no cliente) — exceto /me
if (method === 'GET' && path.indexOf('/app/') === 0 && !path.endsWith('/me')) return false;
return true;
}
// ============================================================
// Parâmetros de path (extraídos do {token})
// ============================================================
static descricaoParam(nome) {
var mapa = {
alias: 'Alias da conexão de banco (ex.: novo_local)',
tableName: 'Nome da tabela',
id: 'ID do registro',
id_cliente: 'ID do cliente',
id_empresa: 'ID da empresa',
mediaId: 'ID da mídia',
conversaId: 'ID da conversa',
evento: 'Nome do evento Evolution (ex.: messages-upsert)',
};
return mapa[nome] || ('Parâmetro "' + nome + '"');
}
static paramsDePath(path) {
var params = [];
var re = /\{([a-zA-Z_]+)\}/g;
var m;
while ((m = re.exec(path)) !== null) {
params.push({ nome: m[1], obrigatorio: true, desc: RoutesController.descricaoParam(m[1]) });
}
return params;
}
// ============================================================
// Catálogo de metadados (desc, query, body, obrigatórios)
// body = objeto exemplo; req = campos de body obrigatórios
// q = [ [nome, obrigatorio(bool), desc], ... ]
// ============================================================
static catalogo() {
return {
// ---- Banco de Dados ----
'GET /api/aliases': { desc: 'Lista os aliases de banco disponíveis e o status de conexão.' },
'GET /api/routes': { desc: 'Lista todas as rotas da API (esta página).' },
'GET /api/{alias}/health': { desc: 'Health check do banco do alias.' },
'GET /api/{alias}/tables': { desc: 'Lista as tabelas do banco.' },
'GET /api/{alias}/tables/{tableName}': { desc: 'Estrutura (colunas e tipos) de uma tabela.' },
'POST /api/{alias}/query': {
desc: 'Executa uma consulta SELECT. Aceita placeholders "?" nos parâmetros.',
body: { sql: 'SELECT CLI_NOME FROM CLIENTES WHERE CLI_CODIGO_ID = ?', params: [1] },
req: ['sql'],
note: 'O campo "params" é opcional (array, na ordem dos "?").',
},
'POST /api/{alias}/execute': {
desc: 'Executa INSERT / UPDATE / DELETE. Retorna affectedRows.',
body: { sql: 'UPDATE CLIENTES SET CLI_EMAIL = ? WHERE CLI_CODIGO_ID = ?', params: ['a@b.com', 1] },
req: ['sql'],
},
'GET /api/databases': { desc: 'Lista as conexões de banco cadastradas (senhas ocultas).' },
'POST /api/databases': {
desc: 'Adiciona ou atualiza uma conexão de banco (persistida em databases_custom.json).',
body: { alias: 'novo_dev', config: { driver: 'postgres', host: '127.0.0.1', port: 15433, database: 'novo_local', schema: 'dev', user: 'postgres', password: 'postgres' } },
req: ['alias', 'config'],
note: 'driver: "postgres" (usa schema) ou "firebird" (database = caminho do .FDB).',
},
'DELETE /api/databases/{alias}': { desc: 'Remove uma conexão customizada (estáticas não podem ser removidas).' },
'POST /api/databases/test': {
desc: 'Testa uma conexão sem cadastrá-la.',
body: { driver: 'postgres', host: '127.0.0.1', port: 15433, database: 'novo_local', schema: 'public', user: 'postgres', password: 'postgres' },
req: ['database'],
},
// ---- Autenticação ----
'POST /app/{alias}/login': {
desc: 'Autentica o usuário e retorna o token JWT.',
body: { USU_LOGIN: 'SUPORTE', USU_SENHA: 'sua-senha' },
req: ['USU_LOGIN', 'USU_SENHA'],
},
'GET /app/{alias}/me': { desc: 'Dados do usuário autenticado.' },
// ---- Clientes ----
'GET /api/{alias}/empresas': { desc: 'Empresas que o usuário logado tem acesso.' },
'GET /api/{alias}/empresa/logo': { desc: 'Logo/identidade visual da empresa (usada no login).' },
'GET /api/{alias}/clients/search': {
desc: 'Busca clientes por nome, CPF ou matrícula (paginado).',
q: [['q', false, 'Termo de busca (nome/CPF/matrícula)'], ['empresaId', false, 'Filtra por empresa'], ['page', false, 'Página (padrão 1)'], ['limit', false, 'Itens por página (padrão 20)']],
},
'GET /api/{alias}/clients/{id}/dependents': { desc: 'Lista os dependentes de um cliente.' },
'PUT /api/{alias}/dependents/{id}/phone': {
desc: 'Atualiza o telefone de um dependente.',
body: { telefone: '75988447843' }, req: ['telefone'],
},
'GET /api/{alias}/dependents/search': {
desc: 'Busca dependentes para associar a uma conversa.',
q: [['q', false, 'Termo de busca'], ['empresaId', false, 'Filtra por empresa']],
},
'GET /api/{alias}/company/{id_empresa}/client/{id_cliente}': { desc: 'Dados completos de um cliente.' },
'PUT /api/{alias}/clients/{id}': {
desc: 'Atualiza dados de contato do cliente.',
body: { telefone: '7532112233', email: 'cliente@email.com', celular: '75988447843' },
note: 'Todos os campos são opcionais — envie só o que deseja alterar.',
},
'GET /api/{alias}/clients/{id_cliente}/carnes': {
desc: 'Títulos (carnês) do cliente, paginados.',
q: [['page', false, 'Página (padrão 1)'], ['limit', false, 'Itens por página (padrão 20)'], ['tipo', false, 'Filtro: abertos, vencidos, pagos (separados por vírgula)']],
},
'GET /api/{alias}/clients/{id_cliente}/listcarne': { desc: 'Cliente + carnês formatados para geração de boleto PDF.' },
'GET /api/{alias}/clients/{id}/convalescentes': { desc: 'Itens de convalescença (módulo estoque) do cliente.' },
'GET /api/{alias}/clients/{id}/conversations': { desc: 'Histórico de conversas do cliente (inclui dependentes).' },
// ---- Chat / Conversas ----
'POST /api/{alias}/conversations/create': {
desc: 'Cria uma conversa manualmente (admin).',
body: { empresaId: 1, numero: '5575988447843', nomeContato: 'Fulano', mensagem: 'Olá', instanciaId: 1, clienteId: null },
req: ['numero'],
},
'GET /api/{alias}/conversations': {
desc: 'Lista conversas com filtros.',
q: [['empresaId', false, 'Empresa (padrão: a do usuário)'], ['status', false, "Status: A,E,F (padrão 'A,E')"], ['filter', false, "'mine' | 'unassigned' | 'all'"]],
},
'GET /api/{alias}/conversations/{id}': { desc: 'Detalhes de uma conversa (inclui dados do cliente).' },
'GET /api/{alias}/conversations/{id}/messages': {
desc: 'Mensagens de uma conversa (paginado).',
q: [['page', false, 'Página (padrão 1)'], ['limit', false, 'Itens por página (padrão 50)']],
},
'POST /api/{alias}/conversations/{id}/messages': {
desc: 'Envia uma mensagem (texto ou mídia) na conversa.',
body: { mensagem: 'Olá, tudo bem?', tipo: 'text', privada: 'N', nomeArquivo: null, midiaBase64: null },
req: ['mensagem'],
note: "tipo: text|image|audio|video|document. Para mídia, envie midiaBase64 + nomeArquivo. privada 'S' = nota interna.",
},
'POST /api/{alias}/conversations/{id}/finalize': { desc: 'Finaliza (encerra) a conversa.' },
'POST /api/{alias}/conversations/{id}/assign': {
desc: 'Atribui a conversa a um atendente.',
body: { usuarioId: 2 }, req: ['usuarioId'],
},
'POST /api/{alias}/conversations/{id}/assign-team': {
desc: 'Atribui a conversa a uma equipe.',
body: { equipeId: 1 }, req: ['equipeId'],
},
'POST /api/{alias}/conversations/{id}/labels': {
desc: 'Adiciona ou remove uma etiqueta da conversa.',
body: { etiquetaId: 3, ativar: true }, req: ['etiquetaId'],
note: 'ativar: true adiciona, false remove.',
},
'POST /api/{alias}/conversations/{id}/link-client': {
desc: 'Associa o contato da conversa a um cliente do cadastro.',
body: { clienteId: 1234 }, req: ['clienteId'],
},
'GET /api/{alias}/media/{mediaId}': { desc: 'Download do binário de uma mídia (imagem/áudio/documento). Retorna o arquivo, não JSON.' },
'POST /api/{alias}/csat/avaliar': {
desc: 'Registra a avaliação de satisfação (CSAT) — formulário público.',
body: { conversaId: 10, empresaId: 1, nota: 5, comentario: 'Ótimo atendimento' },
req: ['conversaId', 'nota'],
note: 'nota: 1 a 5.',
},
// ---- Configurações ----
'GET /api/{alias}/teams': {
desc: 'Lista as equipes da empresa.',
q: [['empresaId', false, 'Empresa (padrão: a do usuário)']],
},
'POST /api/{alias}/teams': {
desc: 'Cria uma equipe.',
body: { nome: 'Financeiro', ordem: 1, membros: [2, 5] },
req: ['nome'],
note: 'membros: array de IDs de usuários (opcional).',
},
'PUT /api/{alias}/teams/{id}': {
desc: 'Atualiza uma equipe.',
body: { nome: 'Financeiro', ordem: 2, membros: [2, 5] },
},
'DELETE /api/{alias}/teams/{id}': { desc: 'Exclui uma equipe.' },
'GET /api/{alias}/company/users': { desc: 'Usuários da empresa (para montar equipes).' },
'PUT /api/{alias}/users/{id}/chat-type': {
desc: 'Altera o tipo de chat do usuário (A=Atendente, G=Gerente).',
body: { tipoChat: 'G' }, req: ['tipoChat'],
},
'GET /api/{alias}/labels': {
desc: 'Lista as etiquetas da empresa.',
q: [['empresaId', false, 'Empresa (padrão: a do usuário)']],
},
'POST /api/{alias}/labels': {
desc: 'Cria uma etiqueta.',
body: { nome: 'Urgente', cor: '#dc2626' }, req: ['nome'],
},
'PUT /api/{alias}/labels/{id}': {
desc: 'Atualiza uma etiqueta.',
body: { nome: 'Urgente', cor: '#dc2626' },
},
'DELETE /api/{alias}/labels/{id}': { desc: 'Exclui uma etiqueta.' },
'GET /api/{alias}/company/config': {
desc: 'Configurações da empresa (saudação, triagem, CSAT, foto...).',
q: [['empresaId', false, 'Empresa (padrão: a do usuário)']],
},
'POST /api/{alias}/company/config': {
desc: 'Salva as configurações da empresa.',
body: { empresaId: 1, saudacao: 'Bem-vindo!', triagemAtiva: 'S', csatAtivo: 'S', instanciaPadraoId: 1 },
note: 'Envie os campos de configuração que deseja salvar.',
},
// ---- WhatsApp / Evolution ----
'GET /api/{alias}/evolution/instances': {
desc: 'Lista as instâncias WhatsApp (Evolution API).',
q: [['empresaId', false, 'Empresa (padrão: a do usuário)']],
},
'POST /api/{alias}/evolution/connect': {
desc: 'Cria/conecta uma instância WhatsApp na Evolution API.',
body: { INS_NOME: 'Atendimento', INS_URL: 'https://evo.exemplo.com', INS_API_KEY: 'sua-api-key', INS_INSTANCE_NAME: 'atendimento1', INS_EMPRESA_ID: 1 },
req: ['INS_NOME', 'INS_URL', 'INS_API_KEY', 'INS_INSTANCE_NAME'],
},
'PUT /api/{alias}/evolution/instances/{id}': {
desc: 'Atualiza uma instância.',
body: { INS_NOME: 'Atendimento', INS_URL: 'https://evo.exemplo.com', INS_API_KEY: 'sua-api-key', INS_INSTANCE_NAME: 'atendimento1' },
},
'DELETE /api/{alias}/evolution/instances/{id}': { desc: 'Exclui uma instância.' },
'POST /api/{alias}/evolution/qrcode/{id}': { desc: 'Gera o QR Code para parear o WhatsApp da instância.' },
'POST /api/{alias}/evolution/refresh-photo/{conversaId}': { desc: 'Atualiza a foto de perfil do contato da conversa.' },
'GET /api/{alias}/webhook/ping': { desc: 'Health check do webhook.' },
'POST /api/{alias}/webhook/evolution': {
desc: 'Recebe eventos da Evolution API (mensagens, status). Chamado pela Evolution, não manualmente.',
note: 'O alias também pode ser omitido (POST /api/webhook/evolution) — é detectado pelo nome da instância.',
},
'POST /api/webhook/evolution': { desc: 'Webhook Evolution sem alias na URL (alias detectado pela instância).' },
// ---- Fluxo (Menus) ----
'GET /api/{alias}/menus': {
desc: 'Menus de atendimento (árvore) por equipe.',
q: [['empresaId', false, 'Empresa (padrão: a do usuário)']],
},
'GET /api/{alias}/menus/flat': { desc: 'Menus em formato plano (lista).' },
'POST /api/{alias}/menus': {
desc: 'Cria um item de menu/submenu do fluxo.',
body: { empresaId: 1, equipeId: 1, titulo: 'Financeiro', tipo: 'M', texto: null, acaoRota: null, acaoMetodo: null, acaoPrompt: null, menuPaiId: null, ordem: 0, etiquetaIds: '3,5' },
req: ['titulo'],
note: "tipo: M=submenu, T=texto, R=rota. etiquetaIds: IDs separados por vírgula.",
},
'PUT /api/{alias}/menus/{id}': {
desc: 'Atualiza um item de menu.',
body: { titulo: 'Financeiro', tipo: 'M', texto: null, acaoRota: null, ordem: 0, etiquetaIds: '3,5' },
},
'DELETE /api/{alias}/menus/{id}': { desc: 'Exclui um item de menu.' },
};
}
static categorizar(path) {
if (path.includes('/webhook/') || path.includes('/evolution/')) return '📱 WhatsApp / Evolution API';
if (path.includes('/csat')) return '⭐ CSAT';
if (path.includes('/media/')) return '📁 Mídia';
if (path.includes('/aliases') || path.includes('/health') || path.includes('/tables') || path.includes('/query') || path.includes('/execute') || path.includes('/databases')) return '🗄️ Banco de Dados';
if (path.includes('/clients') || path.includes('/dependents') || path.includes('/empresa/logo') || path.includes('/empresas') || path.includes('/company/')) return '👥 Clientes';
if (path.includes('/conversations') || path.includes('/conversation')) return '💬 Chat / Conversas';
if (path.includes('/teams') || path.includes('/users') || path.includes('/labels') || path.includes('/company/config') || path.includes('/config')) return '⚙️ Configurações';
if (path.includes('/menus')) return '📋 Fluxo';
if (path.includes('/routes')) return '📡 Rotas';
if (path.includes('/login') || path.includes('/dashboard') || path.includes('/me')) return '🔐 Autenticação';
return '📦 Outros';
}
/** Marca rotas que servem páginas HTML (não retornam JSON). */
static ehPagina(method, path) {
if (method !== 'GET' || path.indexOf('/app/') !== 0) return false;
return !path.endsWith('/me');
}
// ============================================================
// GET /api/routes
// ============================================================
static async list(req, res) {
try {
var rotas = RoutesController.listarRotas();
var cat = RoutesController.catalogo();
rotas.forEach(function (r) {
r.path = RoutesController.normalizar(r.path);
var key = r.method + ' ' + r.path;
var meta = cat[key] || {};
r.desc = meta.desc || '';
r.note = meta.note || '';
r.cat = RoutesController.categorizar(r.path);
r.auth = RoutesController.requerAuth(r.method, r.path);
r.pagina = RoutesController.ehPagina(r.method, r.path);
r.params = RoutesController.paramsDePath(r.path);
r.query = (meta.q || []).map(function (x) {
return { nome: x[0], obrigatorio: !!x[1], desc: x[2] || '' };
});
r.body = meta.body || null;
r.bodyReq = meta.req || [];
});
// Remove duplicatas por method+path (após normalizar)
var vistos = {};
rotas = rotas.filter(function (r) {
var k = r.method + ' ' + r.path;
if (vistos[k]) return false;
vistos[k] = true;
return true;
});
var metodoOrder = { GET: 1, POST: 2, PUT: 3, DELETE: 4, PATCH: 5 };
rotas.sort(function (a, b) {
var mA = metodoOrder[a.method] || 99;
var mB = metodoOrder[b.method] || 99;
if (mA !== mB) return mA - mB;
return a.path.localeCompare(b.path);
});
res.json({ success: true, data: rotas, total: rotas.length });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
}
}
module.exports = RoutesController;
+177
View File
@@ -0,0 +1,177 @@
const { execFile, spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
function logTranscricao(msg) {
try {
var logDir = path.join(__dirname, '../../logs');
if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true });
fs.appendFileSync(path.join(logDir, 'transcricao_' + new Date().toISOString().slice(0,10) + '.log'),
'[' + new Date().toISOString() + '] ' + msg + '\n', 'utf8');
} catch(e) {}
}
const WHISPER_DIR = path.resolve(__dirname, '../../whisper');
const MODEL_PATH = path.resolve(WHISPER_DIR, 'models/ggml-tiny.bin');
// Detecta binario correto por plataforma
var WHISPER_EXE;
var isWindows = os.platform() === 'win32';
if (isWindows) {
WHISPER_EXE = path.resolve(WHISPER_DIR, 'main.exe');
} else {
// Linux/Mac - tenta 'main' primeiro, fallback para 'main.exe' (via Wine)
WHISPER_EXE = path.resolve(WHISPER_DIR, 'main');
if (!fs.existsSync(WHISPER_EXE)) {
WHISPER_EXE = path.resolve(WHISPER_DIR, 'main.exe');
}
}
// Detecta ffmpeg
var ffmpegPath = 'ffmpeg'; // default: espera no PATH
if (isWindows) {
var winPaths = [
path.resolve('node_modules/ffmpeg-static/ffmpeg.exe'),
'C:/Users/Suporte 03/AppData/Local/Microsoft/WinGet/Packages/Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe/ffmpeg-8.1.1-full_build/bin/ffmpeg.exe',
];
for (var wp of winPaths) {
try { fs.accessSync(wp); ffmpegPath = wp; break; } catch(e) {}
}
if (ffmpegPath === 'ffmpeg') {
try { ffmpegPath = require('ffmpeg-static'); } catch(e) {}
}
}
/**
* Transcreve um buffer de audio usando Whisper.cpp
* @param {Buffer} audioBuffer - Buffer do audio (qualquer formato suportado pelo ffmpeg)
* @param {string} [idioma='pt'] - Codigo do idioma (pt, en, etc)
* @returns {Promise<{texto: string, duracao: number}>}
*/
function transcreverAudio(audioBuffer, idioma = 'pt') {
return new Promise((resolve, reject) => {
logTranscricao('transcreverAudio iniciado - tamanho: ' + (audioBuffer ? audioBuffer.length : 0));
if (!audioBuffer || audioBuffer.length < 100) {
logTranscricao('ERRO: Audio muito curto ou vazio');
return reject(new Error('Audio muito curto ou vazio'));
}
if (!fs.existsSync(WHISPER_EXE)) {
logTranscricao('ERRO: Whisper.exe nao encontrado em: ' + WHISPER_EXE);
return reject(new Error('Whisper.cpp nao encontrado em: ' + WHISPER_EXE));
}
if (!fs.existsSync(MODEL_PATH)) {
logTranscricao('ERRO: Modelo nao encontrado em: ' + MODEL_PATH);
return reject(new Error('Modelo nao encontrado em: ' + MODEL_PATH));
}
logTranscricao('Arquivos OK, convertendo audio...');
// Salva audio temporario em WAV (formato que whisper.cpp prefere)
var tmpDir = path.resolve(__dirname, '../../tmp');
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
var inputWav = path.join(tmpDir, 'whisper_input_' + Date.now() + '.wav');
var outputFile = path.join(tmpDir, 'whisper_output_' + Date.now());
try {
// Converte para WAV 16kHz mono usando ffmpeg
logTranscricao('Usando ffmpeg: ' + ffmpegPath);
var inputBufferPath = path.join(tmpDir, 'whisper_raw_' + Date.now() + '.ogg');
fs.writeFileSync(inputBufferPath, audioBuffer);
// Salva copia para debug
var debugPath = path.join(tmpDir, 'debug_audio_' + Date.now() + '.ogg');
fs.writeFileSync(debugPath, audioBuffer);
// Tenta converter com ffmpeg; se falhar com .ogg, tenta como .opus
var ffmpegResult = spawnSync(ffmpegPath, [
'-y', '-i', inputBufferPath,
'-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le',
inputWav
], { stdio: 'pipe', timeout: 30000 });
// Se falhou, tenta extensão .opus (WhatsApp usa opus em container ogg)
if (ffmpegResult.status !== 0) {
var inputOpus = path.join(tmpDir, 'whisper_raw_' + Date.now() + '.opus');
fs.writeFileSync(inputOpus, audioBuffer);
try { fs.unlinkSync(inputBufferPath); } catch(e) {}
inputBufferPath = inputOpus;
ffmpegResult = spawnSync(ffmpegPath, [
'-y', '-f', 'ogg', '-i', inputBufferPath,
'-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le',
inputWav
], { stdio: 'pipe', timeout: 30000 });
}
// Limpa buffer temporario
try { fs.unlinkSync(inputBufferPath); } catch(e) {}
if (ffmpegResult.status !== 0) {
var stderrMsg = (ffmpegResult.stderr || '').toString();
var errLines = stderrMsg.split('\n').filter(function(l) { return l.toLowerCase().includes('error') || l.includes('Error'); });
logTranscricao('ERRO ffmpeg: status=' + ffmpegResult.status + ' | erros: ' + (errLines.join(' | ') || stderrMsg.substring(0, 200)));
logTranscricao('Audio debug salvo em: ' + debugPath);
try { fs.unlinkSync(inputWav); } catch(e) {}
return reject(new Error('Erro ao converter audio: ' + (errLines.join(';') || stderrMsg.substring(0, 200))));
}
try { fs.unlinkSync(debugPath); } catch(e) {}
logTranscricao('Audio convertido para WAV com sucesso: ' + inputWav);
// Executa whisper.cpp
var whisperArgs = [
'-m', MODEL_PATH,
'-f', inputWav,
'-l', idioma,
'-otxt', // Saida em arquivo TXT
'-of', outputFile,
'--no-prints', // Menos output no console
];
execFile(WHISPER_EXE, whisperArgs, { timeout: 120000 }, function(err, stdout, stderr) {
logTranscricao('Whisper executado - err: ' + (err ? err.message : 'null') + ' | stdout tamanho: ' + (stdout||'').length);
try { fs.unlinkSync(inputWav); } catch(e) {}
var transcricao = '';
var txtPath = outputFile + '.txt';
if (fs.existsSync(txtPath)) {
try {
transcricao = fs.readFileSync(txtPath, 'utf8').trim();
logTranscricao('Arquivo saida: ' + transcricao.substring(0, 200));
fs.unlinkSync(txtPath);
} catch(e) { logTranscricao('Erro ler arquivo: ' + (e.message||'')); }
} else {
logTranscricao('Arquivo nao encontrado: ' + txtPath);
}
if (!transcricao && stdout) {
var linhas = stdout.split('\n').filter(function(l) { return l.includes(']'); });
transcricao = linhas.map(function(l) {
var p = l.split(']');
return p.length > 1 ? p[1].trim() : '';
}).filter(Boolean).join(' ');
logTranscricao('Fallback stdout: ' + transcricao.substring(0, 200));
}
if (!transcricao) {
transcricao = '(transcricao nao disponivel)';
logTranscricao('Transcricao vazia');
}
var duracao = Math.round((audioBuffer.length / 16000 / 2) * 100) / 100;
logTranscricao('Resultado: texto=' + transcricao.substring(0, 100) + ' duracao=' + duracao);
resolve({ texto: transcricao, duracao: duracao });
});
} catch(e) {
// Limpa arquivos em caso de erro
try { fs.unlinkSync(inputWav); } catch(ex) {}
reject(e);
}
});
}
module.exports = { transcreverAudio };
File diff suppressed because it is too large Load Diff
+682
View File
@@ -0,0 +1,682 @@
/**
* CAMADA DE BANCO DE DADOS (arquivo único)
* ========================================
* Concentra tudo que diz respeito a banco de dados em UM lugar:
*
* 1) CONFIGURAÇÃO — aliases, drivers, conexões estáticas e customizadas
* 2) DRIVER FIREBIRD
* 3) DRIVER POSTGRES (pool + tradutor de SQL + schema)
* 4) DISPATCHER — API pública usada por todo o sistema
*
* Suporta múltiplos drivers ('postgres' padrão, 'firebird' legado). Cada
* conexão declara o campo `driver`. Conexões adicionadas em runtime (via API
* /api/databases) são persistidas em databases_custom.json (ao lado deste
* arquivo) — é um arquivo de DADOS, não de código.
*/
const path = require('path');
const fs = require('fs');
// ============================================================
// 1) CONFIGURAÇÃO
// ============================================================
const CUSTOM_DB_PATH = path.resolve(__dirname, 'databases_custom.json');
// Conexões dinâmicas salvas em arquivo JSON
var customDatabases = {};
try {
if (fs.existsSync(CUSTOM_DB_PATH)) {
customDatabases = JSON.parse(fs.readFileSync(CUSTOM_DB_PATH, 'utf8'));
}
} catch (e) {
console.error('[Databases] Erro ao carregar databases_custom.json:', e.message);
}
const DRIVERS = ['postgres', 'firebird'];
const DEFAULT_DRIVER = (process.env.DB_DRIVER || 'postgres').toLowerCase();
// Valores padrão por driver
const DRIVER_DEFAULTS = {
postgres: {
host: '127.0.0.1',
port: 5432,
user: 'postgres',
password: 'postgres',
schema: 'public',
ssl: false,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 10000,
},
firebird: {
host: 'localhost',
port: 3050,
user: 'SYSDBA',
password: 'masterkey',
encoding: 'UTF-8',
lowercase_keys: false,
pageSize: 4096,
wireCrypt: 1, // WIRE_CRYPT_ENABLE
},
};
/**
* Conexões estáticas.
* - `novo_local` → PostgreSQL (conexão principal)
* - `firebird_local`→ Firebird (informe o CAMINHO do arquivo .FDB em `database`)
* Sobrescrevíveis pelo .env (PG_* para Postgres, DB_* para Firebird).
*/
const databases = {
novo_local: {
driver: 'postgres',
host: process.env.PG_HOST || '127.0.0.1',
port: parseInt(process.env.PG_PORT, 10) || 15433,
user: process.env.PG_USER || 'postgres',
password: process.env.PG_PASSWORD || 'postgres',
database: process.env.PG_DATABASE || 'novo_local',
schema: process.env.PG_SCHEMA || 'public',
},
firebird_local: {
driver: 'firebird',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT, 10) || 3050,
// CAMINHO do arquivo .FDB: defina DB_DATABASE no .env (absoluto) ou ajuste
// o path.resolve abaixo. Ex.: path.resolve(__dirname, '../db/NOVO.FDB').
database: process.env.DB_DATABASE || path.resolve(__dirname, '../NOVO.FDB'),
user: process.env.DB_USER || 'SYSDBA',
password: process.env.DB_PASSWORD || 'masterkey',
encoding: process.env.DB_ENCODING || 'UTF-8',
lowercase_keys: false,
pageSize: 4096,
wireCrypt: 1, // WIRE_CRYPT_ENABLE
},
};
function salvarCustomDatabases() {
try {
fs.writeFileSync(CUSTOM_DB_PATH, JSON.stringify(customDatabases, null, 2), 'utf8');
return true;
} catch (e) {
console.error('[Databases] Erro ao salvar databases_custom.json:', e.message);
return false;
}
}
/** Normaliza uma config bruta aplicando o driver e seus defaults. */
function normalize(raw) {
const driver = DRIVERS.includes((raw.driver || '').toLowerCase())
? 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 {
driver,
host: raw.host || d.host,
port: raw.port || d.port,
database: raw.database,
user: raw.user || d.user,
password: raw.password || d.password,
encoding: raw.encoding || d.encoding,
lowercase_keys: raw.lowercase_keys || d.lowercase_keys,
role: raw.role || null,
pageSize: raw.pageSize || d.pageSize,
wireCrypt: raw.wireCrypt !== undefined ? raw.wireCrypt : d.wireCrypt,
};
}
/** Configuração normalizada (inclui `driver`) de um alias. */
function getConfig(alias) {
if (!alias) throw new Error('Alias do banco não informado.');
const aliasLower = alias.toLowerCase();
const raw = customDatabases[aliasLower] || databases[aliasLower];
if (!raw) {
throw new Error(`Alias "${alias}" não encontrado. Aliases disponíveis: ${listAliases().join(', ')}`);
}
return normalize(raw);
}
/** Lista de aliases disponíveis. */
function listAliases() {
return Object.keys(databases).concat(
Object.keys(customDatabases).filter((a) => !databases[a])
);
}
/** Todos os aliases com suas configs (custom + estáticas). */
function listAllDatabases() {
var result = {};
Object.keys(databases).forEach(function (alias) {
result[alias] = Object.assign({}, databases[alias], { _tipo: 'estatico' });
});
Object.keys(customDatabases).forEach(function (alias) {
result[alias] = Object.assign({}, customDatabases[alias], { _tipo: 'custom' });
});
return result;
}
/** Adiciona/atualiza uma conexão customizada. */
function addDatabase(alias, config) {
if (!alias || !config || !config.database) {
throw new Error('Alias e database são obrigatórios.');
}
var aliasLower = alias.toLowerCase().replace(/[^a-z0-9_]/g, '_');
var driver = DRIVERS.includes((config.driver || '').toLowerCase())
? config.driver.toLowerCase()
: DEFAULT_DRIVER;
var stored = { driver: driver };
['host', 'port', 'database', 'schema', 'user', 'password', 'ssl',
'encoding', 'lowercase_keys', 'role', 'pageSize', 'wireCrypt',
'max', 'idleTimeoutMillis', 'connectionTimeoutMillis']
.forEach(function (k) {
if (config[k] !== undefined && config[k] !== null && config[k] !== '') {
stored[k] = config[k];
}
});
customDatabases[aliasLower] = stored;
return salvarCustomDatabases();
}
/** Remove uma conexão customizada. */
function removeDatabase(alias) {
if (!alias) return false;
var aliasLower = alias.toLowerCase();
if (!customDatabases[aliasLower]) return false;
delete customDatabases[aliasLower];
return salvarCustomDatabases();
}
// ============================================================
// 2) DRIVER FIREBIRD
// ============================================================
const firebirdDriver = (() => {
const Firebird = require('node-firebird');
function readBlob(blobFunc) {
return new Promise(function (resolve, reject) {
if (typeof blobFunc !== 'function') return resolve(blobFunc);
blobFunc(function (err, name, emitter) {
if (err) return reject(err);
if (!emitter || typeof emitter.on !== 'function') return resolve(null);
var chunks = [];
var total = 0;
emitter.on('data', function (c) { chunks.push(c); total += c.length; });
emitter.on('end', function () { resolve(Buffer.concat(chunks, total)); });
emitter.on('error', reject);
});
});
}
function query(config, sql, params = []) {
return new Promise((resolve, reject) => {
Firebird.attach(config, (err, db) => {
if (err) return reject(new Error(`Erro ao conectar (firebird): ${err.message}`));
var allRows = [];
db.sequentially(sql, params, function (row) {
var keys = Object.keys(row);
var blobPromises = keys.map(function (key) {
var val = row[key];
if (typeof val === 'function') {
return readBlob(val).then(function (data) { row[key] = data; });
}
return Promise.resolve();
});
return Promise.all(blobPromises).then(function () { allRows.push(row); });
}, function (queryErr) {
db.detach();
if (queryErr) return reject(new Error(`Erro na consulta (firebird): ${queryErr.message}`));
resolve(allRows);
});
});
});
}
function execute(config, sql, params = []) {
return new Promise((resolve, reject) => {
Firebird.attach(config, (err, db) => {
if (err) return reject(new Error(`Erro ao conectar (firebird): ${err.message}`));
db.transaction(Firebird.ISOLATION_READ_COMMITTED, (transErr, transaction) => {
if (transErr) {
db.detach();
return reject(new Error(`Erro ao iniciar transação (firebird): ${transErr.message}`));
}
transaction.query(sql, params, (queryErr, result) => {
if (queryErr) {
transaction.rollback();
db.detach();
return reject(new Error(`Erro na execução (firebird): ${queryErr.message}`));
}
transaction.commit((commitErr) => {
db.detach();
if (commitErr) return reject(new Error(`Erro ao commitar (firebird): ${commitErr.message}`));
resolve({ affectedRows: result ? result.length : 0, result });
});
});
});
});
});
}
async function testConnection(config) {
await query(config, 'SELECT 1 FROM RDB$DATABASE');
return true;
}
async function listTables(config) {
const rows = await query(config, `
SELECT TRIM(RDB$RELATION_NAME) AS TABLE_NAME
FROM RDB$RELATIONS
WHERE RDB$SYSTEM_FLAG = 0 AND RDB$RELATION_TYPE = 0
ORDER BY RDB$RELATION_NAME
`);
return rows.map((r) => (r.TABLE_NAME || '').trim()).filter(Boolean);
}
async function tableInfo(config, tableName) {
const rows = await query(config, `
SELECT
rf.RDB$FIELD_NAME AS COLUMN_NAME,
rf.RDB$FIELD_POSITION AS ORDINAL_POSITION,
CASE f.RDB$FIELD_TYPE
WHEN 7 THEN 'SMALLINT' WHEN 8 THEN 'INTEGER' WHEN 16 THEN 'BIGINT'
WHEN 9 THEN 'QUAD' WHEN 10 THEN 'FLOAT' WHEN 27 THEN 'DOUBLE PRECISION'
WHEN 12 THEN 'DATE' WHEN 13 THEN 'TIME' WHEN 35 THEN 'TIMESTAMP'
WHEN 37 THEN 'VARCHAR' WHEN 40 THEN 'CSTRING' WHEN 45 THEN 'BLOB_ID'
WHEN 261 THEN 'BLOB' WHEN 14 THEN 'CHAR' WHEN 41 THEN 'NUMERIC'
ELSE 'UNKNOWN'
END AS DATA_TYPE,
f.RDB$FIELD_LENGTH AS FIELD_LENGTH,
f.RDB$FIELD_SCALE AS FIELD_SCALE,
f.RDB$FIELD_PRECISION AS FIELD_PRECISION,
rf.RDB$NULL_FLAG AS NULL_FLAG
FROM RDB$RELATION_FIELDS rf
INNER JOIN RDB$FIELDS f ON rf.RDB$FIELD_SOURCE = f.RDB$FIELD_NAME
WHERE rf.RDB$RELATION_NAME = ?
ORDER BY rf.RDB$FIELD_POSITION
`, [String(tableName).toUpperCase()]);
return rows.map((row) => ({
name: (row.COLUMN_NAME || '').trim(),
position: row.ORDINAL_POSITION,
type: (row.DATA_TYPE || '').trim(),
length: row.FIELD_LENGTH,
precision: row.FIELD_PRECISION,
scale: row.FIELD_SCALE,
nullable: row.NULL_FLAG !== 1,
}));
}
async function tableExists(config, tableName) {
const r = await query(config,
"SELECT COUNT(*) AS CT FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = ?",
[String(tableName).toUpperCase()]);
return (r[0] && r[0].CT > 0) || false;
}
async function columnExists(config, tableName, columnName) {
const r = await query(config,
"SELECT COUNT(*) AS CT FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = ? AND RDB$FIELD_NAME = ?",
[String(tableName).toUpperCase(), String(columnName).toUpperCase()]);
return (r[0] && r[0].CT > 0) || false;
}
async function close() { /* 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)
// ============================================================
const postgresDriver = (() => {
const pg = require('pg');
// COUNT()/bigint chegam como string no pg; o código histórico usa números.
pg.types.setTypeParser(20, (v) => (v === null ? null : parseInt(v, 10))); // int8 / bigint
pg.types.setTypeParser(1700, (v) => (v === null ? null : parseFloat(v))); // numeric / decimal
const pools = new Map();
function safeSchema(schema) {
const s = String(schema || 'public').trim();
return /^[A-Za-z_][A-Za-z0-9_$]*$/.test(s) ? s : 'public';
}
function poolKey(config) {
return [config.host, config.port, config.database, config.user, safeSchema(config.schema)].join('|');
}
// Schemas que o pool enxerga (configurado + public como fallback)
function schemasFor(config) {
const schema = safeSchema(config.schema);
return schema === 'public' ? ['public'] : [schema, 'public'];
}
function getEntry(config) {
const key = poolKey(config);
let entry = pools.get(key);
if (!entry) {
const schemas = schemasFor(config);
const pool = new pg.Pool({
host: config.host,
port: config.port,
database: config.database,
user: config.user,
password: config.password,
ssl: config.ssl ? { rejectUnauthorized: false } : false,
options: '-c search_path=' + schemas.join(','),
max: config.max || 10,
idleTimeoutMillis: config.idleTimeoutMillis || 30000,
connectionTimeoutMillis: config.connectionTimeoutMillis || 10000,
});
pool.on('error', (err) => console.error('[postgres] Erro inesperado no pool:', err.message));
entry = { pool, schemas, tableSet: null, loadingTables: null };
pools.set(key, entry);
}
return entry;
}
// Carrega (uma vez por pool) os nomes de tabela MAIÚSCULOS p/ decidir aspas
async function ensureTableSet(entry) {
if (entry.tableSet) return entry.tableSet;
if (!entry.loadingTables) {
entry.loadingTables = entry.pool
.query('SELECT table_name FROM information_schema.tables WHERE table_schema = ANY($1)', [entry.schemas])
.then((res) => {
entry.tableSet = new Set(res.rows.map((r) => String(r.table_name).toUpperCase()));
return entry.tableSet;
})
.catch((err) => {
console.error('[postgres] Falha ao carregar nomes de tabela:', err.message);
entry.tableSet = new Set();
return entry.tableSet;
});
}
return entry.loadingTables;
}
// ? -> $1, $2, ... (ignora literais de string)
function convertPlaceholders(sql) {
let out = '', i = 0, n = 1, inStr = false;
while (i < sql.length) {
const ch = sql[i];
if (inStr) {
out += ch;
if (ch === "'") {
if (sql[i + 1] === "'") { out += sql[i + 1]; i += 2; continue; }
inStr = false;
}
i++;
continue;
}
if (ch === "'") { inStr = true; out += ch; i++; continue; }
if (ch === '?') { out += '$' + (n++); i++; continue; }
out += ch; i++;
}
return out;
}
// Aspas nos nomes de tabela conhecidos (MAIÚSCULOS) após FROM/JOIN/INTO/UPDATE/ALTER/DROP
function quoteTableNames(sql, tableSet) {
if (!tableSet || tableSet.size === 0) return sql;
return sql.replace(
/(\b(?:FROM|JOIN|INTO|UPDATE|ALTER\s+TABLE|DROP\s+TABLE)\s+)("?)([A-Za-z_][A-Za-z0-9_$]*)("?)/gi,
(match, kw, q1, name, q2) => {
if (q1 === '"' || q2 === '"') return match;
if (tableSet.has(name.toUpperCase())) return kw + '"' + name.toUpperCase() + '"';
return match;
}
);
}
// Rede de segurança: FIRST n [SKIP m] no topo -> LIMIT/OFFSET (SQL legado)
function translateFirstSkip(sql) {
return sql.replace(
/^(\s*SELECT\s+)FIRST\s+(\d+)(?:\s+SKIP\s+(\d+))?\s+/i,
(m, sel, first, skip) => sel + ' LIMITTAIL LIMIT ' + first + (skip ? ' OFFSET ' + skip : '') + ' '
);
}
function applyLimitTail(sql) {
const marker = / LIMITTAIL( LIMIT \d+(?: OFFSET \d+)?) /;
const m = sql.match(marker);
if (!m) return sql;
return sql.replace(marker, ' ').trimEnd() + m[1];
}
function translateSql(sql, tableSet) {
let out = sql;
out = out.replace(/\bCONTAINING\s+(\?|\$\d+|'(?:[^']|'')*')/gi, "ILIKE ('%' || $1 || '%')");
out = out.replace(/\bFROM\s+RDB\$DATABASE\b/gi, '');
out = quoteTableNames(out, tableSet);
out = translateFirstSkip(out);
out = applyLimitTail(out);
out = convertPlaceholders(out);
return out;
}
function upperKeys(rows) {
return rows.map((row) => {
const o = {};
for (const k in row) o[k.toUpperCase()] = row[k];
return o;
});
}
async function query(config, sql, params = []) {
const entry = getEntry(config);
await ensureTableSet(entry);
const text = translateSql(sql, entry.tableSet);
try {
const res = await entry.pool.query(text, params);
return upperKeys(res.rows);
} catch (err) {
throw new Error(`Erro na consulta (postgres): ${err.message}`);
}
}
async function execute(config, sql, params = []) {
const entry = getEntry(config);
await ensureTableSet(entry);
const text = translateSql(sql, entry.tableSet);
try {
const res = await entry.pool.query(text, params);
return { affectedRows: res.rowCount || 0, result: upperKeys(res.rows || []) };
} catch (err) {
throw new Error(`Erro na execução (postgres): ${err.message}`);
}
}
async function testConnection(config) {
const entry = getEntry(config);
await entry.pool.query('SELECT 1');
return true;
}
async function listTables(config) {
const entry = getEntry(config);
const res = await entry.pool.query(
`SELECT table_name FROM information_schema.tables
WHERE table_schema = $1 AND table_type = 'BASE TABLE'
ORDER BY table_name`,
[safeSchema(config.schema)]
);
return res.rows.map((r) => r.table_name);
}
async function tableInfo(config, tableName) {
const entry = getEntry(config);
// Resolve no primeiro schema do search_path que contém a tabela
const res = await entry.pool.query(
`SELECT column_name, ordinal_position, data_type,
character_maximum_length, numeric_precision, numeric_scale, is_nullable
FROM information_schema.columns
WHERE UPPER(table_name) = UPPER($2)
AND table_schema = (
SELECT table_schema FROM information_schema.tables
WHERE UPPER(table_name) = UPPER($2) AND table_schema = ANY($1)
ORDER BY array_position($1, table_schema) LIMIT 1
)
ORDER BY ordinal_position`,
[entry.schemas, tableName]
);
return res.rows.map((row) => ({
name: row.column_name,
position: row.ordinal_position,
type: (row.data_type || '').toUpperCase(),
length: row.character_maximum_length,
precision: row.numeric_precision,
scale: row.numeric_scale,
nullable: row.is_nullable === 'YES',
}));
}
async function tableExists(config, tableName) {
const entry = getEntry(config);
await ensureTableSet(entry);
if (entry.tableSet) return entry.tableSet.has(String(tableName).toUpperCase());
const res = await entry.pool.query(
`SELECT 1 FROM information_schema.tables
WHERE table_schema = ANY($1) AND UPPER(table_name) = UPPER($2) LIMIT 1`,
[entry.schemas, tableName]
);
return res.rowCount > 0;
}
async function columnExists(config, tableName, columnName) {
const entry = getEntry(config);
const res = await entry.pool.query(
`SELECT 1 FROM information_schema.columns
WHERE table_schema = ANY($1) AND UPPER(table_name) = UPPER($2)
AND UPPER(column_name) = UPPER($3) LIMIT 1`,
[entry.schemas, tableName, columnName]
);
return res.rowCount > 0;
}
async function close() {
const all = Array.from(pools.values()).map((e) => e.pool.end().catch(() => {}));
pools.clear();
await Promise.all(all);
}
return {
query, execute, testConnection, listTables, tableInfo, tableExists, columnExists, close,
_translateSql: translateSql, // exportado para testes
};
})();
// ============================================================
// 4) DISPATCHER — API pública
// ============================================================
const drivers = { postgres: postgresDriver, firebird: firebirdDriver };
function getDriver(alias) {
const config = getConfig(alias);
const driver = drivers[config.driver];
if (!driver) {
throw new Error(`Driver "${config.driver}" não suportado para o alias "${alias}".`);
}
return { config, driver };
}
function query(alias, sql, params = []) {
let d;
try { d = getDriver(alias); } catch (err) { return Promise.reject(err); }
return d.driver.query(d.config, sql, params).catch((err) => { throw new Error(`[${alias}] ${err.message}`); });
}
function execute(alias, sql, params = []) {
let d;
try { d = getDriver(alias); } catch (err) { return Promise.reject(err); }
return d.driver.execute(d.config, sql, params).catch((err) => { throw new Error(`[${alias}] ${err.message}`); });
}
async function testConnection(alias) {
try {
const { config, driver } = getDriver(alias);
await driver.testConnection(config);
return true;
} catch (err) {
console.error(`[${alias}] Falha na conexão:`, err.message);
return false;
}
}
async function testAllConnections() {
const results = {};
for (const alias of listAliases()) {
results[alias] = await testConnection(alias);
}
return results;
}
function listTables(alias) {
const { config, driver } = getDriver(alias);
return driver.listTables(config);
}
function tableInfo(alias, tableName) {
const { config, driver } = getDriver(alias);
return driver.tableInfo(config, tableName);
}
function tableExists(alias, tableName) {
const { config, driver } = getDriver(alias);
return driver.tableExists(config, tableName);
}
function columnExists(alias, tableName, columnName) {
const { config, driver } = getDriver(alias);
return driver.columnExists(config, tableName, columnName);
}
/** Nome do driver de um alias (ex.: 'postgres'). */
function driverOf(alias) {
return getConfig(alias).driver;
}
/** Encerra os pools de todos os drivers (shutdown gracioso). */
async function closeAll() {
await Promise.all(Object.values(drivers).map((d) => d.close && d.close()));
}
module.exports = {
// Acesso a dados
query,
execute,
testConnection,
testAllConnections,
listTables,
tableInfo,
tableExists,
columnExists,
driverOf,
closeAll,
// Configuração / aliases
databases,
DRIVERS,
DEFAULT_DRIVER,
getConfig,
listAliases,
listAllDatabases,
addDatabase,
removeDatabase,
};
+149
View File
@@ -0,0 +1,149 @@
const jwt = require('jsonwebtoken');
const db = require('../database');
const authConfig = require('../config/auth');
/**
* Middleware de autenticação
* Aceita duas formas de autenticação:
* 1. JWT via header: Authorization: Bearer <jwt>
* 2. USU_TOKEN via header: X-Usu-Token: <token> (ou Authorization: Bearer <token>)
*
* Deve ser aplicado apenas nas rotas que exigem autenticação.
* O alias do banco é extraído de req.params.alias.
*/
async function authenticateToken(req, res, next) {
// Extrai o token dos headers
const authHeader = req.headers['authorization'];
const usuTokenHeader = req.headers['x-usu-token'];
let token = null;
let tokenSource = null; // 'jwt' ou 'usu_token'
// 1. Se veio X-Usu-Token, é USU_TOKEN direto
if (usuTokenHeader) {
token = usuTokenHeader;
tokenSource = 'usu_token';
}
// 2. Se veio Authorization, extrai o Bearer
else if (authHeader) {
const parts = authHeader.split(' ');
if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') {
token = parts[1];
tokenSource = 'unknown'; // Tenta JWT primeiro, depois USU_TOKEN
}
}
if (!token) {
return res.status(401).json({
success: false,
error: 'Token de acesso não fornecido. Use Authorization: Bearer <token> ou X-Usu-Token: <token>.',
});
}
// --- Tenta validar como JWT ---
if (tokenSource !== 'usu_token') {
try {
const decoded = jwt.verify(token, authConfig.secret, { issuer: authConfig.issuer });
req.user = decoded;
req.authType = 'jwt';
return next();
} catch (jwtErr) {
if (tokenSource === 'unknown') {
// JWT inválido/expirado — tenta como USU_TOKEN
tokenSource = 'usu_token';
} else {
if (jwtErr.name === 'TokenExpiredError') {
return res.status(401).json({
success: false,
error: 'Token JWT expirado. Faça login novamente.',
});
}
return res.status(403).json({
success: false,
error: 'Token JWT inválido.',
});
}
}
}
// --- Tenta validar como USU_TOKEN ---
if (tokenSource === 'usu_token') {
try {
const alias = req.params.alias;
if (!alias) {
return res.status(400).json({
success: false,
error: 'Alias do banco não informado na URL.',
});
}
const result = await db.query(
alias,
`SELECT USU_CODIGO_ID, USU_NIVEL_ID, USU_NOME, USU_LOGIN,
USU_EMAIL, USU_STATUS, USU_TIPO, USU_ACESSO_WEB
FROM USUARIOS
WHERE USU_TOKEN = ?
AND USU_STATUS = 'A'
AND COALESCE(USU_ACESSO_WEB, 0) = 1`,
[token]
);
if (result.length === 0) {
return res.status(403).json({
success: false,
error: 'Token inválido ou usuário sem permissão.',
});
}
const user = result[0];
// Busca as empresas que o usuário tem acesso
let empresasIds = [];
try {
const empresas = await db.query(alias,
'SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA WHERE USE_USUARIO_ID = ?',
[user.USU_CODIGO_ID]
);
empresasIds = empresas.map(e => e.USE_EMPRESA_ID);
} catch (e) {
// Se a tabela não existir, ignora
}
req.user = {
id: user.USU_CODIGO_ID,
nivelId: user.USU_NIVEL_ID,
nome: user.USU_NOME.trim(),
login: user.USU_LOGIN.trim(),
email: user.USU_EMAIL ? user.USU_EMAIL.trim() : null,
tipo: user.USU_TIPO ? user.USU_TIPO.trim() : null,
alias,
empresas: empresasIds,
authType: 'usu_token',
};
req.authType = 'usu_token';
return next();
} catch (dbErr) {
console.error('Erro ao validar USU_TOKEN:', dbErr.message);
// Alias inexistente na URL → 404 (mensagem genérica, sem listar aliases)
if (dbErr.message.includes('não encontrado') || dbErr.message.includes('Alias')) {
return res.status(404).json({
success: false,
error: 'Alias não encontrado.',
});
}
// Qualquer outra falha de validação → nega o acesso (fail closed)
return res.status(403).json({
success: false,
error: 'Token inválido ou não pôde ser validado.',
});
}
}
return res.status(401).json({
success: false,
error: 'Token de acesso não fornecido.',
});
}
module.exports = authenticateToken;
+27
View File
@@ -0,0 +1,27 @@
/**
* Verificação de papel (Gerente)
* ==============================
* Determina se o usuário autenticado é Gerente (USU_TIPO_CHAT = 'G').
* - Auth via JWT: o papel já vem no token (req.user.tipoChat).
* - Auth via USU_TOKEN: o papel é consultado no banco pelo id do usuário.
*/
const db = require('../database');
async function isGerente(req) {
if (req && req.user && req.user.tipoChat) {
return String(req.user.tipoChat).trim() === 'G';
}
// Fallback (USU_TOKEN): consulta o tipo no banco
if (req && req.user && req.user.id && req.params && req.params.alias) {
try {
const r = await db.query(req.params.alias,
'SELECT USU_TIPO_CHAT FROM USUARIOS WHERE USU_CODIGO_ID = ?', [req.user.id]);
return r.length > 0 && String(r[0].USU_TIPO_CHAT || '').trim() === 'G';
} catch (e) {
return false;
}
}
return false;
}
module.exports = { isGerente };
+182
View File
@@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Conversas - Chatc2</title>
<link rel="stylesheet" href="/css/main.css">
<style>
body { background:#f3f4f6; display:flex; min-height:100vh; }
.sidebar-nav .admin-only { display:none; }
.main { flex:1; display:flex; flex-direction:column; min-width:0; }
.container { flex:1; padding:24px; overflow-y:auto; }
.filters { display:flex; gap:10px; margin-bottom:20px; flex-wrap:wrap; align-items:center; }
.filters select, .filters input { padding:9px 14px; border:2px solid #e5e7eb; border-radius:8px; font-size:13px; outline:none; background:#fff; color:#374151; transition:border-color .15s; }
.filters select:focus, .filters input:focus { border-color:#667eea; }
.filters button { padding:9px 16px; background:#667eea; color:#fff; border:none; border-radius:8px; font-size:13px; font-weight:600; cursor:pointer; transition:background .15s; }
.filters button:hover { background:#5a67d8; }
.status-badge { display:inline-block; padding:3px 10px; border-radius:10px; font-size:11px; font-weight:600; }
.status-E { background:#fef3c7; color:#92400e; }
.status-A { background:#d1fae5; color:#065f46; }
.status-F { background:#f3f4f6; color:#6b7280; }
.atender-btn { padding:5px 12px; border:none; border-radius:6px; background:#667eea; color:#fff; font-size:12px; font-weight:600; cursor:pointer; text-decoration:none; transition:background .15s; display:inline-block; }
.atender-btn:hover { background:#5a67d8; }
.loading { text-align:center; padding:40px; color:#9ca3af; }
</style>
<link rel="stylesheet" href="/css/dark-mode.css">
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});}
window.darkModeApply=function(a){var e=document.body;if(!e)return;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'});};
window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')==='true';};</script>
</head>
<body>
<aside class="sidebar">
<div class="sidebar-brand"><div class="logo">C2</div><div><h2>Chatc2</h2><span id="sidebarAlias">-</span></div></div>
<nav class="sidebar-nav">
<div class="nav-label">Principal</div>
<a href="#" id="navDashboard"><span class="icon">📊</span> Dashboard</a>
<a href="#" id="navClients"><span class="icon">👥</span> Clientes</a>
<a href="#" class="active" id="navChat"><span class="icon">💬</span> Conversas</a>
<div class="nav-label admin-only" id="adminLabel">Administrador</div>
<a href="#" id="navConfig" class="admin-only"><span class="icon">⚙️</span> Configurações</a>
<a href="#" id="navRoutes" class="admin-only"><span class="icon">📡</span> Rotas</a>
</nav>
<div class="sidebar-footer">
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button>
<a onclick="logout()"><span class="icon">🚪</span> Sair</a></div>
</aside>
<div class="main">
<div class="topbar">
<span class="topbar-title">💬 Todas as Conversas</span>
<span id="totalInfo" style="font-size:13px;color:#6b7280"></span>
</div>
<div class="container">
<div class="filters">
<select id="filterStatus" onchange="carregar()">
<option value="A,E">Em aberto</option>
<option value="E">Em espera</option>
<option value="A">Em atendimento</option>
<option value="F">Finalizadas</option>
<option value="A,E,F">Todas</option>
</select>
<select id="filterAtribuicao" onchange="carregar()">
<option value="">Todas</option>
<option value="me">Minhas conversas</option>
<option value="sem">Sem atendente</option>
</select>
<input type="text" id="buscaContato" placeholder="Buscar por nome ou número..." onkeyup="if(event.key==='Enter')carregar()">
<button onclick="carregar()">🔄 Atualizar</button>
</div>
<div class="card" id="tabelaContainer">
<div class="loading"><div class="spinner"></div><p style="margin-top:8px">Carregando conversas...</p></div>
</div>
</div>
</div>
<script>
(function(){
'use strict';
const token = localStorage.getItem('chatc2_token');
const pathParts = window.location.pathname.split('/');
const alias = pathParts[2] || localStorage.getItem('chatc2_alias') || 'lajedo';
localStorage.setItem('chatc2_alias', alias);
const user = JSON.parse(localStorage.getItem('chatc2_user') || '{}');
const empresaId = user.empresas?.[0] || 1;
if (!token) { window.location.href = '/app/' + alias + '/login'; return; }
document.getElementById('sidebarAlias').textContent = alias;
function navClick(e, url) { e.preventDefault(); window.location.href = url; }
document.getElementById('navDashboard').onclick = function(e) { navClick(e, '/app/'+alias+'/dashboard'); };
document.getElementById('navClients').onclick = function(e) { navClick(e, '/app/'+alias+'/clients'); };
document.getElementById('navChat').onclick = function(e) { e.preventDefault(); carregar(); };
document.getElementById('navConfig').onclick = function(e) { navClick(e, '/app/'+alias+'/settings'); };
document.getElementById('navRoutes').onclick = function(e) { navClick(e, '/app/'+alias+'/routes'); };
const tc = user.tipoChat || 'A';
if (tc === 'G') {
document.querySelectorAll('.admin-only').forEach(function(el) { el.style.display = ''; });
}
window.logout = function() {
['chatc2_token','chatc2_alias','chatc2_user'].forEach(function(k) { localStorage.removeItem(k); });
window.location.href = '/app/' + alias + '/login';
};
function api(path) {
return fetch('/api/' + alias + path, { headers: { 'Authorization': 'Bearer ' + token } }).then(function(r) { return r.json(); });
}
window.carregar = async function() {
const container = document.getElementById('tabelaContainer');
container.innerHTML = '<div class="loading"><div class="spinner"></div><p style="margin-top:8px">Carregando conversas...</p></div>';
const status = document.getElementById('filterStatus').value;
const atrib = document.getElementById('filterAtribuicao').value;
let busca = document.getElementById('buscaContato').value.trim();
const data = await api('/conversations?empresaId=' + empresaId + '&status=' + status);
if (!data.success) {
container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div><p>' + data.error + '</p></div>';
return;
}
let convs = data.data || [];
// Filtro de atribuição
if (atrib === 'me') {
convs = convs.filter(function(c) { return c.usuarioId === user.id; });
} else if (atrib === 'sem') {
convs = convs.filter(function(c) { return !c.usuarioId; });
}
// Filtro de busca
if (busca) {
const q = busca.toLowerCase();
convs = convs.filter(function(c) {
return (c.nomeContato && c.nomeContato.toLowerCase().includes(q)) ||
(c.numero && c.numero.includes(q));
});
}
document.getElementById('totalInfo').textContent = convs.length + ' conversa(s)';
if (convs.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="icon">💬</div><p>Nenhuma conversa encontrada</p></div>';
return;
}
let html = '<table><thead><tr><th>Contato</th><th>Número</th><th style="width:100px">Status</th><th>Atendente</th><th>Última msg</th><th style="width:80px">Ação</th></tr></thead><tbody>';
convs.forEach(function(c) {
const statusLabel = c.status === 'E' ? '🟡 Espera' : c.status === 'A' ? '🟢 Atendimento' : '⚫ Finalizado';
const labelsHtml = (c.labels || []).map(function(l) {
return '<span style="display:inline-block;padding:0 6px;border-radius:3px;background:'+(l.cor||'#667eea')+';color:#fff;font-size:10px;margin:1px">'+(l.nome||'')+'</span>';
}).join('');
const ultima = (c.ultimaMsg || '').substring(0, 60);
const usuarioNome = c.usuarioId ? 'Usuário ' + c.usuarioId : '-';
html += '<tr>' +
'<td><strong>' + (c.nomeContato || 'Desconhecido') + '</strong>' + (labelsHtml ? '<br>' + labelsHtml : '') + '</td>' +
'<td>' + (c.numero || '-') + '</td>' +
'<td><span class="status-badge status-' + c.status + '">' + statusLabel + '</span></td>' +
'<td style="font-size:12px">' + usuarioNome + '</td>' +
'<td style="font-size:12px;color:#6b7280;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + ultima + '</td>' +
'<td><a href="/app/' + alias + '/company/' + empresaId + '/conversation/' + c.id + '" class="atender-btn">Atender</a></td>' +
'</tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
};
carregar();
// Auto refresh
setInterval(carregar, 10000);
})();
</script>
<script src="/js/dark-mode.js"></script>
</body>
</html>
+1548
View File
File diff suppressed because it is too large Load Diff
+741
View File
@@ -0,0 +1,741 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Detalhes do Cliente - Chatc2</title>
<link rel="stylesheet" href="/css/main.css">
<style>
body { background:#f3f4f6; display:flex; min-height:100vh; }
.sidebar-nav .admin-only { display:none; }
.main { flex:1; display:flex; flex-direction:column; min-width:0; }
.container { flex:1; padding:24px; overflow-y:auto; }
.loading { text-align:center; padding:80px 20px; color:#9ca3af; }
.error-box { background:#fef2f2; border:1px solid #fecaca; border-radius:12px; padding:32px; text-align:center; color:#991b1b; }
.error-box .icon { font-size:48px; margin-bottom:12px; }
.client-header { background:#fff; border-radius:14px; padding:24px; box-shadow:0 1px 4px rgba(0,0,0,0.08); margin-bottom:20px; display:flex; align-items:center; gap:20px; border:1px solid #f3f4f6; }
.client-avatar { width:68px; height:68px; border-radius:50%; background:linear-gradient(135deg,#667eea,#764ba2); display:flex; align-items:center; justify-content:center; color:#fff; font-size:26px; font-weight:800; flex-shrink:0; box-shadow:0 4px 14px rgba(102,126,234,0.3); }
.client-header-info h1 { font-size:22px; font-weight:800; color:#111827; margin-bottom:4px; letter-spacing:-0.3px; }
.client-header-info .matricula { font-size:14px; color:#6b7280; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:18px; margin-bottom:20px; }
.card h3 { font-size:12px; text-transform:uppercase; letter-spacing:0.07em; color:#9ca3af; margin-bottom:14px; font-weight:700; }
.field { margin-bottom:12px; }
.field:last-child { margin-bottom:0; }
.field .label { font-size:11px; text-transform:uppercase; letter-spacing:0.06em; color:#9ca3af; margin-bottom:3px; font-weight:600; }
.field .value { font-size:15px; color:#111827; font-weight:500; }
.carnes-section { margin-top:20px; }
.carnes-filters { display:flex; gap:10px; margin-bottom:16px; flex-wrap:wrap; align-items:center; }
.carnes-filters label { display:flex; align-items:center; gap:6px; font-size:13px; color:#374151; cursor:pointer; padding:7px 14px; border-radius:8px; border:1px solid #e5e7eb; background:#fff; transition:all 0.15s; user-select:none; font-weight:500; }
.carnes-filters label:hover { border-color:#667eea; color:#667eea; }
.carnes-filters label.filtro-ativo { border-color:#667eea; background:#eef2ff; color:#667eea; font-weight:600; }
.carnes-filters label input { accent-color:#667eea; }
table { font-size:13px; }
.valor { text-align:right; font-family:'SF Mono',monospace; }
.centro { text-align:center; }
.sit-badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:600; }
.vencido-dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:#ef4444; margin-right:4px; }
.back-btn { margin-right:14px; padding:7px 14px; background:#f3f4f6; border:1px solid #e5e7eb; border-radius:8px; font-size:13px; font-weight:500; cursor:pointer; color:#374151; text-decoration:none; transition:all .15s; display:inline-flex; align-items:center; gap:4px; }
.back-btn:hover { background:#e5e7eb; border-color:#d1d5db; }
.empty-msg { text-align:center; padding:32px; color:#9ca3af; font-size:14px; }
</style>
<link rel="stylesheet" href="/css/dark-mode.css">
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});}
window.darkModeApply=function(a){var e=document.body;if(!e)return;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'});};
window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')==='true';};</script>
</head>
<body>
<aside class="sidebar">
<div class="sidebar-brand">
<div class="logo">C2</div>
<div><h2>Chatc2</h2><span id="sidebarAlias">-</span></div>
</div>
<nav class="sidebar-nav">
<div class="nav-label">Principal</div>
<a href="#" id="navDashboard"><span class="icon">📊</span> Dashboard</a>
<a href="#" class="active" id="navClients"><span class="icon">👥</span> Clientes</a>
<a href="#" id="navChatDetail"><span class="icon">💬</span> Conversas</a>
<div class="nav-label admin-only">Administrador</div>
<a href="#" id="navConfigDetail" class="admin-only"><span class="icon">⚙️</span> Configurações</a>
<a href="#" id="navRoutesDetail" class="admin-only"><span class="icon">📡</span> Rotas</a>
</nav>
<div class="sidebar-footer">
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button>
<a onclick="logout()"><span class="icon">🚪</span> Sair</a>
</div>
</aside>
<div class="main">
<div class="topbar">
<a href="#" class="back-btn" id="backBtn">← Voltar</a>
<span class="topbar-title">Detalhes do Cliente</span>
<a href="#" class="back-btn" id="btnIniciarConv" style="font-size:13px;margin-left:auto" title="Iniciar conversa WhatsApp">💬 Iniciar Conversa</a>
</div>
<div class="container" id="container">
<div class="loading" id="loadingState">
<div class="spinner"></div>
<p style="margin-top:12px">Carregando dados do cliente...</p>
</div>
</div>
</div>
<!-- Modal Nova Conversa -->
<div class="modal-overlay" id="modalNovaConvDet" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;z-index:1000">
<div style="background:#fff;border-radius:12px;padding:24px;width:90%;max-width:450px">
<h3 style="margin-bottom:16px">💬 Iniciar Conversa</h3>
<input type="hidden" id="convClienteIdDet">
<input type="hidden" id="convEmpresaIdDet">
<div class="form-group"><label>Número</label><input type="text" id="convNumeroDet" readonly style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:14px;background:#f9fafb;margin-bottom:12px"></div>
<div class="form-group"><label>Cliente</label><input type="text" id="convNomeDet" readonly style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:14px;background:#f9fafb;margin-bottom:12px"></div>
<div class="form-group"><label>Instância WhatsApp</label><select id="convInstanciaDet" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:14px;margin-bottom:12px"></select></div>
<div class="form-group"><label>Mensagem inicial</label><textarea id="convMensagemDet" rows="3" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:14px;resize:vertical;margin-bottom:12px" placeholder="Digite a mensagem que será enviada..."></textarea></div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button onclick="document.getElementById('modalNovaConvDet').style.display='none'" style="padding:8px 16px;border:1px solid #d1d5db;border-radius:8px;background:#fff;cursor:pointer">Cancelar</button>
<button onclick="iniciarConversaDet()" style="padding:8px 16px;background:#667eea;color:#fff;border:none;border-radius:8px;cursor:pointer">💬 Enviar e Abrir Chat</button>
</div>
</div>
</div>
<script>
(function() {
'use strict';
// ---- Dados da URL ----
var pathParts = window.location.pathname.split('/');
var alias = pathParts[2];
var empresaId = pathParts[4];
var clienteId = pathParts[6];
// ---- Token ----
var token = localStorage.getItem('chatc2_token');
if (!token) { window.location.href = '/app/' + alias + '/login'; return; }
localStorage.setItem('chatc2_alias', alias);
// ---- Elementos ----
var container = document.getElementById('container');
var sidebarAlias = document.getElementById('sidebarAlias');
var backBtn = document.getElementById('backBtn');
sidebarAlias.textContent = alias;
backBtn.href = '/app/' + alias + '/clients';
// Botão iniciar conversa - abre modal
document.getElementById('btnIniciarConv').onclick = function(e) {
e.preventDefault();
// Pega dados do cliente do último load (cache na variável global)
var celular = window._clienteData?.celular?.original || window._clienteData?.celular || '';
var nome = window._clienteData?.nome || '';
abrirModalConversa(parseInt(empresaId), parseInt(clienteId), nome, celular.replace(/\D/g,''));
};
// ---- Navegação ----
document.getElementById("navDashboard").onclick = function(e) { e.preventDefault(); window.location.href = "/app/" + alias + "/dashboard"; };
document.getElementById("navClients").onclick = function(e) { e.preventDefault(); window.location.href = "/app/" + alias + "/clients"; };
document.getElementById("navChatDetail").onclick = function(e) { e.preventDefault(); window.location.href = "/app/" + alias + "/company/" + empresaId + "/conversation/0"; };
document.getElementById("navConfigDetail").onclick = function(e) { e.preventDefault(); window.location.href = "/app/" + alias + "/settings"; };
document.getElementById("navRoutesDetail").onclick = function(e) { e.preventDefault(); window.location.href = "/app/" + alias + "/routes"; };
var userDetail = JSON.parse(localStorage.getItem('chatc2_user') || '{}');
var tc = userDetail.tipoChat || "A";
if (tc === "G") {
var admins = document.querySelectorAll(".admin-only");
for (var i = 0; i < admins.length; i++) admins[i].style.display = "";
}
window.logout = function() {
localStorage.removeItem('chatc2_token');
localStorage.removeItem('chatc2_alias');
localStorage.removeItem('chatc2_user');
window.location.href = '/app/' + alias + '/login';
};
// ---- Utilitário fetch com token ----
function apiFetch(url) {
return fetch(url, { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) {
if (r.status === 401 || r.status === 403) {
localStorage.removeItem('chatc2_token');
localStorage.removeItem('chatc2_user');
window.location.href = '/app/' + alias + '/login';
throw new Error('redirect');
}
return r.json();
});
}
// ---- Formata moeda ----
function fmtMoney(v) { return 'R$ ' + (v || 0).toFixed(2).replace('.', ','); }
// ---- Formata data ----
function fmtDate(d) {
if (!d) return '-';
var date = new Date(d + (d.includes('T') ? '' : 'T12:00:00'));
return date.toLocaleDateString('pt-BR');
}
// ============================================================
// CARREGAR CLIENTE
// ============================================================
function loadClient() {
apiFetch('/api/' + alias + '/company/' + empresaId + '/client/' + clienteId)
.then(function(data) {
if (!data.success) {
container.innerHTML = '<div class="error-box"><div class="icon">⚠️</div><h2>Erro</h2><p>' + data.error + '</p></div>';
return;
}
renderClient(data.data);
carregarConversas();
// Verifica se o modulo estoque esta ativo para esconder a aba convalescentes
fetch('/api/' + alias + '/clients/' + clienteId + '/convalescentes', {
headers: { 'Authorization': 'Bearer ' + token }
}).then(function(r){ return r.json(); }).then(function(d) {
var btnC = document.getElementById('abaConvalescentes');
if (btnC && d.moduloInativo === true) {
btnC.style.display = 'none';
}
}).catch(function(){});
})
.catch(function(err) {
if (err.message === 'redirect') return;
container.innerHTML = '<div class="error-box"><div class="icon">⚠️</div><h2>Erro de conexão</h2><p>' + err.message + '</p></div>';
});
}
// Guarda dados do cliente para uso em outros lugares
window._clienteData = null;
function renderClient(c) {
window._clienteData = c;
var sitClass = c.situacao && c.situacao.codigo === 'A' ? 'ativo' : 'inativo';
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() : '?';
container.innerHTML =
'<div class="client-header">' +
'<div class="client-avatar">' + iniciais + '</div>' +
'<div class="client-header-info">' +
'<h1>' + (c.nome || '-') + '</h1>' +
'<div class="matricula">Matrícula: ' + (c.matricula || '-') + '</div>' +
'</div>' +
'<span class="situacao-badge ' + sitClass + '">' + sitLabel + '</span>' +
'</div>' +
'<div class="grid">' +
'<div class="card"><h3>📞 Contato <button onclick="editarContato()" style="float:right;padding:2px 8px;border:1px solid #d1d5db;border-radius:4px;background:#fff;cursor:pointer;font-size:11px">✏️ Editar</button></h3>' +
'<div class="field"><div class="label">Email</div><div class="value" id="campoEmail">' + (c.email || '-') + '</div></div>' +
'<div class="field"><div class="label">Celular</div><div class="value" id="campoCelular">' + (c.celular && c.celular.formatado ? c.celular.formatado : c.celular && c.celular.original ? c.celular.original : '-') + '</div></div>' +
'<div class="field"><div class="label">Telefone</div><div class="value" id="campoTelefone">' + (c.telefone || '-') + '</div></div>' +
'<div id="editContatoForm" style="display:none;margin-top:12px;padding:12px;background:#f9fafb;border-radius:8px">' +
'<input type="email" id="editEmail" placeholder="Email" style="width:100%;padding:8px;border:1px solid #e5e7eb;border-radius:6px;font-size:13px;margin-bottom:8px">' +
'<input type="text" id="editCelular" placeholder="Celular" style="width:100%;padding:8px;border:1px solid #e5e7eb;border-radius:6px;font-size:13px;margin-bottom:8px">' +
'<input type="text" id="editTelefone" placeholder="Telefone" style="width:100%;padding:8px;border:1px solid #e5e7eb;border-radius:6px;font-size:13px;margin-bottom:8px">' +
'<div style="display:flex;gap:8px">' +
'<button onclick="salvarContato()" style="padding:6px 14px;background:#667eea;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:12px">💾 Salvar</button>' +
'<button onclick="cancelarEditarContato()" style="padding:6px 14px;border:1px solid #d1d5db;border-radius:6px;background:#fff;cursor:pointer;font-size:12px">Cancelar</button>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="card"><h3>📍 Endereço</h3>' +
'<div class="field"><div class="label">Logradouro</div><div class="value">' + (c.enderecoFaturamento && c.enderecoFaturamento.logradouro || '-') + '</div></div>' +
'<div class="field"><div class="label">Número</div><div class="value">' + (c.enderecoFaturamento && c.enderecoFaturamento.numero || '-') + '</div></div>' +
'<div class="field"><div class="label">Complemento</div><div class="value">' + (c.enderecoFaturamento && c.enderecoFaturamento.complemento || '-') + '</div></div>' +
'<div class="field"><div class="label">CEP</div><div class="value">' + (c.enderecoFaturamento && c.enderecoFaturamento.cep || '-') + '</div></div>' +
'</div>' +
'<div class="card"><h3>🏙️ Localização</h3>' +
'<div class="field"><div class="label">Cidade</div><div class="value">' + (c.cidade && c.cidade.nome || '-') + '</div></div>' +
'<div class="field"><div class="label">Bairro</div><div class="value">' + (c.bairro && c.bairro.nome || '-') + '</div></div>' +
'</div>' +
'<div class="card"><h3>💰 Cobrança</h3>' +
'<div class="field"><div class="label">Cobrador</div><div class="value">' + (c.cobrador && c.cobrador.nome || 'Não definido') + '</div></div>' +
'<div class="field"><div class="label">Dia</div><div class="value">' + (c.diaCobranca ? c.diaCobranca + 'º dia' : 'Não definido') + '</div></div>' +
'</div>' +
'</div>' +
// ABAS: Conversas | Títulos | Dependentes | Convalescentes
'<div style="display:flex;gap:0;margin-bottom:16px;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.08)">' +
'<button class="aba-ativa" onclick="mudarAba(\'conversas\')" id="abaConversas" style="flex:1;padding:10px;border:none;background:#667eea;color:#fff;font-size:13px;font-weight:600;cursor:pointer">💬 Conversas</button>' +
'<button onclick="mudarAba(\'titulos\')" id="abaTitulos" style="flex:1;padding:10px;border:none;background:#f3f4f6;color:#374151;font-size:13px;cursor:pointer">📄 Títulos</button>' +
'<button onclick="mudarAba(\'dependentes\')" id="abaDependentes" style="flex:1;padding:10px;border:none;background:#f3f4f6;color:#374151;font-size:13px;cursor:pointer">👥 Dependentes</button>' +
'<button onclick="mudarAba(\'convalescentes\')" id="abaConvalescentes" style="flex:1;padding:10px;border:none;background:#f3f4f6;color:#374151;font-size:13px;cursor:pointer">🛏️ Convalescentes</button>' +
'</div>' +
'<div id="conteudoAba">' +
// CONVERSAS (padrão - primeira aba)
'<div class="card" id="conteudoConversas">' +
'<h3>💬 Histórico de Conversas</h3>' +
'<div id="conversasArea"><div class="loading" style="padding:20px"><div class="spinner"></div><p style="margin-top:8px">Carregando histórico de conversas...</p></div></div>' +
'</div>' +
// TÍTULOS
'<div class="carnes-section card" id="conteudoTitulos" style="display:none">' +
'<h3>📄 Títulos</h3>' +
'<div class="carnes-filters" id="carnesFilters">' +
'<label class="filtro-ativo" data-tipo="abertos"><input type="checkbox" checked onchange="filtroClick(this)"> Abertos (<span id="cntAbertos">0</span>)</label>' +
'<label data-tipo="baixados"><input type="checkbox" onchange="filtroClick(this)"> Baixados (<span id="cntBaixados">0</span>)</label>' +
'<label data-tipo="parcial"><input type="checkbox" onchange="filtroClick(this)"> Parcial (<span id="cntParcial">0</span>)</label>' +
'<label class="filtro-ativo" data-tipo="vencidos" style="border-color:#fca5a5;"><input type="checkbox" checked onchange="filtroClick(this)"> 🔴 Vencidos (<span id="cntVencidos">0</span>)</label>' +
'</div>' +
'<div id="carnesArea"><div class="loading" style="padding:20px"><div class="spinner"></div><p style="margin-top:8px">Carregando títulos...</p></div></div>' +
'</div>' +
// DEPENDENTES
'<div class="card" id="conteudoDependentes" style="display:none">' +
'<h3>👥 Dependentes</h3>' +
'<div id="dependentesArea"><div class="loading" style="padding:20px"><div class="spinner"></div><p style="margin-top:8px">Carregando dependentes...</p></div></div>' +
'</div>' +
// CONVALESCENTES
'<div class="card" id="conteudoConvalescentes" style="display:none">' +
'<h3>🛏️ Convalescentes</h3>' +
'<div id="convalescentesArea"><div class="loading" style="padding:20px"><div class="spinner"></div><p style="margin-top:8px">Carregando convalescentes...</p></div></div>' +
'</div>'
'</div>'; // fecha conteudoAba
}
function getParentescoLabel(cod) {
var map = {'01':'Conjugue','02':'Companheiro(a)','03':'Filho(a)','04':'Enteado(a)','05':'Esposo(a)','06':'Excluído','07':'Neto(a)','08':'Pai','09':'Mãe','10':'Sobrinho(a)','11':'Tio(a)','12':'Primo(a)','13':'Irmão(ã)','14':'Sogro(a)','15':'Cunhado(a)','16':'Outros','17':'Agregado','18':'Genro/Nora','19':'Avô(ó)'};
return map[cod] || cod;
}
window.mudarAba = function(aba) {
var btnV = document.getElementById('abaConversas');
var btnT = document.getElementById('abaTitulos');
var btnD = document.getElementById('abaDependentes');
var btnC = document.getElementById('abaConvalescentes');
var cV = document.getElementById('conteudoConversas');
var cT = document.getElementById('conteudoTitulos');
var cD = document.getElementById('conteudoDependentes');
var cC = document.getElementById('conteudoConvalescentes');
var btns = [btnV, btnT, btnD, btnC];
var contents = [cV, cT, cD, cC];
btns.forEach(function(b) { if (b) { b.style.background = '#f3f4f6'; b.style.color = '#374151'; b.style.fontWeight = '400'; } });
contents.forEach(function(c) { if (c) c.style.display = 'none'; });
if (aba === 'conversas') {
btnV.style.background = '#667eea'; btnV.style.color = '#fff'; btnV.style.fontWeight = '600';
cV.style.display = 'block';
carregarConversas();
} else if (aba === 'titulos') {
btnT.style.background = '#667eea'; btnT.style.color = '#fff'; btnT.style.fontWeight = '600';
cT.style.display = 'block';
loadCarnes(1);
} else if (aba === 'dependentes') {
btnD.style.background = '#667eea'; btnD.style.color = '#fff'; btnD.style.fontWeight = '600';
cD.style.display = 'block';
carregarDependentes();
} else if (aba === 'convalescentes') {
btnC.style.background = '#667eea'; btnC.style.color = '#fff'; btnC.style.fontWeight = '600';
cC.style.display = 'block';
carregarConvalescentes();
}
};
async function carregarConversas() {
var area = document.getElementById('conversasArea');
area.innerHTML = '<div class="loading" style="padding:20px"><div class="spinner"></div><p style="margin-top:8px">Carregando histórico...</p></div>';
try {
var res = await fetch('/api/' + alias + '/clients/' + clienteId + '/conversations', {
headers: { 'Authorization': 'Bearer ' + token }
});
var data = await res.json();
if (!data.success || !data.data || data.data.length === 0) {
area.innerHTML = '<div class="empty-msg">🔍 Nenhuma conversa encontrada para este cliente</div>';
return;
}
var html = '<div style="overflow-x:auto"><table><thead><tr>' +
'<th>Data</th><th>Contato</th><th>Tipo</th><th>Status</th><th>Atendente</th><th>Equipe</th><th></th>' +
'</tr></thead><tbody>';
data.data.forEach(function(cv) {
var dataStr = cv.dtUltimaMsg ? new Date(cv.dtUltimaMsg + (cv.dtUltimaMsg.includes('T') ? '' : 'T12:00:00')).toLocaleDateString('pt-BR') + ' ' + new Date(cv.dtUltimaMsg + (cv.dtUltimaMsg.includes('T') ? '' : 'T12:00:00')).toLocaleTimeString('pt-BR', {hour:'2-digit',minute:'2-digit'}) : '-';
var quem = cv.quemFalou || cv.nomeContato || cv.numero || '-';
var tipoPessoa = cv.isTitular ? '👤 Titular' : (cv.parentesco ? '👥 ' + cv.parentesco : '👥 Dependente');
var statusLabel = cv.status === 'A' ? '🟢 Aberta' : cv.status === 'E' ? '🟡 Em espera' : cv.status === 'F' ? '⚫ Finalizada' : cv.status;
var usuario = cv.usuarioNome || (cv.usuarioId ? '#' + cv.usuarioId : '-');
var equipe = cv.equipeNome || (cv.equipeId ? '#' + cv.equipeId : '-');
html += '<tr style="cursor:pointer" onclick="window.open(\'/app/' + alias + '/company/' + cv.empresaId + '/conversation/' + cv.id + '\', \'_blank\')">' +
'<td style="white-space:nowrap;font-size:12px">' + dataStr + '</td>' +
'<td><strong>' + quem + '</strong></td>' +
'<td style="font-size:12px">' + tipoPessoa + '</td>' +
'<td style="font-size:12px">' + statusLabel + '</td>' +
'<td style="font-size:12px">' + usuario + '</td>' +
'<td style="font-size:12px">' + equipe + '</td>' +
'<td><span style="color:#667eea;font-size:12px">Abrir →</span></td>' +
'</tr>';
});
html += '</tbody></table></div>';
area.innerHTML = html;
} catch(err) {
area.innerHTML = '<div class="error-box" style="padding:16px">Erro ao carregar histórico: ' + err.message + '</div>';
}
}
async function carregarDependentes() {
var area = document.getElementById('dependentesArea');
area.innerHTML = '<div class="loading" style="padding:20px"><div class="spinner"></div><p style="margin-top:8px">Carregando...</p></div>';
try {
var res = await fetch('/api/' + alias + '/clients/' + clienteId + '/dependents', {
headers: { 'Authorization': 'Bearer ' + token }
});
var data = await res.json();
if (!data.success || !data.data || data.data.length === 0) {
area.innerHTML = '<div class="empty-msg">🔍 Nenhum dependente encontrado</div>';
return;
}
var html = '<div style="overflow-x:auto"><table><thead><tr><th>Nome</th><th>Parentesco</th><th>Telefone</th><th>Adicional</th><th>Valor</th><th>Situação</th></tr></thead><tbody>';
data.data.forEach(function(d) {
var sit = d.situacao === 'A' ? '<span class="situacao-badge ativo" style="font-size:11px">Ativo</span>' : '<span class="situacao-badge inativo" style="font-size:11px">Inativo</span>';
var adicional = d.adicional === 'S' ? '✅ Sim' : '❌ Não';
var telEdit = d.telefone || '';
html += '<tr><td><strong>' + d.nome + '</strong></td><td>' + getParentescoLabel(d.parentesco) + '</td><td>' +
'<span id="depTel_' + d.id + '">' + (telEdit || '-') + '</span> ' +
'<button onclick="editarTelDep(' + d.id + ')" data-tel="' + telEdit.replace(/"/g,'&quot;') + '" style="padding:1px 6px;border:1px solid #d1d5db;border-radius:4px;background:#fff;cursor:pointer;font-size:10px">✏️</button></td><td>' + adicional + '</td><td style="text-align:right;font-family:monospace">R$ ' + (d.valorContribuicao / 100).toFixed(2) + '</td><td>' + sit + '</td></tr>';
});
html += '</tbody></table></div>';
area.innerHTML = html;
} catch(e) {
area.innerHTML = '<div class="empty-msg">⚠️ Erro ao carregar: ' + e.message + '</div>';
}
}
async function carregarConvalescentes() {
var area = document.getElementById('convalescentesArea');
area.innerHTML = '<div class="loading" style="padding:20px"><div class="spinner"></div><p style="margin-top:8px">Carregando...</p></div>';
try {
var res = await fetch('/api/' + alias + '/clients/' + clienteId + '/convalescentes', {
headers: { 'Authorization': 'Bearer ' + token }
});
var data = await res.json();
if (!data.success || !data.data || data.data.length === 0) {
area.innerHTML = '<div class="empty-msg">🛏️ Nenhum convalescente encontrado</div>';
return;
}
var html = '';
data.data.forEach(function(c) {
var sitLabel = c.situacao || '-';
var sitBadge = c.situacao === 'D' ? '<span class="sit-badge" style="background:#d1fae5;color:#065f46">Devolvido</span>' :
c.situacao === 'E' ? '<span class="sit-badge" style="background:#fef3c7;color:#92400e">Entregue</span>' :
c.situacao === 'A' ? '<span class="sit-badge" style="background:#dbeafe;color:#1e40af">Ativo</span>' :
c.situacao === 'C' ? '<span class="sit-badge" style="background:#fef2f2;color:#991b1b">Cancelado</span>' :
'<span class="sit-badge" style="background:#f3f4f6;color:#6b7280">' + c.situacao + '</span>';
html += '<div style="border:1px solid #e5e7eb;border-radius:8px;padding:16px;margin-bottom:12px">' +
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">' +
'<h4 style="margin:0;font-size:15px">🛏️ Convalescente #' + c.id + '</h4>' +
sitBadge +
'</div>' +
'<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;font-size:13px;margin-bottom:12px">' +
'<div><span style="color:#9ca3af">Valor Total:</span> <strong>R$ ' + (c.valorTotal || 0).toFixed(2) + '</strong></div>' +
'<div><span style="color:#9ca3af">Data Saída:</span> ' + fmtDate(c.dtSaida) + '</div>' +
'<div><span style="color:#9ca3af">Previsão Retorno:</span> ' + fmtDate(c.dtPrevisaoRetorno) + '</div>' +
(c.dtCancelou ? '<div><span style="color:#9ca3af">Data Cancelamento:</span> ' + fmtDate(c.dtCancelou) + '</div>' : '') +
'<div><span style="color:#9ca3af">Cadastrado por:</span> ' + (c.usuarioNome || '-') + '</div>' +
'</div>';
// SUB-ABAS: Boletos | Itens
var temCarnes = c.carnes && c.carnes.length > 0;
var temItens = c.itens && c.itens.length > 0;
if (temCarnes || temItens) {
html += '<div style="margin-top:12px;padding-top:12px;border-top:1px solid #e5e7eb">' +
'<div style="display:flex;gap:0;margin-bottom:8px;border-radius:6px;overflow:hidden">';
if (temCarnes) {
html += '<button class="subaba-ativa" onclick="mudarSubaba(\'carnes_' + c.id + '\',this)" id="subCarnes_' + c.id + '" style="flex:1;padding:6px 10px;border:none;background:#667eea;color:#fff;font-size:11px;font-weight:600;cursor:pointer">📄 Boletos (' + c.carnes.length + ')</button>';
}
if (temItens) {
html += '<button onclick="mudarSubaba(\'itens_' + c.id + '\',this)" id="subItens_' + c.id + '" style="flex:1;padding:6px 10px;border:none;background:#f3f4f6;color:#374151;font-size:11px;cursor:pointer">📦 Itens (' + c.itens.length + ')</button>';
}
html += '</div>';
// Conteudo Boletos
if (temCarnes) {
html += '<div id="conteudoCarnes_' + c.id + '" style="overflow-x:auto"><table style="font-size:12px"><thead><tr>' +
'<th>Vencimento</th><th style="text-align:right">Valor</th><th>Situação</th>' +
'</tr></thead><tbody>';
c.carnes.forEach(function(b) {
html += '<tr>' +
'<td>' + fmtDate(b.vencimento) + '</td>' +
'<td style="text-align:right;font-family:monospace">R$ ' + (b.valorParcela || 0).toFixed(2) + '</td>' +
'<td><span class="sit-badge" style="background:' + b.situacao.color + ';color:' + b.situacao.textColor + '">' + b.situacao.label + '</span></td>' +
'</tr>';
});
html += '</tbody></table></div>';
} else {
html += '<div id="conteudoCarnes_' + c.id + '" style="display:none"></div>';
}
// Conteudo Itens
html += '<div id="conteudoItens_' + c.id + '" style="' + (temCarnes && !temItens ? 'display:none' : '') + ';overflow-x:auto">';
if (temItens) {
html += '<table style="font-size:12px"><thead><tr>' +
'<th>Produto</th><th style="text-align:center">Quantidade</th>' +
'</tr></thead><tbody>';
c.itens.forEach(function(item) {
html += '<tr>' +
'<td>' + (item.produto || '-') + '</td>' +
'<td style="text-align:center;font-weight:600">' + (item.quantidade || 0) + '</td>' +
'</tr>';
});
html += '</tbody></table>';
} else {
html += '<div class="empty-msg" style="padding:12px;font-size:12px">📦 Nenhum item vinculado</div>';
}
html += '</div></div>';
}
html += '</div>';
});
area.innerHTML = html;
} catch(e) {
area.innerHTML = '<div class="empty-msg">⚠️ Erro: ' + e.message + '</div>';
}
}
// Alterna entre sub-abas (Boletos / Itens) dentro de cada convalescente
window.mudarSubaba = function(id, btn) {
var container = btn.parentElement.parentElement;
var botoes = container.querySelectorAll('button[id^="sub"]');
botoes.forEach(function(b) { b.style.background = '#f3f4f6'; b.style.color = '#374151'; });
btn.style.background = '#667eea'; btn.style.color = '#fff';
var prefixo = id.split('_')[0];
var covId = id.split('_')[1];
var cCarnes = document.getElementById('conteudoCarnes_' + covId);
var cItens = document.getElementById('conteudoItens_' + covId);
if (cCarnes) cCarnes.style.display = prefixo === 'carnes' ? '' : 'none';
if (cItens) cItens.style.display = prefixo === 'itens' ? '' : 'none';
};
window.editarTelDep = function(id) {
var btn = document.querySelector('button[onclick="editarTelDep(' + id + ')"]');
var telAtual = btn ? (btn.getAttribute('data-tel') || '') : '';
var novo = prompt('Editar telefone do dependente:', telAtual || '');
if (novo === null || novo.trim() === telAtual) return;
fetch('/api/' + alias + '/dependents/' + id + '/phone', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ telefone: novo.trim() })
}).then(function(r) { return r.json(); }).then(function(d) {
if (d.success) { document.getElementById('depTel_' + id).textContent = novo.trim(); alert('Telefone atualizado!'); }
else alert('Erro: ' + d.error);
}).catch(function(e) { alert('Erro: ' + e.message); });
};
// ============================================================
// CARNES / TÍTULOS
// ============================================================
var carnesPage = 1;
// Chamado sempre que um checkbox de filtro é marcado/desmarcado
window.filtroClick = function(checkbox) {
var label = checkbox.closest('label');
if (checkbox.checked) {
label.classList.add('filtro-ativo');
} else {
label.classList.remove('filtro-ativo');
}
carnesPage = 1;
loadCarnes();
};
function getTiposAtivos() {
var tipos = [];
var labels = document.querySelectorAll('#carnesFilters label[data-tipo]');
for (var i = 0; i < labels.length; i++) {
if (labels[i].classList.contains('filtro-ativo')) {
tipos.push(labels[i].getAttribute('data-tipo'));
}
}
return tipos;
}
// Torna global para os onclick dos botões de paginação
window.loadCarnes = function(page) {
if (page) carnesPage = page;
var area = document.getElementById('carnesArea');
if (!area) return;
var tipos = getTiposAtivos();
if (tipos.length === 0) {
area.innerHTML = '<div class="empty-msg">✅ Selecione ao menos um filtro</div>';
return;
}
var params = 'page=' + carnesPage + '&limit=20&tipo=' + tipos.join(',');
area.innerHTML = '<div class="loading" style="padding:20px"><div class="spinner"></div><p style="margin-top:8px">Carregando títulos...</p></div>';
apiFetch('/api/' + alias + '/clients/' + clienteId + '/carnes?' + params)
.then(function(data) {
if (!data.success) {
area.innerHTML = '<div class="empty-msg">⚠️ ' + data.error + '</div>';
return;
}
// Atualiza contagens nos filtros
if (data.contagens) {
document.getElementById('cntAbertos').textContent = data.contagens.abertos || 0;
document.getElementById('cntBaixados').textContent = data.contagens.baixados || 0;
document.getElementById('cntParcial').textContent = data.contagens.parcial || 0;
document.getElementById('cntVencidos').textContent = data.contagens.vencidos || 0;
}
if (!data.data || data.data.length === 0) {
area.innerHTML = '<div class="empty-msg">🔍 Nenhum título encontrado</div>';
return;
}
renderCarnes(data, area);
})
.catch(function(err) {
if (err.message === 'redirect') return;
area.innerHTML = '<div class="empty-msg">⚠️ ' + err.message + '</div>';
});
}
function renderCarnes(data, area) {
var html = '<div style="overflow-x:auto"><table>' +
'<thead><tr>' +
'<th>#</th><th>Situação</th><th>Vencimento</th><th>Valor</th>' +
'<th>Pagamento</th><th>Agend. Cobrança</th><th>Nosso Número</th><th>Parcela</th>' +
'</tr></thead><tbody>';
for (var i = 0; i < data.data.length; i++) {
var c = data.data[i];
var bg = c.situacao.color || '#f3f4f6';
var tc = c.situacao.textColor || '#6b7280';
var vencDot = c.vencido ? '<span class="vencido-dot"></span>' : '';
html += '<tr>' +
'<td>' + c.id + '</td>' +
'<td><span class="sit-badge" style="background:' + bg + ';color:' + tc + '">' + c.situacao.descricao + '</span></td>' +
'<td>' + vencDot + fmtDate(c.vencimento) + '</td>' +
'<td class="valor">' + fmtMoney(c.valorParcela) + '</td>' +
'<td>' + fmtDate(c.dataPagamento) + '</td>' +
'<td style="font-size:11px">' + fmtDate(c.agendamentoCobranca) + '</td>' +
'<td>' + (c.nossoNumero || '-') + '</td>' +
'<td class="centro">' + (c.parcela || '-') + '/' + (c.totalParcelas || '-') + '</td>' +
'</tr>';
}
html += '</tbody></table></div>';
// Paginação
if (data.totalPages > 1) {
html += '<div class="pagination">';
html += '<button onclick="loadCarnes(' + (carnesPage - 1) + ')" ' + (carnesPage <= 1 ? 'disabled' : '') + '>← Anterior</button>';
var start = Math.max(1, carnesPage - 2);
var end = Math.min(data.totalPages, carnesPage + 2);
if (start > 1) {
html += '<button onclick="loadCarnes(1)">1</button>';
if (start > 2) html += '<button disabled>...</button>';
}
for (var p = start; p <= end; p++) {
html += '<button onclick="loadCarnes(' + p + ')" class="' + (p === carnesPage ? 'active' : '') + '">' + p + '</button>';
}
if (end < data.totalPages) {
if (end < data.totalPages - 1) html += '<button disabled>...</button>';
html += '<button onclick="loadCarnes(' + data.totalPages + ')">' + data.totalPages + '</button>';
}
html += '<button onclick="loadCarnes(' + (carnesPage + 1) + ')" ' + (carnesPage >= data.totalPages ? 'disabled' : '') + '>Próximo →</button>';
html += '</div>';
}
area.innerHTML = html;
}
// ===== EDITAR CONTATO =====
window.editarContato = function() {
var form = document.getElementById('editContatoForm');
document.getElementById('editEmail').value = document.getElementById('campoEmail').textContent === '-' ? '' : document.getElementById('campoEmail').textContent;
document.getElementById('editCelular').value = document.getElementById('campoCelular').textContent === '-' ? '' : document.getElementById('campoCelular').textContent;
document.getElementById('editTelefone').value = document.getElementById('campoTelefone').textContent === '-' ? '' : document.getElementById('campoTelefone').textContent;
form.style.display = 'block';
};
window.cancelarEditarContato = function() {
document.getElementById('editContatoForm').style.display = 'none';
};
window.salvarContato = async function() {
var email = document.getElementById('editEmail').value.trim();
var celular = document.getElementById('editCelular').value.trim();
var telefone = document.getElementById('editTelefone').value.trim();
var data = {};
if (email) data.email = email;
if (celular) data.celular = celular;
if (telefone) data.telefone = telefone;
if (Object.keys(data).length === 0) { alert('Preencha ao menos um campo'); return; }
try {
var r = await fetch('/api/' + alias + '/clients/' + clienteId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify(data)
});
var d = await r.json();
if (d.success) {
alert('Dados atualizados!');
document.getElementById('editContatoForm').style.display = 'none';
loadClient(); // Recarrega os dados
} else {
alert('Erro: ' + d.error);
}
} catch(e) { alert('Erro: ' + e.message); }
};
// ===== MODAL NOVA CONVERSA =====
window.abrirModalConversa = async function(empresaId, clienteId, nome, celular) {
document.getElementById('convEmpresaIdDet').value = empresaId;
document.getElementById('convClienteIdDet').value = clienteId;
document.getElementById('convNumeroDet').value = celular || '';
document.getElementById('convNomeDet').value = nome || '';
document.getElementById('convMensagemDet').value = 'Olá ' + (nome || '') + '! Tudo bem? Como podemos ajudar?';
var sel = document.getElementById('convInstanciaDet');
sel.innerHTML = '<option value="">Carregando...</option>';
try {
var r = await fetch('/api/' + alias + '/evolution/instances?empresaId=' + empresaId, {
headers: { 'Authorization': 'Bearer ' + token }
});
var d = await r.json();
if (d.success && d.data.length > 0) {
sel.innerHTML = d.data.map(function(i) {
return '<option value="' + i.id + '">' + i.nome + ' (' + i.instanceName + ')</option>';
}).join('');
} else {
sel.innerHTML = '<option value="">Nenhuma instância disponível</option>';
}
} catch(e) {
sel.innerHTML = '<option value="">Erro ao carregar</option>';
}
document.getElementById('modalNovaConvDet').style.display = 'flex';
};
window.iniciarConversaDet = async function() {
var empresaId = parseInt(document.getElementById('convEmpresaIdDet').value);
var numero = document.getElementById('convNumeroDet').value;
var nome = document.getElementById('convNomeDet').value;
var mensagem = document.getElementById('convMensagemDet').value.trim();
var instanciaId = document.getElementById('convInstanciaDet').value;
var clienteId = parseInt(document.getElementById('convClienteIdDet').value);
if (!numero || !mensagem) { alert('Informe o número e a mensagem'); return; }
var numCompleto = numero.startsWith('55') ? numero : '55' + numero;
try {
var r = await fetch('/api/' + alias + '/conversations/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ empresaId: empresaId, numero: numCompleto, nomeContato: nome, mensagem: mensagem, instanciaId: instanciaId ? parseInt(instanciaId) : null, clienteId: clienteId || null })
});
var d = await r.json();
if (d.success) {
document.getElementById('modalNovaConvDet').style.display = 'none';
window.location.href = '/app/' + alias + '/company/' + empresaId + '/conversation/' + d.data.id;
} else {
alert('Erro: ' + d.error);
}
} catch(e) { alert('Erro de conexão: ' + e.message); }
};
// Fechar modal ao clicar fora
document.getElementById('modalNovaConvDet').onclick = function(e) { if (e.target === this) this.style.display = 'none'; };
// ---- Iniciar ----
loadClient();
})();
</script>
<script src="/js/dark-mode.js"></script>
</body>
</html>
+346
View File
@@ -0,0 +1,346 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Clientes - Chatc2</title>
<link rel="stylesheet" href="/css/main.css">
<style>
body { background: #f3f4f6; display: flex; min-height: 100vh; }
.sidebar-nav .admin-only { display: none; }
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.container { flex: 1; padding: 24px; overflow-y: auto; }
</style>
<link rel="stylesheet" href="/css/dark-mode.css">
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});}
window.darkModeApply=function(a){var e=document.body;if(!e)return;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'});};
window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')==='true';};</script>
</head>
<body>
<aside class="sidebar">
<div class="sidebar-brand">
<div class="logo">C2</div>
<div><h2>Chatc2</h2><span id="sidebarAlias">lajedo</span></div>
</div>
<nav class="sidebar-nav">
<div class="nav-label">Principal</div>
<a href="#" id="navDashboard"><span class="icon">📊</span> Dashboard</a>
<a href="#" class="active" id="navClients"><span class="icon">👥</span> Clientes</a>
<a href="#" id="navChat"><span class="icon">💬</span> Conversas</a>
<div class="nav-label admin-only" id="adminLabel">Administrador</div>
<a href="#" id="navConfig" class="admin-only"><span class="icon">⚙️</span> Configurações</a>
<a href="#" id="navRoutes" class="admin-only"><span class="icon">📡</span> Rotas</a>
</nav>
<div class="sidebar-footer">
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button>
<a onclick="logout()"><span class="icon">🚪</span> Sair</a>
</div>
</aside>
<div class="main">
<div class="topbar">
<span class="topbar-title">👥 Clientes</span>
<div class="user-info">
<span class="status-badge online">● Online</span>
<span class="user-name" id="userName"></span>
</div>
</div>
<div class="container">
<div class="search-bar">
<select id="empresaSelect">
<option value="">Todas as empresas</option>
</select>
<input type="text" id="searchInput" placeholder="Buscar por nome, matrícula ou CPF..." autofocus />
<button id="btnSearch">🔍 Buscar</button>
<span class="total-info" id="totalInfo"></span>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Matrícula</th>
<th>Nome</th>
<th>CPF</th>
<th>Telefone</th>
<th>Empresa</th>
<th>Situação</th>
<th>Ação</th>
</tr>
</thead>
<tbody id="tableBody">
<tr class="loading-row"><td colspan="7"><div class="spinner"></div><div style="margin-top:8px">Carregando...</div></td></tr>
</tbody>
</table>
<div class="pagination" id="pagination"></div>
</div>
</div>
</div>
<script>
const pathParts = window.location.pathname.split('/');
const alias = pathParts[2] || localStorage.getItem('chatc2_alias') || 'lajedo';
localStorage.setItem('chatc2_alias', alias);
const token = localStorage.getItem('chatc2_token');
const user = JSON.parse(localStorage.getItem('chatc2_user') || '{}');
if (!token) { window.location.href = '/app/' + alias + '/login'; }
// Sidebar admin
const tipoChat = user.tipoChat || 'A';
if (tipoChat === 'G') {
document.querySelectorAll('.admin-only').forEach(function(el) { el.style.display = ''; });
}
document.getElementById('sidebarAlias').textContent = alias;
document.getElementById('userName').textContent = user.nome || '-';
function navClick(e, url) { e.preventDefault(); window.location.href = url; }
document.getElementById('navDashboard').onclick = function(e) { navClick(e, '/app/' + alias + '/dashboard'); };
document.getElementById('navClients').onclick = function(e) { navClick(e, '/app/' + alias + '/clients'); };
document.getElementById('navChat').onclick = function(e) { navClick(e, '/app/' + alias + '/company/' + (user.empresas?.[0] || 1) + '/conversation/0'); };
document.getElementById('navConfig').onclick = function(e) { navClick(e, '/app/' + alias + '/settings'); };
document.getElementById('navRoutes').onclick = function(e) { navClick(e, '/app/' + alias + '/routes'); };
function logout() {
['chatc2_token','chatc2_alias','chatc2_user'].forEach(k => localStorage.removeItem(k));
window.location.href = '/app/' + alias + '/login';
}
// === ESTADO ===
let currentPage = 1, currentSearch = '', currentEmpresa = '';
const LIMIT = 20;
const searchInput = document.getElementById('searchInput');
const btnSearch = document.getElementById('btnSearch');
const tableBody = document.getElementById('tableBody');
const paginationEl = document.getElementById('pagination');
const totalInfo = document.getElementById('totalInfo');
const empresaSelect = document.getElementById('empresaSelect');
// === CARREGAR EMPRESAS DO USUÁRIO ===
async function loadEmpresas() {
try {
const res = await fetch('/api/' + alias + '/empresas', {
headers: { 'Authorization': 'Bearer ' + token }
});
const data = await res.json();
if (data.success && data.data.length > 1) {
data.data.forEach(emp => {
const opt = document.createElement('option');
opt.value = emp.id;
opt.textContent = emp.nomeFantasia || emp.nome;
empresaSelect.appendChild(opt);
});
empresaSelect.style.display = '';
} else {
empresaSelect.style.display = 'none';
}
} catch (e) { /* ignora */ }
}
// === BUSCAR CLIENTES ===
async function fetchClients(page = 1, search = '', empresaId = '') {
tableBody.innerHTML = '<tr class=\"loading-row\"><td colspan=\"7\"><div class=\"spinner\"></div><div style=\"margin-top:8px\">Carregando...</div></td></tr>';
const params = new URLSearchParams({ q: search, page: page, limit: LIMIT });
if (empresaId) params.set('empresaId', empresaId);
try {
const res = await fetch('/api/' + alias + '/clients/search?' + params, {
headers: { 'Authorization': 'Bearer ' + token }
});
const data = await res.json();
if (!data.success) { showError(data.error); return; }
currentPage = data.page;
currentSearch = search;
currentEmpresa = empresaId;
renderTable(data.data, data.empresasPermitidas);
renderPagination(data.total, data.page, data.totalPages);
updateTotalInfo(data.total, data.page, data.totalPages);
} catch (err) {
showError('Erro de conexão: ' + err.message);
}
}
function renderTable(clients, empresasPermitidas) {
if (!clients || clients.length === 0) {
tableBody.innerHTML = '<tr><td colspan=\"7\"><div class=\"empty-state\"><div class=\"icon\">🔍</div><p>Nenhum cliente encontrado</p></div></td></tr>';
return;
}
// Mapeia empresaId -> nome
const empMap = {};
if (empresaSelect.options.length > 1) {
for (let i = 1; i < empresaSelect.options.length; i++) {
empMap[empresaSelect.options[i].value] = empresaSelect.options[i].textContent;
}
}
tableBody.innerHTML = clients.map(c => {
const sit = c.situacao === 'A' ? 'ativo' : 'inativo';
const sitLabel = c.situacao === 'A' ? 'Ativo' : 'Inativo';
const cel = c.celular || c.telefone || '-';
const empNome = empMap[c.empresaId] || 'Empresa ' + c.empresaId;
return '<tr>' +
'<td><strong>' + (c.matricula || '-') + '</strong></td>' +
'<td><a class="client-link" href="/app/' + alias + '/company/' + c.empresaId + '/client/' + c.id + '">' + c.nome + '</a></td>' +
'<td>' + (c.cpf || '-') + '</td>' +
'<td>' + cel + '</td>' +
'<td><span class="empresa-tag">' + empNome + '</span></td>' +
'<td><span class="situacao-badge ' + sit + '">' + sitLabel + '</span></td>' +
'<td>' +
'<a class="client-link" href="/app/' + alias + '/company/' + c.empresaId + '/client/' + c.id + '" title="Ver detalhes">Ver →</a>' +
' <a class="client-link" href="#" onclick="abrirModalConversa(' + c.empresaId + ',' + c.id + ',\'' + (c.nome||'').replace(/'/g,"\\'") + '\',\'' + (c.celular||'').replace(/\D/g,'') + '\');return false" title="Iniciar conversa">💬</a>' +
'</tr>';
}).join('');
}
function renderPagination(total, page, totalPages) {
if (!total || total === 0) { paginationEl.innerHTML = ''; return; }
let html = '<button onclick="goToPage(' + (page - 1) + ')" ' + (page <= 1 ? 'disabled' : '') + '>← Anterior</button>';
const start = Math.max(1, page - 2);
const end = Math.min(totalPages, page + 2);
if (start > 1) {
html += '<button onclick="goToPage(1)">1</button>';
if (start > 2) html += '<button disabled>...</button>';
}
for (let i = start; i <= end; i++) {
html += '<button onclick="goToPage(' + i + ')" class="' + (i === page ? 'active' : '') + '">' + i + '</button>';
}
if (end < totalPages) {
if (end < totalPages - 1) html += '<button disabled>...</button>';
html += '<button onclick="goToPage(' + totalPages + ')">' + totalPages + '</button>';
}
html += '<button onclick="goToPage(' + (page + 1) + ')" ' + (page >= totalPages ? 'disabled' : '') + '>Próximo →</button>';
paginationEl.innerHTML = html;
}
function updateTotalInfo(total, page, totalPages) {
if (total === 0) { totalInfo.textContent = ''; return; }
const start = ((page - 1) * LIMIT) + 1;
const end = Math.min(page * LIMIT, total);
totalInfo.textContent = start + '-' + end + ' de ' + total + ' (pág ' + page + '/' + totalPages + ')';
}
function goToPage(page) { if (page < 1) return; fetchClients(page, currentSearch, currentEmpresa); }
function showError(msg) {
tableBody.innerHTML = '<tr><td colspan=\"7\"><div class=\"empty-state\"><div class=\"icon\">⚠️</div><p>' + msg + '</p></div></td></tr>';
paginationEl.innerHTML = '';
totalInfo.textContent = '';
}
// === EVENTOS ===
btnSearch.addEventListener('click', () => fetchClients(1, searchInput.value.trim(), empresaSelect.value));
searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') fetchClients(1, searchInput.value.trim(), empresaSelect.value); });
empresaSelect.addEventListener('change', () => fetchClients(1, searchInput.value.trim(), empresaSelect.value));
// === INICIAR ===
loadEmpresas().then(() => fetchClients(1, '', ''));
</script>
<script src="/js/dark-mode.js"></script>
</body>
</html>
<!-- Modal Nova Conversa -->
<div class="modal-overlay" id="modalNovaConv" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;z-index:1000">
<div style="background:#fff;border-radius:12px;padding:24px;width:90%;max-width:450px">
<h3 style="margin-bottom:16px">💬 Iniciar Conversa</h3>
<input type="hidden" id="convClienteId">
<input type="hidden" id="convEmpresaId">
<div class="form-group"><label>Número</label><input type="text" id="convNumero" readonly style="background:#f9fafb"></div>
<div class="form-group"><label>Cliente</label><input type="text" id="convNome" readonly style="background:#f9fafb"></div>
<div class="form-group"><label>Instância WhatsApp</label><select id="convInstancia" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:14px"></select></div>
<div class="form-group"><label>Mensagem inicial</label><textarea id="convMensagem" rows="3" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:14px;resize:vertical" placeholder="Digite a mensagem que será enviada..."></textarea></div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn" onclick="fecharModalConv()" style="padding:8px 16px;border:1px solid #d1d5db;border-radius:8px;background:#fff;cursor:pointer">Cancelar</button>
<button class="btn btn-primary" onclick="iniciarConversa()" style="padding:8px 16px;background:#667eea;color:#fff;border:none;border-radius:8px;cursor:pointer">💬 Enviar e Abrir Chat</button>
</div>
</div>
</div>
<script>
// ===== NOVA CONVERSA =====
var modalConvEl = document.getElementById('modalNovaConv');
window.abrirModalConversa = async function(empresaId, clienteId, nome, celular) {
document.getElementById('convEmpresaId').value = empresaId;
document.getElementById('convClienteId').value = clienteId;
document.getElementById('convNumero').value = celular || '';
document.getElementById('convNome').value = nome || '';
document.getElementById('convMensagem').value = 'Olá ' + (nome || '') + '! Tudo bem? Como podemos ajudar?';
// Carregar instâncias
var sel = document.getElementById('convInstancia');
sel.innerHTML = '<option value="">Carregando...</option>';
try {
var r = await fetch('/api/' + alias + '/evolution/instances?empresaId=' + empresaId, {
headers: { 'Authorization': 'Bearer ' + token }
});
var d = await r.json();
if (d.success && d.data.length > 0) {
sel.innerHTML = d.data.map(function(i) {
return '<option value="' + i.id + '">' + i.nome + ' (' + i.instanceName + ')</option>';
}).join('');
} else {
sel.innerHTML = '<option value="">Nenhuma instância disponível</option>';
}
} catch(e) {
sel.innerHTML = '<option value="">Erro ao carregar</option>';
}
modalConvEl.style.display = 'flex';
};
window.fecharModalConv = function() {
modalConvEl.style.display = 'none';
};
window.iniciarConversa = async function() {
var empresaId = document.getElementById('convEmpresaId').value;
var numero = document.getElementById('convNumero').value;
var nome = document.getElementById('convNome').value;
var mensagem = document.getElementById('convMensagem').value.trim();
var instanciaId = document.getElementById('convInstancia').value;
var clienteId = document.getElementById('convClienteId').value;
if (!numero || !mensagem) {
alert('Informe o número e a mensagem');
return;
}
// Adicionar código do país se necessário
var numCompleto = numero.startsWith('55') ? numero : '55' + numero;
try {
var r = await fetch('/api/' + alias + '/conversations/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ empresaId: parseInt(empresaId), numero: numCompleto, nomeContato: nome, mensagem: mensagem, instanciaId: instanciaId ? parseInt(instanciaId) : null, clienteId: clienteId ? parseInt(clienteId) : null })
});
var d = await r.json();
if (d.success) {
fecharModalConv();
window.location.href = '/app/' + alias + '/company/' + empresaId + '/conversation/' + d.data.id;
} else {
alert('Erro: ' + d.error);
}
} catch(e) {
alert('Erro de conexão: ' + e.message);
}
};
// Fechar modal ao clicar fora
modalConvEl.addEventListener('click', function(e) { if (e.target === this) this.style.display = 'none'; });
</script>
+208
View File
@@ -0,0 +1,208 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Avalie seu Atendimento</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: #fff;
border-radius: 20px;
padding: 40px;
max-width: 480px;
width: 100%;
text-align: center;
box-shadow: 0 20px 60px rgba(0,0,0,0.15);
}
.logo { font-size: 48px; margin-bottom: 16px; }
h1 { font-size: 24px; color: #1f2937; margin-bottom: 8px; }
.subtitle { font-size: 14px; color: #6b7280; margin-bottom: 32px; }
.stars {
display: flex;
justify-content: center;
gap: 8px;
margin-bottom: 32px;
direction: rtl;
}
.stars input { display: none; }
.stars label {
font-size: 48px;
cursor: pointer;
color: #d1d5db;
transition: color .2s, transform .15s;
}
.stars label:hover,
.stars label:hover ~ label,
.stars input:checked ~ label {
color: #f59e0b;
transform: scale(1.1);
}
.stars input:checked + label {
color: #f59e0b;
}
textarea {
width: 100%;
padding: 14px 16px;
border: 2px solid #e5e7eb;
border-radius: 12px;
font-size: 14px;
font-family: inherit;
resize: vertical;
min-height: 80px;
outline: none;
transition: border-color .2s;
margin-bottom: 20px;
}
textarea:focus { border-color: #667eea; }
.btn {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: opacity .2s;
}
.btn:hover { opacity: .9; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.rating-text {
font-size: 14px;
color: #6b7280;
margin-bottom: 24px;
min-height: 20px;
}
.success { display: none; }
.success .icon { font-size: 64px; margin-bottom: 16px; }
.success h2 { color: #059669; margin-bottom: 8px; }
.success p { color: #6b7280; }
.erro {
color: #ef4444;
font-size: 14px;
margin-top: 12px;
display: none;
}
</style>
</head>
<body>
<div class="container" id="app">
<div class="logo">💬</div>
<h1>Avalie seu Atendimento</h1>
<p class="subtitle">Sua opinião é muito importante para melhorarmos nosso serviço</p>
<div class="rating-text" id="ratingText">Toque nas estrelas para avaliar</div>
<div class="stars" id="starContainer">
<input type="radio" name="star" id="star5" value="5">
<label for="star5" title="Excelente"></label>
<input type="radio" name="star" id="star4" value="4">
<label for="star4" title="Bom"></label>
<input type="radio" name="star" id="star3" value="3">
<label for="star3" title="Regular"></label>
<input type="radio" name="star" id="star2" value="2">
<label for="star2" title="Ruim"></label>
<input type="radio" name="star" id="star1" value="1">
<label for="star1" title="Péssimo"></label>
</div>
<textarea id="comentario" placeholder="Deixe seu comentário (opcional)..."></textarea>
<button class="btn" id="btnEnviar" onclick="enviar()">Enviar Avaliação</button>
<div class="erro" id="erro"></div>
<div class="success" id="success">
<div class="icon"></div>
<h2>Agradecemos sua avaliação!</h2>
<p>Seu feedback nos ajuda a melhorar cada vez mais.</p>
</div>
</div>
<script>
var alias, conversaId, empresaId;
function getParams() {
var params = new URLSearchParams(window.location.search);
alias = params.get('alias');
conversaId = params.get('conversa');
empresaId = params.get('empresa');
if (!alias || !conversaId || !empresaId) {
document.getElementById('erro').textContent = 'Link inválido. Entre em contato conosco.';
document.getElementById('erro').style.display = 'block';
document.querySelector('.stars').style.display = 'none';
document.querySelector('textarea').style.display = 'none';
document.getElementById('btnEnviar').style.display = 'none';
}
}
var nota = 0;
document.querySelectorAll('.stars input').forEach(function(input) {
input.addEventListener('change', function() {
nota = parseInt(this.value);
var textos = ['', 'Péssimo', 'Ruim', 'Regular', 'Bom', 'Excelente!'];
document.getElementById('ratingText').textContent = textos[nota] || '';
});
});
async function enviar() {
if (nota === 0) {
document.getElementById('erro').textContent = 'Selecione uma avaliação de 1 a 5 estrelas.';
document.getElementById('erro').style.display = 'block';
return;
}
var btn = document.getElementById('btnEnviar');
btn.disabled = true;
btn.textContent = 'Enviando...';
document.getElementById('erro').style.display = 'none';
try {
var resp = await fetch('/api/' + alias + '/csat/avaliar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
conversaId: parseInt(conversaId),
empresaId: parseInt(empresaId),
nota: nota,
comentario: document.getElementById('comentario').value.trim()
})
});
var data = await resp.json();
if (data.success) {
document.getElementById('app').querySelector('.stars').style.display = 'none';
document.querySelector('textarea').style.display = 'none';
document.getElementById('btnEnviar').style.display = 'none';
document.getElementById('ratingText').style.display = 'none';
document.getElementById('success').style.display = 'block';
} else {
throw new Error(data.error || 'Erro ao enviar');
}
} catch(e) {
document.getElementById('erro').textContent = 'Erro ao enviar: ' + e.message;
document.getElementById('erro').style.display = 'block';
btn.disabled = false;
btn.textContent = 'Enviar Avaliação';
}
}
getParams();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+648
View File
@@ -0,0 +1,648 @@
/* =====================================================
CHATC2 - Design System Compartilhado
===================================================== */
/* ===== VARIÁVEIS ===== */
:root {
--primary: #667eea;
--primary-dark: #5a67d8;
--secondary: #764ba2;
--sidebar-from: #1e1b4b;
--sidebar-to: #312e81;
--surface: #ffffff;
--surface-2: #f9fafb;
--surface-3: #f3f4f6;
--border: #e5e7eb;
--border-light: #f3f4f6;
--text-primary: #111827;
--text-secondary: #374151;
--text-muted: #6b7280;
--text-faint: #9ca3af;
--success: #059669;
--success-bg: #d1fae5;
--success-text: #065f46;
--danger: #ef4444;
--danger-bg: #fef2f2;
--danger-text: #991b1b;
--warning: #f59e0b;
--warning-bg: #fef3c7;
--warning-text: #92400e;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
--shadow-md: 0 4px 16px rgba(0,0,0,0.10);
--shadow-lg: 0 20px 60px rgba(0,0,0,0.18);
--transition: all 0.15s ease;
}
/* ===== RESET GLOBAL ===== */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
color: var(--text-primary);
}
/* ===== SIDEBAR COMPARTILHADA ===== */
.sidebar {
width: 240px;
background: linear-gradient(180deg, var(--sidebar-from) 0%, var(--sidebar-to) 100%);
color: #fff;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-brand {
padding: 20px 18px;
border-bottom: 1px solid rgba(255,255,255,0.08);
display: flex;
align-items: center;
gap: 12px;
}
.sidebar-brand .logo {
width: 38px;
height: 38px;
background: rgba(255,255,255,0.18);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 15px;
flex-shrink: 0;
overflow: hidden;
}
.sidebar-brand .logo img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 10px;
}
.sidebar-brand h2 {
font-size: 16px;
font-weight: 700;
color: #fff;
letter-spacing: -0.2px;
}
.sidebar-brand span {
font-size: 11px;
color: rgba(255,255,255,0.45);
display: block;
margin-top: 1px;
}
.sidebar-nav {
padding: 12px 10px;
flex: 1;
overflow-y: auto;
}
.sidebar-nav::-webkit-scrollbar { width: 3px; }
.sidebar-nav::-webkit-scrollbar-track { background: transparent; }
.sidebar-nav::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; }
.sidebar-nav .nav-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: rgba(255,255,255,0.35);
padding: 10px 10px 4px;
font-weight: 600;
}
.sidebar-nav a {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: 8px;
color: rgba(255,255,255,0.72);
text-decoration: none;
font-size: 14px;
transition: var(--transition);
margin-bottom: 1px;
}
.sidebar-nav a:hover {
background: rgba(255,255,255,0.10);
color: #fff;
}
.sidebar-nav a.active {
background: rgba(255,255,255,0.16);
color: #fff;
font-weight: 600;
}
.sidebar-nav a .icon {
width: 22px;
text-align: center;
font-size: 16px;
flex-shrink: 0;
}
.sidebar-footer {
padding: 12px 10px;
border-top: 1px solid rgba(255,255,255,0.08);
}
.sidebar-footer .dark-mode-toggle {
width: 100%;
margin-bottom: 4px;
padding: 9px 12px;
border-radius: 8px;
cursor: pointer;
background: rgba(255,255,255,0.07);
border: 1px solid rgba(255,255,255,0.12);
font-size: 13px;
color: rgba(255,255,255,0.75);
transition: var(--transition);
text-align: left;
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-footer .dark-mode-toggle:hover {
background: rgba(255,255,255,0.14);
color: #fff;
}
.sidebar-footer a {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: 8px;
color: rgba(255,255,255,0.55);
text-decoration: none;
font-size: 13px;
transition: var(--transition);
cursor: pointer;
}
.sidebar-footer a:hover {
background: rgba(255,255,255,0.10);
color: #fca5a5;
}
/* ===== TOPBAR ===== */
.topbar {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 14px 24px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: var(--shadow-sm);
flex-shrink: 0;
}
.topbar-title {
font-size: 17px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.2px;
}
.user-info {
display: flex;
align-items: center;
gap: 14px;
}
.user-name {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
}
.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: var(--success-bg);
color: var(--success-text);
}
/* ===== CARDS ===== */
.card {
background: var(--surface);
border-radius: var(--radius-lg);
padding: 24px;
margin-bottom: 20px;
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
}
.card h3 {
font-size: 15px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 16px;
}
/* ===== INFO GRID ===== */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.info-item {
padding: 14px 16px;
background: var(--surface-2);
border-radius: var(--radius-md);
border: 1px solid var(--border-light);
}
.info-item .label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-faint);
margin-bottom: 4px;
font-weight: 600;
}
.info-item .value {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
/* ===== BOTÕES ===== */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 18px;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
transition: var(--transition);
text-decoration: none;
white-space: nowrap;
}
.btn-primary {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
.btn-primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
.btn-secondary {
background: var(--surface);
color: var(--text-secondary);
border-color: var(--border);
}
.btn-secondary:hover { background: var(--surface-2); }
.btn-danger {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
.btn-danger:hover { background: #dc2626; }
/* ===== FORMULÁRIOS ===== */
.form-group {
margin-bottom: 18px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 6px;
}
.form-control,
input[type="text"],
input[type="password"],
input[type="email"],
input[type="number"],
input[type="search"],
select,
textarea {
width: 100%;
padding: 10px 14px;
border: 2px solid var(--border);
border-radius: var(--radius-md);
font-size: 14px;
font-family: inherit;
color: var(--text-primary);
background: var(--surface-2);
outline: none;
transition: var(--transition);
}
.form-control:focus,
input[type="text"]:focus,
input[type="password"]:focus,
select:focus,
textarea:focus {
border-color: var(--primary);
background: var(--surface);
box-shadow: 0 0 0 3px rgba(102,126,234,0.12);
}
/* ===== TABELAS ===== */
.table-wrapper {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
thead {
background: var(--surface-2);
}
th {
text-align: left;
padding: 12px 16px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
font-weight: 700;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
td {
padding: 12px 16px;
font-size: 14px;
border-bottom: 1px solid var(--border-light);
color: var(--text-secondary);
}
tr:hover td { background: var(--surface-2); }
tr:last-child td { border-bottom: none; }
/* ===== BADGES ===== */
.badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.situacao-badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
.situacao-badge.ativo { background: var(--success-bg); color: var(--success-text); }
.situacao-badge.inativo { background: var(--danger-bg); color: var(--danger-text); }
.empresa-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 5px;
font-size: 11px;
font-weight: 600;
background: #ede9fe;
color: #5b21b6;
}
.client-link {
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
.client-link:hover { text-decoration: underline; }
/* ===== PAGINAÇÃO ===== */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
padding: 14px 16px;
border-top: 1px solid var(--border-light);
}
.pagination button {
padding: 6px 12px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius-sm);
font-size: 13px;
cursor: pointer;
transition: var(--transition);
color: var(--text-secondary);
font-weight: 500;
}
.pagination button:hover:not(:disabled) { background: var(--surface-2); border-color: var(--text-faint); }
.pagination button.active { background: var(--primary); border-color: var(--primary); color: #fff; }
.pagination button:disabled { opacity: 0.4; cursor: not-allowed; }
.pagination .page-info { font-size: 13px; color: var(--text-muted); padding: 0 10px; }
/* ===== MODAL ===== */
.modal-overlay {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: rgba(0,0,0,0.48);
backdrop-filter: blur(4px);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-overlay.show { display: flex; }
.modal-box {
background: var(--surface);
border-radius: var(--radius-lg);
padding: 28px;
width: 90%;
max-width: 440px;
box-shadow: var(--shadow-lg);
animation: modalIn 0.18s ease;
}
@keyframes modalIn {
from { opacity: 0; transform: scale(0.96) translateY(-10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.modal-box h3 {
margin-bottom: 18px;
font-size: 17px;
font-weight: 700;
color: var(--text-primary);
}
.modal-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 20px;
}
/* ===== ESTADOS VAZIOS ===== */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-faint);
}
.empty-state .icon { font-size: 48px; margin-bottom: 12px; display: block; }
.empty-state p { font-size: 14px; }
/* ===== SPINNERS ===== */
.spinner {
display: inline-block;
width: 22px; height: 22px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-row td {
text-align: center;
padding: 40px;
color: var(--text-faint);
}
/* ===== TOKEN BOX ===== */
.token-box {
background: #1f2937;
color: #e5e7eb;
padding: 16px;
border-radius: var(--radius-md);
font-family: 'SF Mono', 'Cascadia Code', Monaco, monospace;
font-size: 12px;
word-break: break-all;
line-height: 1.6;
max-height: 120px;
overflow-y: auto;
}
.btn-copy {
margin-top: 12px;
padding: 8px 16px;
background: #374151;
color: #fff;
border: none;
border-radius: var(--radius-sm);
font-size: 13px;
cursor: pointer;
transition: var(--transition);
font-weight: 500;
}
.btn-copy:hover { background: #4b5563; }
/* ===== SEARCH BAR ===== */
.search-bar {
display: flex;
gap: 10px;
margin-bottom: 20px;
align-items: center;
flex-wrap: wrap;
}
.search-bar input {
flex: 1;
min-width: 180px;
padding: 10px 16px;
border: 2px solid var(--border);
border-radius: var(--radius-md);
font-size: 14px;
outline: none;
background: var(--surface);
transition: var(--transition);
}
.search-bar input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(102,126,234,0.1); }
.search-bar select {
padding: 10px 16px;
border: 2px solid var(--border);
border-radius: var(--radius-md);
font-size: 14px;
outline: none;
background: var(--surface);
min-width: 200px;
cursor: pointer;
transition: var(--transition);
color: var(--text-primary);
}
.search-bar select:focus { border-color: var(--primary); }
.search-bar button {
padding: 10px 20px;
background: var(--primary);
color: #fff;
border: none;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: var(--transition);
white-space: nowrap;
}
.search-bar button:hover { background: var(--primary-dark); }
.search-bar button:disabled { opacity: 0.5; cursor: not-allowed; }
.search-bar .total-info {
font-size: 13px;
color: var(--text-muted);
margin-left: auto;
white-space: nowrap;
}
/* ===== BACK BUTTON ===== */
.back-btn {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--primary);
text-decoration: none;
font-size: 14px;
font-weight: 500;
margin-bottom: 16px;
transition: var(--transition);
}
.back-btn:hover { color: var(--primary-dark); }
/* ===== SCROLLBAR CUSTOMIZADA ===== */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #9ca3af; }
+188
View File
@@ -0,0 +1,188 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - Chatc2</title>
<link rel="stylesheet" href="/css/main.css">
<style>
body { background: #f3f4f6; display: flex; min-height: 100vh; }
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.container { flex: 1; padding: 24px; overflow-y: auto; }
</style>
<link rel="stylesheet" href="/css/dark-mode.css">
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});}
window.darkModeApply=function(a){var e=document.body;if(!e)return;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'});};
window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')==='true';};</script>
</head>
<body>
<!-- SIDEBAR -->
<aside class="sidebar">
<div class="sidebar-brand">
<div class="logo">C2</div>
<div>
<h2>Chatc2</h2>
<span id="sidebarAlias">lajedo</span>
</div>
</div>
<nav class="sidebar-nav">
<div class="nav-label">Principal</div>
<a href="#" class="active" id="navDashboard">
<span class="icon">📊</span> Dashboard
</a>
<a href="#" id="navClients">
<span class="icon">👥</span> Clientes
</a>
<a href="#" id="navChat">
<span class="icon">💬</span> Conversas
</a>
<div class="nav-label" id="adminLabel" style="display:none">Administrador</div>
<a href="#" id="navConfig" style="display:none">
<span class="icon">⚙️</span> Configurações
</a>
<a href="#" id="navAllConvs" style="display:none"><span class="icon">💬</span> Todas Conversas</a>
<a href="#" id="navRoutes" style="display:none">
<span class="icon">📡</span> Rotas
</a>
</nav>
<div class="sidebar-footer">
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button>
<a onclick="logout()">
<span class="icon">🚪</span> Sair
</a>
</div>
</aside>
<!-- MAIN -->
<div class="main">
<div class="topbar">
<span class="topbar-title">Dashboard</span>
<div class="user-info">
<span class="status-badge online">● Online</span>
<span class="user-name" id="userName"></span>
</div>
</div>
<div class="container">
<div class="card">
<h3>👤 Dados do Usuário</h3>
<div class="info-grid">
<div class="info-item">
<div class="label">ID</div>
<div class="value" id="userId">-</div>
</div>
<div class="info-item">
<div class="label">Nome</div>
<div class="value" id="userNameDisplay">-</div>
</div>
<div class="info-item">
<div class="label">Login</div>
<div class="value" id="userLogin">-</div>
</div>
<div class="info-item">
<div class="label">Email</div>
<div class="value" id="userEmail">-</div>
</div>
<div class="info-item">
<div class="label">Nível</div>
<div class="value" id="userNivel">-</div>
</div>
<div class="info-item">
<div class="label">Tipo</div>
<div class="value" id="userTipo">-</div>
</div>
<div class="info-item">
<div class="label">Autenticação</div>
<div class="value" id="userAuthType">-</div>
</div>
</div>
</div>
<div class="card">
<h3>🔑 Token de Acesso</h3>
<button class="btn-copy" onclick="showToken()" id="btnShowToken" style="margin-bottom:8px">Mostrar Token</button>
<div class="token-box" id="tokenDisplay" style="display:none"></div>
<button class="btn-copy" onclick="copyToken()" id="btnCopyToken" style="display:none">Copiar Token</button>
</div>
</div>
</div>
<script>
// Extrai alias da URL
const pathParts = window.location.pathname.split('/');
const alias = pathParts[2] || localStorage.getItem('chatc2_alias') || 'lajedo';
localStorage.setItem('chatc2_alias', alias);
const token = localStorage.getItem('chatc2_token');
const user = JSON.parse(localStorage.getItem('chatc2_user') || '{}');
if (!token) {
window.location.href = '/app/' + alias + '/login';
}
// Sidebar - mostra opções de admin se for Gerente
const tipoChat = user.tipoChat || 'A';
if (tipoChat === 'G') {
document.getElementById('adminLabel').style.display = '';
document.getElementById('navConfig').style.display = '';
document.getElementById('navRoutes').style.display = '';
} else {
// Agente não vê dashboard - redireciona para conversas
window.location.href = '/app/' + alias + '/company/' + (user.empresas?.[0] || 1) + '/conversation/0';
}
document.getElementById('sidebarAlias').textContent = alias;
document.getElementById('userId').textContent = user.id || '-';
document.getElementById('userNameDisplay').textContent = user.nome || '-';
document.getElementById('userName').textContent = user.nome || '-';
document.getElementById('userLogin').textContent = user.login || '-';
document.getElementById('userEmail').textContent = user.email || '-';
document.getElementById('userNivel').textContent = user.nivelId || '-';
document.getElementById('userTipo').textContent = user.tipo || '-';
document.getElementById('userAuthType').textContent = user.authType || 'jwt';
// Token foi carregado, mas fica oculto até clicar em Mostrar Token
// Navegação sidebar
function navClick(e, url) {
e.preventDefault();
window.location.href = url;
}
document.getElementById('navDashboard').onclick = function(e) { navClick(e, '/app/' + alias + '/dashboard'); };
document.getElementById('navClients').onclick = function(e) { navClick(e, '/app/' + alias + '/clients'); };
document.getElementById('navChat').onclick = function(e) { navClick(e, '/app/' + alias + '/company/' + (user.empresas?.[0] || 1) + '/conversation/0'); };
document.getElementById('navConfig').onclick = function(e) { navClick(e, '/app/' + alias + '/settings'); };
document.getElementById('navRoutes').onclick = function(e) { navClick(e, '/app/' + alias + '/routes'); };
function copyToken() {
navigator.clipboard.writeText(token).then(() => {
alert('Token copiado!');
});
}
function logout() {
localStorage.removeItem('chatc2_token');
localStorage.removeItem('chatc2_alias');
localStorage.removeItem('chatc2_user');
window.location.href = '/app/' + alias + '/login';
}
function showToken() {
const display = document.getElementById('tokenDisplay');
const btnShow = document.getElementById('btnShowToken');
const btnCopy = document.getElementById('btnCopyToken');
if (display.style.display === 'none') {
display.textContent = token;
display.style.display = 'block';
btnShow.textContent = 'Ocultar Token';
btnCopy.style.display = 'inline-block';
} else {
display.style.display = 'none';
btnShow.textContent = 'Mostrar Token';
btnCopy.style.display = 'none';
}
}
</script>
<script src="/js/dark-mode.js"></script>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
// Dark Mode Controller - Only handles initialization/persistence
(function() {
if (typeof window.darkModeToggle !== 'function') {
// Ensure function exists (inline script in head should have defined it)
window.darkModeToggle = function() {
var el = document.body;
if (!el) return;
var isDark = localStorage.getItem('chatc2_dark_mode') === 'true';
var enable = !isDark;
el.classList.toggle('dark-mode', enable);
localStorage.setItem('chatc2_dark_mode', enable ? 'true' : 'false');
document.querySelectorAll('.dark-mode-toggle').forEach(function(btn) {
btn.innerHTML = enable ? '☀️ Claro' : '🌙 Escuro';
});
};
window.darkModeApply = function(enable) {
var el = document.body;
if (!el) return;
el.classList.toggle('dark-mode', enable);
localStorage.setItem('chatc2_dark_mode', enable ? 'true' : 'false');
document.querySelectorAll('.dark-mode-toggle').forEach(function(btn) {
btn.innerHTML = enable ? '☀️ Claro' : '🌙 Escuro';
});
};
window.darkModeIsDark = function() { return localStorage.getItem('chatc2_dark_mode') === 'true'; };
}
// Apply on load
if (localStorage.getItem('chatc2_dark_mode') === 'true') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
document.body.classList.add('dark-mode');
document.querySelectorAll('.dark-mode-toggle').forEach(function(btn) {
btn.innerHTML = '☀️ Claro';
});
});
} else {
document.body.classList.add('dark-mode');
document.querySelectorAll('.dark-mode-toggle').forEach(function(btn) {
btn.innerHTML = '☀️ Claro';
});
}
}
})();
+359
View File
@@ -0,0 +1,359 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Chatc2</title>
<link rel="stylesheet" href="/css/main.css">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
background-size: 400% 400%;
animation: gradientShift 12s ease infinite;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.login-container {
background: #fff;
border-radius: 20px;
padding: 44px 40px 36px;
width: 100%;
max-width: 400px;
box-shadow: 0 32px 80px rgba(0,0,0,0.28), 0 0 0 1px rgba(255,255,255,0.1);
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-header .logo {
width: 76px;
height: 76px;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 18px;
font-size: 30px;
color: #fff;
font-weight: 800;
overflow: hidden;
box-shadow: 0 8px 24px rgba(102,126,234,0.4);
}
.login-header .logo img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 20px;
}
.login-header h1 {
font-size: 24px;
font-weight: 800;
color: #111827;
margin-bottom: 6px;
letter-spacing: -0.5px;
}
.login-header p {
color: #6b7280;
font-size: 14px;
}
.form-group {
margin-bottom: 18px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 600;
color: #374151;
margin-bottom: 7px;
}
.form-group input {
width: 100%;
padding: 12px 16px;
border: 2px solid #e5e7eb;
border-radius: 11px;
font-size: 15px;
transition: all 0.15s;
outline: none;
background: #f9fafb;
color: #111827;
}
.form-group input:focus {
border-color: #667eea;
background: #fff;
box-shadow: 0 0 0 4px rgba(102,126,234,0.12);
}
.form-group input.error {
border-color: #ef4444;
background: #fef2f2;
}
.password-wrapper { position: relative; }
.password-wrapper input { padding-right: 46px; }
.toggle-password {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
color: #9ca3af;
font-size: 18px;
padding: 4px;
transition: color 0.15s;
}
.toggle-password:hover { color: #6b7280; }
.btn-login {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
border: none;
border-radius: 11px;
font-size: 16px;
font-weight: 700;
cursor: pointer;
transition: opacity 0.2s, transform 0.1s;
margin-top: 4px;
letter-spacing: 0.2px;
}
.btn-login:hover { opacity: 0.92; }
.btn-login:active { transform: scale(0.98); }
.btn-login:disabled { opacity: 0.6; cursor: not-allowed; }
.error-message {
background: #fef2f2;
color: #dc2626;
padding: 12px 16px;
border-radius: 10px;
font-size: 13px;
margin-bottom: 16px;
display: none;
border: 1px solid #fecaca;
font-weight: 500;
}
.error-message.show { display: block; }
.loading-spinner {
display: none;
width: 20px;
height: 20px;
border: 3px solid rgba(255,255,255,0.35);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
margin: 0 auto;
}
.btn-login.loading .btn-text { display: none; }
.btn-login.loading .loading-spinner { display: block; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Dark mode toggle no login */
.login-dark-toggle {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
}
.login-dark-toggle button {
padding: 10px 16px;
border-radius: 24px;
border: 1px solid rgba(255,255,255,0.3);
background: rgba(255,255,255,0.15);
backdrop-filter: blur(8px);
color: #fff;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
font-weight: 500;
}
.login-dark-toggle button:hover {
background: rgba(255,255,255,0.25);
}
</style>
<link rel="stylesheet" href="/css/dark-mode.css">
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});}
window.darkModeApply=function(a){var e=document.body;if(!e)return;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'});};
window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')==='true';};</script>
</head>
<body>
<div class="login-container">
<div class="login-header">
<div class="logo" id="logoEl">C2</div>
<h1>Bem-vindo</h1>
<p id="empresaNome">Faça login para acessar o Chatc2</p>
</div>
<div id="errorMessage" class="error-message"></div>
<form id="loginForm" autocomplete="off">
<div class="form-group">
<label for="USU_LOGIN">Usuário</label>
<input
type="text"
id="USU_LOGIN"
name="USU_LOGIN"
placeholder="Digite seu usuário"
required
autofocus
/>
</div>
<div class="form-group">
<label for="USU_SENHA">Senha</label>
<div class="password-wrapper">
<input
type="password"
id="USU_SENHA"
name="USU_SENHA"
placeholder="Digite sua senha"
required
/>
<button type="button" class="toggle-password" id="togglePassword" tabindex="-1">
👁️
</button>
</div>
</div>
<button type="submit" class="btn-login" id="btnLogin">
<span class="btn-text">Entrar</span>
<div class="loading-spinner"></div>
</button>
</form>
</div>
<script>
// Extrai o alias da URL: /app/:alias/login
const pathParts = window.location.pathname.split('/');
const alias = pathParts[2] || 'lajedo';
const form = document.getElementById('loginForm');
const btnLogin = document.getElementById('btnLogin');
const errorMsg = document.getElementById('errorMessage');
const loginInput = document.getElementById('USU_LOGIN');
const senhaInput = document.getElementById('USU_SENHA');
const togglePassword = document.getElementById('togglePassword');
const logoEl = document.getElementById('logoEl');
const empresaNome = document.getElementById('empresaNome');
// Mostrar/ocultar senha
togglePassword.addEventListener('click', () => {
const type = senhaInput.getAttribute('type') === 'password' ? 'text' : 'password';
senhaInput.setAttribute('type', type);
togglePassword.textContent = type === 'password' ? '👁️' : '👁️‍🗨️';
});
// Limpar erro ao digitar
loginInput.addEventListener('input', () => {
errorMsg.classList.remove('show');
loginInput.classList.remove('error');
senhaInput.classList.remove('error');
});
senhaInput.addEventListener('input', () => {
errorMsg.classList.remove('show');
senhaInput.classList.remove('error');
});
// Carrega logo da empresa
async function carregarLogo() {
try {
const res = await fetch('/api/' + alias + '/empresa/logo');
const data = await res.json();
if (data.success) {
if (data.fotoUrl) {
logoEl.innerHTML = '<img src="' + data.fotoUrl + '" alt="Logo">';
} else if (data.iniciais) {
logoEl.textContent = data.iniciais;
logoEl.style.background = data.cor || 'linear-gradient(135deg, #667eea, #764ba2)';
}
if (data.nomeFantasia) {
empresaNome.textContent = data.nomeFantasia;
}
}
} catch(e) { /* usa padrão */ }
}
carregarLogo();
// Submit do formulário
form.addEventListener('submit', async (e) => {
e.preventDefault();
const USU_LOGIN = loginInput.value.trim().toUpperCase();
const USU_SENHA = senhaInput.value;
if (!USU_LOGIN || !USU_SENHA) {
showError('Preencha todos os campos.');
return;
}
btnLogin.classList.add('loading');
btnLogin.disabled = true;
errorMsg.classList.remove('show');
try {
const response = await fetch('/app/' + alias + '/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ USU_LOGIN, USU_SENHA }),
});
const data = await response.json();
if (!response.ok || !data.success) {
showError(data.error || 'Erro ao fazer login.');
return;
}
// Salva o token, dados do usuário e alias
localStorage.setItem('chatc2_token', data.token);
localStorage.setItem('chatc2_alias', data.alias || alias);
localStorage.setItem('chatc2_user', JSON.stringify(data.user));
// Redireciona para o dashboard com o alias
window.location.href = '/app/' + alias + '/dashboard';
} catch (err) {
showError('Erro de conexão com o servidor.');
} finally {
btnLogin.classList.remove('loading');
btnLogin.disabled = false;
}
});
function showError(message) {
errorMsg.textContent = message;
errorMsg.classList.add('show');
loginInput.classList.add('error');
senhaInput.classList.add('error');
}
</script>
<div class="login-dark-toggle">
<button class="dark-mode-toggle" onclick="darkModeToggle()">🌙 Escuro</button>
</div>
<script src="/js/dark-mode.js"></script>
</body>
</html>
+336
View File
@@ -0,0 +1,336 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rotas - Chatc2 API</title>
<link rel="stylesheet" href="/css/main.css">
<style>
body { background:#f3f4f6; display:flex; min-height:100vh; }
.sidebar-nav .admin-only { display:none; }
.main { flex:1; display:flex; flex-direction:column; min-width:0; }
.container { flex:1; padding:24px; overflow-y:auto; }
/* Barra de busca/filtros */
.search-box { margin-bottom:18px; display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
.search-box input { flex:1; min-width:220px; padding:10px 16px; border:2px solid #e5e7eb; border-radius:8px; font-size:14px; outline:none; transition:border-color .15s; background:#fff; color:#1f2937; }
.search-box input:focus { border-color:#667eea; }
.search-box select { padding:10px 12px; border:2px solid #e5e7eb; border-radius:8px; font-size:13px; outline:none; background:#fff; cursor:pointer; color:#374151; }
.search-box select:focus { border-color:#667eea; }
.btn-ghost { padding:9px 14px; border:2px solid #e5e7eb; border-radius:8px; background:#fff; cursor:pointer; font-size:13px; color:#374151; }
.btn-ghost:hover { background:#f3f4f6; }
/* Legenda */
.legenda { display:flex; gap:16px; flex-wrap:wrap; font-size:12px; color:#6b7280; margin-bottom:16px; align-items:center; }
.legenda .pill { display:inline-flex; align-items:center; gap:5px; }
/* Categoria */
.cat-group { margin-bottom:26px; }
.cat-title { display:flex; align-items:center; gap:8px; font-size:15px; font-weight:700; color:#374151; margin:0 0 10px; }
.cat-title .count { font-size:11px; background:#e5e7eb; color:#6b7280; padding:2px 8px; border-radius:10px; font-weight:600; }
/* Card de rota (expansível) */
.route { background:#fff; border:1px solid #e5e7eb; border-radius:10px; margin-bottom:8px; overflow:hidden; }
.route[open] { box-shadow:0 1px 6px rgba(0,0,0,.06); border-color:#d1d5db; }
.route-head { list-style:none; cursor:pointer; display:flex; align-items:center; gap:10px; padding:12px 14px; flex-wrap:wrap; }
.route-head::-webkit-details-marker { display:none; }
.route-head:hover { background:#fafafa; }
.route[open] .route-head { border-bottom:1px solid #f0f0f0; background:#fafbff; }
.chev { color:#9ca3af; font-size:11px; transition:transform .15s; }
.route[open] .chev { transform:rotate(90deg); }
.method { display:inline-block; padding:3px 9px; border-radius:5px; font-size:11px; font-weight:700; text-transform:uppercase; color:#fff; min-width:54px; text-align:center; }
.method.get { background:#059669; }
.method.post { background:#2563eb; }
.method.put { background:#d97706; }
.method.delete { background:#dc2626; }
.method.patch { background:#7c3aed; }
.route-path { font-family:'SF Mono',Monaco,'Cascadia Code',monospace; font-size:13px; color:#1f2937; word-break:break-all; }
.route-path .ph { color:#7c3aed; font-style:normal; }
.route-summary-desc { color:#6b7280; font-size:12.5px; flex:1 1 100%; margin-left:64px; }
.auth-badge { font-size:10.5px; font-weight:600; padding:2px 8px; border-radius:20px; white-space:nowrap; }
.auth-yes { background:#fef3c7; color:#92400e; }
.auth-no { background:#d1fae5; color:#065f46; }
/* Detalhe expandido */
.route-detail { padding:14px 16px 16px; }
.block { margin-top:14px; }
.block:first-child { margin-top:0; }
.block-title { font-size:12px; font-weight:700; color:#6b7280; text-transform:uppercase; letter-spacing:.4px; margin-bottom:7px; display:flex; align-items:center; gap:8px; }
.param-table { width:100%; border-collapse:collapse; font-size:13px; }
.param-table th { text-align:left; font-size:10.5px; text-transform:uppercase; color:#9ca3af; padding:4px 10px; border-bottom:1px solid #eee; font-weight:600; }
.param-table td { padding:6px 10px; border-bottom:1px solid #f3f4f6; vertical-align:top; color:#374151; }
.param-table td.pname { font-family:'SF Mono',monospace; color:#1f2937; font-weight:600; white-space:nowrap; }
.tag { font-size:10px; font-weight:700; padding:2px 7px; border-radius:12px; white-space:nowrap; }
.tag.req { background:#fee2e2; color:#b91c1c; }
.tag.opt { background:#f3f4f6; color:#6b7280; }
.code-json { background:#0f172a; color:#e2e8f0; border-radius:8px; padding:12px 14px; font-family:'SF Mono',Monaco,'Cascadia Code',monospace; font-size:12.5px; line-height:1.55; overflow-x:auto; margin:0; white-space:pre; }
.code-curl { background:#111827; color:#a7f3d0; border-radius:8px; padding:12px 14px; font-family:'SF Mono',monospace; font-size:12px; line-height:1.5; overflow-x:auto; margin:0; white-space:pre; }
.req-note { font-size:12px; color:#6b7280; margin-top:7px; }
.req-note code { background:#f3f4f6; padding:1px 6px; border-radius:4px; color:#b91c1c; font-weight:600; }
.note-box { font-size:12.5px; color:#475569; background:#f8fafc; border-left:3px solid #cbd5e1; padding:8px 12px; border-radius:0 6px 6px 0; }
.copy { padding:3px 9px; border:none; border-radius:5px; background:#e5e7eb; cursor:pointer; font-size:11px; color:#374151; }
.copy:hover { background:#d1d5db; }
.copy-light { background:rgba(255,255,255,.12); color:#e2e8f0; }
.copy-light:hover { background:rgba(255,255,255,.24); }
.empty { text-align:center; padding:50px; color:#9ca3af; font-size:14px; }
</style>
<link rel="stylesheet" href="/css/dark-mode.css">
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});}
window.darkModeApply=function(a){var e=document.body;if(!e)return;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'});};
window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')==='true';};</script>
</head>
<body>
<aside class="sidebar">
<div class="sidebar-brand">
<div class="logo">C2</div>
<div><h2>Chatc2</h2><span>API Routes</span></div>
</div>
<nav class="sidebar-nav">
<div class="nav-label">Principal</div>
<a href="#" id="navDashboard"><span class="icon">📊</span> Dashboard</a>
<a href="#" id="navClients"><span class="icon">👥</span> Clientes</a>
<a href="#" id="navChatRoutes"><span class="icon">💬</span> Conversas</a>
<div class="nav-label admin-only">Administrador</div>
<a href="#" id="navConfigRoutes" class="admin-only"><span class="icon">⚙️</span> Configurações</a>
<a href="#" class="active admin-only" id="navRoutesActive"><span class="icon">📡</span> Rotas</a>
</nav>
<div class="sidebar-footer">
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button>
<a onclick="logout()"><span class="icon">🚪</span> Sair</a>
</div>
</aside>
<div class="main">
<div class="topbar">
<span class="topbar-title">📡 Rotas da API</span>
<span id="totalRotas" style="font-size:13px;color:#6b7280"></span>
</div>
<div class="container">
<div class="search-box">
<input type="text" id="searchInput" placeholder="Buscar por método, path ou descrição..." oninput="filtrarRotas()">
<select id="filterCat" onchange="filtrarRotas()"><option value="">Todas as categorias</option></select>
<select id="filterMethod" onchange="filtrarRotas()">
<option value="">Todos os métodos</option>
<option value="GET">GET</option><option value="POST">POST</option>
<option value="PUT">PUT</option><option value="DELETE">DELETE</option>
</select>
<select id="filterAuth" onchange="filtrarRotas()">
<option value="">Pública e protegida</option>
<option value="yes">🔒 Requer token</option>
<option value="no">🔓 Pública</option>
</select>
<button class="btn-ghost" onclick="expandirTodas(true)">Expandir</button>
<button class="btn-ghost" onclick="expandirTodas(false)">Recolher</button>
<button class="btn-ghost" onclick="carregarRotas()">🔄 Atualizar</button>
</div>
<div class="legenda">
<span class="pill"><span class="auth-badge auth-yes">🔒 Token</span> requer <code>Authorization: Bearer &lt;token&gt;</code></span>
<span class="pill"><span class="auth-badge auth-no">🔓 Público</span> sem autenticação</span>
<span class="pill"><span class="tag req">obrigatório</span> / <span class="tag opt">opcional</span></span>
</div>
<div id="statusMsg" class="empty">Carregando rotas...</div>
<div id="rotasContainer"></div>
</div>
</div>
<script>
(function(){
'use strict';
const token = localStorage.getItem('chatc2_token');
const alias = localStorage.getItem('chatc2_alias') || 'novo_local';
const user = JSON.parse(localStorage.getItem('chatc2_user') || '{}');
if (!token) { window.location.href = '/app/' + alias + '/login'; return; }
document.getElementById("navDashboard").onclick = function(e){ e.preventDefault(); window.location.href = "/app/" + alias + "/dashboard"; };
document.getElementById("navClients").onclick = function(e){ e.preventDefault(); window.location.href = "/app/" + alias + "/clients"; };
document.getElementById("navChatRoutes").onclick = function(e){ e.preventDefault(); window.location.href = "/app/" + alias + "/company/" + (user.empresas?.[0] || 1) + "/conversation/0"; };
document.getElementById("navConfigRoutes").onclick = function(e){ e.preventDefault(); window.location.href = "/app/" + alias + "/settings"; };
document.getElementById("navRoutesActive").onclick = function(e){ e.preventDefault(); window.location.href = "/app/" + alias + "/routes"; };
if ((user.tipoChat || "A") === "G") {
document.querySelectorAll(".admin-only").forEach(function(el){ el.style.display = ""; });
}
window.logout = function(){
['chatc2_token','chatc2_alias','chatc2_user'].forEach(function(k){ localStorage.removeItem(k); });
window.location.href = '/app/' + alias + '/login';
};
var todasRotas = [];
function esc(s){ return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
// Realça os {placeholders} no path; troca {alias} pelo alias real
function fmtPath(path){
return esc(path.replace(/\{alias\}/g, alias))
.replace(/\{([a-zA-Z_]+)\}/g, '<i class="ph">{$1}</i>');
}
function copyBtn(texto, light){
return '<button class="copy' + (light ? ' copy-light' : '') + '" data-copy="' +
encodeURIComponent(texto) + '">📋 Copiar</button>';
}
function tabelaParams(titulo, itens){
if (!itens || !itens.length) return '';
var linhas = itens.map(function(it){
return '<tr><td class="pname">' + esc(it.nome) + '</td><td>' +
(it.obrigatorio ? '<span class="tag req">obrigatório</span>' : '<span class="tag opt">opcional</span>') +
'</td><td>' + esc(it.desc) + '</td></tr>';
}).join('');
return '<div class="block"><div class="block-title">' + titulo + '</div>' +
'<table class="param-table"><thead><tr><th>Nome</th><th>Obrigatório</th><th>Descrição</th></tr></thead>' +
'<tbody>' + linhas + '</tbody></table></div>';
}
function blocoBody(r){
if (!r.body) return '';
var json = JSON.stringify(r.body, null, 2);
var reqNote = (r.bodyReq && r.bodyReq.length)
? 'Obrigatórios: ' + r.bodyReq.map(function(f){ return '<code>' + esc(f) + '</code>'; }).join(', ')
: 'Todos os campos do exemplo são opcionais.';
return '<div class="block"><div class="block-title">Body (JSON) ' + copyBtn(json) + '</div>' +
'<pre class="code-json">' + esc(json) + '</pre>' +
'<div class="req-note">' + reqNote + '</div></div>';
}
function exemploCurl(r){
if (r.pagina) return '';
var url = window.location.origin + r.path.replace(/\{alias\}/g, alias);
var p = ['curl -X ' + r.method + ' "' + url + '"'];
if (r.auth) p.push('-H "Authorization: Bearer <SEU_TOKEN>"');
if (r.body) { p.push('-H "Content-Type: application/json"'); p.push("-d '" + JSON.stringify(r.body) + "'"); }
var cmd = p.join(' \\\n ');
return '<div class="block"><div class="block-title">Exemplo (cURL) ' + copyBtn(cmd, true) + '</div>' +
'<pre class="code-curl">' + esc(cmd) + '</pre></div>';
}
function cardRota(r){
var authBadge = r.auth
? '<span class="auth-badge auth-yes">🔒 Token</span>'
: '<span class="auth-badge auth-no">🔓 Público</span>';
var detalhe = '';
detalhe += '<div class="note-box">' + (r.auth
? 'Requer cabeçalho <code>Authorization: Bearer &lt;token&gt;</code>.'
: 'Rota pública — não precisa de token.') +
(r.pagina ? ' Retorna uma página HTML (abre no navegador).' : '') + '</div>';
if (r.desc) detalhe = '<div class="block"><div class="block-title">Descrição</div>' + esc(r.desc) + '</div>' + detalhe;
detalhe += tabelaParams('Parâmetros de URL', r.params);
detalhe += tabelaParams('Parâmetros de query', r.query);
detalhe += blocoBody(r);
if (r.note) detalhe += '<div class="block"><div class="note-box">️ ' + esc(r.note) + '</div></div>';
detalhe += exemploCurl(r);
return '<details class="route">' +
'<summary class="route-head">' +
'<span class="chev">▶</span>' +
'<span class="method ' + r.method.toLowerCase() + '">' + r.method + '</span>' +
'<span class="route-path">' + fmtPath(r.path) + '</span>' +
authBadge +
(r.desc ? '<span class="route-summary-desc">' + esc(r.desc) + '</span>' : '') +
'</summary>' +
'<div class="route-detail">' + detalhe + '</div>' +
'</details>';
}
function categoriasUnicas(rotas){
var cats = {};
rotas.forEach(function(r){ if (r.cat) cats[r.cat] = true; });
return Object.keys(cats).sort();
}
async function carregarRotas(){
var statusEl = document.getElementById('statusMsg');
var container = document.getElementById('rotasContainer');
statusEl.style.display = 'block';
statusEl.textContent = 'Carregando rotas...';
container.innerHTML = '';
try {
var res = await fetch('/api/routes');
var json = await res.json();
if (!json.success || !json.data) {
statusEl.textContent = '❌ Erro ao carregar rotas: ' + (json.error || 'resposta inválida');
return;
}
todasRotas = json.data;
var selectCat = document.getElementById('filterCat');
var cats = categoriasUnicas(todasRotas);
selectCat.innerHTML = '<option value="">Todas as categorias</option>' +
cats.map(function(c){ return '<option value="' + c.replace(/"/g,'&quot;') + '">' + c + '</option>'; }).join('');
statusEl.style.display = 'none';
renderRotas();
} catch(e){
statusEl.textContent = '❌ Erro ao carregar: ' + e.message;
}
}
function renderRotas(){
var container = document.getElementById('rotasContainer');
var q = (document.getElementById('searchInput').value || '').toLowerCase();
var catFiltro = document.getElementById('filterCat').value;
var methodFiltro = document.getElementById('filterMethod').value;
var authFiltro = document.getElementById('filterAuth').value;
var filtradas = todasRotas.filter(function(r){
if (catFiltro && r.cat !== catFiltro) return false;
if (methodFiltro && r.method !== methodFiltro) return false;
if (authFiltro === 'yes' && !r.auth) return false;
if (authFiltro === 'no' && r.auth) return false;
if (q) {
return r.method.toLowerCase().includes(q) ||
r.path.toLowerCase().includes(q) ||
(r.desc || '').toLowerCase().includes(q);
}
return true;
});
var grupos = {};
filtradas.forEach(function(r){
var cat = r.cat || '📦 Outros';
(grupos[cat] = grupos[cat] || []).push(r);
});
var html = '';
Object.keys(grupos).sort().forEach(function(cat){
var rotas = grupos[cat];
html += '<div class="cat-group"><div class="cat-title">' + cat +
' <span class="count">' + rotas.length + '</span></div>' +
rotas.map(cardRota).join('') + '</div>';
});
container.innerHTML = html || '<div class="empty">Nenhuma rota encontrada com os filtros atuais.</div>';
document.getElementById('totalRotas').textContent = filtradas.length + ' de ' + todasRotas.length + ' rotas';
}
window.filtrarRotas = function(){ renderRotas(); };
window.expandirTodas = function(abrir){
document.querySelectorAll('#rotasContainer details').forEach(function(d){ d.open = abrir; });
};
// Cópia (delegação) — funciona para path, body e cURL
document.addEventListener('click', function(ev){
var btn = ev.target.closest('.copy');
if (!btn) return;
ev.preventDefault();
var texto = decodeURIComponent(btn.getAttribute('data-copy') || '');
navigator.clipboard.writeText(texto).then(function(){
var orig = btn.innerHTML;
btn.innerHTML = '✅ Copiado';
setTimeout(function(){ btn.innerHTML = orig; }, 1400);
});
});
carregarRotas();
})();
</script>
<script src="/js/dark-mode.js"></script>
</body>
</html>
+858
View File
@@ -0,0 +1,858 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Configurações - Chatc2</title>
<link rel="stylesheet" href="/css/main.css">
<style>
body { background:#f3f4f6; display:flex; min-height:100vh; }
.sidebar-nav .admin-only { display:none; }
.main { flex:1; display:flex; flex-direction:column; min-width:0; }
.container { flex:1; padding:24px; overflow-y:auto; }
.tabs { display:flex; margin-bottom:24px; background:#fff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.08); border:1px solid #f3f4f6; }
.tabs button { flex:1; padding:13px 16px; border:none; background:#fff; font-size:14px; cursor:pointer; border-bottom:2px solid transparent; transition:all .15s; color:#6b7280; font-weight:500; }
.tabs button:hover { background:#f9fafb; color:#374151; }
.tabs button.active { border-bottom-color:#667eea; color:#667eea; font-weight:700; background:#fafbff; }
.tab-content { display:none; }
.tab-content.active { display:block; }
.form-group { margin-bottom:16px; }
.form-group label { display:block; font-size:13px; font-weight:600; color:#374151; margin-bottom:5px; }
.form-group input, .form-group select, .form-group textarea { width:100%; padding:10px 14px; border:2px solid #e5e7eb; border-radius:8px; font-size:14px; outline:none; background:#f9fafb; color:#111827; transition:all .15s; font-family:inherit; }
.form-group input:focus, .form-group select:focus, .form-group textarea:focus { border-color:#667eea; background:#fff; box-shadow:0 0 0 3px rgba(102,126,234,0.1); }
.form-group textarea { min-height:80px; resize:vertical; }
.form-group .toggle { display:flex; align-items:center; gap:8px; }
.form-group .toggle input { width:auto; }
.btn { display:inline-flex; align-items:center; gap:6px; padding:10px 20px; border:none; border-radius:8px; font-size:14px; font-weight:600; cursor:pointer; transition:all .15s; }
.btn-primary { background:#667eea; color:#fff; }
.btn-primary:hover { background:#5a67d8; }
.btn-danger { background:#ef4444; color:#fff; }
.btn-danger:hover { background:#dc2626; }
.btn-sm { padding:6px 12px; font-size:12px; border-radius:6px; }
.badge { display:inline-block; padding:2px 9px; border-radius:10px; font-size:11px; font-weight:600; }
.badge-success { background:#d1fae5; color:#065f46; }
.badge-danger { background:#fef2f2; color:#991b1b; }
.user-info { display:flex; align-items:center; gap:14px; }
.user-name { font-size:14px; font-weight:500; color:#374151; }
.status-badge { display:inline-flex; align-items:center; gap:5px; padding:4px 12px; border-radius:20px; font-size:12px; font-weight:600; }
.status-badge.online { background:#d1fae5; color:#065f46; }
</style>
<link rel="stylesheet" href="/css/dark-mode.css">
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});}
window.darkModeApply=function(a){var e=document.body;if(!e)return;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'});};
window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')==='true';};</script>
</head>
<body>
<aside class="sidebar">
<div class="sidebar-brand">
<div class="logo">C2</div>
<div><h2>Chatc2</h2><span id="sidebarAlias">-</span></div>
</div>
<nav class="sidebar-nav">
<div class="nav-label">Principal</div>
<a href="#" id="navDashboard"><span class="icon">📊</span> Dashboard</a>
<a href="#" id="navClientsSide"><span class="icon">👥</span> Clientes</a>
<a href="#" id="navChat"><span class="icon">💬</span> Conversas</a>
<div class="nav-label admin-only" id="adminLabelSet">Administrador</div>
<a href="#" class="active admin-only" id="navConfig"><span class="icon">⚙️</span> Configurações</a>
<a href="#" id="navRoutesSet" class="admin-only"><span class="icon">📡</span> Rotas</a>
</nav>
<div class="sidebar-footer">
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button>
<a onclick="logout()"><span class="icon">🚪</span> Sair</a></div>
</aside>
<div class="main">
<div class="topbar"><span class="topbar-title">⚙️ Configurações</span></div>
<div class="container">
<div class="tabs">
<button class="active" onclick="ativarAba('equipe',this)">👥 Equipe</button>
<button onclick="ativarAba('fluxo',this)">📋 Fluxo</button>
<button onclick="ativarAba('empresa',this)">🏢 Empresa</button>
<button onclick="ativarAba('etiquetas',this)">🏷️ Etiquetas</button>
<button onclick="ativarAba('conexao',this)">📱 Conexão</button>
</div>
<!-- Aba Fluxo -->
<div class="tab-content" id="tabFluxo">
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3 style="margin:0">📋 Fluxo de Atendimento</h3>
</div>
<p style="color:#6b7280;font-size:13px;margin-bottom:16px">
Configure os submenus que aparecem quando o cliente seleciona uma equipe.
As opções iniciais são sempre as <strong>Equipes</strong> cadastradas,
com a opção de <strong>Segunda via de Boleto</strong> no final.
</p>
<div id="menusList"><p style="color:#9ca3af">Carregando...</p></div>
</div>
</div>
<!-- Aba Equipe -->
<div class="tab-content active" id="tabEquipe">
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3 style="margin:0">Equipes</h3>
<button class="btn btn-primary btn-sm" onclick="mostrarModalEquipe()">+ Nova Equipe</button>
</div>
<div id="equipesList"><p style="color:#9ca3af">Carregando...</p></div>
</div>
<div class="card">
<h3>Usuários da Empresa</h3>
<div id="usuariosList"><p style="color:#9ca3af">Carregando...</p></div>
</div>
</div>
<!-- Aba Empresa -->
<div class="tab-content" id="tabEmpresa">
<div class="card">
<h3>Configurações da Empresa</h3>
<div id="configForm"><p style="color:#9ca3af">Carregando...</p></div>
</div>
</div>
<!-- Aba Etiquetas -->
<div class="tab-content" id="tabEtiquetas">
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3 style="margin:0">Etiquetas</h3>
<button class="btn btn-primary btn-sm" onclick="mostrarModalEtiqueta()">+ Nova Etiqueta</button>
</div>
<div id="etiquetasList"><p style="color:#9ca3af">Carregando...</p></div>
</div>
</div>
<!-- Aba Conexão -->
<div class="tab-content" id="tabConexao">
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3 style="margin:0">📱 Conexões WhatsApp</h3>
<button class="btn btn-primary btn-sm" onclick="mostrarModalConexao()">+ Nova Conexão</button>
</div>
<div id="conexoesList"><p style="color:#9ca3af">Carregando...</p></div>
</div>
</div>
</div>
</div>
<!-- Modal Equipe -->
<div class="modal-overlay" id="modalEquipe" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;z-index:1000">
<div style="background:#fff;border-radius:12px;padding:24px;width:90%;max-width:400px">
<h3 id="modalEquipeTitle">Nova Equipe</h3>
<input type="hidden" id="editEquipeId">
<div class="form-group"><label>Nome da Equipe</label><input type="text" id="equipeNome" placeholder="Ex: Atendimento"></div>
<div class="form-group"><label>Ordem</label><input type="number" id="equipeOrdem" value="0" min="0" style="width:80px"><span style="font-size:12px;color:#9ca3af;margin-left:8px">(menor = aparece primeiro)</span></div>
<div class="form-group"><label>Membros</label><div id="equipeMembros"></div></div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn" onclick="fecharModal('modalEquipe')" style="background:#f3f4f6">Cancelar</button>
<button class="btn btn-primary" onclick="salvarEquipe()">Salvar</button>
</div>
</div>
</div>
<!-- Modal Etiqueta -->
<div class="modal-overlay" id="modalEtiqueta" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;z-index:1000">
<div style="background:#fff;border-radius:12px;padding:24px;width:90%;max-width:400px">
<h3 id="modalEtiquetaTitle">Nova Etiqueta</h3>
<input type="hidden" id="editEtiquetaId">
<div class="form-group"><label>Nome</label><input type="text" id="etiquetaNome" placeholder="Ex: Cliente VIP"></div>
<div class="form-group"><label>Cor</label><input type="color" id="etiquetaCor" value="#667eea"></div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn" onclick="fecharModal('modalEtiqueta')" style="background:#f3f4f6">Cancelar</button>
<button class="btn btn-primary" onclick="salvarEtiqueta()">Salvar</button>
</div>
</div>
</div>
<!-- Modal Menu -->
<div class="modal-overlay" id="modalMenu" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;z-index:1000">
<div style="background:#fff;border-radius:12px;padding:24px;width:90%;max-width:500px">
<h3 id="modalMenuTitle">Novo Submenu</h3>
<input type="hidden" id="editMenuId">
<input type="hidden" id="editMenuEquipeId">
<input type="hidden" id="editMenuPaiId">
<div class="form-group" id="menuEquipeGroup"><label>Equipe</label><select id="menuEquipe"><option value="">Selecione...</option></select></div>
<div class="form-group"><label>Título</label><input type="text" id="menuTitulo" placeholder="Ex: Alteração Cadastral"></div>
<div class="form-group">
<label>Tipo</label>
<select id="menuTipo" onchange="mudarTipoMenu()">
<option value="M">📂 Submenu (mostra mais opções)</option>
<option value="T">💬 Texto (envia mensagem fixa)</option>
<option value="R">⚙️ Ação (executa alteração via API)</option>
</select>
</div>
<div class="form-group" id="menuTextoGroup" style="display:none"><label>Texto a ser enviado</label><textarea id="menuTexto" rows="3" placeholder="Digite a mensagem..."></textarea></div>
<div class="form-group" id="menuRotaGroup" style="display:none">
<label>Ação (Rota)</label>
<select id="menuAcaoRota">
<option value="">Selecione uma ação...</option>
<option value="listar_carnes">📄 Listar carnês / Boleto</option>
<option value="alterar_email">📧 Alterar E-mail</option>
<option value="alterar_celular">📱 Alterar Celular</option>
<option value="alterar_endereco">🏠 Alterar Endereço</option>
<option value="info_cliente">️ Mostrar dados do cliente</option>
</select>
<div style="margin-top:8px"><label>Pergunta ao cliente (se precisar de dados)</label>
<textarea id="menuAcaoPrompt" rows="2" placeholder="Ex: Informe seu novo e-mail:" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:13px;resize:vertical"></textarea></div>
</div>
<div class="form-group"><label>Ordem</label><input type="number" id="menuOrdem" value="0" min="0" style="width:80px"></div>
<div class="form-group"><label>Subordinado a (opcional)</label><select id="menuPai"><option value="">Nenhum (raiz da equipe)</option></select></div>
<div class="form-group"><label>Etiquetas (adicionar ao selecionar)</label><div id="menuEtiquetasContainer" style="display:flex;flex-wrap:wrap;gap:6px;padding:6px;border:1px solid #e5e7eb;border-radius:8px;min-height:36px"></div></div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px">
<button class="btn" onclick="fecharModal('modalMenu')" style="background:#f3f4f6">Cancelar</button>
<button class="btn btn-primary" onclick="salvarMenu()">Salvar</button>
</div>
</div>
</div>
<!-- Modal Conexão -->
<div class="modal-overlay" id="modalConexao" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;z-index:1000">
<div style="background:#fff;border-radius:12px;padding:24px;width:90%;max-width:500px">
<h3 id="modalConexaoTitle">Nova Conexão WhatsApp</h3>
<input type="hidden" id="editConexaoId">
<div class="form-group"><label>Nome da Instância</label><input type="text" id="conNome" placeholder="Ex: WhatsApp Comercial"></div>
<div class="form-group"><label>URL Evolution API</label><input type="text" id="conUrl" value="https://evoatende.c2sistemas.com.br" placeholder="https://evoatende.c2sistemas.com.br"></div>
<div class="form-group"><label>API Key</label><input type="text" id="conApiKey" placeholder="API Key"></div>
<div class="form-group"><label>Nome da Instância (Evolution)</label><input type="text" id="conInstance" placeholder="Ex: empresa1-wpp"></div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn" onclick="fecharModal('modalConexao')" style="background:#f3f4f6">Cancelar</button>
<button class="btn btn-primary" onclick="salvarConexao()" id="btnSalvarConexao">Conectar</button>
</div>
</div>
</div>
<script>
(function(){
'use strict';
const token = localStorage.getItem('chatc2_token');
const pathParts = window.location.pathname.split('/');
const alias = pathParts[2] || localStorage.getItem('chatc2_alias') || 'lajedo';
localStorage.setItem('chatc2_alias', alias);
const user = JSON.parse(localStorage.getItem('chatc2_user') || '{}');
const empresaId = user.empresas?.[0] || 1;
if (!token) { window.location.href = '/app/' + alias + '/login'; return; }
document.getElementById('sidebarAlias').textContent = alias;
document.getElementById("navDashboard").onclick = function(e) { e.preventDefault(); window.location.href = "/app/" + alias + "/dashboard"; };
document.getElementById("navChat").onclick = function(e) { e.preventDefault(); window.location.href = "/app/" + alias + "/company/" + empresaId + "/conversation/0"; };
document.getElementById("navConfig").onclick = function(e) { e.preventDefault(); window.location.href = "/app/" + alias + "/settings"; };
var navClientsSide = document.getElementById("navClientsSide");
if (navClientsSide) navClientsSide.onclick = function(e) { e.preventDefault(); window.location.href = "/app/" + alias + "/clients"; };
var navRoutesSet = document.getElementById("navRoutesSet");
if (navRoutesSet) navRoutesSet.onclick = function(e) { e.preventDefault(); window.location.href = "/app/" + alias + "/routes"; };
var tc = user.tipoChat || "A";
if (tc === "G") {
var admins = document.querySelectorAll(".admin-only");
for (var i = 0; i < admins.length; i++) admins[i].style.display = "";
}
window.logout = function() {
['chatc2_token','chatc2_alias','chatc2_user'].forEach(function(k) { localStorage.removeItem(k); });
window.location.href = '/app/' + alias + '/login';
};
function api(path, opts) {
return fetch('/api/' + alias + path, Object.assign({
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }
}, opts)).then(function(r) { return r.json(); });
}
// ===== ABAS =====
window.ativarAba = function(aba, btn) {
document.querySelectorAll('.tabs button').forEach(function(b) { b.classList.remove('active'); });
document.querySelectorAll('.tab-content').forEach(function(t) { t.classList.remove('active'); });
btn.classList.add('active');
document.getElementById('tab' + aba.charAt(0).toUpperCase() + aba.slice(1)).classList.add('active');
};
// ===== EQUIPES =====
async function carregarEquipes() {
var data = await api('/teams?empresaId=' + empresaId);
var div = document.getElementById('equipesList');
if (!data.success || !data.data || data.data.length === 0) {
div.innerHTML = '<p style="color:#9ca3af">Nenhuma equipe cadastrada</p>';
return;
}
div.innerHTML = '<table><thead><tr><th>Ordem</th><th>Nome</th><th>Membros</th><th>Ações</th></tr></thead><tbody>' +
data.data.map(function(eq) {
var membros = (eq.membros || []).map(function(m) { return m.nome; }).join(', ') || '-';
return '<tr><td>' + (eq.ordem || 0) + '</td><td><strong>' + eq.nome + '</strong></td><td>' + membros + '</td><td>' +
'<button class="btn btn-sm" onclick="editarEquipe(' + eq.id + ',' + (eq.ordem || 0) + ',\'' + eq.nome.replace(/'/g,"\\'") + '\')" style="background:#f3f4f6;margin-right:4px">✏️</button>' +
'<button class="btn btn-sm btn-danger" onclick="excluirEquipe(' + eq.id + ')">🗑️</button></td></tr>';
}).join('') + '</tbody></table>';
}
window.mostrarModalEquipe = function() {
document.getElementById('editEquipeId').value = '';
document.getElementById('equipeOrdem').value = 0;
document.getElementById('equipeNome').value = '';
document.getElementById('modalEquipeTitle').textContent = 'Nova Equipe';
carregarUsuariosCheckbox();
document.getElementById('modalEquipe').style.display = 'flex';
};
window.editarEquipe = function(id, ordem, nome) {
document.getElementById('editEquipeId').value = id;
document.getElementById('equipeOrdem').value = ordem;
document.getElementById('equipeNome').value = nome;
document.getElementById('modalEquipeTitle').textContent = 'Editar Equipe';
carregarUsuariosCheckbox(id);
document.getElementById('modalEquipe').style.display = 'flex';
};
async function carregarUsuariosCheckbox(equipeId) {
var div = document.getElementById('equipeMembros');
var data = await api('/company/users?empresaId=' + empresaId);
if (!data.success) { div.innerHTML = '<p style="color:#9ca3af">Erro ao carregar</p>'; return; }
var membrosAtuais = [];
if (equipeId) {
var eqData = await api('/teams?empresaId=' + empresaId);
var eq = (eqData.data || []).find(function(e) { return e.id === equipeId; });
if (eq) membrosAtuais = (eq.membros || []).map(function(m) { return m.id; });
}
div.innerHTML = (data.data || []).map(function(u) {
var checked = membrosAtuais.includes(u.id) ? 'checked' : '';
return '<label style="display:flex;align-items:center;gap:8px;padding:6px 0;font-size:13px"><input type="checkbox" value="' + u.id + '" ' + checked + '> ' + u.nome + ' (' + u.login + ')</label>';
}).join('');
}
window.salvarEquipe = async function() {
var id = document.getElementById('editEquipeId').value;
var nome = document.getElementById('equipeNome').value.trim();
var ordem = parseInt(document.getElementById('equipeOrdem').value) || 0;
var membros = Array.from(document.querySelectorAll('#equipeMembros input:checked')).map(function(cb) { return parseInt(cb.value); });
if (!nome) return alert('Informe o nome da equipe');
if (id) {
var r = await api('/teams/' + id, { method: 'PUT', body: JSON.stringify({ nome: nome, ordem: ordem, membros: membros }) });
if (r.success) { fecharModal('modalEquipe'); carregarEquipes(); }
} else {
var r = await api('/teams', { method: 'POST', body: JSON.stringify({ nome: nome, ordem: ordem, membros: membros, empresaId: empresaId }) });
if (r.success) { fecharModal('modalEquipe'); carregarEquipes(); }
}
};
window.excluirEquipe = async function(id) {
if (!confirm('Excluir esta equipe?')) return;
var r = await api('/teams/' + id, { method: 'DELETE' });
if (r.success) carregarEquipes();
};
// ===== USUÁRIOS =====
async function carregarUsuarios() {
var data = await api('/company/users?empresaId=' + empresaId);
var div = document.getElementById('usuariosList');
if (!data.success || !data.data || data.data.length === 0) {
div.innerHTML = '<p style="color:#9ca3af">Nenhum usuário encontrado</p>';
return;
}
div.innerHTML = '<table><thead><tr><th>Nome</th><th>Login</th><th>Tipo Chat</th></tr></thead><tbody>' +
data.data.map(function(u) {
var tipo = u.tipoChat === 'A' ? '<span class="badge badge-success">Atendente</span>' : u.tipoChat === 'G' ? '<span class="badge badge-success">Gerente</span>' : '<span class="badge badge-danger">Bloqueado</span>';
return '<tr><td>' + u.nome + '</td><td>' + u.login + '</td><td>' + tipo + '</td></tr>';
}).join('') + '</tbody></table>';
}
// ===== FLUXO DE ATENDIMENTO (MENUS) =====
async function carregarMenus() {
var [eqData, menuData] = await Promise.all([
api('/teams?empresaId=' + empresaId),
api('/menus?empresaId=' + empresaId)
]);
var div = document.getElementById('menusList');
if (!eqData.success || !eqData.data || eqData.data.length === 0) {
div.innerHTML = '<p style="color:#9ca3af">Crie equipes primeiro para configurar os submenus.</p>';
return;
}
var menusAgrupados = menuData.success ? menuData.data : [];
var html = '';
eqData.data.forEach(function(eq) {
var menusEq = menusAgrupados.find(function(g) { return g.equipeId === eq.id; });
var temMenus = menusEq && menusEq.menus && menusEq.menus.length > 0;
html += '<div style="margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;overflow:hidden">';
html += '<div style="background:#f9fafb;padding:10px 14px;font-weight:600;font-size:14px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between">';
html += '<span>👥 ' + eq.nome + '</span>';
html += '<button class="btn btn-sm" onclick="mostrarModalMenu(' + eq.id + ')" style="background:#e0e7ff">+ Submenu</button>';
html += '</div>';
if (temMenus) {
html += '<div style="padding:4px 0">' + montarArvoreHtml(menusEq.menus, 0) + '</div>';
} else {
html += '<p style="padding:12px 14px;color:#9ca3af;font-size:13px;margin:0">Nenhum submenu configurado.</p>';
}
html += '</div>';
});
div.innerHTML = html;
}
function montarArvoreHtml(menus, nivel) {
if (!menus || menus.length === 0) return '';
var html = '<div style="padding-left:' + (nivel * 20 + 8) + 'px">';
menus.forEach(function(m) {
var tipoIcon = m.tipo === 'M' ? '📂' : m.tipo === 'T' ? '💬' : m.tipo === 'R' ? '⚙️' : '❓';
html += '<div style="display:flex;align-items:center;padding:6px 10px;border-bottom:1px solid #f3f4f6">';
html += '<span style="flex:1;font-size:13px">' + tipoIcon + ' ' + m.titulo + ' <span style="color:#9ca3af;font-size:11px">(' + m.tipo + ')</span></span>';
html += '<button class="btn btn-sm" onclick="mostrarModalMenu(' + m.equipeId + ',' + m.id + ')" style="background:#f3f4f6;margin-right:4px;padding:4px 8px;font-size:11px">✏️</button>';
html += '<button class="btn btn-sm" onclick="excluirMenu(' + m.id + ',\'' + m.titulo.replace(/'/g,"\\'") + '\')" style="background:#fef2f2;color:#991b1b;padding:4px 8px;font-size:11px">🗑️</button>';
html += '<button class="btn btn-sm" onclick="mostrarModalMenu(' + m.equipeId + ',null,' + m.id + ')" style="margin-left:4px;padding:4px 8px;font-size:11px"> Sub</button>';
html += '</div>';
if (m.filhos && m.filhos.length > 0) {
html += montarArvoreHtml(m.filhos, nivel + 1);
}
});
html += '</div>';
return html;
}
window.mostrarModalMenu = async function(equipeId, editMenuId, parentMenuId) {
// Carrega equipes para o select
var eqData = await api('/teams?empresaId=' + empresaId);
var selectEq = document.getElementById('menuEquipe');
selectEq.innerHTML = '<option value="">Selecione...</option>';
(eqData.data || []).forEach(function(eq) {
var opt = document.createElement('option');
opt.value = eq.id;
opt.textContent = eq.nome;
if (eq.id === equipeId) opt.selected = true;
selectEq.appendChild(opt);
});
document.getElementById('editMenuId').value = editMenuId || '';
document.getElementById('editMenuEquipeId').value = equipeId || '';
document.getElementById('editMenuPaiId').value = parentMenuId || '';
document.getElementById('menuTitulo').value = '';
document.getElementById('menuTipo').value = 'M';
document.getElementById('menuTexto').value = '';
document.getElementById('menuOrdem').value = 0;
document.getElementById('menuAcaoRota').value = '';
document.getElementById('menuAcaoPrompt').value = '';
document.getElementById('menuTextoGroup').style.display = 'none';
document.getElementById('menuRotaGroup').style.display = 'none';
document.getElementById('menuEquipeGroup').style.display = parentMenuId ? 'none' : 'block';
// Carrega menus pai disponíveis
var selectPai = document.getElementById('menuPai');
selectPai.innerHTML = '<option value="">Nenhum (raiz da equipe)</option>';
if (!parentMenuId) {
var flatData = await api('/menus/flat?empresaId=' + empresaId + '&equipeId=' + equipeId);
if (flatData.success && flatData.data) {
flatData.data.forEach(function(m) {
if (m.id !== editMenuId) {
var opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.titulo;
selectPai.appendChild(opt);
}
});
}
}
// Carrega etiquetas disponiveis como checkboxes
var etiquetasData = await api('/labels?empresaId=' + empresaId);
var etiquetasSelecionadas = editMenuId ? await buscarEtiquetasDoMenu(editMenuId) : [];
if (!parentMenuId) etiquetasSelecionadas = etiquetasSelecionadas || [];
renderEtiquetasCheckbox(etiquetasData, etiquetasSelecionadas);
if (editMenuId) {
// Modo edição: busca dados
document.getElementById('modalMenuTitle').textContent = 'Editar Submenu';
// Como a API retorna agrupado, precisamos buscar os dados de outra forma
// Vamos usar uma chamada direta
var allData = await api('/menus?empresaId=' + empresaId);
if (allData.success) {
var found = null;
allData.data.forEach(function(g) {
function buscar(m, id) {
if (m.id === id) return m;
if (m.filhos) {
for (var f of m.filhos) {
var r = buscar(f, id);
if (r) return r;
}
}
return null;
}
(g.menus || []).forEach(function(m) {
var r = buscar(m, editMenuId);
if (r) found = r;
});
});
if (found) {
document.getElementById('menuTitulo').value = found.titulo || '';
document.getElementById('menuTipo').value = found.tipo || 'M';
document.getElementById('menuTexto').value = found.texto || '';
document.getElementById('menuOrdem').value = found.ordem || 0;
document.getElementById('menuAcaoRota').value = found.acaoRota || '';
document.getElementById('menuAcaoPrompt').value = found.acaoPrompt || '';
if (found.paiId) {
selectPai.value = found.paiId;
}
if (found.tipo === 'T') {
document.getElementById('menuTextoGroup').style.display = 'block';
document.getElementById('menuRotaGroup').style.display = 'none';
} else if (found.tipo === 'R') {
document.getElementById('menuTextoGroup').style.display = 'none';
document.getElementById('menuRotaGroup').style.display = 'block';
}
}
}
} else {
document.getElementById('modalMenuTitle').textContent = 'Novo Submenu';
// Se tem um pai, marca no select
if (parentMenuId) {
selectPai.value = parentMenuId;
}
}
document.getElementById('modalMenu').style.display = 'flex';
};
window.mudarTipoMenu = function() {
var tipo = document.getElementById('menuTipo').value;
document.getElementById('menuTextoGroup').style.display = tipo === 'T' ? 'block' : 'none';
document.getElementById('menuRotaGroup').style.display = tipo === 'R' ? 'block' : 'none';
};
// Busca etiquetas configuradas em um menu (percorre a arvore da API)
async function buscarEtiquetasDoMenu(menuId) {
var allData = await api('/menus?empresaId=' + empresaId);
console.log('[Menu] buscarEtiquetasDoMenu - menuId:', menuId, '| allData:', JSON.stringify(allData).substring(0, 300));
var ids = [];
function percorrer(lista) {
(lista || []).forEach(function(m) {
if (m.id === menuId) {
if (m.etiquetaIds) ids = m.etiquetaIds.split(',').map(function(x){return parseInt(x.trim());}).filter(function(x){return !isNaN(x);});
}
if (m.filhos) percorrer(m.filhos);
});
}
allData.data.forEach(function(g) { percorrer(g.menus); });
return ids;
}
// Renderiza checkboxes de etiquetas no modal
function renderEtiquetasCheckbox(data, selecionadas) {
var container = document.getElementById('menuEtiquetasContainer');
if (!data.success || !data.data || data.data.length === 0) {
container.innerHTML = '<span style="color:#9ca3af;font-size:12px">Nenhuma etiqueta cadastrada</span>';
return;
}
container.innerHTML = data.data.map(function(e) {
var checked = selecionadas.includes(e.id) ? 'checked' : '';
return '<label style="display:flex;align-items:center;gap:4px;padding:4px 8px;background:#f3f4f6;border-radius:6px;font-size:12px;cursor:pointer">' +
'<input type="checkbox" value="' + e.id + '" ' + checked + ' style="accent-color:#667eea"> ' +
'<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:' + (e.cor || '#667eea') + '"></span> ' +
e.nome +
'</label>';
}).join('');
}
window.salvarMenu = async function() {
var editId = document.getElementById('editMenuId').value;
var equipeId = document.getElementById('editMenuEquipeId').value || document.getElementById('menuEquipe').value;
var paiId = document.getElementById('editMenuPaiId').value || document.getElementById('menuPai').value;
var titulo = document.getElementById('menuTitulo').value.trim();
var tipo = document.getElementById('menuTipo').value;
var texto = document.getElementById('menuTexto').value;
var ordem = parseInt(document.getElementById('menuOrdem').value) || 0;
var acaoRota = document.getElementById('menuAcaoRota').value;
var acaoPrompt = document.getElementById('menuAcaoPrompt').value;
// Etiquetas selecionadas
var etiquetaIds = Array.from(document.querySelectorAll('#menuEtiquetasContainer input[type="checkbox"]:checked')).map(function(cb) { return parseInt(cb.value); }).filter(function(v) { return !isNaN(v); }).join(',');
if (!titulo) return alert('Informe o título');
if (!equipeId) return alert('Selecione a equipe');
var body = {
empresaId: empresaId,
equipeId: parseInt(equipeId),
titulo: titulo,
tipo: tipo,
texto: tipo === 'T' ? texto : null,
ordem: ordem,
acaoRota: tipo === 'R' ? acaoRota : null,
acaoPrompt: tipo === 'R' ? acaoPrompt : null,
menuPaiId: paiId ? parseInt(paiId) : null,
etiquetaIds: etiquetaIds || null,
};
console.log('[Menu] Salvando body:', JSON.stringify(body));
if (editId) {
var r = await api('/menus/' + editId, { method: 'PUT', body: JSON.stringify(body) });
console.log('[Menu] Resposta save:', JSON.stringify(r));
if (r.success) { fecharModal('modalMenu'); carregarMenus(); }
else alert('Erro: ' + (r.error || 'desconhecido'));
} else {
var r = await api('/menus', { method: 'POST', body: JSON.stringify(body) });
if (r.success) { fecharModal('modalMenu'); carregarMenus(); }
else alert('Erro: ' + (r.error || 'desconhecido'));
}
};
window.excluirMenu = async function(id, titulo) {
if (!confirm('Excluir o submenu "' + titulo + '" e todos os seus sub-itens?')) return;
var r = await api('/menus/' + id, { method: 'DELETE' });
if (r.success) carregarMenus();
};
// ===== ETIQUETAS =====
async function carregarEtiquetas() {
var data = await api('/labels?empresaId=' + empresaId);
var div = document.getElementById('etiquetasList');
if (!data.success || !data.data || data.data.length === 0) {
div.innerHTML = '<p style="color:#9ca3af">Nenhuma etiqueta cadastrada</p>';
return;
}
div.innerHTML = '<table><thead><tr><th>Nome</th><th>Cor</th><th>Ações</th></tr></thead><tbody>' +
data.data.map(function(l) {
return '<tr><td><strong>' + l.nome + '</strong></td><td><span class="tag" style="background:' + l.cor + '">' + l.cor + '</span></td><td>' +
'<button class="btn btn-sm" onclick="editarEtiqueta(' + l.id + ',\'' + l.nome.replace(/'/g,"\\'") + '\',\'' + l.cor + '\')" style="background:#f3f4f6;margin-right:4px">✏️</button>' +
'<button class="btn btn-sm btn-danger" onclick="excluirEtiqueta(' + l.id + ')">🗑️</button></td></tr>';
}).join('') + '</tbody></table>';
}
window.mostrarModalEtiqueta = function() {
document.getElementById('editEtiquetaId').value = '';
document.getElementById('etiquetaNome').value = '';
document.getElementById('etiquetaCor').value = '#667eea';
document.getElementById('modalEtiquetaTitle').textContent = 'Nova Etiqueta';
document.getElementById('modalEtiqueta').style.display = 'flex';
};
window.editarEtiqueta = function(id, nome, cor) {
document.getElementById('editEtiquetaId').value = id;
document.getElementById('etiquetaNome').value = nome;
document.getElementById('etiquetaCor').value = cor;
document.getElementById('modalEtiquetaTitle').textContent = 'Editar Etiqueta';
document.getElementById('modalEtiqueta').style.display = 'flex';
};
window.salvarEtiqueta = async function() {
var id = document.getElementById('editEtiquetaId').value;
var nome = document.getElementById('etiquetaNome').value.trim();
var cor = document.getElementById('etiquetaCor').value;
if (!nome) return alert('Informe o nome');
if (id) {
var r = await api('/labels/' + id, { method: 'PUT', body: JSON.stringify({ nome: nome, cor: cor }) });
if (r.success) { fecharModal('modalEtiqueta'); carregarEtiquetas(); }
} else {
var r = await api('/labels', { method: 'POST', body: JSON.stringify({ nome: nome, cor: cor }) });
if (r.success) { fecharModal('modalEtiqueta'); carregarEtiquetas(); }
}
};
window.excluirEtiqueta = async function(id) {
if (!confirm('Excluir esta etiqueta?')) return;
var r = await api('/labels/' + id, { method: 'DELETE' });
if (r.success) carregarEtiquetas();
};
// ===== CONFIGURAÇÕES EMPRESA =====
async function carregarConfig() {
var data = await api('/company/config?empresaId=' + empresaId);
var div = document.getElementById('configForm');
if (!data.success) { div.innerHTML = '<p style="color:#ef4444">Erro ao carregar</p>'; return; }
var cfg = data.data;
div.innerHTML =
'<div class="form-group"><label>Instância Padrão</label><select id="cfgInstancia"><option value="">Nenhuma</option></select></div>' +
'<div class="form-group"><div class="toggle"><input type="checkbox" id="cfgFoto" ' + (cfg.fotoCelular === 'S' ? 'checked' : '') + '> <label for="cfgFoto">Atualizar foto do cliente conforme WhatsApp</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"><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="cfgTriagem" ' + (cfg.triagemAtiva === 'S' ? 'checked' : '') + '> <label for="cfgTriagem">📋 Ativar fluxo de triagem (menu de opções)</label></div></div>' +
'<div class="form-group" id="triagemConfig" style="display:' + (cfg.triagemAtiva === 'S' ? 'block' : 'none') + ';padding:12px;background:#f9fafb;border-radius:8px;margin-bottom:12px">' +
'<div class="form-group"><label>Mensagem de boas-vindas (use {EMPRESA} para o nome)</label><textarea id="cfgTriagemWelcome" rows="2" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:13px;resize:vertical">' + (cfg.triagemMsgWelcome || '') + '</textarea></div>' +
'<div class="form-group"><label>Mensagem após escolha</label><textarea id="cfgTriagemAfter" rows="2" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:13px;resize:vertical">' + (cfg.triagemMsgAfter || '') + '</textarea></div>' +
'<div class="form-group"><label>Número da opção "Segunda via de Boleto"</label><input type="text" id="cfgTriagemBoletoNum" value="' + (cfg.triagemBoletoNumero || '0') + '" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:14px"></div>' +
'</div>' +
'<div class="form-group"><div class="toggle"><input type="checkbox" id="cfgCsat" ' + (cfg.csatAtivo === 'S' ? 'checked' : '') + '> <label for="cfgCsat">Ativar CSAT (Pesquisa de satisfação)</label></div></div>' +
'<div class="form-group"><label>Mensagem CSAT</label><textarea id="cfgCsatMsg">' + (cfg.csatMensagem || '') + '</textarea></div>' +
'<button class="btn btn-primary" onclick="salvarConfig()">💾 Salvar Configurações</button>';
// Evento para toggle da triagem
document.getElementById('cfgTriagem').addEventListener('change', function() {
document.getElementById('triagemConfig').style.display = this.checked ? 'block' : 'none';
});
// Carrega instâncias no select
var instData = await api('/evolution/instances?empresaId=' + empresaId);
if (instData.success && instData.data) {
var select = document.getElementById('cfgInstancia');
instData.data.forEach(function(ins) {
var opt = document.createElement('option');
opt.value = ins.id;
opt.textContent = ins.nome + ' (' + ins.instanceName + ')';
if (ins.id === cfg.instanciaPadraoId) opt.selected = true;
select.appendChild(opt);
});
}
}
window.salvarConfig = async function() {
var data = {
empresaId: empresaId,
instanciaPadraoId: parseInt(document.getElementById('cfgInstancia').value) || null,
fotoCelular: document.getElementById('cfgFoto').checked ? 'S' : 'N',
saudacaoAtiva: document.getElementById('cfgSaudacao').checked ? 'S' : 'N',
saudacaoMensagem: document.getElementById('cfgSaudacaoMsg').value,
enviarNomeUsuario: document.getElementById('cfgNomeUser').checked ? 'S' : 'N',
triagemAtiva: document.getElementById('cfgTriagem').checked ? 'S' : 'N',
triagemMsgWelcome: document.getElementById('cfgTriagemWelcome').value,
triagemMsgAfter: document.getElementById('cfgTriagemAfter').value,
triagemBoletoNumero: document.getElementById('cfgTriagemBoletoNum').value,
csatAtivo: document.getElementById('cfgCsat').checked ? 'S' : 'N',
csatMensagem: document.getElementById('cfgCsatMsg').value,
};
var r = await api('/company/config', { method: 'POST', body: JSON.stringify(data) });
if (r.success) alert('Configurações salvas com sucesso!');
};
// O addEventListener do cfgTriagem é adicionado dentro do carregarConfig() após criar o HTML
// ===== CONEXÕES =====
async function carregarConexoes() {
var data = await api('/evolution/instances?empresaId=' + empresaId);
var div = document.getElementById('conexoesList');
if (!data.success || !data.data || data.data.length === 0) {
div.innerHTML = '<p style="color:#9ca3af">Nenhuma conexão cadastrada</p>';
return;
}
div.innerHTML = '<table><thead><tr><th>Nome</th><th>Instância</th><th>URL</th><th>Status</th><th>Ações</th></tr></thead><tbody>' +
data.data.map(function(ins) {
var status = ins.status === 'A' ? '<span class="badge badge-success">✅ Conectado</span>' :
ins.status === 'C' ? '<span class="badge badge-success">🟡 Conectando</span>' :
ins.status === 'D' ? '<span class="badge badge-danger">⛔ Desconectado</span>' : '<span class="badge badge-danger">❌ ' + ins.status + '</span>';
var nomeEsc = (ins.nome || '').replace(/'/g, "\\'");
return '<tr><td><strong>' + ins.nome + '</strong></td><td>' + ins.instanceName + '</td><td style="font-size:11px;max-width:150px;overflow:hidden;text-overflow:ellipsis">' + ins.url + '</td><td>' + status + '</td><td>' +
'<button class="btn btn-sm" onclick="editarConexao(' + ins.id + ')" style="background:#f3f4f6;margin-right:4px">✏️</button>' +
'<button class="btn btn-sm" onclick="excluirConexao(' + ins.id + ',\'' + nomeEsc + '\')" style="background:#fef2f2;color:#991b1b;margin-right:4px">🗑️</button>' +
'<button class="btn btn-sm" onclick="gerarQRCode(' + ins.id + ')" style="background:#f3f4f6">📱 QR Code</button></td></tr>';
}).join('') + '</tbody></table>';
}
window.mostrarModalConexao = function() {
document.getElementById('editConexaoId').value = '';
document.getElementById('conNome').value = '';
document.getElementById('conUrl').value = 'https://evoatende.c2sistemas.com.br';
document.getElementById('conApiKey').value = '';
document.getElementById('conInstance').value = '';
document.getElementById('modalConexaoTitle').textContent = 'Nova Conexão WhatsApp';
document.getElementById('btnSalvarConexao').textContent = 'Conectar';
document.getElementById('modalConexao').style.display = 'flex';
};
window.gerarQRCode = async function(id) {
var r = await api('/evolution/qrcode/' + id, { method: 'POST' });
if (r.success && r.data && (r.data.qrcode || r.data.base64)) {
var qr = r.data.qrcode || r.data.base64;
var imgSrc = qr.startsWith('data:') ? qr : 'data:image/png;base64,' + qr;
var w = window.open('', '_blank', 'width=400,height=500');
w.document.write('<html><head><title>QR Code</title></head><body style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;font-family:sans-serif">' +
'<h2 style="margin-bottom:20px">Escaneie o QR Code</h2>' +
'<img src="' + imgSrc + '" style="max-width:300px">' +
'<p style="margin-top:20px;color:#6b7280">Abra o WhatsApp no celular e escaneie</p></body></html>');
} else {
alert('Erro ao gerar QR Code: ' + JSON.stringify(r.data));
}
};
// ===== EDITAR CONEXÃO =====
window.editarConexao = async function(id) {
// Busca dados atuais da API
var data = await api('/evolution/instances?empresaId=' + empresaId);
if (!data.success) return alert('Erro ao carregar dados');
var ins = data.data.find(function(i) { return i.id === id; });
if (!ins) return alert('Instância não encontrada');
document.getElementById('editConexaoId').value = id;
document.getElementById('conNome').value = ins.nome;
document.getElementById('conUrl').value = ins.url;
document.getElementById('conApiKey').value = ins.apiKey;
document.getElementById('conInstance').value = ins.instanceName;
document.getElementById('modalConexaoTitle').textContent = 'Editar Conexão WhatsApp';
document.getElementById('btnSalvarConexao').textContent = 'Salvar';
document.getElementById('modalConexao').style.display = 'flex';
};
// ===== EXCLUIR CONEXÃO =====
window.excluirConexao = async function(id, nome) {
if (!confirm('Excluir a conexão "' + nome + '"?')) return;
var r = await api('/evolution/instances/' + id, { method: 'DELETE' });
if (r.success) {
carregarConexoes();
} else {
alert('Erro: ' + r.error);
}
};
window.salvarConexao = async function() {
var editId = document.getElementById('editConexaoId').value;
var data = {
INS_NOME: document.getElementById('conNome').value.trim(),
INS_URL: document.getElementById('conUrl').value.trim(),
INS_API_KEY: document.getElementById('conApiKey').value.trim(),
INS_INSTANCE_NAME: document.getElementById('conInstance').value.trim(),
};
if (!data.INS_NOME || !data.INS_URL || !data.INS_API_KEY || !data.INS_INSTANCE_NAME)
return alert('Preencha todos os campos');
var r;
if (editId) {
// Atualizar
r = await api('/evolution/instances/' + editId, { method: 'PUT', body: JSON.stringify(data) });
} else {
// Criar nova
data.INS_EMPRESA_ID = empresaId;
r = await api('/evolution/connect', { method: 'POST', body: JSON.stringify(data) });
}
if (r.success) {
fecharModal('modalConexao');
document.getElementById('editConexaoId').value = '';
document.getElementById('modalConexaoTitle').textContent = 'Nova Conexão WhatsApp';
document.getElementById('btnSalvarConexao').textContent = 'Conectar';
carregarConexoes();
if (!editId && r.data && r.data.id) gerarQRCode(r.data.id);
} else {
alert('Erro: ' + r.error);
}
};
// ===== MODAIS =====
window.fecharModal = function(id) { document.getElementById(id).style.display = 'none'; };
// Fechar modal ao clicar fora
document.querySelectorAll('.modal-overlay').forEach(function(el) {
el.addEventListener('click', function(e) { if (e.target === this) this.style.display = 'none'; });
});
// ===== INICIAR =====
carregarEquipes();
carregarUsuarios();
carregarMenus();
carregarEtiquetas();
carregarConexoes();
carregarConfig();
})();
</script>
<script src="/js/dark-mode.js"></script>
</body>
</html>
+59
View File
@@ -0,0 +1,59 @@
const express = require('express');
const router = express.Router();
const AuthController = require('../controllers/authController');
const ClientController = require('../controllers/clientController');
const authenticateToken = require('../middlewares/auth');
// === Rotas públicas (sem autenticação) ===
// Página de login (GET)
router.get('/:alias/login', AuthController.loginPage);
// API de login (POST) - retorna o token JWT
router.post('/:alias/login', AuthController.login);
// Dashboard do app (proteção via JavaScript no cliente)
router.get('/:alias/dashboard', AuthController.dashboard);
// Página de listagem de clientes (proteção via JavaScript no cliente)
router.get('/:alias/clients', (req, res) => {
const path = require('path');
res.sendFile(path.resolve(__dirname, '../public/client-list.html'));
});
// Página de detalhes do cliente (proteção via JavaScript no cliente)
router.get('/:alias/company/:id_empresa/client/:id_cliente', (req, res) => {
const path = require('path');
res.sendFile(path.resolve(__dirname, '../public/client-detail.html'));
});
// Página de configurações
router.get('/:alias/settings', (req, res) => {
const path = require('path');
res.sendFile(path.resolve(__dirname, '../public/settings.html'));
});
// Página de rotas da API
router.get('/:alias/routes', (req, res) => {
const path = require('path');
res.sendFile(path.resolve(__dirname, '../public/routes.html'));
});
// Página de todas as conversas (admin)
router.get('/:alias/conversations/all', (req, res) => {
const path = require('path');
res.sendFile(path.resolve(__dirname, '../public/admin-conversations.html'));
});
// Pagina de CSAT (avaliacao) - sem autenticacao
router.get('/:alias/csat', (req, res) => {
const path = require('path');
res.sendFile(path.resolve(__dirname, '../public/csat.html'));
});
// === Rotas protegidas (com autenticação) ===
// Dados do usuário logado
router.get('/:alias/me', authenticateToken, AuthController.me);
module.exports = router;
+22
View File
@@ -0,0 +1,22 @@
const express = require('express');
const router = express.Router();
const ChatController = require('../controllers/chatController');
const authenticateToken = require('../middlewares/auth');
// Página do chat (proteção via JavaScript)
router.get('/:alias/company/:empresaId/conversation/:conversaId', ChatController.chatPage);
// Rotas protegidas da API
router.post('/:alias/conversations/create', authenticateToken, ChatController.createConversation);
router.get('/:alias/conversations', authenticateToken, ChatController.listConversations);
router.get('/:alias/conversations/:id', authenticateToken, ChatController.getConversation);
router.get('/:alias/conversations/:id/messages', authenticateToken, ChatController.getMessages);
router.post('/:alias/conversations/:id/messages', authenticateToken, ChatController.sendMessage);
router.post('/:alias/conversations/:id/finalize', authenticateToken, ChatController.finalizeConversation);
router.post('/:alias/conversations/:id/assign', authenticateToken, ChatController.assignConversation);
router.post('/:alias/conversations/:id/assign-team', authenticateToken, ChatController.assignTeam);
router.post('/:alias/conversations/:id/labels', authenticateToken, ChatController.toggleLabel);
router.post('/:alias/conversations/:id/link-client', authenticateToken, ChatController.linkClient);
router.get('/:alias/media/:mediaId', ChatController.getMedia);
module.exports = router;
+38
View File
@@ -0,0 +1,38 @@
const express = require('express');
const router = express.Router();
const ConfigController = require('../controllers/configController');
const EvolutionController = require('../controllers/evolutionController');
const authenticateToken = require('../middlewares/auth');
// Todas as rotas protegidas
router.use('/:alias', authenticateToken);
// Equipes
router.get('/:alias/teams', ConfigController.listTeams);
router.post('/:alias/teams', ConfigController.createTeam);
router.put('/:alias/teams/:id', ConfigController.updateTeam);
router.delete('/:alias/teams/:id', ConfigController.deleteTeam);
// Usuários da empresa
router.get('/:alias/company/users', ConfigController.listCompanyUsers);
router.put('/:alias/users/:id/chat-type', ConfigController.updateUserChatType);
// Etiquetas
router.get('/:alias/labels', ConfigController.listLabels);
router.post('/:alias/labels', ConfigController.createLabel);
router.put('/:alias/labels/:id', ConfigController.updateLabel);
router.delete('/:alias/labels/:id', ConfigController.deleteLabel);
// Configurações da empresa
router.get('/:alias/company/config', ConfigController.getCompanyConfig);
router.post('/:alias/company/config', ConfigController.saveCompanyConfig);
// Evolution API
router.get('/:alias/evolution/instances', EvolutionController.listInstances);
router.post('/:alias/evolution/connect', EvolutionController.connect);
router.put('/:alias/evolution/instances/:id', EvolutionController.updateInstance);
router.delete('/:alias/evolution/instances/:id', EvolutionController.deleteInstance);
router.post('/:alias/evolution/qrcode/:id', EvolutionController.generateQRCode);
router.post('/:alias/evolution/refresh-photo/:conversaId', EvolutionController.refreshPhoto);
module.exports = router;
+11
View File
@@ -0,0 +1,11 @@
const express = require('express');
const router = express.Router();
const DatabaseController = require('../controllers/databaseController');
const authenticateToken = require('../middlewares/auth');
router.get('/databases', authenticateToken, DatabaseController.list);
router.post('/databases', authenticateToken, DatabaseController.save);
router.delete('/databases/:alias', authenticateToken, DatabaseController.remove);
router.post('/databases/test', authenticateToken, DatabaseController.test);
module.exports = router;
+66
View File
@@ -0,0 +1,66 @@
const express = require('express');
const router = express.Router();
const GenericController = require('../controllers/genericController');
const ClientController = require('../controllers/clientController');
const EmpresaController = require('../controllers/empresaController');
const authenticateToken = require('../middlewares/auth');
// === Rotas públicas (sem alias, sem autenticação) ===
// Listar aliases disponíveis
router.get('/aliases', GenericController.listAliases);
// Logo da empresa (pública - usada na tela de login)
router.get('/:alias/empresa/logo', EmpresaController.getLogo);
// === Rotas protegidas (com alias) ===
// Aplica autenticação em TODAS as rotas abaixo
router.use('/:alias', authenticateToken);
// Health check do alias
router.get('/:alias/health', GenericController.healthCheck);
// Listar todas as tabelas do banco
router.get('/:alias/tables', GenericController.listTables);
// Informações da estrutura de uma tabela específica
router.get('/:alias/tables/:tableName', GenericController.tableInfo);
// Executar consulta SELECT (via POST)
router.post('/:alias/query', GenericController.query);
// Executar INSERT, UPDATE, DELETE (via POST)
router.post('/:alias/execute', GenericController.execute);
// Empresas do usuário logado (GET)
router.get('/:alias/empresas', ClientController.listUserEmpresas);
// Busca de clientes com paginação (GET)
router.get('/:alias/clients/search', ClientController.searchClients);
// Dependentes de um cliente
router.get('/:alias/clients/:id/dependents', ClientController.listDependents);
// Atualizar telefone do dependente
router.put('/:alias/dependents/:id/phone', ClientController.updateDependentPhone);
// Buscar dependentes para associar
router.get('/:alias/dependents/search', ClientController.searchDependents);
// Dados completos de um cliente (GET) — usado pela página de detalhes
router.get('/:alias/company/:id_empresa/client/:id_cliente', ClientController.clientDetails);
// Atualizar dados do cliente (PUT)
router.put('/:alias/clients/:id', ClientController.updateClient);
// Títulos (carnes) de um cliente
router.get('/:alias/clients/:id_cliente/carnes', ClientController.clientCarnes);
// Dados do cliente + carnes para geracao de boleto
router.get('/:alias/clients/:id_cliente/listcarne', ClientController.clientListCarne);
router.get('/:alias/clients/:id/convalescentes', ClientController.clientConvalescentes);
// Historico de conversas do cliente (inclui dependentes)
router.get('/:alias/clients/:id/conversations', ClientController.clientConversations);
module.exports = router;
+42
View File
@@ -0,0 +1,42 @@
const express = require('express');
const router = express.Router();
const genericRoutes = require('./genericRoutes');
const authRoutes = require('./authRoutes');
const chatRoutes = require('./chatRoutes');
const configRoutes = require('./configRoutes');
const menuRoutes = require('./menuRoutes');
const databaseRoutes = require('./databaseRoutes');
const RoutesController = require('../controllers/routesController');
const EvolutionController = require('../controllers/evolutionController');
// Webhook Evolution (sem auth - DEVE vir antes de qualquer middleware de auth)
// Suporta URL com alias (ex: /api/lajedo/webhook/evolution) ou sem alias
// (ex: /api/webhook/evolution) — o alias é detectado automaticamente
router.post('/api/webhook/evolution', EvolutionController.webhook);
router.post('/api/webhook/evolution/:evento', EvolutionController.webhook);
router.post('/api/:alias/webhook/evolution', EvolutionController.webhook);
router.post('/api/:alias/webhook/evolution/:evento', EvolutionController.webhook);
router.get('/api/:alias/webhook/ping', EvolutionController.webhookPing);
// Mídia (sem auth - para carregar imagens/áudios no chat)
router.get('/api/:alias/media/:mediaId', require('../controllers/chatController').getMedia);
// CSAT (sem auth - formulário público de avaliação)
router.post('/api/:alias/csat/avaliar', require('../controllers/chatController').csatAvaliar);
// Rota de descoberta de rotas (sem auth)
router.get('/api/routes', RoutesController.list);
// Rotas da API (autenticação aplicada internamente)
router.use('/api', genericRoutes);
router.use('/api', chatRoutes);
router.use('/api', configRoutes);
router.use('/api', menuRoutes);
router.use('/api', databaseRoutes);
// Rotas do aplicativo (proteção aplicada internamente)
router.use('/app', authRoutes);
router.use('/app', chatRoutes);
module.exports = router;
+12
View File
@@ -0,0 +1,12 @@
const express = require('express');
const router = express.Router();
const MenuController = require('../controllers/menuController');
const authenticateToken = require('../middlewares/auth');
router.get('/:alias/menus', authenticateToken, MenuController.listMenus);
router.get('/:alias/menus/flat', authenticateToken, MenuController.listMenusFlat);
router.post('/:alias/menus', authenticateToken, MenuController.createMenu);
router.put('/:alias/menus/:id', authenticateToken, MenuController.updateMenu);
router.delete('/:alias/menus/:id', authenticateToken, MenuController.deleteMenu);
module.exports = router;
+52
View File
@@ -0,0 +1,52 @@
require('dotenv').config();
const app = require('./app');
const db = require('./database');
const { listAliases } = db;
const PORT = parseInt(process.env.PORT, 10) || 3000;
async function start() {
console.log('🔄 Testando conexões com os bancos...\n');
// Testa conexão com todos os aliases configurados
const results = await db.testAllConnections();
let allOk = true;
for (const [alias, ok] of Object.entries(results)) {
const icon = ok ? '✅' : '❌';
console.log(` ${icon} ${alias}: ${ok ? 'Conectado' : 'Falha na conexão'}`);
if (!ok) allOk = false;
}
if (allOk) {
console.log('\n✅ Todos os bancos conectados com sucesso!');
} else {
console.log('\n⚠️ Alguns bancos não puderam ser conectados. Verifique as configurações.');
}
const server = app.listen(PORT, () => {
console.log(`\n🚀 API Chatc2 rodando em http://localhost:${PORT}`);
console.log(`📋 Aliases disponíveis: ${listAliases().join(', ')}`);
console.log(`📋 Login: http://localhost:${PORT}/app/<alias>/login`);
console.log(`📋 Listar aliases: http://localhost:${PORT}/api/aliases`);
console.log('\nPressione Ctrl+C para parar o servidor.');
});
// Encerramento gracioso (fecha os pools de conexão)
const shutdown = async (sig) => {
console.log(`\n${sig} recebido. Encerrando...`);
server.close(async () => {
try { await db.closeAll(); } catch (_) {}
process.exit(0);
});
// Failsafe caso o close demore
setTimeout(() => process.exit(0), 5000).unref();
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
}
start().catch((err) => {
console.error('Erro ao iniciar servidor:', err);
process.exit(1);
});