Compare commits

..

29 Commits

Author SHA1 Message Date
AyronSantos 836298889f fix: comentar atualizarFotoContato e limpar CLI_FOTO corrompidos
- Comenta chamadas automáticas de atualizarFotoContato no webhook
- CFE_FOTO_CELULAR = '\''N'\'' em ambos os bancos
- CLI_FOTO limpos nos bancos novo_local e novo
2026-07-14 12:22:51 +00:00
AyronSantos cd48e8c458 fix: nao registrar migracao se after() falhar; corrigir colunas faltantes no banco novo
- fix: migrationRunner.after() que falha agora impede registro da migracao
- fix: adicionar EQU_MENSAGEM e EQU_ETIQUETA_ID ao banco novo
- fix: recriar generators faltantes e tabela CHATC2_MOTIVOS_ATENDIMENTO no banco novo
2026-07-08 17:55:29 +00:00
AyronSantos 44a84301a3 fix: remove cache do configService; remove cacheService.js (nao usado)
- configService agora le direto do banco (sem TTL, sem stale data)
- invalidarConfig virou no-op (não há cache para invalidar)
- cacheService.js removido (so era usado pelo configService)
- endpoint /metrics sem referencia ao cacheService
2026-07-01 17:07:18 +00:00
AyronSantos bc03f5dc1d fix: corrigir nome da tabela de MENSAGENS_CLIENTES para MENSAGEM_CLIENTES
- fix: migration #25 agora so cria o generator (tabela ja existe no ERP)
- fix: insert usa MENSAGEM_CLIENTES (singular) e GEN_MENSAGEM_CLIENTES
- fix: database.js whitelist atualizado para GEN_MENSAGEM_CLIENTES
2026-07-01 16:36:25 +00:00
AyronSantos 0b2748a579 fix: criar tabela MENSAGENS_CLIENTES (migracao #25) e corrigir insert com MSC_CODIGO_ID
- add: migration #25 cria tabela MENSAGENS_CLIENTES + generator
- fix: insert agora usa db.nextId para gerar MSC_CODIGO_ID
- fix: adiciona GEN_MENSAGENS_CLIENTES ao whitelist no database.js
2026-07-01 16:30:10 +00:00
AyronSantos 3113f9afbd fix: remove ALTER TABLE EQU_ORDEM duplicado no ibexpert.sql 2026-07-01 14:26:35 +00:00
AyronSantos 46767b5fe2 feat: add migracoes_ibexpert.sql para execucao direta no IBExpert 2026-07-01 14:07:09 +00:00
AyronSantos d906d9e715 fix: remove SET TERM do migracoes.sql; add instrucoes para IBExpert 2026-07-01 14:02:39 +00:00
AyronSantos 6e080720cd fix: add SET TERM no migracoes.sql para compatibilidade com IBExpert 2026-07-01 13:57:47 +00:00
AyronSantos abdd176cc0 chore: gitignore .pi .impeccable logs tmp uploads; remove do tracking
- Adiciona .pi/ e .impeccable/ ao .gitignore (ferramentas de dev)
- Adiciona logs/, tmp/, uploads/ ao .gitignore (runtime artifacts)
- Remove .pi/ e .impeccable/ do tracking git
2026-07-01 13:49:41 +00:00
AyronSantos 9cf5f00243 ponytail: remove resolucaoSetup, compressVideo, sendEvolutionMessage, sendEvolution; add migration #24
- delete: src/resolucaoSetup.js (migration paralela, consolidado em #24)
- shrink: chatController.sendEvolutionMessage (140 -> 6 linhas, delega ao evolutionService)
- shrink: triageController.sendEvolution (55 -> 10 linhas, delega ao evolutionService)
- delete: compressVideo em chatController (codigo morto, chamado so pelo old sendEvolutionMessage)
- add: migration #24 consolida DDL que estava espalhada em runtime
- fix: configController.remove garantirEstrutura calls (arquivo removido)
- net: -270 linhas, -1 arquivo
2026-07-01 13:24:06 +00:00
AyronSantos c9d2c668ee adição de agendamento de conversa 2026-06-29 12:27:48 +00:00
AyronSantos 71778cbd24 adicionado opção de salvar resolução nas mensagens do cliente 2026-06-25 14:32:42 +00:00
AyronSantos a2892cb241 correções layout dispositivos moveis 2026-06-25 12:50:33 +00:00
AyronSantos 1ddf9b7def atualizacoes 2026-06-25 12:30:47 +00:00
AyronSantos bb80be896f feat: nova conversa com número avulso + normalização WhatsApp + dark mode no modal
- Botão '+' na sidebar para iniciar conversa com qualquer número
- Busca automática: cliente → dependente → número novo
- Campo de mensagem inicial personalizável no modal
- Normalização de número (DDI 55) ao enviar via Evolution API
- Modal de nova conversa com suporte a dark mode
- Correção: foto do cliente não é mais atualizada a cada mensagem
- Correção: triagem não processa respostas se já tem atendente+equipe
2026-06-24 16:52:48 +00:00
AyronSantos 29190cd71c alterações de melhorias 2026-06-24 14:13:09 +00:00
AyronSantos d942694ec0 Adicionais 2026-06-23 17:00:08 +00:00
AyronSantos 12d419de7c feat: comprehensive design system overhaul
Applied impeccable design critique, audit, harden, layout, distill, and polish across all pages.

DESIGN SYSTEM:
- Created PRODUCT.md (5 design principles, WCAG 2.1 AA target)
- Created DESIGN.md (34 color tokens, 6 radius scale, full component spec)
- Created .impeccable/design.json (sidecar with tonal ramps, shadows, motion)

ACCESSIBILITY:
- 33 aria-labels across 7 pages (was ~13)
- 63 <label> elements (was ~30)
- 6 <main> landmarks + role=navigation on all sidebars
- Esc to close modals on 4 pages
- Keyboard shortcuts (Ctrl+J/K, Ctrl+F) on chat

THEMING:
- 216 hardcoded colors replaced with var() references
- 449 !important removed from dark-mode.css
- Dark mode script unified (was duplicated 9x inline)
- All modals converted to .modal-overlay + .modal design system classes

CHAT:
- Error states for messages (.msg.erro, .msg.enviando)
- Connection status indicator
- Confirmation modal before finalizar
- Input bar reorganized (3 visible actions, + menu)
- Right panel accordions
- Send button disabled when empty
- Touch targets 44px on mobile

CLIENT DETAIL:
- Tabs/badges/sub-tabs use CSS classes instead of inline styles
- 'Iniciar Conversa' button now primary action
- alert() replaced with inline feedback
- Modal converted to design system classes

CLIENT LIST:
- Modal moved inside <body> (was HTML-invalid)
- alert() replaced with inline feedback
- Modal uses .show pattern

SETTINGS:
- 4 modals converted to design system classes
- 14 alert() calls replaced with inline feedback
- fecharModal() uses classList instead of style.display

ROUTES:
- border-radius normalized, hardcoded colors fixed

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:22:03 -03:00
61 changed files with 8161 additions and 3074 deletions
+47
View File
@@ -0,0 +1,47 @@
# ============================================================
# Configuracao do Chatc2
# ============================================================
# Driver padrao (firebird)
DB_DRIVER=firebird
# ------------------------------------------------------------
# Banco Firebird (alias "novo_local")
# ------------------------------------------------------------
DB_HOST=localhost
DB_PORT=3050
DB_USER=SYSDBA
DB_PASSWORD=masterkey
DB_ENCODING=UTF-8
# Caminho do arquivo .FDB. Se vazio, usa ../NOVO.FDB (raiz do projeto).
# Ex. Windows: DB_DATABASE=C:\caminho\para\NOVO.FDB
# Ex. Linux: DB_DATABASE=/opt/chatc2/db/NOVO.FDB
DB_DATABASE=
# Servidor
PORT=3000
JWT_SECRET=CHATc2_1781527593_87c0a20ff1606d3e2aa0900eda4ecda9
JWT_EXPIRES_IN=1h
# URLs de acesso
LOCAL_URL=http://10.0.0.88:3000
EXTERNAL_URL=https://atendchat.assantos.app.br
# ------------------------------------------------------------
# Seguranca
# ------------------------------------------------------------
# CORS: origens permitidas (separadas por virgula). Se vazio, usa
# LOCAL_URL + EXTERNAL_URL. Requisicoes same-origin nao sao afetadas.
# CORS_ORIGINS=https://atendchat.assantos.app.br,http://10.0.0.88:3000
# Token de verificacao do webhook Evolution. Se definido, a Evolution deve
# enviar este valor no header apikey (ou x-webhook-token). Vazio = sem checagem.
# WEBHOOK_TOKEN=
# ------------------------------------------------------------
# Administracao
# ------------------------------------------------------------
# Token de segundo fator para endpoints de SQL direto (/api/:alias/query e
# /api/:alias/execute). Deve ser enviado no header X-Admin-Token.
# Gere um token forte: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# ADMIN_TOKEN=
+13
View File
@@ -8,9 +8,22 @@ src/databases_custom.json
*.fdb *.fdb
*.rar *.rar
relatorio_migracao_*.json relatorio_migracao_*.json
relatorio_migracao_*.txt
# Dados do Postgres (cluster local)
pgdata/
# Logs / temporários # Logs / temporários
*.log *.log
_inspect*.js _inspect*.js
whisper/ whisper/
logs/
tmp/
uploads/
# Ferramentas
.claude .claude
.pi/
.impeccable/
.gitignore
CONTEXTO.md
+252
View File
@@ -0,0 +1,252 @@
---
name: Chatc2
description: Plataforma de atendimento omnichannel — ágil, moderna, centrada no operador.
colors:
indigo-funcional: "#667eea"
indigo-profundo: "#5a67d8"
violeta-apoio: "#764ba2"
meia-noite-inicio: "#1e1b4b"
meia-noite-fim: "#312e81"
papel-limpo: "#ffffff"
papel-suave: "#f9fafb"
papel-frio: "#f3f4f6"
grafite: "#111827"
grafite-medio: "#374151"
grafite-suave: "#6b7280"
cinza-nevoa: "#9ca3af"
borda-padrao: "#e5e7eb"
verde-confirma: "#059669"
verde-confirma-bg: "#d1fae5"
verde-confirma-texto: "#065f46"
vermelho-alerta: "#ef4444"
vermelho-alerta-bg: "#fef2f2"
vermelho-alerta-texto: "#991b1b"
ambar-aviso: "#f59e0b"
ambar-aviso-bg: "#fef3c7"
ambar-aviso-texto: "#92400e"
verde-whatsapp: "#25d366"
verde-whatsapp-hover: "#22c55e"
bolha-agente: "#dcf8c6"
bolha-privada-bg: "#fef9c3"
bolha-privada-borda: "#fde047"
bolha-privada-texto: "#854d0e"
chat-wallpaper: "#e5ddd5"
violeta-tab: "#a78bfa"
violeta-suave-bg: "#ede9fe"
violeta-suave-texto: "#5b21b6"
vermelho-hover: "#dc2626"
cinza-borda-hover: "#d1d5db"
typography:
body:
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif"
fontSize: "14px"
fontWeight: 400
lineHeight: 1.5
letterSpacing: "normal"
display:
fontFamily: "inherit"
fontSize: "24px"
fontWeight: 800
lineHeight: 1.2
letterSpacing: "-0.5px"
title:
fontFamily: "inherit"
fontSize: "17px"
fontWeight: 700
lineHeight: 1.3
letterSpacing: "-0.2px"
label:
fontFamily: "inherit"
fontSize: "11px"
fontWeight: 600
lineHeight: 1.4
letterSpacing: "0.06em"
rounded:
xs: "4px"
sm: "6px"
md: "8px"
lg: "12px"
xl: "16px"
pill: "9999px"
spacing:
xs: "4px"
sm: "8px"
md: "12px"
lg: "16px"
xl: "24px"
components:
button-primary:
backgroundColor: "{colors.indigo-funcional}"
textColor: "{colors.papel-limpo}"
rounded: "{rounded.md}"
padding: "9px 18px"
button-primary-hover:
backgroundColor: "{colors.indigo-profundo}"
textColor: "{colors.papel-limpo}"
rounded: "{rounded.md}"
button-secondary:
backgroundColor: "{colors.papel-limpo}"
textColor: "{colors.grafite-medio}"
rounded: "{rounded.md}"
padding: "9px 18px"
card:
backgroundColor: "{colors.papel-limpo}"
rounded: "{rounded.lg}"
padding: "24px"
input-text:
backgroundColor: "{colors.papel-suave}"
textColor: "{colors.grafite}"
rounded: "{rounded.md}"
padding: "10px 14px"
---
# Design System: Chatc2
## 1. Overview
**Creative North Star: "O Terminal Humanizado"**
O Chatc2 é um terminal funcional — preciso, confiável, sem firulas — mas que não esquece que do outro lado da tela há uma pessoa. A interface respira. Curvas suaves (`6px``16px`) substituem cantos duros de terminal. O espaço entre elementos é generoso o suficiente para não cansar, mas econômico o bastante para manter densidade de informação. Toques de índigo funcional guiam o olhar sem gritar. O sidebar noturno (`#1e1b4b``#312e81`) ancora a experiência com profundidade, como um painel de comando que nunca apaga.
O sistema rejeita explicitamente a poluição visual e a ambiguidade. Cada estado tem feedback imediato. Cada cor tem um porquê. Cada pixel de sombra existe para hierarquia, não por decoração.
**Key Characteristics:**
- Paleta enxuta: um acento índigo + violeta de apoio + neutros limpos. Sem cores decorativas.
- Dark mode como cidadão de primeira classe, não adaptação tardia.
- Sombras leves — o suficiente para destacar superfícies, nunca para pesar.
- Tipografia de sistema: rápida, nativa, zero latência de carregamento.
- Componentes táteis que respondem ao toque e ao hover com confiança.
## 2. Colors
A paleta do Chatc2 é funcional e direta. Um índigo funcional (`#667eea`) carrega a identidade — aparece em botões primários, links, foco de inputs e ações principais. Um violeta de apoio (`#764ba2`) complementa em gradientes e momentos de destaque visual. Tudo o mais são neutros limpos e semânticos de estado.
### Primary
- **Índigo funcional** (`#667eea`): Ação primária. Botões principais, links, borda de foco em inputs, elementos de navegação ativos. O acento que diz "aqui se faz".
- **Índigo profundo** (`#5a67d8`): Estado hover/ativo do primário. Nunca usado sozinho; sempre como resposta ao toque.
### Secondary
- **Violeta de apoio** (`#764ba2`): Gradientes decorativos (login, sidebar em mobile), complemento do primário. Uso estritamente em contextos de identidade visual, nunca em elementos interativos isolados.
- **Meia-noite início** (`#1e1b4b`) / **Meia-noite fim** (`#312e81`): Degradê do sidebar esquerdo. Âncora visual escura que contrasta com o canvas claro. Não usar essas cores fora do sidebar.
### Neutral
- **Papel limpo** (`#ffffff`): Fundo de cards e superfícies elevadas.
- **Papel suave** (`#f9fafb`): Fundo de inputs e áreas secundárias.
- **Papel frio** (`#f3f4f6`): Fundo do corpo da página. O canvas base.
- **Borda padrão** (`#e5e7eb`): Bordas de inputs, separadores de tabela, delimitação de cards.
- **Grafite** (`#111827`): Texto primário. Máximo contraste para leitura.
- **Grafite médio** (`#374151`): Texto secundário e rótulos.
- **Grafite suave** (`#6b7280`): Texto terciário, placeholders, metadados.
- **Cinza névoa** (`#9ca3af`): Texto desabilitado, ícones inativos, estados vazios.
### Semantic
- **Verde confirma** (`#059669`): Sucesso, status online, ações concluídas. Fundo `verde-confirma-bg` (`#d1fae5`), texto `verde-confirma-texto` (`#065f46`).
- **Vermelho alerta** (`#ef4444`): Erro, perigo, exclusão. Fundo `vermelho-alerta-bg` (`#fef2f2`), texto `vermelho-alerta-texto` (`#991b1b`). Hover `vermelho-hover` (`#dc2626`).
- **Âmbar aviso** (`#f59e0b`): Atenção, pendência. Fundo `ambar-aviso-bg` (`#fef3c7`), texto `ambar-aviso-texto` (`#92400e`).
### Chat
- **Verde WhatsApp** (`#25d366`): Botão de envio do chat. Referência direta ao mensageiro. Hover `verde-whatsapp-hover` (`#22c55e`).
- **Bolha agente** (`#dcf8c6`): Fundo da bolha de mensagem do atendente. Contraste com o branco do cliente para identificação instantânea.
- **Bolha privada fundo** (`#fef9c3`) / **borda** (`#fde047`) / **texto** (`#854d0e`): Trio semântico para mensagens privadas entre atendentes.
- **Chat wallpaper** (`#e5ddd5`): Fundo texturizado da área de mensagens. Padrão sutil que evoca familiaridade sem poluir.
- **Violeta tab** (`#a78bfa`): Indicador de tab ativa na sidebar esquerda. Única cor saturada no painel noturno.
- **Violeta suave bg** (`#ede9fe`) / **texto** (`#5b21b6`): Tags de empresa e badges de categoria.
- **Cinza borda hover** (`#d1d5db`): Escurecimento sutil de bordas ao hover em botões secundários.
### Named Rules
**The One Voice Rule.** O índigo funcional ocupa ≤10% de qualquer tela. Sua raridade é o ponto. Se tudo é índigo, nada é importante.
**The Semantic Triplet Rule.** Estados (sucesso, erro, aviso) sempre usam o trio completo: cor de destaque + fundo claro + texto escuro. Nunca usar a cor de destaque como fundo de texto corrido.
## 3. Typography
**Font:** Sistema nativo (`-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif`). Mono: `'SF Mono', 'Cascadia Code', Monaco, monospace` (tokens, código, dados técnicos).
**Character:** Limpa, nativa, invisível. A tipografia não compete com o conteúdo. O sistema entrega o que o OS oferece de melhor, sem dependências externas, com renderização instantânea e zero FOIT.
### Hierarchy
- **Display** (800, `24px`, 1.2): Título da página de login. Único momento de grandeza tipográfica. Nunca em telas internas.
- **Title** (700, `17px`, 1.3, `-0.2px`): Título da topbar. Cabeçalho de seção principal.
- **Body** (400, `14px`, 1.5): Texto corrido, labels de formulário, conteúdo de mensagens, células de tabela. Largura máxima de linha: ~75 caracteres quando aplicável.
- **Label** (600, `11px`, 1.4, `0.06em`, uppercase): Rótulos de navegação (`nav-label`), cabeçalhos de tabela (`th`), metadados. Sempre em caixa alta com tracking expandido.
### Named Rules
**The Native-First Rule.** Nunca importar webfonts. O sistema operacional já tem tipografia excelente. Menos uma dependência, menos um ponto de falha, carregamento instantâneo.
**The Weight Discipline Rule.** Apenas três pesos convivem: 400 (corpo), 600 (labels, semibold), 700800 (títulos). Nunca usar 300, 500 ou 900.
## 4. Elevation
O Chatc2 usa elevação sutil — o suficiente para separar superfícies, nunca para dramatizar. Cards flutuam levemente sobre o fundo; modais se destacam com sobreposição escura e blur. O sidebar noturno é plano (sem sombra) por design: ele é uma âncora fixa, não uma camada flutuante.
### Shadow Vocabulary
- **Ambient** (`0 1px 3px rgba(0,0,0,0.08)`): Cards estáticos, topbar. A sombra padrão. Mal se nota; está lá só para evitar que a superfície se dissolva no fundo.
- **Floating** (`0 4px 16px rgba(0,0,0,0.10)`): Elementos que precisam de destaque moderado (ex: previews, dropdowns).
- **Modal** (`0 20px 60px rgba(0,0,0,0.18)`): Exclusivo para overlays de modal. Sombra pronunciada + `backdrop-filter: blur(4px)` no overlay. A combinação diz "isto está acima de tudo".
### Named Rules
**The Flat-by-Default Rule.** A superfície base (`papel-frio`) é plana. Sombras aparecem apenas quando um elemento precisa de separação hierárquica — nunca por decoração. Se um card não tem motivo para flutuar, ele não flutua.
**The Radius Discipline Rule.** O sistema usa 6 raios: `xs` (4px, cantos internos e badges), `sm` (6px, logos e elementos compactos), `md` (8px, botões, inputs, labels — o raio de trabalho padrão), `lg` (12px, cards, bolhas de chat), `xl` (16px, modais e containers principais), `pill` (9999px, elementos totalmente arredondados como botão enviar e avatares). Nenhum outro valor é permitido sem atualizar o DESIGN.md.
**The Dark Mode Elevation Rule.** No dark mode, sombras são abolidas. A diferenciação entre superfícies é 100% tonal (variação de cor de fundo). `box-shadow` em fundo escuro produz halos fantasma; use `background-color` mais claro em vez disso.
## 5. Components
Cada componente do Chatc2 segue a filosofia tátil-e-confiante: bordas visíveis, feedback imediato ao hover/foco, transições rápidas (`150ms ease`). Nada é estático. Tudo responde.
### Buttons
- **Shape:** Bordas arredondadas (`8px` radius, token `md`). Botão de envio do chat usa `pill` (totalmente redondo, 40px).
- **Primary:** Fundo `índigo-funcional` (`#667eea`), texto branco. Padding `9px 18px`. Fonte 14px, peso 600. `transition: all 0.15s ease`.
- **Primary Hover:** Fundo `índigo-profundo` (`#5a67d8`). A transição de cor é o feedback.
- **Primary Active:** `transform: scale(0.98)`. Resposta tátil imediata.
- **Send (Chat):** Botão circular `pill`, 40×40px, fundo `verde-whatsapp` (`#25d366`). Hover `verde-whatsapp-hover` (`#22c55e`) + `scale(1.06)`. Referência direta ao mensageiro.
- **Secondary:** Fundo `papel-limpo` (`#ffffff`), texto `grafite-médio` (`#374151`), borda `1px solid` `borda-padrão` (`#e5e7eb`). Hover escurece o fundo para `papel-suave` (`#f9fafb`), borda `cinza-borda-hover` (`#d1d5db`).
- **Danger:** Fundo `vermelho-alerta` (`#ef4444`), texto branco. Hover `vermelho-hover` (`#dc2626`). Uso exclusivo em ações destrutivas (excluir, finalizar).
- **Icon (Chat Input):** Botões circulares `pill`, 36×36px, fundo transparente, cor `grafite-suave` (`#6b7280`). Hover: fundo `papel-frio` (`#f3f4f6`), cor `grafite-médio` (`#374151`). Devem ter `aria-label` descritivo e tooltip.
- **Disabled:** Opacidade 0.50.6, `cursor: not-allowed`. Nunca usar cores apagadas como substituto.
### Inputs / Fields
- **Style:** Fundo `papel-suave` (`#f9fafb`), borda `2px solid` `borda-padrão` (`#e5e7eb`), radius `md` (`8px`), padding `10px 14px`. Fonte 14px.
- **Focus:** Borda `índigo-funcional` (`#667eea`), fundo `papel-limpo` (`#ffffff`), `box-shadow: 0 0 0 3px rgba(102,126,234,0.12)`. O ring externo de 3px é a assinatura de foco do Chatc2 — visível, mas nunca agressivo.
- **Chat textarea:** Exceção visual — radius `pill` para formato de pílula. Fundo branco. Foco borda `índigo-funcional`. Placeholder `cinza-névoa` (`#9ca3af`).
- **Error:** Borda `vermelho-alerta` (`#ef4444`), fundo `vermelho-alerta-bg` (`#fef2f2`).
- **Placeholder:** Cor `grafite-suave` (`#6b7280`). Nunca usar cor de placeholder como label substituto.
### Cards
- **Style:** Fundo `papel-limpo` (`#ffffff`), radius `lg` (`12px`), padding `24px`. Borda `1px solid` `papel-frio` (`#f3f4f6`). Sombra `ambient`.
- **Card sem borda:** Para layouts internos onde o card já é distinguível por contraste de fundo, a borda pode ser omitida.
- **Card do Dashboard:** Stats cards têm `border-left: 4px solid` com cor semântica (verde para positivos, vermelho para alertas, índigo para neutros). A faixa lateral é o único ornamento permitido em cards.
### Chat Bubbles
- **Cliente:** Alinhado à esquerda, fundo branco, borda `1px solid` `papel-frio`. Radius `lg` (`12px`), canto inferior esquerdo `xs` (`4px`). Sombra `ambient`.
- **Atendente:** Alinhado à direita, fundo `bolha-agente` (`#dcf8c6`). Mesmo radius assimétrico. Sombra `ambient`.
- **Sistema:** Centralizado, fundo `rgba(255,255,255,0.75)`, texto `grafite-suave` (`#6b7280`), radius `md` (`8px`), itálico.
- **Privada:** Fundo `bolha-privada-bg` (`#fef9c3`), texto `bolha-privada-texto` (`#854d0e`), borda `1px solid` `bolha-privada-borda` (`#fde047`).
- **Erro (envio falhou):** Fundo `vermelho-alerta-bg` (`#fef2f2`), borda `1px solid` `vermelho-alerta` (`#ef4444`), ícone ⚠️ + botão "Reenviar". Nunca deixar mensagem desaparecer silenciosamente.
- **Enviando:** Opacidade 0.6 + spinner pequeno no canto da bolha. Transição para opacidade 1 quando confirmada.
## 6. Do's and Don'ts
### Do:
- **Do** usar o índigo funcional (`#667eea`) em ≤10% da superfície visível. Ele é um acento, não uma cor de fundo.
- **Do** garantir que todo estado interativo tenha feedback em ≤150ms (`transition: all 0.15s ease`).
- **Do** comunicar estado com ícone + cor. Nunca apenas cor. (Ex: badge de status com ícone E cor verde/vermelha.)
- **Do** usar o trio semântico completo (cor + fundo-claro + texto-escuro) para qualquer indicador de estado.
- **Do** manter o dark mode com diferenciação tonal (variação de `background-color`), nunca com sombras.
- **Do** usar `border-left: 4px solid` com cor semântica para destacar stat cards no dashboard. É o único ornamento estrutural permitido.
- **Do** usar padding mínimo de `24px` em cards, `14px 24px` na topbar. Espaço é funcional — reduz fadiga visual.
- **Do** preservar a barra lateral noturna (`#1e1b4b``#312e81`) em todas as páginas. É a âncora de navegação consistente.
### Don't:
- **Don't** criar interfaces abarrotadas onde o atendente precise adivinhar onde as coisas estão. Toda ação frequente deve estar a ≤2 cliques de distância.
- **Don't** usar mais de 3 pesos tipográficos na mesma tela. A disciplina de peso (`400`, `600`, `700800`) é lei.
- **Don't** importar webfonts externas. A regra é nativa. Sempre.
- **Don't** usar sombras em dark mode. Elas viram ruído fantasma em fundos escuros.
- **Don't** usar cores semânticas como cor de fundo para texto corrido. Vermelho e verde existem para badges e bordas, nunca para pintar parágrafos.
- **Don't** criar modais sem overlay escuro + blur. O backdrop-filter é parte da hierarquia visual.
- **Don't** usar border-radius fora da escala documentada (`xs`, `sm`, `md`, `lg`, `xl`, `pill`). Se um novo raio é necessário, ele deve ser adicionado ao DESIGN.md primeiro.
- **Don't** omitir o ring de foco de 3px em inputs. É a assinatura de acessibilidade do Chatc2. Se o input está em foco, o ring aparece.
- **Don't** deixar mensagem de chat falhar silenciosamente. Toda bolha que não foi entregue deve mostrar estado de erro com opção "Reenviar".
- **Don't** usar ícones sem `aria-label` ou texto visível. Botões de ação no chat (📎, 🎤, 🔓) precisam de nomes acessíveis.
- **Don't** colocar mais de 3 ações visíveis na barra de input do chat. Agrupe ações secundárias atrás de um menu "+".
+78
View File
@@ -0,0 +1,78 @@
# Chatc2 — Dockerfile
# Multi-stage: build (whisper.cpp compilação) + runtime (Node + Firebird client)
#
# Build:
# docker build -t chatc2 .
#
# Run (com docker-compose):
# docker compose up -d
# ============================================================
# STAGE 1: Compilar whisper.cpp
# ============================================================
FROM alpine:3.20 AS whisper-builder
RUN apk add --no-cache git make g++ cmake
WORKDIR /build
RUN git clone --depth 1 https://github.com/ggerganov/whisper.cpp.git && \
cd whisper.cpp && \
cmake -B build && \
cmake --build build --config Release -j $(nproc)
# Baixa modelo tiny
RUN cd /build/whisper.cpp && \
mkdir -p models && \
wget -q -O models/ggml-tiny.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin
# ============================================================
# STAGE 2: Runtime Node.js
# ============================================================
FROM node:22-alpine
# Dependências do sistema: Firebird client, ffmpeg
RUN apk add --no-cache \
firebird-dev \
ffmpeg \
cmake \
make \
g++ \
python3
WORKDIR /app
# Copia package.json e instala dependências
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
# Copia código fonte
COPY src/ ./src/
COPY scripts/ ./scripts/
COPY public/ ./public/
# Copia whisper.cpp compilado e modelo
COPY --from=whisper-builder /build/whisper.cpp/build/bin/main /app/whisper/main
COPY --from=whisper-builder /build/whisper.cpp/models/ggml-tiny.bin /app/whisper/models/ggml-tiny.bin
# Copia configuração PM2
COPY ecosystem.config.js ./
# Diretórios de runtime
RUN mkdir -p /app/db /app/logs /app/uploads/audio /app/tmp
# Variáveis padrão (sobrescrever no docker-compose ou .env)
ENV PORT=3000 \
NODE_ENV=production \
DB_HOST=firebird \
DB_PORT=3050 \
DB_DATABASE=/app/db/NOVO.FDB \
DB_USER=SYSDBA \
DB_PASSWORD=masterkey
EXPOSE 3000
# Healthcheck
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3000/api/novo_local/health || exit 1
CMD ["node", "src/server.js"]
+43
View File
@@ -0,0 +1,43 @@
# Product
## Register
product
## Users
Atendentes de cobrança, call center e suporte ao cliente de empresas do setor funerário. Os usuários são originados de um ERP legado e importados para o Chatc2. Trabalham em desktop e mobile, com dois níveis de acesso: Agente (A) e Gerente/Admin (G). Passam longas jornadas na plataforma (8h+), que centraliza todo o atendimento da empresa.
## Product Purpose
Plataforma multi-empresa de atendimento omnichannel que unifica WhatsApp (Evolution API), triagem automatizada, geração de boletos em PDF, transcrição de áudio (Whisper.cpp) e gestão de clientes. Existe para substituir o atendimento fragmentado (telefone, WhatsApp pessoal, sistemas diferentes) por uma central única, rápida e organizada. Sucesso = atendente resolve mais rápido, cliente sai satisfeito.
## Brand Personality
Ágil, moderno, direto. A interface deve transmitir competência sem arrogância, velocidade sem ansiedade. O tom é funcional e respeitoso — não é uma rede social, é uma ferramenta de trabalho que inspira confiança.
Referência positiva: Chatwoot — estrutura limpa de chat multi-agente, mas o Chatc2 deve ser **menos carregado de informação**, com foco no essencial.
## Anti-references
- Interfaces abarrotadas onde o atendente se sente perdido ou sobrecarregado
- Navegação ambígua ou escondida que exige adivinhar onde as coisas estão
- Excesso de distrações visuais, animações chamativas, cores gritantes
- Qualquer padrão que faça o usuário duvidar do estado atual da operação
## Design Principles
1. **Clareza acima de tudo** — Informação certa no momento certo. Zero poluição visual. O atendente acha o que precisa sem pensar.
2. **Fluidez operacional** — Mínimo atrito, cliques e esperas. Fluxos de atendimento (chat, triagem, boleto) rápidos e previsíveis.
3. **Resistência à fadiga** — Conforto para uso prolongado. Dark mode caprichado, contraste equilibrado, tipografia legível, animações sutis.
4. **Confiança visível** — Status sempre claros, feedback imediato, navegação óbvia. O atendente nunca se sente perdido.
5. **Acessibilidade inclusiva** — Mirar WCAG 2.1 AA. Cores não são o único meio de comunicar estado. Suporte a zoom, teclado e leitores de tela.
## Accessibility & Inclusion
- Meta: WCAG 2.1 AA
- Contraste adequado em ambos os temas (claro e escuro)
- Estados de UI comunicados por ícone + cor (nunca só cor)
- Suporte a navegação por teclado em todos os fluxos críticos
- Considerar `prefers-reduced-motion` para animações
- Textos com tamanho mínimo legível (14px corpo, 11px metadados)
+21 -41
View File
@@ -20,14 +20,13 @@ SERVER_DOMAIN="${SERVER_DOMAIN:-}" # Opcional: seu-dominio.com.br
JWT_SECRET="" JWT_SECRET=""
NGROK_URL="" NGROK_URL=""
# PostgreSQL EXTERNO (banco principal "novo_local"). # Firebird LOCAL (banco principal).
# Informe via variaveis de ambiente antes de rodar, ou edite o .env depois. # Informe via variaveis de ambiente antes de rodar, ou edite o .env depois.
PG_HOST="${PG_HOST:-}" DB_HOST="${DB_HOST:-localhost}"
PG_PORT="${PG_PORT:-5432}" DB_PORT="${DB_PORT:-3050}"
PG_USER="${PG_USER:-postgres}" DB_USER="${DB_USER:-SYSDBA}"
PG_PASSWORD="${PG_PASSWORD:-}" DB_PASSWORD="${DB_PASSWORD:-masterkey}"
PG_DATABASE="${PG_DATABASE:-postgres}" DB_DATABASE="${DB_DATABASE:-}"
PG_SCHEMA="${PG_SCHEMA:-public}"
# Cores # Cores
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
@@ -247,28 +246,18 @@ cat > "$CHATC2_DIR/.env" << EOF
# ============================================================ # ============================================================
# Driver padrao do banco principal # Driver padrao do banco principal
DB_DRIVER=postgres DB_DRIVER=firebird
# ------------------------------------------------------------ # ------------------------------------------------------------
# PostgreSQL (EXTERNO) — banco principal "novo_local" # Firebird (alias "novo_local")
# ------------------------------------------------------------ # ------------------------------------------------------------
PG_HOST=${PG_HOST} DB_HOST=${DB_HOST}
PG_PORT=${PG_PORT} DB_PORT=${DB_PORT}
PG_USER=${PG_USER} DB_USER=${DB_USER}
PG_PASSWORD=${PG_PASSWORD} DB_PASSWORD=${DB_PASSWORD}
PG_DATABASE=${PG_DATABASE}
PG_SCHEMA=${PG_SCHEMA}
# ------------------------------------------------------------
# Firebird (legado / alias "firebird_local")
# ------------------------------------------------------------
DB_HOST=localhost
DB_PORT=3050
DB_USER=SYSDBA
DB_PASSWORD=masterkey
DB_ENCODING=UTF-8 DB_ENCODING=UTF-8
# Caminho do .FDB (vazio = usa ../NOVO.FDB na raiz do projeto) # Caminho do .FDB (vazio = usa ../NOVO.FDB na raiz do projeto)
DB_DATABASE= DB_DATABASE=${DB_DATABASE}
# Servidor # Servidor
PORT=3000 PORT=3000
@@ -289,12 +278,9 @@ EXTERNAL_URL=${EXTERNAL_URL}
EOF EOF
chown "$CHATC2_USER:$CHATC2_USER" "$CHATC2_DIR/.env" chown "$CHATC2_USER:$CHATC2_USER" "$CHATC2_DIR/.env"
log "Arquivo .env criado/atualizado com JWT_SECRET seguro" log "Arquivo .env criado/atualizado com JWT_SECRET seguro"
log " PG_HOST/DB = ${PG_HOST:-(vazio)} / ${PG_DATABASE} (schema: ${PG_SCHEMA})" log " Firebird = ${DB_HOST}:${DB_PORT} (database: ${DB_DATABASE:-(vazio)})"
log " LOCAL_URL = ${LOCAL_URL}" log " LOCAL_URL = ${LOCAL_URL}"
log " EXTERNAL_URL = ${EXTERNAL_URL}" log " EXTERNAL_URL = ${EXTERNAL_URL}"
if [ -z "$PG_HOST" ] || [ -z "$PG_PASSWORD" ]; then
warn "PostgreSQL externo nao configurado: edite $CHATC2_DIR/.env (PG_HOST, PG_PASSWORD, PG_DATABASE, PG_SCHEMA) antes de iniciar."
fi
# ████████████████████████████████████████████████████ # ████████████████████████████████████████████████████
# PASSO 9: MIGRACOES DO BANCO # PASSO 9: MIGRACOES DO BANCO
@@ -314,23 +300,22 @@ else
warn "Apos copiar, execute: chown $CHATC2_USER:firebird $CHATC2_DIR/db/*.FDB && chmod 660 $CHATC2_DIR/db/*.FDB" warn "Apos copiar, execute: chown $CHATC2_USER:firebird $CHATC2_DIR/db/*.FDB && chmod 660 $CHATC2_DIR/db/*.FDB"
fi fi
info "Passo 9/12: Migracoes (Firebird legado)..." info "Passo 9/12: Migracoes (Firebird)..."
# O schema do PostgreSQL e gerenciado no banco EXTERNO — nao migramos aqui. # Migracoes Firebird rodam se houver um .FDB local (alias novo_local).
# Migracoes Firebird so rodam se houver um .FDB local (alias firebird_local).
if ls "$CHATC2_DIR/db/"*.FDB "$CHATC2_DIR/"*.FDB >/dev/null 2>&1; then if ls "$CHATC2_DIR/db/"*.FDB "$CHATC2_DIR/"*.FDB >/dev/null 2>&1; then
if [ -f "$CHATC2_DIR/scripts/migracoes.js" ]; then if [ -f "$CHATC2_DIR/scripts/migracoes.js" ]; then
su - "$CHATC2_USER" -c "cd $CHATC2_DIR && node scripts/migracoes.js firebird_local 2>&1 | tail -10" || warn "Migracoes Firebird podem ter falhado" su - "$CHATC2_USER" -c "cd $CHATC2_DIR && node scripts/migracoes.js novo_local 2>&1 | tail -10" || warn "Migracoes Firebird podem ter falhado"
log "Migracoes Firebird executadas" log "Migracoes Firebird executadas"
fi fi
else else
info "Sem .FDB local — pulando migracoes Firebird (schema do Postgres e externo)." info "Sem .FDB local — pulando migracoes Firebird."
fi fi
# ████████████████████████████████████████████████████ # ████████████████████████████████████████████████████
# PASSO 9b: HABILITAR USUARIOS ADMIN PARA WEB # PASSO 9b: HABILITAR USUARIOS ADMIN PARA WEB
# ████████████████████████████████████████████████████ # ████████████████████████████████████████████████████
info "Passo 9b/12: Habilitando usuarios admin para acesso web..." info "Passo 9b/12: Habilitando usuarios admin para acesso web..."
# Habilita acesso web para admins (Postgres principal e Firebird se presente) # Habilita acesso web para admins (Firebird)
cat > /tmp/chatc2-habilitar-web.js << 'SCRIPTJS' cat > /tmp/chatc2-habilitar-web.js << 'SCRIPTJS'
const db = require('DATABASE_PATH'); const db = require('DATABASE_PATH');
@@ -352,16 +337,11 @@ const db = require('DATABASE_PATH');
SCRIPTJS SCRIPTJS
sed -i "s|DATABASE_PATH|$CHATC2_DIR/src/database|" /tmp/chatc2-habilitar-web.js sed -i "s|DATABASE_PATH|$CHATC2_DIR/src/database|" /tmp/chatc2-habilitar-web.js
# Postgres externo (alias principal) # Firebird (alias principal)
su - "$CHATC2_USER" -c "cd $CHATC2_DIR && node /tmp/chatc2-habilitar-web.js novo_local" 2>&1 || true su - "$CHATC2_USER" -c "cd $CHATC2_DIR && node /tmp/chatc2-habilitar-web.js novo_local" 2>&1 || true
# Firebird local (apenas se houver .FDB)
if ls "$CHATC2_DIR/db/"*.FDB "$CHATC2_DIR/"*.FDB >/dev/null 2>&1; then
su - "$CHATC2_USER" -c "cd $CHATC2_DIR && node /tmp/chatc2-habilitar-web.js firebird_local" 2>&1 || true
fi
rm -f /tmp/chatc2-habilitar-web.js rm -f /tmp/chatc2-habilitar-web.js
log "Usuarios admin verificados (Postgres + Firebird se presente)" log "Usuarios admin verificados (Firebird)"
# ████████████████████████████████████████████████████ # ████████████████████████████████████████████████████
# PASSO 10: NGINX # PASSO 10: NGINX
+59
View File
@@ -0,0 +1,59 @@
# Chatc2 — Docker Compose
#
# Uso:
# docker compose up -d (iniciar)
# docker compose logs -f chatc2 (ver logs)
# docker compose down (parar)
# docker compose restart chatc2 (reiniciar)
services:
# ============================================================
# Firebird 3.0
# ============================================================
firebird:
image: jacobalberty/firebird:3.0
container_name: chatc2-firebird
environment:
ISC_PASSWORD: masterkey
FIREBIRD_DATABASE: NOVO.FDB
FIREBIRD_USER: SYSDBA
FIREBIRD_PASSWORD: masterkey
ports:
- "3050:3050"
volumes:
- firebird_data:/firebird/data
- ./db/NOVO.FDB:/firebird/data/NOVO.FDB:rw
restart: unless-stopped
healthcheck:
test: ["CMD", "/usr/local/firebird/bin/isql", "-user", "SYSDBA", "-password", "masterkey", "localhost:NOVO.FDB", "-q", "SELECT 1 FROM RDB\$DATABASE;"]
interval: 30s
timeout: 5s
retries: 3
# ============================================================
# Chatc2 App
# ============================================================
chatc2:
build: .
container_name: chatc2-app
ports:
- "3000:3000"
env_file:
- .env
environment:
- DB_HOST=firebird
- DB_PORT=3050
- DB_DATABASE=/app/db/NOVO.FDB
- LOCAL_URL=http://localhost:3000
volumes:
- ./db/NOVO.FDB:/app/db/NOVO.FDB:rw
- ./logs:/app/logs
- ./uploads:/app/uploads
- ./whisper/models:/app/whisper/models:ro
depends_on:
firebird:
condition: service_healthy
restart: unless-stopped
volumes:
firebird_data:
+50
View File
@@ -0,0 +1,50 @@
/**
* PM2 Ecosystem File — Chatc2
*
* Uso:
* npm install -g pm2 (instalar PM2 globalmente)
* pm2 start ecosystem.config.js
* pm2 save (salvar para auto-início)
* pm2 startup (iniciar com o sistema)
*
* Comandos úteis:
* pm2 status (ver processos)
* pm2 logs chatc2 (ver logs)
* pm2 restart chatc2 (reiniciar)
* pm2 reload chatc2 (reiniciar sem downtime)
* pm2 stop chatc2 (parar)
*/
module.exports = {
apps: [{
name: 'chatc2',
script: 'src/server.js',
cwd: __dirname,
// Cluster: usa todos os CPUs disponíveis
instances: 'max',
exec_mode: 'cluster',
// Reinício automático em caso de crash
autorestart: true,
max_restarts: 10,
restart_delay: 5000,
// Watch desabilitado em produção
watch: false,
// Limites de memória
max_memory_restart: '512M',
// Logs
log_date_format: 'YYYY-MM-DD HH:mm:ss',
error_file: 'logs/pm2-error.log',
out_file: 'logs/pm2-out.log',
merge_logs: true,
// Variáveis de ambiente
env: {
NODE_ENV: 'production',
},
}],
};
+18 -151
View File
@@ -7,15 +7,16 @@
"": { "": {
"name": "chatc2", "name": "chatc2",
"version": "2.0.0", "version": "2.0.0",
"hasInstallScript": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^5.2.1", "express": "^5.2.1",
"ffmpeg-static": "^5.3.0", "ffmpeg-static": "^5.3.0",
"helmet": "^8.2.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"node-firebird": "^2.3.1", "node-firebird": "^2.3.1"
"pg": "^8.21.0"
} }
}, },
"node_modules/@derhuerst/http-basic": { "node_modules/@derhuerst/http-basic": {
@@ -526,6 +527,18 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/helmet": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz",
"integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"url": "https://github.com/sponsors/EvanHahn"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -832,134 +845,6 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/pg": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.13.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.14.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
"integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
"integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/progress": { "node_modules/progress": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@@ -983,9 +868,9 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.15.1", "version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "side-channel": "^1.1.0"
@@ -1212,15 +1097,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1314,15 +1190,6 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
} }
} }
} }
+10 -5
View File
@@ -1,14 +1,19 @@
{ {
"name": "chatc2", "name": "chatc2",
"version": "2.0.0", "version": "2.0.0",
"description": "API REST multi-banco (PostgreSQL + Firebird) para a plataforma de atendimento Chatc2", "description": "API REST Firebird para a plataforma de atendimento Chatc2",
"main": "src/server.js", "main": "src/server.js",
"scripts": { "scripts": {
"start": "node src/server.js", "start": "node src/server.js",
"dev": "node --watch src/server.js" "dev": "node --watch src/server.js",
"pm2:start": "pm2 start ecosystem.config.js",
"pm2:stop": "pm2 stop chatc2",
"pm2:restart": "pm2 restart chatc2",
"pm2:logs": "pm2 logs chatc2",
"pm2:status": "pm2 status",
"postinstall": "node scripts/postinstall.js"
}, },
"keywords": [ "keywords": [
"postgres",
"firebird", "firebird",
"api", "api",
"express" "express"
@@ -21,8 +26,8 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^5.2.1", "express": "^5.2.1",
"ffmpeg-static": "^5.3.0", "ffmpeg-static": "^5.3.0",
"helmet": "^8.2.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"node-firebird": "^2.3.1", "node-firebird": "^2.3.1"
"pg": "^8.21.0"
} }
} }
+74
View File
@@ -0,0 +1,74 @@
/**
* Script para corrigir registros existentes com SITUACAO = NULL
*
* Uso: node scripts/corrigir-situacao.js <alias> [schema]
* Ex: node scripts/corrigir-situacao.js novo
* Ex: node scripts/corrigir-situacao.js novo at
*/
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') });
const db = require('../src/database');
async function main() {
const alias = process.argv[2] || 'novo';
const schema = process.argv[3] ? process.argv[3].trim() : '';
const dot = schema ? '.' : '';
console.log(`📌 Corrigindo registros no alias "${alias}"${schema ? ', schema "' + schema + '"' : ''}...\n`);
// 1. Corrigir CHATC2_INSTANCIAS sem INS_SITUACAO
const sql1 = `UPDATE ${schema}${dot}"CHATC2_INSTANCIAS" SET "INS_SITUACAO" = 'A' WHERE "INS_SITUACAO" IS NULL OR "INS_SITUACAO" = ''`;
console.log(`📌 ${sql1}`);
try {
const r1 = await db.execute(alias, sql1);
console.log(`✅ CHATC2_INSTANCIAS: ${r1.affectedRows} registro(s) atualizado(s)`);
} catch (e) {
console.log(`⚠️ CHATC2_INSTANCIAS: ${e.message.substring(0, 100)}`);
}
// 2. Corrigir CHATC2_CONVERSAS sem CON_SITUACAO
const sql2 = `UPDATE ${schema}${dot}"CHATC2_CONVERSAS" SET "CON_SITUACAO" = 'A' WHERE "CON_SITUACAO" IS NULL OR "CON_SITUACAO" = ''`;
console.log(`📌 ${sql2}`);
try {
const r2 = await db.execute(alias, sql2);
console.log(`✅ CHATC2_CONVERSAS: ${r2.affectedRows} registro(s) atualizado(s)`);
} catch (e) {
console.log(`⚠️ CHATC2_CONVERSAS: ${e.message.substring(0, 100)}`);
}
// 3. Corrigir CHATC2_CONVERSAS_MENSAGENS sem CME_SITUACAO
const sql3 = `UPDATE ${schema}${dot}"CHATC2_CONVERSAS_MENSAGENS" SET "CME_SITUACAO" = 'A' WHERE "CME_SITUACAO" IS NULL OR "CME_SITUACAO" = ''`;
console.log(`📌 ${sql3}`);
try {
const r3 = await db.execute(alias, sql3);
console.log(`✅ CHATC2_CONVERSAS_MENSAGENS: ${r3.affectedRows} registro(s) atualizado(s)`);
} catch (e) {
console.log(`⚠️ CHATC2_CONVERSAS_MENSAGENS: ${e.message.substring(0, 100)}`);
}
// 4. Verifica conversa 61111 especificamente
const sql4 = `SELECT "CON_CODIGO_ID", "CON_SITUACAO", "CON_STATUS" FROM ${schema}${dot}"CHATC2_CONVERSAS" WHERE "CON_CODIGO_ID" = '61111'`;
console.log(`📌 ${sql4}`);
try {
const conv = await db.query(alias, sql4);
if (conv.length > 0) {
console.log(`\n📋 Conversa 61111: SITUACAO=${conv[0].CON_SITUACAO || 'NULL'}, STATUS=${conv[0].CON_STATUS}`);
// Conta mensagens
const sql5 = `SELECT COUNT(*) AS CT FROM ${schema}${dot}"CHATC2_CONVERSAS_MENSAGENS" WHERE "CME_CONVERSA_ID" = '61111' AND "CME_SITUACAO" = 'A'`;
console.log(`📌 ${sql5}`);
const msgs = await db.query(alias, sql5);
console.log(`📋 Mensagens ativas na conversa 61111: ${msgs[0]?.CT || 0}`);
} else {
console.log(`\n❌ Conversa 61111 não encontrada`);
}
} catch (e) {
console.log(`⚠️ Verificação 61111: ${e.message.substring(0, 100)}`);
}
console.log(`\n✅ Correção concluída!`);
process.exit(0);
}
main().catch(err => {
console.error(`❌ Erro: ${err.message}`);
process.exit(1);
});
+13 -479
View File
@@ -1,490 +1,24 @@
/** /**
* ARQUIVO DE MIGRAÇÕES DO BANCO DE DADOS * Script CLI de migrações.
* ======================================== *
* * Uso: node scripts/migracoes.js [alias]
* Este script contém TODAS as alterações estruturais feitas no banco de dados *
* desde a criação inicial do projeto. * As migrações também rodam automaticamente no boot do servidor.
* * Este script existe para execução manual/diagnóstico.
* 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'); const migrationRunner = require('./migrationRunner');
// Este script aplica DDL no dialeto Firebird (BLOB SUB_TYPE, etc.). const alias = process.argv[2] || 'novo_local';
// 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';
// ============================================================ (async () => {
// 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(`\n=== MIGRAÇÕES DE BANCO DE DADOS ===`);
console.log(`Alias: ${alias}\n`); console.log(`Alias: ${alias}\n`);
// Blindagem: a DDL aqui é Firebird. Se o alias for Postgres (banco externo, const count = await migrationRunner.executarMigracoes(alias);
// gerenciado fora), não há o que aplicar — sai sem erro. console.log(`\n${count} migração(ões) executada(s). Total: ${migrationRunner.MIGRACOES.length}.`);
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); process.exit(0);
} })().catch((err) => {
console.error('❌ Erro:', err.message);
main().catch(function(err) {
console.error('❌ Erro fatal:', err.message);
process.exit(1); process.exit(1);
}); });
+580
View File
@@ -0,0 +1,580 @@
-- ============================================================
-- CHATC2 - MIGRAÇÕES DE BANCO DE DADOS (Firebird)
-- ============================================================
-- Execute este arquivo no seu banco .FDB usando:
-- IBExpert: Tools > Execute SQL Script > External terminator: ^
-- isql (linha de comando):
-- isql -user SYSDBA -pass masterkey localhost:SEU.FDB
-- SET TERM ^ ;
-- INPUT migracoes.sql;
-- SET TERM ; ^
-- EXIT;
--
-- Cada bloco é idempotente: se a tabela/coluna já existe, o erro é ignorado.
-- ============================================================
-- ============================================================
-- TABELA DE CONTROLE (deve ser a primeira)
-- ============================================================
EXECUTE BLOCK AS BEGIN
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = 'CHATC2_CONTROLE_MIGRACOES')) THEN
EXECUTE STATEMENT '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''
)';
END;
^
COMMIT;
^
-- ============================================================
-- MIGRAÇÃO 1: Campo USU_TIPO_CHAT em USUARIOS
-- ============================================================
EXECUTE BLOCK AS BEGIN
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = 'USUARIOS' AND RDB$FIELD_NAME = 'USU_TIPO_CHAT')) THEN
EXECUTE STATEMENT 'ALTER TABLE USUARIOS ADD USU_TIPO_CHAT CHAR(1) DEFAULT ''A''';
END;
^
COMMIT;
^
-- ============================================================
-- MIGRAÇÃO 2: Tabela CHATC2_INSTANCIAS (WhatsApp / Evolution API)
-- ============================================================
EXECUTE BLOCK AS BEGIN
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = 'CHATC2_INSTANCIAS')) THEN
BEGIN
EXECUTE STATEMENT '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''
)';
EXECUTE STATEMENT 'CREATE SEQUENCE GEN_INSTANCIAS';
END
END;
^
COMMIT;
^
-- ============================================================
-- MIGRAÇÃO 3: Tabela CHATC2_EQUIPES
-- ============================================================
EXECUTE BLOCK AS BEGIN
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = 'CHATC2_EQUIPES')) THEN
BEGIN
EXECUTE STATEMENT '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''
)';
EXECUTE STATEMENT 'CREATE SEQUENCE GEN_EQUIPES';
END
END;
^
COMMIT;
^
-- ============================================================
-- MIGRAÇÃO 4: Tabela CHATC2_USU_EQUIPES (usuários x equipes)
-- ============================================================
EXECUTE BLOCK AS BEGIN
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = 'CHATC2_USU_EQUIPES')) THEN
EXECUTE STATEMENT '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)
)';
END;
^
COMMIT;
^
-- ============================================================
-- MIGRAÇÃO 5: Tabela CHATC2_ETIQUETAS
-- ============================================================
EXECUTE BLOCK AS BEGIN
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = 'CHATC2_ETIQUETAS')) THEN
BEGIN
EXECUTE STATEMENT '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''
)';
EXECUTE STATEMENT 'CREATE SEQUENCE GEN_ETIQUETAS';
END
END;
^
COMMIT;
^
-- ============================================================
-- MIGRAÇÃO 6: Tabela CHATC2_CONVERSAS
-- ============================================================
EXECUTE BLOCK AS BEGIN
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = 'CHATC2_CONVERSAS')) THEN
BEGIN
EXECUTE STATEMENT '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''
)';
EXECUTE STATEMENT 'CREATE SEQUENCE GEN_CONVERSAS';
END
END;
^
COMMIT;
^
-- ============================================================
-- MIGRAÇÃO 7: Tabela CHATC2_CONVERSAS_MENSAGENS
-- ============================================================
EXECUTE BLOCK AS BEGIN
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = 'CHATC2_CONVERSAS_MENSAGENS')) THEN
BEGIN
EXECUTE STATEMENT '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''
)';
EXECUTE STATEMENT 'CREATE SEQUENCE GEN_CONVERSAS_MENSAGENS';
END
END;
^
COMMIT;
^
-- ============================================================
-- MIGRAÇÃO 8: Tabela CHATC2_MENSAGENS_ATENDIMENTOS (mídias)
-- ============================================================
EXECUTE BLOCK AS BEGIN
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = 'CHATC2_MENSAGENS_ATENDIMENTOS')) THEN
BEGIN
EXECUTE STATEMENT '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''
)';
EXECUTE STATEMENT 'CREATE SEQUENCE GEN_MENSAGENS_ATENDIMENTO';
END
END;
^
COMMIT;
^
-- ============================================================
-- MIGRAÇÃO 9: Tabela CHATC2_CONVERSAS_ETIQUETAS
-- ============================================================
EXECUTE BLOCK AS BEGIN
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = 'CHATC2_CONVERSAS_ETIQUETAS')) THEN
EXECUTE STATEMENT '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)
)';
END;
^
COMMIT;
^
-- ============================================================
-- MIGRAÇÃO 10: Tabela CHATC2_CSAT_AVALIACOES
-- ============================================================
EXECUTE BLOCK AS BEGIN
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = 'CHATC2_CSAT_AVALIACOES')) THEN
BEGIN
EXECUTE STATEMENT '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
)';
EXECUTE STATEMENT 'CREATE SEQUENCE GEN_CSAT_AVALIACOES';
END
END;
^
COMMIT;
^
-- ============================================================
-- MIGRAÇÃO 11: Tabela CHATC2_CONFIGURACOES_EMPRESA
-- ============================================================
EXECUTE BLOCK AS BEGIN
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = 'CHATC2_CONFIGURACOES_EMPRESA')) THEN
EXECUTE STATEMENT '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''
)';
END;
^
COMMIT;
^
-- ============================================================
-- MIGRAÇÃO 12: EQU_ORDEM em CHATC2_EQUIPES
-- ============================================================
EXECUTE BLOCK AS BEGIN
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = 'CHATC2_EQUIPES' AND RDB$FIELD_NAME = 'EQU_ORDEM')) THEN
EXECUTE STATEMENT 'ALTER TABLE CHATC2_EQUIPES ADD EQU_ORDEM INTEGER DEFAULT 0';
END;
^
COMMIT;
^
-- ============================================================
-- MIGRAÇÃO 13: Campos de triagem em CHATC2_CONFIGURACOES_EMPRESA
-- ============================================================
EXECUTE BLOCK AS BEGIN
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = 'CHATC2_CONFIGURACOES_EMPRESA' AND RDB$FIELD_NAME = 'CFE_TRIAGEM_ATIVA')) THEN
BEGIN
EXECUTE STATEMENT 'ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_ENVIAR_NOME_USUARIO CHAR(1) DEFAULT ''N''';
EXECUTE STATEMENT 'ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_TRIAGEM_ATIVA CHAR(1) DEFAULT ''N''';
EXECUTE STATEMENT 'ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_TRIAGEM_MSG_WELCOME VARCHAR(500)';
EXECUTE STATEMENT 'ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_TRIAGEM_MSG_AFTER VARCHAR(500)';
EXECUTE STATEMENT 'ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_TRIAGEM_BOLETO_NUMERO VARCHAR(10) DEFAULT ''0''';
END
END;
^
COMMIT;
^
-- ============================================================
-- MIGRAÇÃO 14: CON_MENU_ESTADO em CHATC2_CONVERSAS
-- ============================================================
EXECUTE BLOCK AS BEGIN
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = 'CHATC2_CONVERSAS' AND RDB$FIELD_NAME = 'CON_MENU_ESTADO')) THEN
EXECUTE STATEMENT 'ALTER TABLE CHATC2_CONVERSAS ADD CON_MENU_ESTADO VARCHAR(100)';
END;
^
COMMIT;
^
-- ============================================================
-- MIGRAÇÃO 15: CON_ETIQUETAS_DESC em CHATC2_CONVERSAS
-- ============================================================
EXECUTE BLOCK AS BEGIN
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = 'CHATC2_CONVERSAS' AND RDB$FIELD_NAME = 'CON_ETIQUETAS_DESC')) THEN
EXECUTE STATEMENT 'ALTER TABLE CHATC2_CONVERSAS ADD CON_ETIQUETAS_DESC VARCHAR(500)';
END;
^
COMMIT;
^
-- ============================================================
-- MIGRAÇÃO 16: Tabela CHATC2_MENUS_EMPRESA (fluxo de atendimento)
-- ============================================================
EXECUTE BLOCK AS BEGIN
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = 'CHATC2_MENUS_EMPRESA')) THEN
EXECUTE STATEMENT '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''
)';
END;
^
COMMIT;
^
-- ============================================================
-- MIGRAÇÃO 17: Novos campos em CHATC2_CONVERSAS
-- ============================================================
EXECUTE BLOCK AS BEGIN
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = 'CHATC2_CONVERSAS' AND RDB$FIELD_NAME = 'CON_USUARIO_NOME')) THEN
BEGIN
EXECUTE STATEMENT 'ALTER TABLE CHATC2_CONVERSAS ADD CON_USUARIO_NOME VARCHAR(100)';
EXECUTE STATEMENT 'ALTER TABLE CHATC2_CONVERSAS ADD CON_EQUIPE_NOME VARCHAR(100)';
END
END;
^
COMMIT;
^
-- ============================================================
-- MIGRAÇÃO 18: Etiquetas nos menus de fluxo
-- ============================================================
EXECUTE BLOCK AS BEGIN
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = 'CHATC2_MENUS_EMPRESA' AND RDB$FIELD_NAME = 'MNE_ETIQUETA_IDS')) THEN
EXECUTE STATEMENT 'ALTER TABLE CHATC2_MENUS_EMPRESA ADD MNE_ETIQUETA_IDS VARCHAR(200)';
END;
^
COMMIT;
^
-- ============================================================
-- MIGRAÇÃO 19: Transcrição de áudio
-- ============================================================
EXECUTE BLOCK AS BEGIN
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = 'CHATC2_MENSAGENS_ATENDIMENTOS' AND RDB$FIELD_NAME = 'MAT_TRANSCRICAO')) THEN
EXECUTE STATEMENT 'ALTER TABLE CHATC2_MENSAGENS_ATENDIMENTOS ADD MAT_TRANSCRICAO BLOB SUB_TYPE TEXT';
END;
^
COMMIT;
^
-- ============================================================
-- MIGRAÇÃO 20: Generators thread-safe para todas as tabelas Chatc2
-- Cria sequences faltantes e inicializa todas com MAX atual + 1
-- ============================================================
EXECUTE BLOCK AS
DECLARE max_val INTEGER;
BEGIN
-- Tabelas que já tinham sequence (criadas nas migrações 2-8, 10).
-- Nomes podem variar - este bloco garante que existam com o nome
-- usado pelo código (nextId). Se a sequence com outro nome já
-- existir, mantém ambas (a antiga fica dormente).
-- CHATC2_CONVERSAS: já tinha GEN_CONVERSAS (migração 6)
IF (NOT EXISTS(SELECT 1 FROM RDB$GENERATORS WHERE RDB$GENERATOR_NAME = 'GEN_CHATC2_CONVERSAS')) THEN
EXECUTE STATEMENT 'CREATE SEQUENCE GEN_CHATC2_CONVERSAS';
SELECT COALESCE(MAX(CON_CODIGO_ID), 0) + 1 FROM CHATC2_CONVERSAS INTO :max_val;
EXECUTE STATEMENT 'SET GENERATOR GEN_CHATC2_CONVERSAS TO ' || :max_val;
-- CHATC2_CONVERSAS_MENSAGENS: já tinha GEN_CONVERSAS_MENSAGENS (migração 7)
IF (NOT EXISTS(SELECT 1 FROM RDB$GENERATORS WHERE RDB$GENERATOR_NAME = 'GEN_CONVERSAS_MENSAGENS')) THEN
EXECUTE STATEMENT 'CREATE SEQUENCE GEN_CONVERSAS_MENSAGENS';
SELECT COALESCE(MAX(CME_CODIGO_ID), 0) + 1 FROM CHATC2_CONVERSAS_MENSAGENS INTO :max_val;
EXECUTE STATEMENT 'SET GENERATOR GEN_CONVERSAS_MENSAGENS TO ' || :max_val;
-- CHATC2_MENSAGENS_ATENDIMENTOS: já tinha GEN_MENSAGENS_ATENDIMENTO (migração 8)
-- Criamos com nome corrigido (plural ATENDIMENTOS)
IF (NOT EXISTS(SELECT 1 FROM RDB$GENERATORS WHERE RDB$GENERATOR_NAME = 'GEN_MENSAGENS_ATENDIMENTOS')) THEN
EXECUTE STATEMENT 'CREATE SEQUENCE GEN_MENSAGENS_ATENDIMENTOS';
SELECT COALESCE(MAX(MAT_CODIGO_ID), 0) + 1 FROM CHATC2_MENSAGENS_ATENDIMENTOS INTO :max_val;
EXECUTE STATEMENT 'SET GENERATOR GEN_MENSAGENS_ATENDIMENTOS TO ' || :max_val;
-- CHATC2_EQUIPES: já tinha GEN_EQUIPES (migração 3)
IF (NOT EXISTS(SELECT 1 FROM RDB$GENERATORS WHERE RDB$GENERATOR_NAME = 'GEN_CHATC2_EQUIPES')) THEN
EXECUTE STATEMENT 'CREATE SEQUENCE GEN_CHATC2_EQUIPES';
SELECT COALESCE(MAX(EQU_CODIGO_ID), 0) + 1 FROM CHATC2_EQUIPES INTO :max_val;
EXECUTE STATEMENT 'SET GENERATOR GEN_CHATC2_EQUIPES TO ' || :max_val;
-- CHATC2_ETIQUETAS: já tinha GEN_ETIQUETAS (migração 5)
IF (NOT EXISTS(SELECT 1 FROM RDB$GENERATORS WHERE RDB$GENERATOR_NAME = 'GEN_CHATC2_ETIQUETAS')) THEN
EXECUTE STATEMENT 'CREATE SEQUENCE GEN_CHATC2_ETIQUETAS';
SELECT COALESCE(MAX(ETI_CODIGO_ID), 0) + 1 FROM CHATC2_ETIQUETAS INTO :max_val;
EXECUTE STATEMENT 'SET GENERATOR GEN_CHATC2_ETIQUETAS TO ' || :max_val;
-- CHATC2_INSTANCIAS: já tinha GEN_INSTANCIAS (migração 2)
IF (NOT EXISTS(SELECT 1 FROM RDB$GENERATORS WHERE RDB$GENERATOR_NAME = 'GEN_CHATC2_INSTANCIAS')) THEN
EXECUTE STATEMENT 'CREATE SEQUENCE GEN_CHATC2_INSTANCIAS';
SELECT COALESCE(MAX(INS_CODIGO_ID), 0) + 1 FROM CHATC2_INSTANCIAS INTO :max_val;
EXECUTE STATEMENT 'SET GENERATOR GEN_CHATC2_INSTANCIAS TO ' || :max_val;
-- CHATC2_CSAT_AVALIACOES: já tinha GEN_CSAT_AVALIACOES (migração 10)
IF (NOT EXISTS(SELECT 1 FROM RDB$GENERATORS WHERE RDB$GENERATOR_NAME = 'GEN_CHATC2_CSAT_AVALIACOES')) THEN
EXECUTE STATEMENT 'CREATE SEQUENCE GEN_CHATC2_CSAT_AVALIACOES';
SELECT COALESCE(MAX(CSA_CODIGO_ID), 0) + 1 FROM CHATC2_CSAT_AVALIACOES INTO :max_val;
EXECUTE STATEMENT 'SET GENERATOR GEN_CHATC2_CSAT_AVALIACOES TO ' || :max_val;
-- CHATC2_MENUS_EMPRESA: NÃO tinha sequence (criada na migração 16)
IF (NOT EXISTS(SELECT 1 FROM RDB$GENERATORS WHERE RDB$GENERATOR_NAME = 'GEN_CHATC2_MENUS_EMPRESA')) THEN
EXECUTE STATEMENT 'CREATE SEQUENCE GEN_CHATC2_MENUS_EMPRESA';
SELECT COALESCE(MAX(MNE_CODIGO_ID), 0) + 1 FROM CHATC2_MENUS_EMPRESA INTO :max_val;
EXECUTE STATEMENT 'SET GENERATOR GEN_CHATC2_MENUS_EMPRESA TO ' || :max_val;
-- CHATC2_MOTIVOS_ATENDIMENTO: tabela criada por resolucaoSetup.js (sem sequence)
IF (NOT EXISTS(SELECT 1 FROM RDB$GENERATORS WHERE RDB$GENERATOR_NAME = 'GEN_CHATC2_MOTIVOS_ATENDIMENTO')) THEN
EXECUTE STATEMENT 'CREATE SEQUENCE GEN_CHATC2_MOTIVOS_ATENDIMENTO';
SELECT COALESCE(MAX(MOT_CODIGO_ID), 0) + 1 FROM "CHATC2_MOTIVOS_ATENDIMENTO" INTO :max_val;
EXECUTE STATEMENT 'SET GENERATOR GEN_CHATC2_MOTIVOS_ATENDIMENTO TO ' || :max_val;
END;
^
COMMIT;
^
-- ============================================================
-- MIGRAÇÃO 21: Índices para busca de telefone
-- ============================================================
EXECUTE BLOCK AS BEGIN
IF (NOT EXISTS(SELECT 1 FROM RDB$INDICES WHERE RDB$INDEX_NAME = 'IDX_CLIENTES_CELULAR')) THEN
EXECUTE STATEMENT 'CREATE INDEX IDX_CLIENTES_CELULAR ON CLIENTES (CLI_CELULAR)';
IF (NOT EXISTS(SELECT 1 FROM RDB$INDICES WHERE RDB$INDEX_NAME = 'IDX_CLIENTES_FONE1')) THEN
EXECUTE STATEMENT 'CREATE INDEX IDX_CLIENTES_FONE1 ON CLIENTES (CLI_FONE1)';
IF (NOT EXISTS(SELECT 1 FROM RDB$INDICES WHERE RDB$INDEX_NAME = 'IDX_CLIENTES_FONE2')) THEN
EXECUTE STATEMENT 'CREATE INDEX IDX_CLIENTES_FONE2 ON CLIENTES (CLI_FONE2)';
IF (NOT EXISTS(SELECT 1 FROM RDB$INDICES WHERE RDB$INDEX_NAME = 'IDX_CLIENTES_EMPRESA')) THEN
EXECUTE STATEMENT 'CREATE INDEX IDX_CLIENTES_EMPRESA ON CLIENTES (CLI_EMPRESA_ID)';
IF (NOT EXISTS(SELECT 1 FROM RDB$INDICES WHERE RDB$INDEX_NAME = 'IDX_CONV_NUMERO_EMPRESA')) THEN
EXECUTE STATEMENT 'CREATE INDEX IDX_CONV_NUMERO_EMPRESA ON CHATC2_CONVERSAS (CON_NUMERO, CON_EMPRESA_ID)';
IF (NOT EXISTS(SELECT 1 FROM RDB$INDICES WHERE RDB$INDEX_NAME = 'IDX_CONV_STATUS')) THEN
EXECUTE STATEMENT 'CREATE INDEX IDX_CONV_STATUS ON CHATC2_CONVERSAS (CON_STATUS)';
END;
^
COMMIT;
^
-- ============================================================
-- MIGRAÇÃO 22: Mensagem e etiqueta por equipe (triagem)
-- ============================================================
EXECUTE BLOCK AS BEGIN
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = 'CHATC2_EQUIPES' AND RDB$FIELD_NAME = 'EQU_MENSAGEM')) THEN
EXECUTE STATEMENT 'ALTER TABLE CHATC2_EQUIPES ADD EQU_MENSAGEM VARCHAR(500)';
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = 'CHATC2_EQUIPES' AND RDB$FIELD_NAME = 'EQU_ETIQUETA_ID')) THEN
EXECUTE STATEMENT 'ALTER TABLE CHATC2_EQUIPES ADD EQU_ETIQUETA_ID INTEGER';
END;
^
COMMIT;
^
-- ============================================================
-- MIGRAÇÃO 23: Agendamento de conversas
-- ============================================================
EXECUTE BLOCK AS BEGIN
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = 'CHATC2_CONVERSAS_AGENDAMENTOS')) THEN
EXECUTE STATEMENT 'CREATE TABLE CHATC2_CONVERSAS_AGENDAMENTOS (
CAG_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
CAG_CONVERSA_ID INTEGER NOT NULL,
CAG_EMPRESA_ID INTEGER,
CAG_DATA DATE NOT NULL,
CAG_HORA TIME,
CAG_NOTA VARCHAR(300),
CAG_STATUS CHAR(1) DEFAULT ''P'',
CAG_USUARIO_ID INTEGER,
CAG_DT_CRIACAO TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)';
END;
^
COMMIT;
^
-- ============================================================
-- MIGRAÇÃO 24: DDL do fluxo de resolução (runtime -> migration)
-- ============================================================
EXECUTE BLOCK AS BEGIN
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = 'CHATC2_MOTIVOS_ATENDIMENTO')) THEN
EXECUTE STATEMENT 'CREATE TABLE ''CHATC2_MOTIVOS_ATENDIMENTO'' (
MOT_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
MOT_EMPRESA_ID INTEGER,
MOT_DESCRICAO VARCHAR(150),
MOT_SITUACAO CHAR(1) DEFAULT ''A''
)';
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = 'CHATC2_CONFIGURACOES_EMPRESA' AND RDB$FIELD_NAME = 'CFE_MOTIVO_VISUALIZAR')) THEN
EXECUTE STATEMENT 'ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_MOTIVO_VISUALIZAR CHAR(1) DEFAULT ''N''';
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = 'CHATC2_CONFIGURACOES_EMPRESA' AND RDB$FIELD_NAME = 'CFE_MOTIVO_OBRIGATORIO')) THEN
EXECUTE STATEMENT 'ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_MOTIVO_OBRIGATORIO CHAR(1) DEFAULT ''N''';
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = 'CHATC2_CONFIGURACOES_EMPRESA' AND RDB$FIELD_NAME = 'CFE_RESOLUCAO_VISUALIZAR')) THEN
EXECUTE STATEMENT 'ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_RESOLUCAO_VISUALIZAR CHAR(1) DEFAULT ''N''';
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = 'CHATC2_CONFIGURACOES_EMPRESA' AND RDB$FIELD_NAME = 'CFE_RESOLUCAO_OBRIGATORIO')) THEN
EXECUTE STATEMENT 'ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_RESOLUCAO_OBRIGATORIO CHAR(1) DEFAULT ''N''';
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = 'CHATC2_CONFIGURACOES_EMPRESA' AND RDB$FIELD_NAME = 'CFE_ENVIAR_BOLETO')) THEN
EXECUTE STATEMENT 'ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_ENVIAR_BOLETO CHAR(1) DEFAULT ''N''';
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = 'CHATC2_CONFIGURACOES_EMPRESA' AND RDB$FIELD_NAME = 'CFE_RESOLUCAO_SALVAR_MSC')) THEN
EXECUTE STATEMENT 'ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_RESOLUCAO_SALVAR_MSC CHAR(1) DEFAULT ''N''';
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = 'CHATC2_CONVERSAS' AND RDB$FIELD_NAME = 'CON_MOTIVO_ID')) THEN
EXECUTE STATEMENT 'ALTER TABLE CHATC2_CONVERSAS ADD CON_MOTIVO_ID INTEGER';
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = 'CHATC2_CONVERSAS' AND RDB$FIELD_NAME = 'CON_RESOLUCAO')) THEN
EXECUTE STATEMENT 'ALTER TABLE CHATC2_CONVERSAS ADD CON_RESOLUCAO VARCHAR(4000)';
END;
^
COMMIT;
^
-- ============================================================
-- REGISTRO DAS MIGRAÇÕES (marca como executadas)
-- ============================================================
EXECUTE BLOCK AS
DECLARE mig_id INTEGER;
DECLARE mig_desc VARCHAR(200);
BEGIN
-- Só insere se não existir (idempotente)
FOR SELECT MIG_ID, MIG_DESCRICAO FROM (
SELECT 1 AS MIG_ID, 'Campo USU_TIPO_CHAT em USUARIOS' AS MIG_DESCRICAO FROM RDB$DATABASE UNION ALL
SELECT 2, 'Tabela CHATC2_INSTANCIAS' FROM RDB$DATABASE UNION ALL
SELECT 3, 'Tabela CHATC2_EQUIPES' FROM RDB$DATABASE UNION ALL
SELECT 4, 'Tabela CHATC2_USU_EQUIPES' FROM RDB$DATABASE UNION ALL
SELECT 5, 'Tabela CHATC2_ETIQUETAS' FROM RDB$DATABASE UNION ALL
SELECT 6, 'Tabela CHATC2_CONVERSAS' FROM RDB$DATABASE UNION ALL
SELECT 7, 'Tabela CHATC2_CONVERSAS_MENSAGENS' FROM RDB$DATABASE UNION ALL
SELECT 8, 'Tabela CHATC2_MENSAGENS_ATENDIMENTOS' FROM RDB$DATABASE UNION ALL
SELECT 9, 'Tabela CHATC2_CONVERSAS_ETIQUETAS' FROM RDB$DATABASE UNION ALL
SELECT 10, 'Tabela CHATC2_CSAT_AVALIACOES' FROM RDB$DATABASE UNION ALL
SELECT 11, 'Tabela CHATC2_CONFIGURACOES_EMPRESA' FROM RDB$DATABASE UNION ALL
SELECT 12, 'EQU_ORDEM em CHATC2_EQUIPES' FROM RDB$DATABASE UNION ALL
SELECT 13, 'Campos de triagem em config' FROM RDB$DATABASE UNION ALL
SELECT 14, 'CON_MENU_ESTADO em conversas' FROM RDB$DATABASE UNION ALL
SELECT 15, 'CON_ETIQUETAS_DESC em conversas' FROM RDB$DATABASE UNION ALL
SELECT 16, 'Tabela CHATC2_MENUS_EMPRESA' FROM RDB$DATABASE UNION ALL
SELECT 17, 'CON_USUARIO_NOME e CON_EQUIPE_NOME' FROM RDB$DATABASE UNION ALL
SELECT 18, 'MNE_ETIQUETA_IDS nos menus' FROM RDB$DATABASE UNION ALL
SELECT 19, 'MAT_TRANSCRICAO nas mídias' FROM RDB$DATABASE UNION ALL
SELECT 20, 'Generators thread-safe para todas as tabelas' FROM RDB$DATABASE UNION ALL
SELECT 21, 'Índices para busca de telefone' FROM RDB$DATABASE UNION ALL
SELECT 22, 'Mensagem e etiqueta por equipe' FROM RDB$DATABASE UNION ALL
SELECT 23, 'Tabela de agendamentos' FROM RDB$DATABASE UNION ALL
SELECT 24, 'DDL do fluxo de resolução' FROM RDB$DATABASE
) AS migracoes
INTO :mig_id, :mig_desc
DO BEGIN
IF (NOT EXISTS(SELECT 1 FROM CHATC2_CONTROLE_MIGRACOES WHERE MIG_ID = :mig_id)) THEN
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (:mig_id, :mig_desc);
END
END;
^
COMMIT;
^
-- ============================================================
-- FIM
-- ============================================================
-- Após executar, verifique com:
-- SELECT * FROM CHATC2_CONTROLE_MIGRACOES ORDER BY MIG_ID;
-- Deve mostrar 24 migrações registradas.
+308
View File
@@ -0,0 +1,308 @@
-- ============================================================
-- CHATC2 - SCRIPT DE CRIAÇÃO DO BANCO (IBExpert)
-- ============================================================
-- Execute cada bloco separadamente no IBExpert SQL Editor.
-- Se algum comando falhar com "already exists", ignore e continue.
-- ============================================================
-- ============================================================
-- TABELA DE CONTROLE DE MIGRAÇÕES
-- ============================================================
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'
);
-- ============================================================
-- 1. USU_TIPO_CHAT em USUARIOS
-- ============================================================
ALTER TABLE USUARIOS ADD USU_TIPO_CHAT CHAR(1) DEFAULT 'A';
-- ============================================================
-- 2. Tabela CHATC2_INSTANCIAS
-- ============================================================
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'
);
-- ============================================================
-- 3. Tabela CHATC2_EQUIPES
-- ============================================================
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'
);
-- ============================================================
-- 4. Tabela CHATC2_USU_EQUIPES
-- ============================================================
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)
);
-- ============================================================
-- 5. Tabela CHATC2_ETIQUETAS
-- ============================================================
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'
);
-- ============================================================
-- 6. Tabela CHATC2_CONVERSAS
-- ============================================================
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'
);
-- ============================================================
-- 7. Tabela CHATC2_CONVERSAS_MENSAGENS
-- ============================================================
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'
);
-- ============================================================
-- 8. Tabela CHATC2_MENSAGENS_ATENDIMENTOS
-- ============================================================
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'
);
-- ============================================================
-- 9. Tabela CHATC2_CONVERSAS_ETIQUETAS
-- ============================================================
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)
);
-- ============================================================
-- 10. Tabela CHATC2_CSAT_AVALIACOES
-- ============================================================
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
);
-- ============================================================
-- 11. Tabela CHATC2_CONFIGURACOES_EMPRESA
-- ============================================================
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'
);
-- ============================================================
-- 12. EQU_ORDEM em CHATC2_EQUIPES (já incluso no CREATE TABLE acima)
-- ============================================================
-- Já existe. Pule este comando.
-- ============================================================
-- 13. Campos de triagem
-- ============================================================
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';
-- ============================================================
-- 14. CON_MENU_ESTADO em conversas
-- ============================================================
ALTER TABLE CHATC2_CONVERSAS ADD CON_MENU_ESTADO VARCHAR(100);
-- ============================================================
-- 15. CON_ETIQUETAS_DESC em conversas
-- ============================================================
ALTER TABLE CHATC2_CONVERSAS ADD CON_ETIQUETAS_DESC VARCHAR(500);
-- ============================================================
-- 16. Tabela CHATC2_MENUS_EMPRESA
-- ============================================================
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'
);
-- ============================================================
-- 17. Novos campos em conversas
-- ============================================================
ALTER TABLE CHATC2_CONVERSAS ADD CON_USUARIO_NOME VARCHAR(100);
ALTER TABLE CHATC2_CONVERSAS ADD CON_EQUIPE_NOME VARCHAR(100);
-- ============================================================
-- 18. Etiquetas nos menus
-- ============================================================
ALTER TABLE CHATC2_MENUS_EMPRESA ADD MNE_ETIQUETA_IDS VARCHAR(200);
-- ============================================================
-- 19. Transcrição de áudio
-- ============================================================
ALTER TABLE CHATC2_MENSAGENS_ATENDIMENTOS ADD MAT_TRANSCRICAO BLOB SUB_TYPE TEXT;
-- ============================================================
-- 20. Generators (sequences)
-- ============================================================
CREATE GENERATOR GEN_CHATC2_CONVERSAS;
CREATE GENERATOR GEN_CONVERSAS_MENSAGENS;
CREATE GENERATOR GEN_MENSAGENS_ATENDIMENTOS;
CREATE GENERATOR GEN_CHATC2_EQUIPES;
CREATE GENERATOR GEN_CHATC2_ETIQUETAS;
CREATE GENERATOR GEN_CHATC2_INSTANCIAS;
CREATE GENERATOR GEN_CHATC2_MENUS_EMPRESA;
CREATE GENERATOR GEN_CHATC2_CSAT_AVALIACOES;
CREATE GENERATOR GEN_CHATC2_MOTIVOS_ATENDIMENTO;
CREATE GENERATOR GEN_CHATC2_AGENDAMENTOS;
-- ============================================================
-- 21. Índices
-- ============================================================
CREATE INDEX IDX_CLIENTES_CELULAR ON CLIENTES (CLI_CELULAR);
CREATE INDEX IDX_CLIENTES_FONE1 ON CLIENTES (CLI_FONE1);
CREATE INDEX IDX_CLIENTES_FONE2 ON CLIENTES (CLI_FONE2);
CREATE INDEX IDX_CLIENTES_EMPRESA ON CLIENTES (CLI_EMPRESA_ID);
CREATE INDEX IDX_CONV_NUMERO_EMPRESA ON CHATC2_CONVERSAS (CON_NUMERO, CON_EMPRESA_ID);
CREATE INDEX IDX_CONV_STATUS ON CHATC2_CONVERSAS (CON_STATUS);
-- ============================================================
-- 22. Mensagem e etiqueta por equipe
-- ============================================================
ALTER TABLE CHATC2_EQUIPES ADD EQU_MENSAGEM VARCHAR(500);
ALTER TABLE CHATC2_EQUIPES ADD EQU_ETIQUETA_ID INTEGER;
-- ============================================================
-- 23. Tabela de agendamentos
-- ============================================================
CREATE TABLE CHATC2_CONVERSAS_AGENDAMENTOS (
CAG_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
CAG_CONVERSA_ID INTEGER NOT NULL,
CAG_EMPRESA_ID INTEGER,
CAG_DATA DATE NOT NULL,
CAG_HORA TIME,
CAG_NOTA VARCHAR(300),
CAG_STATUS CHAR(1) DEFAULT 'P',
CAG_USUARIO_ID INTEGER,
CAG_DT_CRIACAO TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ============================================================
-- 24. Fluxo de resolução
-- ============================================================
CREATE TABLE "CHATC2_MOTIVOS_ATENDIMENTO" (
MOT_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
MOT_EMPRESA_ID INTEGER,
MOT_DESCRICAO VARCHAR(150),
MOT_SITUACAO CHAR(1) DEFAULT 'A'
);
ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_MOTIVO_VISUALIZAR CHAR(1) DEFAULT 'N';
ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_MOTIVO_OBRIGATORIO CHAR(1) DEFAULT 'N';
ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_RESOLUCAO_VISUALIZAR CHAR(1) DEFAULT 'N';
ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_RESOLUCAO_OBRIGATORIO CHAR(1) DEFAULT 'N';
ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_ENVIAR_BOLETO CHAR(1) DEFAULT 'N';
ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_RESOLUCAO_SALVAR_MSC CHAR(1) DEFAULT 'N';
ALTER TABLE CHATC2_CONVERSAS ADD CON_MOTIVO_ID INTEGER;
ALTER TABLE CHATC2_CONVERSAS ADD CON_RESOLUCAO VARCHAR(4000);
-- ============================================================
-- REGISTRA MIGRAÇÕES COMO EXECUTADAS
-- ============================================================
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (1, 'USU_TIPO_CHAT');
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (2, 'Tabela CHATC2_INSTANCIAS');
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (3, 'Tabela CHATC2_EQUIPES');
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (4, 'Tabela CHATC2_USU_EQUIPES');
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (5, 'Tabela CHATC2_ETIQUETAS');
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (6, 'Tabela CHATC2_CONVERSAS');
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (7, 'Tabela CHATC2_CONVERSAS_MENSAGENS');
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (8, 'Tabela CHATC2_MENSAGENS_ATENDIMENTOS');
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (9, 'Tabela CHATC2_CONVERSAS_ETIQUETAS');
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (10, 'Tabela CHATC2_CSAT_AVALIACOES');
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (11, 'Tabela CHATC2_CONFIGURACOES_EMPRESA');
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (12, 'EQU_ORDEM');
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (13, 'Campos de triagem');
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (14, 'CON_MENU_ESTADO');
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (15, 'CON_ETIQUETAS_DESC');
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (16, 'Tabela CHATC2_MENUS_EMPRESA');
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (17, 'CON_USUARIO_NOME e CON_EQUIPE_NOME');
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (18, 'MNE_ETIQUETA_IDS');
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (19, 'MAT_TRANSCRICAO');
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (20, 'Generators');
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (21, 'Índices');
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (22, 'Mensagem/etiqueta por equipe');
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (23, 'Agendamentos');
INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (24, 'Fluxo de resolução');
+224
View File
@@ -0,0 +1,224 @@
/**
* Runner de migrações — executável via CLI ou automaticamente no boot.
*
* Exporta uma função `executarMigracoes(alias)` que aplica todas as migrações
* pendentes de forma idempotente. Usado por server.js no boot e por
* scripts/migracoes.js para execução manual.
*/
const db = require('../src/database');
// ============================================================
// LISTA DE MIGRAÇÕES
// ============================================================
// SEMPRE adicione NOVAS migrações no FINAL do array, com ID sequencial.
// NUNCA remova ou modifique migrações já existentes.
const MIGRACOES = [
{ id: 1, descricao: 'Adicionar USU_TIPO_CHAT em USUARIOS (A=Atendente, G=Gerente)', sql: ['ALTER TABLE USUARIOS ADD USU_TIPO_CHAT CHAR(1) DEFAULT \'A\''] },
{ id: 2, descricao: 'Criar tabela CHATC2_INSTANCIAS para conexões WhatsApp', sql: [`CREATE TABLE CHATC2_INSTANCIAS (INS_CODIGO_ID INTEGER NOT NULL PRIMARY KEY, INS_EMPRESA_ID INTEGER NOT NULL, INS_NOME VARCHAR(100), INS_URL VARCHAR(500), INS_API_KEY VARCHAR(500), INS_INSTANCE_NAME VARCHAR(100), INS_STATUS CHAR(1) DEFAULT 'D', INS_QR_CODE BLOB SUB_TYPE 0, INS_DT_CONEXAO TIMESTAMP, INS_DT_CADASTRO TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INS_SITUACAO CHAR(1) DEFAULT 'A')`] },
{ id: 3, descricao: 'Criar tabela CHATC2_EQUIPES', sql: [`CREATE TABLE CHATC2_EQUIPES (EQU_CODIGO_ID INTEGER NOT NULL PRIMARY KEY, EQU_EMPRESA_ID INTEGER NOT NULL, EQU_NOME VARCHAR(100), EQU_ORDEM INTEGER DEFAULT 0, EQU_SITUACAO CHAR(1) DEFAULT 'A')`] },
{ id: 4, descricao: 'Criar tabela CHATC2_USU_EQUIPES (relação N:N)', sql: ['CREATE TABLE CHATC2_USU_EQUIPES (EQU_EQUIPE_ID INTEGER NOT NULL, EQU_USUARIO_ID INTEGER NOT NULL, PRIMARY KEY (EQU_EQUIPE_ID, EQU_USUARIO_ID))'] },
{ id: 5, descricao: 'Criar tabela CHATC2_ETIQUETAS', sql: [`CREATE TABLE CHATC2_ETIQUETAS (ETI_CODIGO_ID INTEGER NOT NULL PRIMARY KEY, ETI_EMPRESA_ID INTEGER NOT NULL, ETI_NOME VARCHAR(50), ETI_COR VARCHAR(7) DEFAULT '#667eea', ETI_SITUACAO CHAR(1) DEFAULT 'A')`] },
{ id: 6, descricao: 'Criar tabela CHATC2_CONVERSAS', sql: [`CREATE TABLE CHATC2_CONVERSAS (CON_CODIGO_ID INTEGER NOT NULL PRIMARY KEY, CON_EMPRESA_ID INTEGER NOT NULL, CON_INSTANCIA_ID INTEGER, CON_CLIENTE_ID INTEGER, CON_NUMERO VARCHAR(20), CON_NOME_CONTATO VARCHAR(100), CON_STATUS CHAR(1) DEFAULT 'E', CON_USUARIO_ID INTEGER, CON_EQUIPE_ID INTEGER, CON_DT_INICIO TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CON_DT_FINAL TIMESTAMP, CON_DT_ULTIMA_MSG TIMESTAMP, CON_SAUDACAO_ENVIADA CHAR(1) DEFAULT 'N', CON_CSAT_ENVIADO CHAR(1) DEFAULT 'N', CON_PRIMEIRA_MSG TIMESTAMP, CON_ORIGEM VARCHAR(20) DEFAULT 'whatsapp', CON_SITUACAO CHAR(1) DEFAULT 'A')`] },
{ id: 7, descricao: 'Criar tabela CHATC2_CONVERSAS_MENSAGENS', sql: [`CREATE TABLE CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID INTEGER NOT NULL PRIMARY KEY, CME_CONVERSA_ID INTEGER NOT NULL, CME_REMETENTE CHAR(1) DEFAULT 'C', CME_USUARIO_ID INTEGER, CME_MENSAGEM BLOB SUB_TYPE 1, CME_TEXTO VARCHAR(4000), CME_TIPO VARCHAR(20) DEFAULT 'text', CME_DT_ENVIO TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CME_PRIVADA CHAR(1) DEFAULT 'N', CME_LIDA CHAR(1) DEFAULT 'N', CME_MIDIA_ID INTEGER, CME_SITUACAO CHAR(1) DEFAULT 'A')`] },
{ id: 8, descricao: 'Criar tabela CHATC2_MENSAGENS_ATENDIMENTOS para mídias', sql: [`CREATE TABLE CHATC2_MENSAGENS_ATENDIMENTOS (MAT_CODIGO_ID INTEGER NOT NULL PRIMARY KEY, MAT_CONVERSA_ID INTEGER, MAT_MENSAGEM_ID INTEGER, MAT_NOME_ARQUIVO VARCHAR(255), MAT_TIPO_ARQUIVO VARCHAR(50), MAT_ARQUIVO BLOB SUB_TYPE 0, MAT_DT_CADASTRO TIMESTAMP DEFAULT CURRENT_TIMESTAMP, MAT_SITUACAO CHAR(1) DEFAULT 'A')`] },
{ id: 9, descricao: 'Criar tabela CHATC2_CONVERSAS_ETIQUETAS', sql: ['CREATE TABLE CHATC2_CONVERSAS_ETIQUETAS (CET_CONVERSA_ID INTEGER NOT NULL, CET_ETIQUETA_ID INTEGER NOT NULL, PRIMARY KEY (CET_CONVERSA_ID, CET_ETIQUETA_ID))'] },
{ id: 10, descricao: 'Criar tabela CHATC2_CSAT_AVALIACOES', sql: [`CREATE TABLE CHATC2_CSAT_AVALIACOES (CSA_CODIGO_ID INTEGER NOT NULL PRIMARY KEY, CSA_CONVERSA_ID INTEGER, CSA_EMPRESA_ID INTEGER, CSA_CLIENTE_ID INTEGER, CSA_NOTA INTEGER, CSA_COMENTARIO VARCHAR(500), CSA_DT_AVALIACAO TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`] },
{ id: 11, descricao: 'Criar tabela CHATC2_CONFIGURACOES_EMPRESA (base)', sql: [`CREATE TABLE CHATC2_CONFIGURACOES_EMPRESA (CFE_EMPRESA_ID INTEGER NOT NULL PRIMARY KEY, CFE_INSTANCIA_PADRAO_ID INTEGER, CFE_FOTO_CELULAR CHAR(1) DEFAULT 'N', CFE_SAUDACAO_ATIVA CHAR(1) DEFAULT 'S', CFE_SAUDACAO_MENSAGEM VARCHAR(500), CFE_CSAT_ATIVO CHAR(1) DEFAULT 'N', CFE_CSAT_MENSAGEM VARCHAR(500) DEFAULT 'Avalie seu atendimento de 1 a 5 estrelas')`] },
{ id: 12, descricao: 'Adicionar EQU_ORDEM em CHATC2_EQUIPES (ordenação)', sql: ['ALTER TABLE CHATC2_EQUIPES ADD EQU_ORDEM INTEGER DEFAULT 0'] },
{ id: 13, descricao: 'Adicionar campos de triagem em CHATC2_CONFIGURACOES_EMPRESA', sql: ['ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_ENVIAR_NOME_USUARIO CHAR(1) DEFAULT \'N\'', 'ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_TRIAGEM_ATIVA CHAR(1) DEFAULT \'N\'', 'ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_TRIAGEM_MSG_WELCOME VARCHAR(500)', 'ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_TRIAGEM_MSG_AFTER VARCHAR(500)', 'ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_TRIAGEM_BOLETO_NUMERO VARCHAR(10) DEFAULT \'0\''] },
{ id: 14, descricao: 'Adicionar CON_MENU_ESTADO em CHATC2_CONVERSAS (navegação triagem)', sql: ['ALTER TABLE CHATC2_CONVERSAS ADD CON_MENU_ESTADO VARCHAR(100)'] },
{ id: 15, descricao: 'Adicionar CON_ETIQUETAS_DESC em CHATC2_CONVERSAS', sql: ['ALTER TABLE CHATC2_CONVERSAS ADD CON_ETIQUETAS_DESC VARCHAR(500)'] },
{ id: 16, descricao: 'Criar tabela CHATC2_MENUS_EMPRESA para fluxo personalizado', sql: [`CREATE TABLE CHATC2_MENUS_EMPRESA (MNE_CODIGO_ID INTEGER NOT NULL PRIMARY KEY, MNE_EMPRESA_ID INTEGER, MNE_EQUIPE_ID INTEGER, MNE_MENU_PAI_ID INTEGER, MNE_ORDEM INTEGER DEFAULT 0, MNE_TITULO VARCHAR(100), MNE_TIPO CHAR(1) DEFAULT 'M', MNE_TEXTO BLOB SUB_TYPE TEXT, MNE_ACAO_ROTA VARCHAR(100), MNE_ACAO_METODO VARCHAR(10), MNE_ACAO_PROMPT VARCHAR(300), MNE_SITUACAO CHAR(1) DEFAULT 'A')`] },
{ id: 17, descricao: 'Adicionar CON_USUARIO_NOME e CON_EQUIPE_NOME em CHATC2_CONVERSAS', sql: ['ALTER TABLE CHATC2_CONVERSAS ADD CON_USUARIO_NOME VARCHAR(100)', 'ALTER TABLE CHATC2_CONVERSAS ADD CON_EQUIPE_NOME VARCHAR(100)'] },
{ id: 18, descricao: 'Adicionar MNE_ETIQUETA_IDS em CHATC2_MENUS_EMPRESA', sql: ['ALTER TABLE CHATC2_MENUS_EMPRESA ADD MNE_ETIQUETA_IDS VARCHAR(200)'] },
{ id: 19, descricao: 'Adicionar MAT_TRANSCRICAO em CHATC2_MENSAGENS_ATENDIMENTOS', sql: ['ALTER TABLE CHATC2_MENSAGENS_ATENDIMENTOS ADD MAT_TRANSCRICAO BLOB SUB_TYPE TEXT'] },
{ id: 20, descricao: 'Criar generators Firebird para eliminar race condition',
sql: [
'CREATE GENERATOR GEN_CHATC2_CONVERSAS', 'CREATE GENERATOR GEN_CONVERSAS_MENSAGENS',
'CREATE GENERATOR GEN_MENSAGENS_ATENDIMENTOS', 'CREATE GENERATOR GEN_CHATC2_EQUIPES',
'CREATE GENERATOR GEN_CHATC2_ETIQUETAS', 'CREATE GENERATOR GEN_CHATC2_INSTANCIAS',
'CREATE GENERATOR GEN_CHATC2_MENUS_EMPRESA', 'CREATE GENERATOR GEN_CHATC2_CSAT_AVALIACOES',
'CREATE GENERATOR GEN_CHATC2_MOTIVOS_ATENDIMENTO',
],
async after(alias) {
const generators = [
{ gen: 'GEN_CHATC2_CONVERSAS', tabela: 'CHATC2_CONVERSAS', col: 'CON_CODIGO_ID' },
{ gen: 'GEN_CONVERSAS_MENSAGENS', tabela: 'CHATC2_CONVERSAS_MENSAGENS', col: 'CME_CODIGO_ID' },
{ gen: 'GEN_MENSAGENS_ATENDIMENTOS', tabela: 'CHATC2_MENSAGENS_ATENDIMENTOS', col: 'MAT_CODIGO_ID' },
{ gen: 'GEN_CHATC2_EQUIPES', tabela: 'CHATC2_EQUIPES', col: 'EQU_CODIGO_ID' },
{ gen: 'GEN_CHATC2_ETIQUETAS', tabela: 'CHATC2_ETIQUETAS', col: 'ETI_CODIGO_ID' },
{ gen: 'GEN_CHATC2_INSTANCIAS', tabela: 'CHATC2_INSTANCIAS', col: 'INS_CODIGO_ID' },
{ gen: 'GEN_CHATC2_MENUS_EMPRESA', tabela: 'CHATC2_MENUS_EMPRESA', col: 'MNE_CODIGO_ID' },
{ gen: 'GEN_CHATC2_CSAT_AVALIACOES', tabela: 'CHATC2_CSAT_AVALIACOES', col: 'CSA_CODIGO_ID' },
{ gen: 'GEN_CHATC2_MOTIVOS_ATENDIMENTO', tabela: '"CHATC2_MOTIVOS_ATENDIMENTO"', col: 'MOT_CODIGO_ID' },
];
for (const g of generators) {
try {
const r = await db.query(alias, `SELECT COALESCE(MAX(${g.col}), 0) + 1 AS VAL FROM ${g.tabela}`);
await db.execute(alias, `SET GENERATOR ${g.gen} TO ${r[0]?.VAL || 1}`);
} catch (_) {}
}
},
},
// ----------------------------------------------------------
// MIGRAÇÃO 21: Índices para busca de telefone (webhook)
// ----------------------------------------------------------
{
id: 21,
descricao: 'Índices para busca de cliente por telefone (acelera webhook)',
sql: [
// Índice na tabela CLIENTES para busca por CELULAR/FONE1/FONE2
'CREATE INDEX IDX_CLIENTES_CELULAR ON CLIENTES (CLI_CELULAR)',
'CREATE INDEX IDX_CLIENTES_FONE1 ON CLIENTES (CLI_FONE1)',
'CREATE INDEX IDX_CLIENTES_FONE2 ON CLIENTES (CLI_FONE2)',
'CREATE INDEX IDX_CLIENTES_EMPRESA ON CLIENTES (CLI_EMPRESA_ID)',
// Índice composto para a query mais comum do webhook
'CREATE INDEX IDX_CONV_NUMERO_EMPRESA ON CHATC2_CONVERSAS (CON_NUMERO, CON_EMPRESA_ID)',
'CREATE INDEX IDX_CONV_STATUS ON CHATC2_CONVERSAS (CON_STATUS)',
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 22: Mensagem e etiqueta por equipe (triagem)
// ----------------------------------------------------------
{
id: 22,
descricao: 'Adicionar EQU_MENSAGEM e EQU_ETIQUETA_ID em CHATC2_EQUIPES',
sql: [
'ALTER TABLE CHATC2_EQUIPES ADD EQU_MENSAGEM VARCHAR(500)',
'ALTER TABLE CHATC2_EQUIPES ADD EQU_ETIQUETA_ID INTEGER',
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 23: Agendamento de conversas
// ----------------------------------------------------------
{
id: 23,
descricao: 'Criar tabela CHATC2_CONVERSAS_AGENDAMENTOS (retorno agendado)',
sql: [
`CREATE TABLE CHATC2_CONVERSAS_AGENDAMENTOS (
CAG_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
CAG_CONVERSA_ID INTEGER NOT NULL,
CAG_EMPRESA_ID INTEGER,
CAG_DATA DATE NOT NULL,
CAG_HORA TIME,
CAG_NOTA VARCHAR(300),
CAG_STATUS CHAR(1) DEFAULT 'P',
CAG_USUARIO_ID INTEGER,
CAG_DT_CRIACAO TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 24: DDL que estava no resolucaoSetup.js (runtime)
// ----------------------------------------------------------
{
id: 24,
descricao: 'Consolidar DDL do fluxo de resolução (antes criado em runtime)',
sql: [
`CREATE TABLE "CHATC2_MOTIVOS_ATENDIMENTO" (
MOT_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
MOT_EMPRESA_ID INTEGER,
MOT_DESCRICAO VARCHAR(150),
MOT_SITUACAO CHAR(1) DEFAULT 'A'
)`,
"ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_MOTIVO_VISUALIZAR CHAR(1) DEFAULT 'N'",
"ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_MOTIVO_OBRIGATORIO CHAR(1) DEFAULT 'N'",
"ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_RESOLUCAO_VISUALIZAR CHAR(1) DEFAULT 'N'",
"ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_RESOLUCAO_OBRIGATORIO CHAR(1) DEFAULT 'N'",
"ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_ENVIAR_BOLETO CHAR(1) DEFAULT 'N'",
"ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_RESOLUCAO_SALVAR_MSC CHAR(1) DEFAULT 'N'",
'ALTER TABLE CHATC2_CONVERSAS ADD CON_MOTIVO_ID INTEGER',
'ALTER TABLE CHATC2_CONVERSAS ADD CON_RESOLUCAO VARCHAR(4000)',
],
},
];
// ============================================================
// EXECUTOR
// ============================================================
async function garantirControle(alias) {
try {
await db.execute(alias, `CREATE TABLE CHATC2_CONTROLE_MIGRACOES (MIG_ID INTEGER NOT NULL PRIMARY KEY, MIG_DESCRICAO VARCHAR(200), MIG_DATA_EXECUCAO TIMESTAMP DEFAULT CURRENT_TIMESTAMP, MIG_STATUS CHAR(1) DEFAULT 'A')`);
} catch (_) {}
}
async function migracaoJaExecutada(alias, id) {
try {
const r = await db.query(alias, 'SELECT COUNT(*) AS CT FROM CHATC2_CONTROLE_MIGRACOES WHERE MIG_ID = ?', [id]);
return r[0]?.CT > 0;
} catch (_) { return false; }
}
async function registrarMigracao(alias, id, descricao) {
try {
await db.execute(alias, 'INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (?, ?)', [id, descricao]);
} catch (_) {}
}
/**
* Executa todas as migrações pendentes para um alias.
* Idempotente: cada migração roda uma única vez.
*
* @param {string} alias
* @param {boolean} [silencioso=false] - Se true, não loga no console
* @returns {Promise<number>} Quantidade de migrações executadas
*/
async function executarMigracoes(alias, silencioso) {
if (!silencioso) console.log(`📦 Verificando migrações para "${alias}"...`);
const driver = db.driverOf(alias);
if (driver !== 'firebird') {
if (!silencioso) console.log(` ⏭️ Driver "${driver}" — sem migrações DDL`);
return 0;
}
await garantirControle(alias);
let executadas = 0;
for (const mig of MIGRACOES) {
if (await migracaoJaExecutada(alias, mig.id)) continue;
if (!silencioso) console.log(` ▶️ [${mig.id}] ${mig.descricao}`);
let erros = 0;
for (const cmd of mig.sql) {
try {
await db.execute(alias, cmd);
} catch (e) {
if (e.message.includes('already exists') || e.message.includes('Violation') || e.message.includes('duplicate')) {
continue; // idempotente
}
if (!silencioso) console.log(`${e.message.substring(0, 100)}`);
erros++;
}
}
if (erros === 0) {
// Executa callback after() se existir (ex: inicializar generators)
if (typeof mig.after === 'function') {
try {
await mig.after(alias);
} catch (afterErr) {
if (!silencioso) console.log(` ⚠️ after() falhou: ${afterErr.message.substring(0, 120)}`);
erros++;
}
}
await registrarMigracao(alias, mig.id, mig.descricao);
executadas++;
}
}
if (executadas > 0 && !silencioso) console.log(`${executadas} migração(ões) aplicada(s)`);
else if (!silencioso) console.log(` ✅ Nenhuma migração pendente`);
return executadas;
}
module.exports = { executarMigracoes, MIGRACOES };
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env node
/**
* postinstall.js - Aplica patches de compatibilidade no node-firebird
* após npm install, garantindo compatibilidade com Node.js 22+.
*/
const fs = require('fs');
const path = require('path');
const connPath = path.resolve(__dirname, '../node_modules/node-firebird/lib/wire/connection.js');
if (!fs.existsSync(connPath)) {
console.log('[patch] connection.js not found, skipping');
process.exit(0);
}
let content = fs.readFileSync(connPath, 'utf8');
let patched = false;
// ============================================================
// Patch 1: SRP empty buffer (Firebird 3.0 Legacy_Auth)
// ============================================================
const srpOld = (
' // TODO : Fallback Srp256 to Srp ?\n' +
' /*if (!d.buffer) {\n' +
' cnx.sendOpContAuth(\n' +
' cnx.clientKeys.public.toString(16),\n' +
' DEFAULT_ENCODING,\n' +
' accept.pluginName\n' +
' );\n' +
'\n' +
' return cb(new Error(\'login\'));\n' +
' }*/\n' +
'\n' +
' // Check buffer contains salt\n' +
' var saltLen = d.buffer.readUInt16LE(0);'
);
const srpNew = (
' // No auth data from server - server accepted the connection\n' +
' // without requiring SRP. This happens with Firebird 3.0 when\n' +
' // the server already validated the client.\n' +
' if (!d || !d.buffer) {\n' +
" accept.authData = '';\n" +
" accept.sessionKey = '';\n" +
' } else {\n' +
' // Check buffer contains salt\n' +
' var saltLen = d.buffer.readUInt16LE(0);'
);
if (content.includes(srpOld)) {
content = content.replace(srpOld, srpNew);
patched = true;
console.log('[patch] Patch 1 (SRP) applied');
}
// ============================================================
// Patch 2: Missing closing brace (Node.js 22+ syntax)
// ============================================================
const syntaxOld = (
' accept.authData = proof.authData.toString(16);\n' +
' accept.sessionKey = proof.clientSessionKey;\n' +
' } else if (accept.pluginName === Const.AUTH_PLUGIN_LEGACY) {'
);
const syntaxNew = (
' accept.authData = proof.authData.toString(16);\n' +
' accept.sessionKey = proof.clientSessionKey;\n' +
' } // fecha o else do if (!d || !d.buffer)\n' +
' } else if (accept.pluginName === Const.AUTH_PLUGIN_LEGACY) {'
);
if (content.includes(syntaxOld)) {
content = content.replace(syntaxOld, syntaxNew);
patched = true;
console.log('[patch] Patch 2 (syntax Node22) applied');
}
if (patched) {
fs.writeFileSync(connPath, content, 'utf8');
console.log('[patch] node-firebird patched successfully');
} else {
console.log('[patch] node-firebird already patched or not needed');
}
+10 -2
View File
@@ -1,5 +1,6 @@
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const helmet = require('helmet');
const path = require('path'); const path = require('path');
const routes = require('./routes'); const routes = require('./routes');
const authenticateToken = require('./middlewares/auth'); const authenticateToken = require('./middlewares/auth');
@@ -7,6 +8,13 @@ const authenticateToken = require('./middlewares/auth');
const app = express(); const app = express();
// Middlewares // Middlewares
// Segurança: headers HTTP protetores (X-Content-Type-Options, X-Frame-Options, etc.)
// CSP configurado de forma permissiva para não quebrar inline scripts/styles existentes
app.use(helmet({
contentSecurityPolicy: false, // Desabilitado: app usa inline scripts/styles em várias páginas
crossOriginEmbedderPolicy: false, // Permite carregar recursos cross-origin (Evolution API, etc.)
}));
// CORS: restringível via CORS_ORIGINS (lista separada por vírgula no .env). // 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. // 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(',')) const corsOrigins = (process.env.CORS_ORIGINS || [process.env.LOCAL_URL, process.env.EXTERNAL_URL].filter(Boolean).join(','))
@@ -62,9 +70,9 @@ app.use(routes);
// Rota raiz // Rota raiz
app.get('/', (req, res) => { app.get('/', (req, res) => {
res.json({ res.json({
name: 'API Firebird - Chatc2', name: 'API Chatc2 - Firebird',
version: '2.0.0', version: '2.0.0',
description: 'API multi-banco com sistema de aliases', description: 'API Firebird com sistema de aliases',
auth: { auth: {
loginPage: 'GET /app/:alias/login', loginPage: 'GET /app/:alias/login',
login: 'POST /app/:alias/login', login: 'POST /app/:alias/login',
+181
View File
@@ -0,0 +1,181 @@
/**
* Configuração de conexões (aliases de banco).
*
* Gerencia aliases estáticos (definidos em código) e customizados
* (adicionados via API e persistidos em databases_custom.json).
*/
const path = require('path');
const fs = require('fs');
const CUSTOM_DB_PATH = path.resolve(__dirname, '..', 'databases_custom.json');
// Conexões dinâmicas salvas em JSON
let customDatabases = {};
try {
if (fs.existsSync(CUSTOM_DB_PATH)) {
customDatabases = JSON.parse(fs.readFileSync(CUSTOM_DB_PATH, 'utf8'));
}
} catch (e) {
console.error('[Databases] Erro ao carregar databases_custom.json:', e.message);
}
// Valores padrão para Firebird
const DRIVER_DEFAULTS = {
firebird: {
host: 'localhost',
port: 3050,
user: 'SYSDBA',
password: 'masterkey',
encoding: 'UTF-8',
lowercase_keys: false,
pageSize: 4096,
wireCrypt: 1,
},
};
/**
* Conexões estáticas.
*/
const databases = {
novo_local: {
driver: 'firebird',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT, 10) || 3050,
database: process.env.DB_DATABASE || path.resolve(__dirname, '../../db/NOVO.FDB'),
user: process.env.DB_USER || 'SYSDBA',
password: process.env.DB_PASSWORD || 'masterkey',
encoding: process.env.DB_ENCODING || 'UTF-8',
lowercase_keys: false,
pageSize: 4096,
wireCrypt: 1,
},
novo: {
driver: 'firebird',
host: '177.70.23.53',
port: 3050,
database: 'sigef_web_novo',
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,
},
paz_suprema: {
driver: 'firebird',
host: '177.70.23.53',
port: 3050,
database: 'sigef_web_paz_suprema',
user: process.env.DB_USER || 'SYSDBA',
password: process.env.DB_PASSWORD || 'masterkey',
encoding: process.env.DB_ENCODING || 'UTF-8',
lowercase_keys: false,
pageSize: 4096,
wireCrypt: 1,
},
};
function salvarCustomDatabases() {
try {
fs.writeFileSync(CUSTOM_DB_PATH, JSON.stringify(customDatabases, null, 2), 'utf8');
return true;
} catch (e) {
console.error('[Databases] Erro ao salvar databases_custom.json:', e.message);
return false;
}
}
/** Normaliza uma config bruta aplicando defaults do driver. */
function normalize(raw) {
const d = DRIVER_DEFAULTS['firebird'];
return {
driver: 'firebird',
host: raw.host || d.host,
port: raw.port || d.port,
database: raw.database,
user: raw.user || d.user,
password: raw.password || d.password,
encoding: raw.encoding || d.encoding,
lowercase_keys: raw.lowercase_keys || d.lowercase_keys,
role: raw.role || null,
pageSize: raw.pageSize || d.pageSize,
wireCrypt: raw.wireCrypt !== undefined ? raw.wireCrypt : d.wireCrypt,
};
}
/** Configuração normalizada de um alias. */
function getConfig(alias) {
if (!alias) throw new Error('Alias do banco não informado.');
const aliasLower = alias.toLowerCase();
const raw = customDatabases[aliasLower] || databases[aliasLower];
if (!raw) {
throw new Error(`Alias "${alias}" não encontrado. Aliases disponíveis: ${listAliases().join(', ')}`);
}
return normalize(raw);
}
/** Lista de aliases disponíveis. */
function listAliases() {
return Object.keys(databases).concat(
Object.keys(customDatabases).filter((a) => !databases[a])
);
}
/** Todos os aliases com tipo (estático ou custom). */
function listAllDatabases() {
const result = {};
Object.keys(databases).forEach((alias) => {
result[alias] = Object.assign({}, databases[alias], { _tipo: 'estatico' });
});
Object.keys(customDatabases).forEach((alias) => {
result[alias] = Object.assign({}, customDatabases[alias], { _tipo: 'custom' });
});
return result;
}
/** Adiciona/atualiza conexão customizada. */
function addDatabase(alias, config) {
if (!alias || !config || !config.database) {
throw new Error('Alias e database são obrigatórios.');
}
const aliasLower = alias.toLowerCase().replace(/[^a-z0-9_]/g, '_');
const stored = { driver: 'firebird' };
['host', 'port', 'database', 'user', 'password',
'encoding', 'lowercase_keys', 'role', 'pageSize', 'wireCrypt']
.forEach((k) => {
if (config[k] !== undefined && config[k] !== null && config[k] !== '') {
stored[k] = config[k];
}
});
customDatabases[aliasLower] = stored;
return salvarCustomDatabases();
}
/** Remove uma conexão customizada. */
function removeDatabase(alias) {
if (!alias) return false;
const aliasLower = alias.toLowerCase();
if (!customDatabases[aliasLower]) return false;
delete customDatabases[aliasLower];
return salvarCustomDatabases();
}
/** Nome do driver de um alias (sempre 'firebird'). */
function driverOf(alias) {
return 'firebird';
}
module.exports = {
databases,
customDatabases,
getConfig,
listAliases,
listAllDatabases,
addDatabase,
removeDatabase,
driverOf,
DRIVER_DEFAULTS,
};
+2 -2
View File
@@ -75,7 +75,7 @@ class AuthController {
FROM USUARIOS FROM USUARIOS
WHERE USU_LOGIN = ? WHERE USU_LOGIN = ?
AND (USU_SENHA = ? OR USU_SENHA_WEB = ?) AND (USU_SENHA = ? OR USU_SENHA_WEB = ?)
AND COALESCE(USU_ACESSO_WEB, 0) = 1 AND COALESCE(USU_ACESSO_WEB, '0') IN ('1', 'S')
AND USU_STATUS = 'A' AND USU_STATUS = 'A'
`; `;
@@ -99,7 +99,7 @@ class AuthController {
'SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA WHERE USE_USUARIO_ID = ?', 'SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA WHERE USE_USUARIO_ID = ?',
[user.USU_CODIGO_ID] [user.USU_CODIGO_ID]
); );
const empresasIds = empresas.map(e => e.USE_EMPRESA_ID); const empresasIds = empresas.map(e => Number(e.USE_EMPRESA_ID)).filter(n => !isNaN(n));
// Atualiza a data do último acesso // Atualiza a data do último acesso
await db.execute( await db.execute(
File diff suppressed because it is too large Load Diff
+68 -7
View File
@@ -24,7 +24,7 @@ class ClientController {
); );
const empresas = result.map(row => ({ const empresas = result.map(row => ({
id: row.EMP_CODIGO_ID, id: Number(row.EMP_CODIGO_ID),
nome: (row.EMP_NOME || '').trim(), nome: (row.EMP_NOME || '').trim(),
nomeFantasia: (row.EMP_NOME_FANTASIA || '').trim(), nomeFantasia: (row.EMP_NOME_FANTASIA || '').trim(),
})); }));
@@ -74,7 +74,8 @@ class ClientController {
c.CLI_ENDERECO, c.CLI_ENDERECO,
c.CLI_NUMERO_FAT, c.CLI_NUMERO_FAT,
c.CLI_BAIRRO_FAT, c.CLI_BAIRRO_FAT,
c.CLI_CIDADES_ID c.CLI_CIDADES_ID,
c.CLI_FOTO
FROM CLIENTES c FROM CLIENTES c
WHERE c.CLI_CODIGO_ID = ? WHERE c.CLI_CODIGO_ID = ?
AND c.CLI_EMPRESA_ID = ? AND c.CLI_EMPRESA_ID = ?
@@ -133,6 +134,29 @@ class ClientController {
// Interpreta a situação // Interpreta a situação
const situacao = client.CLI_SITUACAO?.trim() === 'A' ? 'Ativo' : 'Inativo'; const situacao = client.CLI_SITUACAO?.trim() === 'A' ? 'Ativo' : 'Inativo';
// Foto do cliente (CLI_FOTO) — pode vir como Buffer (bytea/blob) ou string base64
let fotoCliente = null;
if (client.CLI_FOTO) {
const f = client.CLI_FOTO;
if (Buffer.isBuffer(f)) {
const s = f.toString('utf8');
if (/^[A-Za-z0-9+/]+=*$/.test(s.slice(0, 30))) {
fotoCliente = s;
} else {
let prefixo = 'data:image/jpeg;base64,';
if (f[0] === 0x89 && f[1] === 0x50) prefixo = 'data:image/png;base64,';
else if (f[0] === 0x47 && f[1] === 0x49) prefixo = 'data:image/gif;base64,';
else if (f[0] === 0x52 && f[1] === 0x49) prefixo = 'data:image/webp;base64,';
fotoCliente = prefixo + f.toString('base64');
}
} else {
fotoCliente = String(f);
}
}
// Matrícula: converte com segurança (pode ser numérica ou string no banco)
const matriculaStr = client.CLI_MATRICULA == null ? '' : String(client.CLI_MATRICULA).trim();
// Monta o endereço completo de faturamento // Monta o endereço completo de faturamento
const enderecoFaturamento = [ const enderecoFaturamento = [
client.CLI_ENDERECO_FAT?.trim(), client.CLI_ENDERECO_FAT?.trim(),
@@ -147,7 +171,8 @@ class ClientController {
clienteId: id_cliente, clienteId: id_cliente,
data: { data: {
nome: client.CLI_NOME?.trim(), nome: client.CLI_NOME?.trim(),
matricula: client.CLI_MATRICULA?.trim(), matricula: matriculaStr,
foto: fotoCliente,
situacao: { situacao: {
codigo: client.CLI_SITUACAO?.trim(), codigo: client.CLI_SITUACAO?.trim(),
descricao: situacao, descricao: situacao,
@@ -274,6 +299,7 @@ class ClientController {
const total = countResult[0].TOTAL; const total = countResult[0].TOTAL;
// Busca paginada // Busca paginada
const dataParams = params.concat([offset, limit]);
const dataSql = ` const dataSql = `
SELECT SELECT
c.CAR_CODIGO_ID, c.CAR_CODIGO_ID,
@@ -290,10 +316,10 @@ class ClientController {
FROM CARNES c FROM CARNES c
${where} ${where}
ORDER BY c.CAR_DT_VENCIMENTO ORDER BY c.CAR_DT_VENCIMENTO
OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY OFFSET ? ROWS FETCH NEXT ? ROWS ONLY
`; `;
const result = await db.query(alias, dataSql, params); const result = await db.query(alias, dataSql, dataParams);
const carnes = result.map(row => { const carnes = result.map(row => {
const situacao = row.CAR_SITUACAO; const situacao = row.CAR_SITUACAO;
@@ -439,6 +465,7 @@ class ClientController {
const total = countResult[0].TOTAL; const total = countResult[0].TOTAL;
// Busca paginada // Busca paginada
const dataParams = params.concat([offset, limit]);
const dataSql = ` const dataSql = `
SELECT SELECT
c.CLI_CODIGO_ID, c.CLI_CODIGO_ID,
@@ -453,10 +480,10 @@ class ClientController {
FROM CLIENTES c FROM CLIENTES c
${where} ${where}
ORDER BY c.CLI_NOME ORDER BY c.CLI_NOME
OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY OFFSET ? ROWS FETCH NEXT ? ROWS ONLY
`; `;
const result = await db.query(alias, dataSql, params); const result = await db.query(alias, dataSql, dataParams);
const clients = result.map(row => ({ const clients = result.map(row => ({
id: row.CLI_CODIGO_ID, id: row.CLI_CODIGO_ID,
@@ -982,6 +1009,40 @@ class ClientController {
res.status(500).json({ success: false, error: err.message }); res.status(500).json({ success: false, error: err.message });
} }
} }
/**
* Atualiza a data de agendamento de cobrança de um título.
* PUT /api/:alias/clients/:clienteId/carne/:carneId/agendamento
*/
static async atualizarAgendamento(req, res) {
try {
const { alias, clienteId, carneId } = req.params;
const { dataAgendamento } = req.body;
// Verifica autenticação básica
const userId = req.user?.id;
if (!userId) return res.status(403).json({ success: false, error: 'Não autenticado.' });
if (!dataAgendamento) {
return res.status(400).json({ success: false, error: 'Data de agendamento é obrigatória.' });
}
// Verifica se o título pertence ao cliente
const carne = await db.query(alias,
'SELECT CAR_CODIGO_ID FROM CARNES WHERE CAR_CODIGO_ID = ? AND CAR_CLIENTE_ID = ?',
[parseInt(carneId), parseInt(clienteId)]);
if (carne.length === 0) {
return res.status(404).json({ success: false, error: 'Título não encontrado para este cliente.' });
}
await db.execute(alias,
'UPDATE CARNES SET CAR_DT_AGENDAMENTO_COBRANCA = ? WHERE CAR_CODIGO_ID = ?',
[dataAgendamento, parseInt(carneId)]);
res.json({ success: true, message: 'Agendamento atualizado.' });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
}
} }
module.exports = ClientController; module.exports = ClientController;
+103 -12
View File
@@ -1,5 +1,6 @@
const db = require('../database'); const db = require('../database');
const { isGerente } = require('../middlewares/roles'); const { isGerente } = require('../middlewares/roles');
const configService = require('../services/configService');
class ConfigController { class ConfigController {
// ==================== CHATC2_EQUIPES ==================== // ==================== CHATC2_EQUIPES ====================
@@ -23,7 +24,14 @@ class ConfigController {
t.members = members.map(m => ({ id: m.USU_CODIGO_ID, nome: (m.USU_NOME || '').trim(), login: (m.USU_LOGIN || '').trim() })); 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 })) }); res.json({ success: true, data: teams.map(t => ({
id: t.EQU_CODIGO_ID,
nome: (t.EQU_NOME || '').trim(),
ordem: t.EQU_ORDEM || 0,
mensagem: (t.EQU_MENSAGEM || '').trim(),
etiquetaId: t.EQU_ETIQUETA_ID || null,
membros: t.members
})) });
} catch (err) { res.status(500).json({ success: false, error: err.message }); } } catch (err) { res.status(500).json({ success: false, error: err.message }); }
} }
@@ -35,11 +43,14 @@ class ConfigController {
const empresaId = req.user?.empresas?.[0] || req.body.empresaId; const empresaId = req.user?.empresas?.[0] || req.body.empresaId;
if (!nome) return res.status(400).json({ success: false, error: 'Nome obrigatório.' }); 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 = await db.nextId(alias, 'GEN_CHATC2_EQUIPES');
const newId = (maxId[0]?.ID || 0) + 1;
const ordem = req.body.ordem !== undefined ? req.body.ordem : 0; const ordem = req.body.ordem !== undefined ? req.body.ordem : 0;
const mensagem = (req.body.mensagem || '').trim();
const etiquetaId = req.body.etiquetaId || null;
await db.execute(alias, 'INSERT INTO CHATC2_EQUIPES (EQU_CODIGO_ID, EQU_EMPRESA_ID, EQU_NOME, EQU_ORDEM) VALUES (?, ?, ?, ?)', [newId, empresaId, nome, ordem]); await db.execute(alias,
'INSERT INTO CHATC2_EQUIPES (EQU_CODIGO_ID, EQU_EMPRESA_ID, EQU_NOME, EQU_ORDEM, EQU_MENSAGEM, EQU_ETIQUETA_ID) VALUES (?, ?, ?, ?, ?, ?)',
[newId, empresaId, nome, ordem, mensagem || null, etiquetaId]);
if (membros && membros.length > 0) { if (membros && membros.length > 0) {
for (const userId of membros) { for (const userId of membros) {
@@ -59,6 +70,8 @@ class ConfigController {
if (nome) await db.execute(alias, 'UPDATE CHATC2_EQUIPES SET EQU_NOME = ? WHERE EQU_CODIGO_ID = ?', [nome, id]); 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 (req.body.ordem !== undefined) await db.execute(alias, 'UPDATE CHATC2_EQUIPES SET EQU_ORDEM = ? WHERE EQU_CODIGO_ID = ?', [req.body.ordem, id]);
if (req.body.mensagem !== undefined) await db.execute(alias, 'UPDATE CHATC2_EQUIPES SET EQU_MENSAGEM = ? WHERE EQU_CODIGO_ID = ?', [req.body.mensagem || null, id]);
if (req.body.etiquetaId !== undefined) await db.execute(alias, 'UPDATE CHATC2_EQUIPES SET EQU_ETIQUETA_ID = ? WHERE EQU_CODIGO_ID = ?', [req.body.etiquetaId || null, id]);
if (membros) { if (membros) {
await db.execute(alias, 'DELETE FROM CHATC2_USU_EQUIPES WHERE EQU_EQUIPE_ID = ?', [id]); await db.execute(alias, 'DELETE FROM CHATC2_USU_EQUIPES WHERE EQU_EQUIPE_ID = ?', [id]);
@@ -118,8 +131,7 @@ class ConfigController {
const { nome, cor } = req.body; const { nome, cor } = req.body;
const empresaId = req.user?.empresas?.[0]; const empresaId = req.user?.empresas?.[0];
const maxId = await db.query(alias, 'SELECT MAX(ETI_CODIGO_ID) AS ID FROM CHATC2_ETIQUETAS'); const newId = await db.nextId(alias, 'GEN_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']); 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 } }); res.json({ success: true, data: { id: newId } });
@@ -152,12 +164,15 @@ class ConfigController {
const { alias } = req.params; const { alias } = req.params;
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0]; const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
let config = await db.query(alias, let config = await db.query(alias,
'SELECT * FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId] 'SELECT * FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]
); );
const flagsPadrao = { motivoVisualizar: 'N', motivoObrigatorio: 'N', resolucaoVisualizar: 'N', resolucaoObrigatorio: 'N', enviarBoleto: 'N', resolucaoSalvarMSC: 'N' };
if (config.length === 0) { if (config.length === 0) {
const cfg = { empresaId, fotoCelular: 'N', saudacaoAtiva: 'S', saudacaoMensagem: '', csatAtivo: 'N', csatMensagem: '', enviarNomeUsuario: 'N', triagemAtiva: 'N', triagemMsgWelcome: '', triagemMsgAfter: '', triagemBoletoNumero: '0', instanciaPadraoId: null }; const cfg = Object.assign({ empresaId, fotoCelular: 'N', saudacaoAtiva: 'S', saudacaoMensagem: '', csatAtivo: 'N', csatMensagem: '', enviarNomeUsuario: 'N', triagemAtiva: 'N', triagemMsgWelcome: '', triagemMsgAfter: '', triagemBoletoNumero: '0', instanciaPadraoId: null }, flagsPadrao);
res.json({ success: true, data: cfg }); res.json({ success: true, data: cfg });
} else { } else {
const c = config[0]; const c = config[0];
@@ -174,17 +189,90 @@ class ConfigController {
triagemMsgWelcome: c.CFE_TRIAGEM_MSG_WELCOME || '', triagemMsgWelcome: c.CFE_TRIAGEM_MSG_WELCOME || '',
triagemMsgAfter: c.CFE_TRIAGEM_MSG_AFTER || '', triagemMsgAfter: c.CFE_TRIAGEM_MSG_AFTER || '',
triagemBoletoNumero: (c.CFE_TRIAGEM_BOLETO_NUMERO || '0').trim(), triagemBoletoNumero: (c.CFE_TRIAGEM_BOLETO_NUMERO || '0').trim(),
enviarBoleto: (c.CFE_ENVIAR_BOLETO || 'N').trim(),
// Fluxo de Resolução
motivoVisualizar: (c.CFE_MOTIVO_VISUALIZAR || 'N').trim(),
motivoObrigatorio: (c.CFE_MOTIVO_OBRIGATORIO || 'N').trim(),
resolucaoVisualizar: (c.CFE_RESOLUCAO_VISUALIZAR || 'N').trim(),
resolucaoObrigatorio: (c.CFE_RESOLUCAO_OBRIGATORIO || 'N').trim(),
resolucaoSalvarMSC: (c.CFE_RESOLUCAO_SALVAR_MSC || 'N').trim(),
}}); }});
} }
} catch (err) { res.status(500).json({ success: false, error: err.message }); } } catch (err) { res.status(500).json({ success: false, error: err.message }); }
} }
// ==================== FLUXO DE RESOLUÇÃO ====================
/** Salva as 4 flags do Fluxo de Resolução (sem tocar nas demais configs). */
static async saveResolucaoConfig(req, res) {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem alterar o fluxo de resolução.' });
const { alias } = req.params;
const empresaId = req.body.empresaId || req.user?.empresas?.[0];
const sn = (v) => (v === 'S' || v === true ? 'S' : 'N');
const mv = sn(req.body.motivoVisualizar), mo = sn(req.body.motivoObrigatorio);
const rv = sn(req.body.resolucaoVisualizar), ro = sn(req.body.resolucaoObrigatorio);
const exists = await db.query(alias, 'SELECT COUNT(*) AS T FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]);
if (exists[0].T > 0) {
await db.execute(alias,
`UPDATE CHATC2_CONFIGURACOES_EMPRESA SET CFE_MOTIVO_VISUALIZAR = ?, CFE_MOTIVO_OBRIGATORIO = ?,
CFE_RESOLUCAO_VISUALIZAR = ?, CFE_RESOLUCAO_OBRIGATORIO = ? WHERE CFE_EMPRESA_ID = ?`,
[mv, mo, rv, ro, empresaId]);
} else {
await db.execute(alias,
`INSERT INTO CHATC2_CONFIGURACOES_EMPRESA (CFE_EMPRESA_ID, CFE_MOTIVO_VISUALIZAR, CFE_MOTIVO_OBRIGATORIO, CFE_RESOLUCAO_VISUALIZAR, CFE_RESOLUCAO_OBRIGATORIO)
VALUES (?, ?, ?, ?, ?)`,
[empresaId, mv, mo, rv, ro]);
}
configService.invalidarConfig(alias, empresaId);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/** Lista os motivos de atendimento (leitura aberta — usada no chat). */
static async listMotivos(req, res) {
try {
const { alias } = req.params;
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
const rows = await db.query(alias,
`SELECT MOT_CODIGO_ID, MOT_DESCRICAO FROM "CHATC2_MOTIVOS_ATENDIMENTO"
WHERE MOT_EMPRESA_ID = ? AND MOT_SITUACAO = 'A' ORDER BY MOT_DESCRICAO`, [empresaId]);
res.json({ success: true, data: rows.map((m) => ({ id: m.MOT_CODIGO_ID, descricao: (m.MOT_DESCRICAO || '').trim() })) });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
static async createMotivo(req, res) {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem cadastrar motivos.' });
const { alias } = req.params;
const descricao = (req.body.descricao || '').trim();
if (!descricao) return res.status(400).json({ success: false, error: 'Descrição obrigatória.' });
const empresaId = req.body.empresaId || req.user?.empresas?.[0];
const newId = await db.nextId(alias, 'GEN_CHATC2_MOTIVOS_ATENDIMENTO');
await db.execute(alias,
`INSERT INTO "CHATC2_MOTIVOS_ATENDIMENTO" (MOT_CODIGO_ID, MOT_EMPRESA_ID, MOT_DESCRICAO, MOT_SITUACAO)
VALUES (?, ?, ?, 'A')`, [newId, empresaId, descricao]);
res.json({ success: true, data: { id: newId } });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
static async deleteMotivo(req, res) {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem remover motivos.' });
const { alias, id } = req.params;
await db.execute(alias, `UPDATE "CHATC2_MOTIVOS_ATENDIMENTO" SET MOT_SITUACAO = 'I' WHERE MOT_CODIGO_ID = ?`, [id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
static async saveCompanyConfig(req, res) { static async saveCompanyConfig(req, res) {
try { try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem alterar as configurações da empresa.' }); if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem alterar as configurações da empresa.' });
const { alias } = req.params; const { alias } = req.params;
const data = req.body; const data = req.body;
const empresaId = data.empresaId || req.user?.empresas?.[0]; const empresaId = data.empresaId || req.user?.empresas?.[0];
const enviarBoleto = (data.enviarBoleto === 'S' || data.enviarBoleto === true) ? 'S' : 'N';
const resolucaoSalvarMSC = (data.resolucaoSalvarMSC === 'S' || data.resolucaoSalvarMSC === true) ? 'S' : 'N';
// Upsert // Upsert
const exists = await db.query(alias, 'SELECT COUNT(*) AS T FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]); const exists = await db.query(alias, 'SELECT COUNT(*) AS T FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]);
@@ -195,18 +283,21 @@ class ConfigController {
CFE_INSTANCIA_PADRAO_ID = ?, CFE_FOTO_CELULAR = ?, CFE_SAUDACAO_ATIVA = ?, CFE_INSTANCIA_PADRAO_ID = ?, CFE_FOTO_CELULAR = ?, CFE_SAUDACAO_ATIVA = ?,
CFE_SAUDACAO_MENSAGEM = ?, CFE_CSAT_ATIVO = ?, CFE_CSAT_MENSAGEM = ?, CFE_SAUDACAO_MENSAGEM = ?, CFE_CSAT_ATIVO = ?, CFE_CSAT_MENSAGEM = ?,
CFE_ENVIAR_NOME_USUARIO = ?, CFE_TRIAGEM_ATIVA = ?, CFE_TRIAGEM_MSG_WELCOME = ?, CFE_ENVIAR_NOME_USUARIO = ?, CFE_TRIAGEM_ATIVA = ?, CFE_TRIAGEM_MSG_WELCOME = ?,
CFE_TRIAGEM_MSG_AFTER = ?, CFE_TRIAGEM_BOLETO_NUMERO = ? CFE_TRIAGEM_MSG_AFTER = ?, CFE_TRIAGEM_BOLETO_NUMERO = ?, CFE_ENVIAR_BOLETO = ?,
CFE_RESOLUCAO_SALVAR_MSC = ?
WHERE CFE_EMPRESA_ID = ? WHERE CFE_EMPRESA_ID = ?
`, [data.instanciaPadraoId || null, data.fotoCelular || 'N', data.saudacaoAtiva || 'S', data.saudacaoMensagem || '', data.csatAtivo || 'N', data.csatMensagem || '', data.enviarNomeUsuario || 'N', data.triagemAtiva || 'N', data.triagemMsgWelcome || '', data.triagemMsgAfter || '', data.triagemBoletoNumero || '0', empresaId]); `, [data.instanciaPadraoId || null, data.fotoCelular || 'N', data.saudacaoAtiva || 'S', data.saudacaoMensagem || '', data.csatAtivo || 'N', data.csatMensagem || '', data.enviarNomeUsuario || 'N', data.triagemAtiva || 'N', data.triagemMsgWelcome || '', data.triagemMsgAfter || '', data.triagemBoletoNumero || '0', enviarBoleto, resolucaoSalvarMSC, empresaId]);
} else { } else {
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONFIGURACOES_EMPRESA (CFE_EMPRESA_ID, CFE_INSTANCIA_PADRAO_ID, CFE_FOTO_CELULAR, INSERT INTO CHATC2_CONFIGURACOES_EMPRESA (CFE_EMPRESA_ID, CFE_INSTANCIA_PADRAO_ID, CFE_FOTO_CELULAR,
CFE_SAUDACAO_ATIVA, CFE_SAUDACAO_MENSAGEM, CFE_CSAT_ATIVO, CFE_CSAT_MENSAGEM, CFE_ENVIAR_NOME_USUARIO, CFE_SAUDACAO_ATIVA, CFE_SAUDACAO_MENSAGEM, CFE_CSAT_ATIVO, CFE_CSAT_MENSAGEM, CFE_ENVIAR_NOME_USUARIO,
CFE_TRIAGEM_ATIVA, CFE_TRIAGEM_MSG_WELCOME, CFE_TRIAGEM_MSG_AFTER, CFE_TRIAGEM_BOLETO_NUMERO) CFE_TRIAGEM_ATIVA, CFE_TRIAGEM_MSG_WELCOME, CFE_TRIAGEM_MSG_AFTER, CFE_TRIAGEM_BOLETO_NUMERO, CFE_ENVIAR_BOLETO,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) CFE_RESOLUCAO_SALVAR_MSC)
`, [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']); VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [empresaId, data.instanciaPadraoId || null, data.fotoCelular || 'N', data.saudacaoAtiva || 'S', data.saudacaoMensagem || '', data.csatAtivo || 'N', data.csatMensagem || '', data.enviarNomeUsuario || 'N', data.triagemAtiva || 'N', data.triagemMsgWelcome || '', data.triagemMsgAfter || '', data.triagemBoletoNumero || '0', enviarBoleto, resolucaoSalvarMSC]);
} }
configService.invalidarConfig(alias, empresaId);
res.json({ success: true }); res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); } } catch (err) { res.status(500).json({ success: false, error: err.message }); }
} }
+131
View File
@@ -0,0 +1,131 @@
const db = require('../database');
const { isGerente } = require('../middlewares/roles');
const presence = require('../presence');
/**
* Resolve a lista de empresas para a consulta, respeitando a permissão do
* usuário. Se vier empresaId e o usuário tiver acesso, usa ela; senão usa
* todas as empresas do usuário.
*/
function empresasPermitidas(req) {
const minhas = (req.user && req.user.empresas) || [];
const pedida = parseInt(req.query.empresaId, 10);
if (pedida && minhas.includes(pedida)) return [pedida];
return minhas;
}
class DashboardController {
/**
* Ping de presença marca o atendente como online.
* POST /api/:alias/dashboard/ping
*/
static async ping(req, res) {
const { alias } = req.params;
if (req.user && req.user.id != null) presence.registrar(alias, req.user.id);
res.json({ success: true });
}
/**
* Estatísticas do dashboard (apenas gerente).
* GET /api/:alias/dashboard/stats?empresaId=
*/
static async stats(req, res) {
try {
if (!(await isGerente(req))) {
return res.status(403).json({ success: false, error: 'Apenas gerentes podem visualizar o dashboard.' });
}
const { alias } = req.params;
const empresas = empresasPermitidas(req);
if (empresas.length === 0) {
return res.json({ success: true, data: {
conversas: { abertas: 0, naoAtendidas: 0, naoAtribuidas: 0, pendentes: 0 },
atendentes: { disponiveis: 0, desconectados: 0, lista: [] },
trafego: [],
} });
}
const ph = empresas.map(() => '?').join(',');
// ===== Conversas =====
// Abertas: têm atendente E equipe
const abertas = await db.query(alias,
`SELECT COUNT(*) AS CT FROM CHATC2_CONVERSAS
WHERE CON_EMPRESA_ID IN (${ph}) AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A'
AND CON_USUARIO_ID IS NOT NULL AND CON_EQUIPE_ID IS NOT NULL`, empresas);
// Não atendidas: têm atendente+equipe e a última mensagem foi do cliente ('C')
const naoAtendidas = await db.query(alias,
`SELECT COUNT(*) AS CT FROM CHATC2_CONVERSAS c
WHERE c.CON_EMPRESA_ID IN (${ph}) AND c.CON_STATUS IN ('A','E') AND c.CON_SITUACAO = 'A'
AND c.CON_USUARIO_ID IS NOT NULL AND c.CON_EQUIPE_ID IS NOT NULL
AND (SELECT m.CME_REMETENTE FROM CHATC2_CONVERSAS_MENSAGENS m
WHERE m.CME_CONVERSA_ID = c.CON_CODIGO_ID
ORDER BY m.CME_DT_ENVIO DESC FETCH FIRST 1 ROWS ONLY) = 'C'`, empresas);
// Não atribuídas: sem atendente e sem equipe
const naoAtribuidas = await db.query(alias,
`SELECT COUNT(*) AS CT FROM CHATC2_CONVERSAS
WHERE CON_EMPRESA_ID IN (${ph}) AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A'
AND CON_USUARIO_ID IS NULL AND CON_EQUIPE_ID IS NULL`, empresas);
// Pendentes: têm apenas equipe (sem atendente)
const pendentes = await db.query(alias,
`SELECT COUNT(*) AS CT FROM CHATC2_CONVERSAS
WHERE CON_EMPRESA_ID IN (${ph}) AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A'
AND CON_USUARIO_ID IS NULL AND CON_EQUIPE_ID IS NOT NULL`, empresas);
// Agendadas para hoje (status P = Pendente)
const agendadas = await db.query(alias,
`SELECT COUNT(*) AS CT FROM CHATC2_CONVERSAS_AGENDAMENTOS
WHERE CAG_EMPRESA_ID IN (${ph}) AND CAG_DATA = CURRENT_DATE AND CAG_STATUS = 'P'`,
empresas);
// ===== Atendentes =====
const usuarios = await db.query(alias,
`SELECT DISTINCT u.USU_CODIGO_ID, u.USU_NOME
FROM USUARIOS u INNER JOIN USUARIOS_EMPRESA ue ON u.USU_CODIGO_ID = ue.USE_USUARIO_ID
WHERE ue.USE_EMPRESA_ID IN (${ph}) AND u.USU_STATUS = 'A' AND COALESCE(u.USU_ACESSO_WEB,0) = 1
ORDER BY u.USU_NOME`, empresas);
const online = presence.idsOnline(alias);
let disponiveis = 0, desconectados = 0;
const lista = usuarios.map(function (u) {
const on = online.has(Number(u.USU_CODIGO_ID));
if (on) disponiveis++; else desconectados++;
return { id: u.USU_CODIGO_ID, nome: (u.USU_NOME || '').trim(), online: on };
});
// ===== Tráfego das conversas (últimos 365 dias, por dia) =====
const trafegoRows = await db.query(alias,
`SELECT CAST(COALESCE(CON_DT_INICIO, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG) AS DATE) AS DIA, COUNT(*) AS CT
FROM CHATC2_CONVERSAS
WHERE CON_EMPRESA_ID IN (${ph})
AND COALESCE(CON_DT_INICIO, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG) >= CURRENT_DATE - 365
GROUP BY CAST(COALESCE(CON_DT_INICIO, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG) AS DATE)`,
empresas);
const fmtDia = (d) => {
if (!d) return null;
if (typeof d === 'string') return d.split('T')[0];
if (d instanceof Date) return d.toISOString().split('T')[0];
return String(d);
};
const trafego = trafegoRows
.map(function (r) { return { dia: fmtDia(r.DIA), total: Number(r.CT) || 0 }; })
.filter(function (r) { return r.dia; });
res.json({ success: true, data: {
conversas: {
abertas: Number(abertas[0]?.CT) || 0,
naoAtendidas: Number(naoAtendidas[0]?.CT) || 0,
naoAtribuidas: Number(naoAtribuidas[0]?.CT) || 0,
pendentes: Number(pendentes[0]?.CT) || 0,
agendadas: Number(agendadas[0]?.CT) || 0,
},
atendentes: { disponiveis, desconectados, lista },
trafego,
} });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
}
module.exports = DashboardController;
+20 -50
View File
@@ -89,59 +89,29 @@ class DatabaseController {
return res.status(400).json({ success: false, error: 'database é obrigatório.' }); return res.status(400).json({ success: false, error: 'database é obrigatório.' });
} }
var driver = (req.body.driver || DEFAULT_DRIVER).toLowerCase(); var Firebird = require('node-firebird');
var config = {
if (driver === 'postgres') { host: req.body.host || 'localhost',
var pg = require('pg'); port: req.body.port || 3050,
var schema = String(req.body.schema || 'public').trim(); database: req.body.database,
var safe = /^[A-Za-z_][A-Za-z0-9_$]*$/.test(schema) ? schema : 'public'; user: req.body.user || 'SYSDBA',
var searchPath = safe === 'public' ? 'public' : safe + ',public'; password: req.body.password || 'masterkey',
var client = new pg.Client({ encoding: 'UTF-8',
host: req.body.host || '127.0.0.1', lowercase_keys: false,
port: req.body.port || 5432, pageSize: 4096,
database: req.body.database, };
user: req.body.user || 'postgres', await new Promise(function (resolve, reject) {
password: req.body.password || 'postgres', Firebird.attach(config, function (err, conn) {
ssl: req.body.ssl ? { rejectUnauthorized: false } : false, if (err) return reject(new Error('Falha ao conectar: ' + err.message));
options: '-c search_path=' + searchPath, conn.query('SELECT 1 AS TEST FROM RDB$DATABASE', [], function (qErr, rows) {
connectionTimeoutMillis: 8000, conn.detach();
}); if (qErr) return reject(new Error('Falha na consulta: ' + qErr.message));
try { resolve(rows);
await client.connect();
await client.query('SELECT 1');
// Confirma que o schema existe
var sc = await client.query('SELECT 1 FROM information_schema.schemata WHERE schema_name = $1', [safe]);
if (sc.rowCount === 0) {
throw new Error('Schema "' + safe + '" não encontrado no banco "' + req.body.database + '".');
}
} finally {
await client.end().catch(function () {});
}
} else {
var Firebird = require('node-firebird');
var config = {
host: req.body.host || 'localhost',
port: req.body.port || 3050,
database: req.body.database,
user: req.body.user || 'SYSDBA',
password: req.body.password || 'masterkey',
encoding: 'UTF-8',
lowercase_keys: false,
pageSize: 4096,
};
await new Promise(function (resolve, reject) {
Firebird.attach(config, function (err, conn) {
if (err) return reject(new Error('Falha ao conectar: ' + err.message));
conn.query('SELECT 1 AS TEST FROM RDB$DATABASE', [], function (qErr, rows) {
conn.detach();
if (qErr) return reject(new Error('Falha na consulta: ' + qErr.message));
resolve(rows);
});
}); });
}); });
} });
res.json({ success: true, message: '✅ Conexão (' + driver + ') estabelecida com sucesso!' }); res.json({ success: true, message: '✅ Conexão (firebird) estabelecida com sucesso!' });
} catch (err) { } catch (err) {
res.status(500).json({ success: false, error: err.message }); res.status(500).json({ success: false, error: err.message });
} }
+1 -1
View File
@@ -38,7 +38,7 @@ class EmpresaController {
} catch (fe) { /* ignora e tenta o BLOB */ } } catch (fe) { /* ignora e tenta o BLOB */ }
} }
// 2. BLOB EMP_FOTO (Buffer no postgres/firebird; string base64 em alguns casos) // 2. BLOB EMP_FOTO (Buffer no firebird; string base64 em alguns casos)
const blobData = e.EMP_FOTO; const blobData = e.EMP_FOTO;
let base64; let base64;
if (Buffer.isBuffer(blobData)) { if (Buffer.isBuffer(blobData)) {
+314 -179
View File
@@ -3,6 +3,31 @@ const https = require('https');
const http = require('http'); const http = require('http');
const { listAliases } = db; const { listAliases } = db;
const TriageController = require('./triageController'); const TriageController = require('./triageController');
const mediaService = require('../services/mediaService');
// ponytail: anti-loop — limita respostas automáticas por conversa
const triagemRateLimit = new Map();
const TRIAGEM_MAX = 5; // max respostas auto em 60s
const TRIAGEM_WINDOW = 60000; // janela de 60 segundos
function triagemExcedeu(conversaId) {
const agora = Date.now();
const entry = triagemRateLimit.get(conversaId);
if (!entry || agora - entry.inicio > TRIAGEM_WINDOW) {
triagemRateLimit.set(conversaId, { count: 1, inicio: agora });
return false;
}
entry.count++;
return entry.count > TRIAGEM_MAX;
}
// Limpeza periódica
setInterval(() => {
const agora = Date.now();
for (const [k, v] of triagemRateLimit) {
if (agora - v.inicio > TRIAGEM_WINDOW * 2) triagemRateLimit.delete(k);
}
}, 60000).unref();
// Cache de instancia -> alias para evitar consultas repetidas // Cache de instancia -> alias para evitar consultas repetidas
var cacheInstanciaAlias = {}; var cacheInstanciaAlias = {};
@@ -217,8 +242,7 @@ class EvolutionController {
return res.status(403).json({ success: false, error: 'Sem permissão para esta empresa.' }); return res.status(403).json({ success: false, error: 'Sem permissão para esta empresa.' });
} }
const maxId = await db.query(alias, 'SELECT MAX(INS_CODIGO_ID) AS ID FROM CHATC2_INSTANCIAS'); const newId = await db.nextId(alias, 'GEN_CHATC2_INSTANCIAS');
const newId = (maxId[0]?.ID || 0) + 1;
// Tenta criar a instância na Evolution // Tenta criar a instância na Evolution
let instanceCreated = false; let instanceCreated = false;
@@ -242,18 +266,18 @@ class EvolutionController {
}); });
instanceCreated = true; instanceCreated = true;
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_INSTANCIAS (INS_CODIGO_ID, INS_EMPRESA_ID, INS_NOME, INS_URL, INS_API_KEY, INS_INSTANCE_NAME, INS_STATUS) INSERT INTO CHATC2_INSTANCIAS (INS_CODIGO_ID, INS_EMPRESA_ID, INS_NOME, INS_URL, INS_API_KEY, INS_INSTANCE_NAME, INS_STATUS, INS_SITUACAO)
VALUES (?, ?, ?, ?, ?, ?, 'C') VALUES (?, ?, ?, ?, ?, ?, 'C', 'A')
`, [newId, INS_EMPRESA_ID || userEmpresas[0], INS_NOME, INS_URL, INS_API_KEY, INS_INSTANCE_NAME]); `, [newId, INS_EMPRESA_ID || userEmpresas[0], INS_NOME, INS_URL, INS_API_KEY, INS_INSTANCE_NAME]);
break; break;
} catch (e) { /* Tenta próximo endpoint */ } } catch (e) { /* Tenta próximo endpoint */ }
} }
if (!instanceCreated) { if (!instanceCreated) {
// Salva mesmo sem conseguir criar na Evolution // Salva mesmo sem conseguir criar na Evolution (status 'D' = desconectado)
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_INSTANCIAS (INS_CODIGO_ID, INS_EMPRESA_ID, INS_NOME, INS_URL, INS_API_KEY, INS_INSTANCE_NAME, INS_STATUS) INSERT INTO CHATC2_INSTANCIAS (INS_CODIGO_ID, INS_EMPRESA_ID, INS_NOME, INS_URL, INS_API_KEY, INS_INSTANCE_NAME, INS_STATUS, INS_SITUACAO)
VALUES (?, ?, ?, ?, ?, ?, 'D') VALUES (?, ?, ?, ?, ?, ?, 'D', 'A')
`, [newId, INS_EMPRESA_ID || userEmpresas[0], INS_NOME, INS_URL, INS_API_KEY, INS_INSTANCE_NAME]); `, [newId, INS_EMPRESA_ID || userEmpresas[0], INS_NOME, INS_URL, INS_API_KEY, INS_INSTANCE_NAME]);
} }
@@ -263,6 +287,27 @@ class EvolutionController {
} }
} }
/**
* Listar instâncias de uma empresa
/**
* Busca instâncias conectadas no servidor Evolution.
* POST /api/:alias/evolution/fetch-instances
*/
static async fetchInstances(req, res) {
try {
const { url, apiKey } = req.body;
if (!url || !apiKey) return res.status(400).json({ success: false, error: 'URL e API Key obrigatórios.' });
const result = await evolutionRequest(url, apiKey, '/instance/fetchInstances', 'GET');
if (Array.isArray(result)) {
res.json({ success: true, data: result.map(r => ({ instanceName: r.instanceName || r.name, connectionStatus: r.connectionStatus || r.status })) });
} else if (result && result.instance) {
res.json({ success: true, data: [{ instanceName: result.instance.instanceName || result.instance.name, connectionStatus: result.instance.connectionStatus || result.instance.status }] });
} else {
res.json({ success: true, data: [] });
}
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/** /**
* Listar instâncias de uma empresa * Listar instâncias de uma empresa
* GET /api/:alias/evolution/instances?empresaId= * GET /api/:alias/evolution/instances?empresaId=
@@ -270,8 +315,21 @@ class EvolutionController {
static async listInstances(req, res) { static async listInstances(req, res) {
try { try {
const { alias } = req.params; const { alias } = req.params;
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
if (!req.user?.empresas?.includes(empresaId)) return res.status(403).json({ success: false, error: 'Sem permissão.' }); // Garante que userEmpresas está carregado (fallback para DB se JWT não tiver)
let userEmpresas = req.user?.empresas || [];
if (userEmpresas.length === 0 && req.user?.id) {
try {
const empresasDb = await db.query(alias,
'SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA WHERE USE_USUARIO_ID = ?',
[req.user.id]
);
userEmpresas = empresasDb.map(e => e.USE_EMPRESA_ID);
} catch (e) { /* ignora falha */ }
}
const empresaId = parseInt(req.query.empresaId) || userEmpresas[0];
if (!userEmpresas.includes(empresaId)) return res.status(403).json({ success: false, error: 'Sem permissão.' });
const result = await db.query(alias, const result = await db.query(alias,
'SELECT * FROM CHATC2_INSTANCIAS WHERE INS_EMPRESA_ID = ? AND INS_SITUACAO = \'A\' ORDER BY INS_NOME', 'SELECT * FROM CHATC2_INSTANCIAS WHERE INS_EMPRESA_ID = ? AND INS_SITUACAO = \'A\' ORDER BY INS_NOME',
@@ -440,10 +498,10 @@ class EvolutionController {
// Reaproveita a funcao de atualizar foto // Reaproveita a funcao de atualizar foto
var numero = conv[0].CON_NUMERO || ''; var numero = conv[0].CON_NUMERO || '';
var empresaId = conv[0].CON_EMPRESA_ID; var empresaId = conv[0].CON_EMPRESA_ID;
// A funcao atualizarFotoContato esta no escopo do processWebhook, entao recriamos a logica aqui // Ação explícita do usuário: força a busca (ignora a flag CFE_FOTO_CELULAR)
await EvolutionController.atualizarFotoContato(alias, conv[0].CON_CLIENTE_ID, numero, empresaId); const atualizou = await EvolutionController.atualizarFotoContato(alias, conv[0].CON_CLIENTE_ID, numero, empresaId, true);
res.json({ success: true, message: 'Foto atualizada.' }); res.json({ success: true, atualizada: !!atualizou, message: atualizou ? 'Foto atualizada.' : 'Nenhuma foto encontrada para este contato.' });
} catch (err) { } catch (err) {
res.status(500).json({ success: false, error: err.message }); res.status(500).json({ success: false, error: err.message });
} }
@@ -452,12 +510,14 @@ class EvolutionController {
/** /**
* Versão estática de atualizarFotoContato para uso externo * Versão estática de atualizarFotoContato para uso externo
*/ */
static async atualizarFotoContato(alias, clienteId, numero, empresaId) { static async atualizarFotoContato(alias, clienteId, numero, empresaId, forcar) {
if (!clienteId) return; if (!clienteId) return false;
try { try {
const cfg = await db.query(alias, if (!forcar) {
"SELECT CFE_FOTO_CELULAR FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?", [empresaId]); const cfg = await db.query(alias,
if (cfg.length === 0 || cfg[0].CFE_FOTO_CELULAR !== 'S') return; "SELECT CFE_FOTO_CELULAR FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?", [empresaId]);
if (cfg.length === 0 || cfg[0].CFE_FOTO_CELULAR !== 'S') return false;
}
const inst = await db.query(alias, const inst = await db.query(alias,
"SELECT * FROM CHATC2_INSTANCIAS WHERE INS_EMPRESA_ID = ? AND INS_SITUACAO = 'A' FETCH FIRST 1 ROWS ONLY", [empresaId]); "SELECT * FROM CHATC2_INSTANCIAS WHERE INS_EMPRESA_ID = ? AND INS_SITUACAO = 'A' FETCH FIRST 1 ROWS ONLY", [empresaId]);
@@ -468,29 +528,34 @@ class EvolutionController {
const instanceName = (inst[0].INS_INSTANCE_NAME || '').trim(); const instanceName = (inst[0].INS_INSTANCE_NAME || '').trim();
if (!url || !apiKey || !instanceName) return; if (!url || !apiKey || !instanceName) return;
// Evolution API 2.4.0 NAO possui endpoint getProfile (disponivel apenas a partir da v2.5+) // Busca a foto de perfil via Evolution: POST <URL>/chat/fetchProfile/<instancia> { number }
console.log('[Foto] Evolution API ' + url + ' - getProfile nao disponivel nesta versao. A foto do WhatsApp sera usada quando atualizar a Evolution.');
// Mesmo sem o endpoint, tentamos alguns formatos conhecidos // Normaliza o numero para o padrao WhatsApp BR: 55(DDI) + DDD + 9XXXXXXXX
var profileEndpoints = [ function normalizarNumero(n) {
'/chat/getProfile/' + encodeURIComponent(instanceName) + '?number=' + numero, var d = String(n || '').replace(/\D/g, '');
'/chat/getProfile/' + encodeURIComponent(instanceName) + '/' + numero, if (!d) return '';
'/contact/getProfile/' + encodeURIComponent(instanceName) + '?number=' + numero, if (!d.startsWith('55')) d = '55' + d; // garante DDI Brasil
]; var resto = d.slice(2); // DDD + assinante
if (resto.length === 10) { // DDD(2) + 8 digitos -> insere o 9 (celular)
var profile = null; resto = resto.slice(0, 2) + '9' + resto.slice(2);
for (var ep of profileEndpoints) {
try {
profile = await evolutionRequest(url, apiKey, ep, 'GET');
if (profile) break;
} catch(e) {
profile = null;
} }
return '55' + resto;
}
var numeroFmt = normalizarNumero(numero);
if (!numeroFmt) return false;
var profile = null;
try {
profile = await evolutionRequest(url, apiKey,
'/chat/fetchProfile/' + encodeURIComponent(instanceName), 'POST', { number: numeroFmt });
} catch (e) {
console.log('[Foto] fetchProfile falhou:', (e.message || '').substring(0, 120));
return false;
} }
if (!profile) { if (!profile) {
console.log('[Foto] Nenhum endpoint getProfile disponivel. Evolution API precisa ser atualizada.'); console.log('[Foto] Nenhum perfil retornado para', numeroFmt);
return; return false;
} }
let fotoUrl = null; let fotoUrl = null;
@@ -505,31 +570,45 @@ class EvolutionController {
fotoUrl = profile.response.profilePicUrl || profile.response.picUrl || null; fotoUrl = profile.response.profilePicUrl || profile.response.picUrl || null;
} }
if (fotoUrl && fotoUrl.startsWith('http')) { if (fotoUrl && String(fotoUrl).startsWith('http')) {
console.log('[Foto] Baixando foto de:', fotoUrl.substring(0, 80)); console.log('[Foto] Baixando foto de:', String(fotoUrl).substring(0, 80));
try { try {
var http = require('http');
var https = require('https');
var fotoBuffer = await new Promise(function(resolve, reject) { var fotoBuffer = await new Promise(function(resolve, reject) {
var u = new URL(fotoUrl); var u = new URL(fotoUrl);
var lib = u.protocol === 'https:' ? https : http; var lib = u.protocol === 'https:' ? https : http;
lib.get(fotoUrl, { timeout: 15000, headers: { 'apikey': apiKey } }, function(imgRes) { lib.get(fotoUrl, { timeout: 15000 }, function(imgRes) {
// Valida Content-Type
var ct = (imgRes.headers['content-type'] || '').toLowerCase();
if (ct && !ct.startsWith('image/')) {
console.log('[Foto] Content-Type invalido:', ct, '- recusando');
return reject(new Error('Not an image: ' + ct));
}
var chunks = []; var chunks = [];
imgRes.on('data', function(c) { chunks.push(c); }); imgRes.on('data', function(c) { chunks.push(c); });
imgRes.on('end', function() { resolve(Buffer.concat(chunks)); }); imgRes.on('end', function() { resolve(Buffer.concat(chunks)); });
}).on('error', reject).on('timeout', function() { this.destroy(); reject(new Error('Timeout')); }); }).on('error', reject).on('timeout', function() { this.destroy(); reject(new Error('Timeout')); });
}); });
if (fotoBuffer && fotoBuffer.length > 100) { // Valida buffer: tamanho realista (500B a 2MB) e header de imagem
var imgData = fotoBuffer.toString('base64'); if (fotoBuffer && fotoBuffer.length > 500 && fotoBuffer.length < 2 * 1024 * 1024) {
if (!mediaService.validarHeaderMidia(fotoBuffer, 'image/jpeg')) {
console.log('[Foto] Buffer nao tem header de imagem valido - recusando. Header:', fotoBuffer.slice(0, 4).toString('hex'));
return false;
}
await db.execute(alias, await db.execute(alias,
'UPDATE CLIENTES SET CLI_FOTO = ? WHERE CLI_CODIGO_ID = ?', 'UPDATE CLIENTES SET CLI_FOTO = ? WHERE CLI_CODIGO_ID = ?',
[imgData, clienteId]); [fotoBuffer.toString('base64'), clienteId]);
console.log('[Foto] Foto atualizada para cliente', clienteId, '- tamanho:', fotoBuffer.length); console.log('[Foto] Foto atualizada para cliente', clienteId, '- bytes:', fotoBuffer.length);
return true;
} else if (fotoBuffer) {
console.log('[Foto] Buffer com tamanho invalido:', fotoBuffer ? fotoBuffer.length : 0);
} }
} catch(imgErr) { } catch(imgErr) {
console.error('[Foto] Erro ao baixar imagem:', imgErr.message.substring(0, 100)); console.error('[Foto] Erro ao baixar imagem:', (imgErr.message || '').substring(0, 120));
} }
} else {
console.log('[Foto] Perfil sem foto disponivel para', numeroFmt);
} }
return false;
} catch(e) { } catch(e) {
console.error('[Foto] Erro:', e.message); console.error('[Foto] Erro:', e.message);
} }
@@ -629,6 +708,75 @@ class EvolutionController {
} }
} }
/**
* Busca um cliente pelo número de telefone em TODOS os aliases disponíveis.
* Tenta primeiro o alias principal (mais rápido), depois os demais.
* Retorna { cliente, alias } ou null se não encontrado em nenhum banco.
*/
async function buscarClienteGlobal(numero, empresaId, aliasPrincipal) {
const todosAliases = db.listAliases();
const termo1 = numero;
const termo2 = numero.startsWith('55') ? numero.substring(2) : numero;
const ultimosDigitos = numero.slice(-8);
// Busca primeiro no alias principal (cache quente), depois nos outros
const aliasesOrdenados = [aliasPrincipal];
for (const a of todosAliases) {
if (a !== aliasPrincipal) aliasesOrdenados.push(a);
}
for (const aliasAtual of aliasesOrdenados) {
try {
// Tenta com número completo (DDI 55)
let clientes = await db.query(aliasAtual,
`SELECT CLI_CODIGO_ID, CLI_NOME FROM CLIENTES
WHERE (REPLACE(REPLACE(REPLACE(COALESCE(CLI_CELULAR,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE1,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE2,''),'-',''),'(',''),')','') LIKE '%' || ? || '%')
AND CLI_EMPRESA_ID = ?`,
[termo1, termo1, termo1, empresaId]
);
// Tenta sem DDI
if (clientes.length === 0 && termo2 !== termo1) {
clientes = await db.query(aliasAtual,
`SELECT CLI_CODIGO_ID, CLI_NOME FROM CLIENTES
WHERE (REPLACE(REPLACE(REPLACE(COALESCE(CLI_CELULAR,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE1,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE2,''),'-',''),'(',''),')','') LIKE '%' || ? || '%')
AND CLI_EMPRESA_ID = ?`,
[termo2, termo2, termo2, empresaId]
);
}
// Último recurso: últimos 8 dígitos
if (clientes.length === 0) {
clientes = await db.query(aliasAtual,
`SELECT CLI_CODIGO_ID, CLI_NOME FROM CLIENTES
WHERE (REPLACE(REPLACE(REPLACE(COALESCE(CLI_CELULAR,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE1,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE2,''),'-',''),'(',''),')','') LIKE '%' || ? || '%')
AND CLI_EMPRESA_ID = ?`,
[ultimosDigitos, ultimosDigitos, ultimosDigitos, empresaId]
);
}
if (clientes.length > 0) {
console.log('[Webhook] Cliente encontrado no alias:', aliasAtual, '| ID:', clientes[0].CLI_CODIGO_ID);
return { cliente: clientes[0], alias: aliasAtual };
}
} catch (e) {
// Alias offline ou com erro — pula para o próximo
if (e.message && !e.message.includes('não encontrado')) {
console.log('[Webhook] Alias', aliasAtual, 'indisponível:', e.message.substring(0, 60));
}
}
}
console.log('[Webhook] Cliente NÃO encontrado em nenhum alias para número:', numero);
return null;
}
async function processWebhook(alias, body) { async function processWebhook(alias, body) {
try { try {
const data = body.data || body; const data = body.data || body;
@@ -677,7 +825,8 @@ async function processWebhook(alias, body) {
// Busca conversa existente pelo número exato ou pelos últimos 8 dígitos // Busca conversa existente pelo número exato ou pelos últimos 8 dígitos
let conversations = await db.query(alias, let conversations = await db.query(alias,
`SELECT CON_CODIGO_ID, CON_STATUS, CON_CLIENTE_ID, CON_SAUDACAO_ENVIADA, CON_CSAT_ENVIADO `SELECT CON_CODIGO_ID, CON_STATUS, CON_CLIENTE_ID, CON_SAUDACAO_ENVIADA, CON_CSAT_ENVIADO,
CON_MENU_ESTADO, CON_USUARIO_ID, CON_EQUIPE_ID
FROM CHATC2_CONVERSAS WHERE CON_NUMERO = ? AND CON_EMPRESA_ID = ? FROM CHATC2_CONVERSAS WHERE CON_NUMERO = ? AND CON_EMPRESA_ID = ?
AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A' AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A'
ORDER BY CON_DT_INICIO DESC`, ORDER BY CON_DT_INICIO DESC`,
@@ -687,7 +836,8 @@ async function processWebhook(alias, body) {
if (conversations.length === 0) { if (conversations.length === 0) {
const ultimos8 = numero.slice(-8); const ultimos8 = numero.slice(-8);
conversations = await db.query(alias, conversations = await db.query(alias,
`SELECT CON_CODIGO_ID, CON_STATUS, CON_CLIENTE_ID, CON_SAUDACAO_ENVIADA, CON_CSAT_ENVIADO `SELECT CON_CODIGO_ID, CON_STATUS, CON_CLIENTE_ID, CON_SAUDACAO_ENVIADA, CON_CSAT_ENVIADO,
CON_MENU_ESTADO, CON_USUARIO_ID, CON_EQUIPE_ID
FROM CHATC2_CONVERSAS WHERE CON_NUMERO LIKE '%' || ? || '%' AND CON_EMPRESA_ID = ? FROM CHATC2_CONVERSAS WHERE CON_NUMERO LIKE '%' || ? || '%' AND CON_EMPRESA_ID = ?
AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A' AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A'
ORDER BY CON_DT_INICIO DESC`, ORDER BY CON_DT_INICIO DESC`,
@@ -698,80 +848,95 @@ async function processWebhook(alias, body) {
let conversa; let conversa;
var isNovaConversa = false; var isNovaConversa = false;
if (conversations.length === 0) { if (conversations.length === 0) {
// Antes de criar nova, verifica se há conversa finalizada COM agendamento pendente
// Se o cliente retornou antes da data agendada, reabre a conversa existente
const agendada = await db.query(alias, `
SELECT c.CON_CODIGO_ID, c.CON_CLIENTE_ID, c.CON_NOME_CONTATO, c.CON_INSTANCIA_ID,
a.CAG_CODIGO_ID AS AG_ID, a.CAG_NOTA
FROM CHATC2_CONVERSAS c
JOIN CHATC2_CONVERSAS_AGENDAMENTOS a ON a.CAG_CONVERSA_ID = c.CON_CODIGO_ID
WHERE c.CON_NUMERO = ? AND c.CON_EMPRESA_ID = ?
AND c.CON_STATUS = 'F' AND c.CON_SITUACAO = 'A'
AND a.CAG_STATUS = 'P'
ORDER BY a.CAG_DATA DESC
FETCH FIRST 1 ROWS ONLY
`, [numero, empresaId]);
// Tenta também pelos últimos 8 dígitos
let agendada8 = [];
if (agendada.length === 0) {
const ultimos8 = numero.slice(-8);
agendada8 = await db.query(alias, `
SELECT c.CON_CODIGO_ID, c.CON_CLIENTE_ID, c.CON_NOME_CONTATO, c.CON_INSTANCIA_ID,
a.CAG_CODIGO_ID AS AG_ID, a.CAG_NOTA
FROM CHATC2_CONVERSAS c
JOIN CHATC2_CONVERSAS_AGENDAMENTOS a ON a.CAG_CONVERSA_ID = c.CON_CODIGO_ID
WHERE c.CON_NUMERO LIKE '%' || ? || '%' AND c.CON_EMPRESA_ID = ?
AND c.CON_STATUS = 'F' AND c.CON_SITUACAO = 'A'
AND a.CAG_STATUS = 'P'
ORDER BY a.CAG_DATA DESC
FETCH FIRST 1 ROWS ONLY
`, [ultimos8, empresaId]);
}
const agReabrir = agendada.length > 0 ? agendada[0] : (agendada8.length > 0 ? agendada8[0] : null);
if (agReabrir) {
// Reabre a conversa agendada em vez de criar nova
console.log('[Webhook] Reabrindo conversa agendada:', agReabrir.CON_CODIGO_ID, 'agendamento:', agReabrir.AG_ID);
await db.execute(alias,
"UPDATE CHATC2_CONVERSAS SET CON_STATUS = 'A', CON_DT_FINAL = NULL, CON_DT_ULTIMA_MSG = CURRENT_TIMESTAMP WHERE CON_CODIGO_ID = ?",
[agReabrir.CON_CODIGO_ID]);
await db.execute(alias,
"UPDATE CHATC2_CONVERSAS_AGENDAMENTOS SET CAG_STATUS = 'R' WHERE CAG_CODIGO_ID = ?",
[agReabrir.AG_ID]);
conversa = await db.query(alias, 'SELECT * FROM CHATC2_CONVERSAS WHERE CON_CODIGO_ID = ?', [agReabrir.CON_CODIGO_ID]);
conversa = conversa[0];
// Se o cliente não estava vinculado, tenta vincular agora
if (!conversa.CON_CLIENTE_ID) {
const resultadoGlobal = await buscarClienteGlobal(numero, empresaId, alias);
if (resultadoGlobal) {
await db.execute(alias,
'UPDATE CHATC2_CONVERSAS SET CON_CLIENTE_ID = ?, CON_NOME_CONTATO = ? WHERE CON_CODIGO_ID = ?',
[resultadoGlobal.cliente.CLI_CODIGO_ID, resultadoGlobal.cliente.CLI_NOME.trim(), conversa.CON_CODIGO_ID]);
}
}
// Pula o bloco de criação (já temos conversa)
} else {
isNovaConversa = true; isNovaConversa = true;
const maxId = await db.query(alias, 'SELECT MAX(CON_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS'); const newId = await db.nextId(alias, 'GEN_CHATC2_CONVERSAS');
const newId = (maxId[0]?.ID || 0) + 1;
const nomeContato = data.pushName || data.notifyName || sender || ''; const nomeContato = data.pushName || data.notifyName || sender || '';
// Busca cliente pelo número em múltiplos campos de telefone // Busca cliente pelo número em TODOS os aliases (multi-banco)
// Tenta com o número completo (com DDI 55) e também sem o DDI
let clienteId = null; let clienteId = null;
let nomeCliente = null; let nomeCliente = null;
var aliasEfetivo = alias; // alias onde o cliente foi encontrado (pode ser diferente)
// Atualiza foto do contato se configurado (usa metodo estatico)
// A funcao local foi movida para EvolutionController.atualizarFotoContato
const termo1 = numero; // 5582996258669
const termo2 = numero.startsWith('55') ? numero.substring(2) : numero; // 8299625869
const clientes = await db.query(alias, const resultadoGlobal = await buscarClienteGlobal(numero, empresaId, alias);
`SELECT CLI_CODIGO_ID, CLI_NOME FROM CLIENTES if (resultadoGlobal) {
WHERE (REPLACE(REPLACE(REPLACE(COALESCE(CLI_CELULAR,''),'-',''),'(',''),')','') LIKE '%' || ? || '%' clienteId = resultadoGlobal.cliente.CLI_CODIGO_ID;
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE1,''),'-',''),'(',''),')','') LIKE '%' || ? || '%' nomeCliente = (resultadoGlobal.cliente.CLI_NOME || '').trim();
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE2,''),'-',''),'(',''),')','') LIKE '%' || ? || '%') aliasEfetivo = resultadoGlobal.alias;
AND CLI_EMPRESA_ID = ?`, // ponytail: CLI_FOTO desativado (causa erro no ERP)
[termo1, termo1, termo1, empresaId] // EvolutionController.atualizarFotoContato(aliasEfetivo, clienteId, numero, empresaId).catch(function() {});
);
// Se não achou com DDI, tenta sem DDI
if (clientes.length === 0 && termo2 !== termo1) {
const clientes2 = await db.query(alias,
`SELECT CLI_CODIGO_ID, CLI_NOME FROM CLIENTES
WHERE (REPLACE(REPLACE(REPLACE(COALESCE(CLI_CELULAR,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE1,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE2,''),'-',''),'(',''),')','') LIKE '%' || ? || '%')
AND CLI_EMPRESA_ID = ?`,
[termo2, termo2, termo2, empresaId]
);
if (clientes2.length > 0) {
clientes.push(...clientes2);
}
}
// Último recurso: tenta os últimos 8 dígitos
if (clientes.length === 0) {
const ultimosDigitos = numero.slice(-8);
const clientes3 = await db.query(alias,
`SELECT CLI_CODIGO_ID, CLI_NOME FROM CLIENTES
WHERE (REPLACE(REPLACE(REPLACE(COALESCE(CLI_CELULAR,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE1,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE2,''),'-',''),'(',''),')','') LIKE '%' || ? || '%')
AND CLI_EMPRESA_ID = ?`,
[ultimosDigitos, ultimosDigitos, ultimosDigitos, empresaId]
);
if (clientes3.length > 0) {
clientes.push(...clientes3);
}
}
if (clientes.length > 0) {
clienteId = clientes[0].CLI_CODIGO_ID;
nomeCliente = (clientes[0].CLI_NOME || '').trim();
// Atualiza foto do contato
EvolutionController.atualizarFotoContato(alias, clienteId, numero, empresaId).catch(function() {});
} }
// Usa nome do cliente do BD se encontrado, senão usa o nome do WhatsApp // Usa nome do cliente do BD se encontrado, senão usa o nome do WhatsApp
const nomeFinal = nomeCliente || nomeContato; const nomeFinal = nomeCliente || nomeContato;
await db.execute(alias, ` await db.execute(aliasEfetivo, `
INSERT INTO CHATC2_CONVERSAS (CON_CODIGO_ID, CON_EMPRESA_ID, CON_INSTANCIA_ID, CON_NUMERO, INSERT INTO CHATC2_CONVERSAS (CON_CODIGO_ID, CON_EMPRESA_ID, CON_INSTANCIA_ID, CON_NUMERO,
CON_NOME_CONTATO, CON_CLIENTE_ID, CON_STATUS, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG) CON_NOME_CONTATO, CON_CLIENTE_ID, CON_STATUS, CON_SITUACAO, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG)
VALUES (?, ?, ?, ?, ?, ?, 'E', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?, ?, ?, 'E', 'A', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`, [newId, empresaId, instanciaId, numero, nomeFinal, clienteId]); `, [newId, empresaId, instanciaId, numero, nomeFinal, clienteId]);
console.log('[Webhook] Conversa criada no alias:', aliasEfetivo, '| ID:', newId);
conversa = await db.query(alias, 'SELECT * FROM CHATC2_CONVERSAS WHERE CON_CODIGO_ID = ?', [newId]); conversa = await db.query(aliasEfetivo, 'SELECT * FROM CHATC2_CONVERSAS WHERE CON_CODIGO_ID = ?', [newId]);
conversa = conversa[0]; conversa = conversa[0];
const config = await db.query(alias, const config = await db.query(aliasEfetivo,
'SELECT CFE_SAUDACAO_ATIVA, CFE_SAUDACAO_MENSAGEM FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', 'SELECT CFE_SAUDACAO_ATIVA, CFE_SAUDACAO_MENSAGEM FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?',
[empresaId] [empresaId]
); );
@@ -779,16 +944,16 @@ async function processWebhook(alias, body) {
if (config.length > 0 && config[0].CFE_SAUDACAO_ATIVA === 'S') { if (config.length > 0 && config[0].CFE_SAUDACAO_ATIVA === 'S') {
const msgSaudacao = (config[0].CFE_SAUDACAO_MENSAGEM || '').trim(); const msgSaudacao = (config[0].CFE_SAUDACAO_MENSAGEM || '').trim();
if (msgSaudacao) { if (msgSaudacao) {
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const saudacaoMsgId = await db.nextId(aliasEfetivo, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(aliasEfetivo, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversa.CON_CODIGO_ID, msgSaudacao]); `, [saudacaoMsgId, conversa.CON_CODIGO_ID, msgSaudacao]);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_SAUDACAO_ENVIADA = 'S' WHERE CON_CODIGO_ID = ?", [conversa.CON_CODIGO_ID]); await db.execute(aliasEfetivo, "UPDATE CHATC2_CONVERSAS SET CON_SAUDACAO_ENVIADA = 'S' WHERE CON_CODIGO_ID = ?", [conversa.CON_CODIGO_ID]);
// Envia saudação via Evolution API // Envia saudação via Evolution API
try { try {
const inst = await db.query(alias, const inst = await db.query(aliasEfetivo,
"SELECT * FROM CHATC2_INSTANCIAS WHERE INS_CODIGO_ID = ? AND INS_SITUACAO = 'A'", [instanciaId]); "SELECT * FROM CHATC2_INSTANCIAS WHERE INS_CODIGO_ID = ? AND INS_SITUACAO = 'A'", [instanciaId]);
if (inst.length > 0) { if (inst.length > 0) {
await evolutionRequest( await evolutionRequest(
@@ -806,72 +971,34 @@ async function processWebhook(alias, body) {
// Envia menu de triagem SEMPRE que for conversa nova // Envia menu de triagem SEMPRE que for conversa nova
// (sendMenu internamente verifica se CFE_TRIAGEM_ATIVA = 'S') // (sendMenu internamente verifica se CFE_TRIAGEM_ATIVA = 'S')
try { try {
const triagemInst = await db.query(alias, const triagemInst = await db.query(aliasEfetivo,
"SELECT * FROM CHATC2_INSTANCIAS WHERE INS_CODIGO_ID = ? AND INS_SITUACAO = 'A'", [instanciaId]); "SELECT * FROM CHATC2_INSTANCIAS WHERE INS_CODIGO_ID = ? AND INS_SITUACAO = 'A'", [instanciaId]);
if (triagemInst.length > 0) { if (triagemInst.length > 0) {
await TriageController.sendMenu(alias, conversa.CON_CODIGO_ID, empresaId, instanciaId, numero); await TriageController.sendMenu(aliasEfetivo, conversa.CON_CODIGO_ID, empresaId, instanciaId, numero);
} }
} catch(e) { console.error('[Webhook] Erro ao enviar triagem:', e.message); } } catch(e) { console.error('[Webhook] Erro ao enviar triagem:', e.message); }
} // fecha else (nova conversa)
} else { } else {
conversa = conversations[0]; conversa = conversations[0];
// Se a conversa existente não tem cliente vinculado, tenta vincular agora // Se a conversa existente não tem cliente vinculado, tenta vincular agora
if (!conversa.CON_CLIENTE_ID) { if (!conversa.CON_CLIENTE_ID) {
const termo1 = numero; // Busca cliente em TODOS os aliases (multi-banco)
const termo2 = numero.startsWith('55') ? numero.substring(2) : numero; const resultadoGlobal = await buscarClienteGlobal(numero, empresaId, alias);
if (resultadoGlobal) {
const clientes = await db.query(alias, var cliId = resultadoGlobal.cliente.CLI_CODIGO_ID;
`SELECT CLI_CODIGO_ID, CLI_NOME FROM CLIENTES const nomeCliente = (resultadoGlobal.cliente.CLI_NOME || '').trim();
WHERE (REPLACE(REPLACE(REPLACE(COALESCE(CLI_CELULAR,''),'-',''),'(',''),')','') LIKE '%' || ? || '%' var aliasEfetivo = resultadoGlobal.alias;
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE1,''),'-',''),'(',''),')','') LIKE '%' || ? || '%' await db.execute(aliasEfetivo,
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE2,''),'-',''),'(',''),')','') LIKE '%' || ? || '%')
AND CLI_EMPRESA_ID = ?`,
[termo1, termo1, termo1, empresaId]
);
if (clientes.length === 0 && termo2 !== termo1) {
const clientes2 = await db.query(alias,
`SELECT CLI_CODIGO_ID, CLI_NOME FROM CLIENTES
WHERE (REPLACE(REPLACE(REPLACE(COALESCE(CLI_CELULAR,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE1,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE2,''),'-',''),'(',''),')','') LIKE '%' || ? || '%')
AND CLI_EMPRESA_ID = ?`,
[termo2, termo2, termo2, empresaId]
);
if (clientes2.length > 0) clientes.push(...clientes2);
}
// Último recurso: tenta os últimos 8 dígitos
if (clientes.length === 0) {
const ultimosDigitos = numero.slice(-8);
const clientes3 = await db.query(alias,
`SELECT CLI_CODIGO_ID, CLI_NOME FROM CLIENTES
WHERE (REPLACE(REPLACE(REPLACE(COALESCE(CLI_CELULAR,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE1,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE2,''),'-',''),'(',''),')','') LIKE '%' || ? || '%')
AND CLI_EMPRESA_ID = ?`,
[ultimosDigitos, ultimosDigitos, ultimosDigitos, empresaId]
);
if (clientes3.length > 0) clientes.push(...clientes3);
}
if (clientes.length > 0) {
var cliId = clientes[0].CLI_CODIGO_ID;
const nomeCliente = (clientes[0].CLI_NOME || '').trim();
await db.execute(alias,
'UPDATE CHATC2_CONVERSAS SET CON_CLIENTE_ID = ?, CON_NOME_CONTATO = ? WHERE CON_CODIGO_ID = ?', 'UPDATE CHATC2_CONVERSAS SET CON_CLIENTE_ID = ?, CON_NOME_CONTATO = ? WHERE CON_CODIGO_ID = ?',
[cliId, nomeCliente, conversa.CON_CODIGO_ID]); [cliId, nomeCliente, conversa.CON_CODIGO_ID]);
conversa.CON_CLIENTE_ID = cliId; conversa.CON_CLIENTE_ID = cliId;
// Atualiza foto do contato // ponytail: CLI_FOTO desativado
EvolutionController.atualizarFotoContato(alias, cliId, numero, empresaId).catch(function() {}); // EvolutionController.atualizarFotoContato(aliasEfetivo, cliId, numero, empresaId).catch(function() {});
} }
} else {
// Mesmo ja tendo cliente vinculado, tenta atualizar a foto
// (Evolution API 2.4.0 pode estar retornando foto agora)
EvolutionController.atualizarFotoContato(alias, conversa.CON_CLIENTE_ID, numero, empresaId).catch(function(e) {
console.error('[Webhook] Erro ao atualizar foto de conversa existente:', e.message);
});
} }
// NOTA: foto NÃO é mais atualizada a cada mensagem para conversas já vinculadas.
// A atualização ocorre apenas na criação da conversa ou na primeira vinculação do cliente.
await db.execute(alias, 'UPDATE CHATC2_CONVERSAS SET CON_DT_ULTIMA_MSG = CURRENT_TIMESTAMP WHERE CON_CODIGO_ID = ?', [conversa.CON_CODIGO_ID]); await db.execute(alias, 'UPDATE CHATC2_CONVERSAS SET CON_DT_ULTIMA_MSG = CURRENT_TIMESTAMP WHERE CON_CODIGO_ID = ?', [conversa.CON_CODIGO_ID]);
} }
@@ -1146,8 +1273,7 @@ async function processWebhook(alias, body) {
var b64Salvar = mediaBuffer.toString('base64'); var b64Salvar = mediaBuffer.toString('base64');
var mimeTypeSalvar = mimeType || (mediaTipo + '/' + ext); var mimeTypeSalvar = mimeType || (mediaTipo + '/' + ext);
var maxMidia = await db.query(alias, 'SELECT MAX(MAT_CODIGO_ID) AS ID FROM CHATC2_MENSAGENS_ATENDIMENTOS'); midiaId = await db.nextId(alias, 'GEN_MENSAGENS_ATENDIMENTOS');
midiaId = (maxMidia[0]?.ID || 0) + 1;
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_MENSAGENS_ATENDIMENTOS (MAT_CODIGO_ID, MAT_CONVERSA_ID, MAT_MENSAGEM_ID, MAT_NOME_ARQUIVO, MAT_TIPO_ARQUIVO, MAT_ARQUIVO) INSERT INTO CHATC2_MENSAGENS_ATENDIMENTOS (MAT_CODIGO_ID, MAT_CONVERSA_ID, MAT_MENSAGEM_ID, MAT_NOME_ARQUIVO, MAT_TIPO_ARQUIVO, MAT_ARQUIVO)
@@ -1193,26 +1319,35 @@ async function processWebhook(alias, body) {
} // fim if (mediaInfo) } // fim if (mediaInfo)
if (messageText || messageType !== 'conversation' || midiaId) { if (messageText || messageType !== 'conversation' || midiaId) {
var maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const webhookMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
if (midiaId) { if (midiaId) {
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO, CME_MIDIA_ID) INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO, CME_MIDIA_ID)
VALUES (?, ?, 'C', ?, ?, CURRENT_TIMESTAMP, ?) VALUES (?, ?, 'C', ?, ?, 'A', CURRENT_TIMESTAMP, ?)
`, [(maxMsgId[0]?.ID || 0) + 1, conversa.CON_CODIGO_ID, messageText, messageType, midiaId]); `, [webhookMsgId, conversa.CON_CODIGO_ID, messageText, messageType, midiaId]);
} else { } else {
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'C', ?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, 'C', ?, ?, 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversa.CON_CODIGO_ID, messageText, messageType]); `, [webhookMsgId, conversa.CON_CODIGO_ID, messageText, messageType]);
} }
// Processa resposta do cliente à triagem (se estiver em modo triagem) // Processa resposta do cliente à triagem (APENAS se conversa está em modo triagem)
// Só processa para conversas já existentes. Para conversas novas, a mensagem // Condições: conversa existente, tem texto, é mensagem de conversa, não é do agente,
// que disparou a criação foi enviada ANTES do menu, então não deve ser processada. // E está em triagem (CON_MENU_ESTADO preenchido) E ainda não tem atendente+equipe.
if (!isNovaConversa && messageText && messageType === 'conversation' && !data.key?.fromMe) { var emTriagem = conversa.CON_MENU_ESTADO &&
TriageController.processResponse(alias, conversa.CON_CODIGO_ID, empresaId, instanciaId, numero, messageText) (!conversa.CON_USUARIO_ID || !conversa.CON_EQUIPE_ID);
.catch(function(e) { console.error('[Triagem] Erro ao processar resposta:', e.message); }); if (!isNovaConversa && messageText && messageType === 'conversation' && !data.key?.fromMe && emTriagem) {
if (triagemExcedeu(conversa.CON_CODIGO_ID)) {
console.log('[Webhook] Loop detectado na conversa', conversa.CON_CODIGO_ID, '- triagem pausada. Aguardando atendente.');
await db.execute(alias,
"UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = NULL WHERE CON_CODIGO_ID = ?",
[conversa.CON_CODIGO_ID]);
} else {
TriageController.processResponse(alias, conversa.CON_CODIGO_ID, empresaId, instanciaId, numero, messageText)
.catch(function(e) { console.error('[Triagem] Erro ao processar resposta:', e.message); });
}
} }
} }
} catch (err) { } catch (err) {
+37 -5
View File
@@ -1,7 +1,23 @@
const db = require('../database'); const db = require('../database');
const crypto = require('crypto');
const { listAliases } = db; const { listAliases } = db;
const { isGerente } = require('../middlewares/roles'); const { isGerente } = require('../middlewares/roles');
/**
* Valida o token de administração (segundo fator para SQL direto).
* Comparação em tempo constante para prevenir timing attacks.
*/
function validarAdminToken(req) {
const esperado = process.env.ADMIN_TOKEN;
if (!esperado) return false; // Se não configurado, ninguém acessa
const recebido = req.headers['x-admin-token'] || '';
try {
return crypto.timingSafeEqual(Buffer.from(recebido), Buffer.from(esperado));
} catch (_) {
return false;
}
}
class GenericController { class GenericController {
/** /**
* Executa uma consulta SQL genérica (SELECT) * Executa uma consulta SQL genérica (SELECT)
@@ -15,6 +31,10 @@ class GenericController {
return res.status(403).json({ success: false, error: 'Apenas gerentes podem executar SQL direto.' }); return res.status(403).json({ success: false, error: 'Apenas gerentes podem executar SQL direto.' });
} }
if (!validarAdminToken(req)) {
return res.status(403).json({ success: false, error: 'Token de administração (X-Admin-Token) inválido ou não configurado.' });
}
if (!sql) { if (!sql) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
@@ -42,6 +62,10 @@ class GenericController {
return res.status(403).json({ success: false, error: 'Apenas gerentes podem executar SQL direto.' }); return res.status(403).json({ success: false, error: 'Apenas gerentes podem executar SQL direto.' });
} }
if (!validarAdminToken(req)) {
return res.status(403).json({ success: false, error: 'Token de administração (X-Admin-Token) inválido ou não configurado.' });
}
if (!sql) { if (!sql) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
@@ -94,23 +118,31 @@ class GenericController {
} }
/** /**
* Health check do alias específico * Health check completo do alias específico.
* Verifica: conectividade Firebird, uso de memória, uptime.
*/ */
static async healthCheck(req, res) { static async healthCheck(req, res) {
try { try {
const { alias } = req.params; const { alias } = req.params;
const t0 = Date.now();
const isConnected = await db.testConnection(alias); const isConnected = await db.testConnection(alias);
const pingMs = Date.now() - t0;
const mem = process.memoryUsage();
res.json({ res.json({
success: true, success: true,
alias, alias,
connected: isConnected, connected: isConnected,
pingMs,
uptimeSeconds: Math.floor(process.uptime()),
memory: {
rssMB: Math.round(mem.rss / 1024 / 1024),
heapUsedMB: Math.round(mem.heapUsed / 1024 / 1024),
heapTotalMB: Math.round(mem.heapTotal / 1024 / 1024),
},
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
} catch (err) { } catch (err) {
res.status(500).json({ res.status(500).json({ success: false, error: err.message });
success: false,
error: err.message,
});
} }
} }
+1 -2
View File
@@ -172,8 +172,7 @@ class MenuController {
await MenuController.garantirTabela(alias); await MenuController.garantirTabela(alias);
const maxId = await db.query(alias, 'SELECT MAX(MNE_CODIGO_ID) AS ID FROM CHATC2_MENUS_EMPRESA'); const newId = await db.nextId(alias, 'GEN_CHATC2_MENUS_EMPRESA');
const newId = (maxId[0]?.ID || 0) + 1;
// Se tem menuPaiId, o equipeId é herdado do pai // Se tem menuPaiId, o equipeId é herdado do pai
var equipeIdFinal = menuPaiId ? null : (equipeId || null); var equipeIdFinal = menuPaiId ? null : (equipeId || null);
+3 -3
View File
@@ -145,14 +145,14 @@ class RoutesController {
'GET /api/databases': { desc: 'Lista as conexões de banco cadastradas (senhas ocultas).' }, 'GET /api/databases': { desc: 'Lista as conexões de banco cadastradas (senhas ocultas).' },
'POST /api/databases': { 'POST /api/databases': {
desc: 'Adiciona ou atualiza uma conexão de banco (persistida em databases_custom.json).', desc: 'Adiciona ou atualiza uma conexão de banco (persistida em databases_custom.json).',
body: { alias: 'novo_dev', config: { driver: 'postgres', host: '127.0.0.1', port: 15433, database: 'novo_local', schema: 'dev', user: 'postgres', password: 'postgres' } }, body: { alias: 'novo_dev', config: { driver: 'firebird', host: '127.0.0.1', port: 3050, database: '/opt/chatc2/db/NOVO.FDB', user: 'SYSDBA', password: 'masterkey' } },
req: ['alias', 'config'], req: ['alias', 'config'],
note: 'driver: "postgres" (usa schema) ou "firebird" (database = caminho do .FDB).', note: 'driver: "firebird" (database = caminho do .FDB).',
}, },
'DELETE /api/databases/{alias}': { desc: 'Remove uma conexão customizada (estáticas não podem ser removidas).' }, 'DELETE /api/databases/{alias}': { desc: 'Remove uma conexão customizada (estáticas não podem ser removidas).' },
'POST /api/databases/test': { 'POST /api/databases/test': {
desc: 'Testa uma conexão sem cadastrá-la.', desc: 'Testa uma conexão sem cadastrá-la.',
body: { driver: 'postgres', host: '127.0.0.1', port: 15433, database: 'novo_local', schema: 'public', user: 'postgres', password: 'postgres' }, body: { driver: 'firebird', host: '127.0.0.1', port: 3050, database: '/opt/chatc2/db/NOVO.FDB', user: 'SYSDBA', password: 'masterkey' },
req: ['database'], req: ['database'],
}, },
+99 -109
View File
@@ -7,7 +7,6 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const db = require('../database'); const db = require('../database');
const https = require('https'); const https = require('https');
const http = require('http');
function logTriage(msg) { function logTriage(msg) {
try { try {
@@ -18,6 +17,16 @@ function logTriage(msg) {
} catch(e) {} } catch(e) {}
} }
// Helper: normaliza número para formato WhatsApp (DDI + 9º dígito)
function _normalizarWhatsApp(numero) {
let n = String(numero || '').replace(/\D/g, '');
if (!n) return '';
if (!n.startsWith('55')) n = '55' + n;
const resto = n.slice(2);
if (resto.length === 10) n = '55' + resto.slice(0, 2) + '9' + resto.slice(2);
return n;
}
class TriageController { class TriageController {
/** /**
* Envia o menu principal (equipes + boleto) * Envia o menu principal (equipes + boleto)
@@ -37,7 +46,7 @@ class TriageController {
const boletoNum = String(cfg.CFE_TRIAGEM_BOLETO_NUMERO || '0').trim(); const boletoNum = String(cfg.CFE_TRIAGEM_BOLETO_NUMERO || '0').trim();
const equipes = await db.query(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", "SELECT EQU_CODIGO_ID, EQU_NOME, EQU_MENSAGEM, EQU_ETIQUETA_ID FROM CHATC2_EQUIPES WHERE EQU_EMPRESA_ID = ? AND EQU_SITUACAO = 'A' ORDER BY EQU_ORDEM, EQU_NOME",
[empresaId] [empresaId]
); );
@@ -70,11 +79,11 @@ class TriageController {
} }
// Salva mensagem do sistema // Salva mensagem do sistema
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'triagem', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'triagem', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, menuText]); `, [maxMsgId, conversaId, menuText]);
// Envia via Evolution // Envia via Evolution
await TriageController.sendEvolution(alias, instanciaId, numero, menuText); await TriageController.sendEvolution(alias, instanciaId, numero, menuText);
@@ -119,11 +128,11 @@ class TriageController {
menuText += '\n0 - Voltar'; menuText += '\n0 - Voltar';
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'triagem', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'triagem', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, menuText]); `, [maxMsgId, conversaId, menuText]);
await TriageController.sendEvolution(alias, instanciaId, numero, menuText); await TriageController.sendEvolution(alias, instanciaId, numero, menuText);
// Salva estado // Salva estado
@@ -224,7 +233,7 @@ class TriageController {
if (estado === 'root' || estado === '') { if (estado === 'root' || estado === '') {
logTriage('7-processResponse root - resposta: ' + JSON.stringify(resposta) + ' | boletoNum: ' + JSON.stringify(boletoNum)); logTriage('7-processResponse root - resposta: ' + JSON.stringify(resposta) + ' | boletoNum: ' + JSON.stringify(boletoNum));
const equipes = await db.query(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", "SELECT EQU_CODIGO_ID, EQU_NOME, EQU_MENSAGEM, EQU_ETIQUETA_ID FROM CHATC2_EQUIPES WHERE EQU_EMPRESA_ID = ? AND EQU_SITUACAO = 'A' ORDER BY EQU_ORDEM, EQU_NOME",
[empresaId] [empresaId]
); );
@@ -238,17 +247,37 @@ class TriageController {
[eq.EQU_CODIGO_ID, conversaId] [eq.EQU_CODIGO_ID, conversaId]
); );
// Mensagem personalizada da equipe (se configurada)
const msgEquipe = (eq.EQU_MENSAGEM || '').trim();
if (msgEquipe) {
const msgEqId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [msgEqId, conversaId, msgEquipe]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgEquipe);
}
// Etiqueta automática da equipe (se configurada)
if (eq.EQU_ETIQUETA_ID) {
try {
await db.execute(alias,
'INSERT INTO CHATC2_CONVERSAS_ETIQUETAS (CET_CONVERSA_ID, CET_ETIQUETA_ID) VALUES (?, ?)',
[conversaId, eq.EQU_ETIQUETA_ID]);
} catch (_) { /* ignora duplicata */ }
}
// Verifica se existem submenus configurados para esta equipe // Verifica se existem submenus configurados para esta equipe
var temSubmenus = await TriageController.enviarSubmenu(alias, conversaId, empresaId, instanciaId, numero, null, eq.EQU_CODIGO_ID); var temSubmenus = await TriageController.enviarSubmenu(alias, conversaId, empresaId, instanciaId, numero, null, eq.EQU_CODIGO_ID);
if (!temSubmenus) { if (!temSubmenus) {
// Sem submenus: envia msgAfter e finaliza triagem // Sem submenus: envia msgAfter e finaliza triagem
if (msgAfter) { if (msgAfter) {
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, msgAfter]); `, [maxMsgId, conversaId, msgAfter]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgAfter); await TriageController.sendEvolution(alias, instanciaId, numero, msgAfter);
} }
// Remove estado de triagem (conversa fica com equipe atribuída) // Remove estado de triagem (conversa fica com equipe atribuída)
@@ -375,11 +404,11 @@ class TriageController {
// Texto: envia mensagem fixa e FINALIZA (não mostra menu novamente) // Texto: envia mensagem fixa e FINALIZA (não mostra menu novamente)
var textoMsg = menuEscolhido.MNE_TEXTO ? menuEscolhido.MNE_TEXTO.toString() : ''; var textoMsg = menuEscolhido.MNE_TEXTO ? menuEscolhido.MNE_TEXTO.toString() : '';
if (textoMsg) { if (textoMsg) {
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, textoMsg]); `, [maxMsgId, conversaId, textoMsg]);
await TriageController.sendEvolution(alias, instanciaId, numero, textoMsg); await TriageController.sendEvolution(alias, instanciaId, numero, textoMsg);
} }
// Finaliza triagem (não mostra menu novamente) // Finaliza triagem (não mostra menu novamente)
@@ -394,11 +423,11 @@ class TriageController {
msgPrompt = msgPrompt.replace(/{NOME}/g, 'Cliente'); msgPrompt = msgPrompt.replace(/{NOME}/g, 'Cliente');
msgPrompt = msgPrompt.replace(/{EMPRESA}/g, ''); msgPrompt = msgPrompt.replace(/{EMPRESA}/g, '');
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, msgPrompt]); `, [maxMsgId, conversaId, msgPrompt]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgPrompt); await TriageController.sendEvolution(alias, instanciaId, numero, msgPrompt);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = 'coleta_" + menuEscolhido.MNE_CODIGO_ID + "' WHERE CON_CODIGO_ID = ?", [conversaId]); await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = 'coleta_" + menuEscolhido.MNE_CODIGO_ID + "' WHERE CON_CODIGO_ID = ?", [conversaId]);
@@ -519,11 +548,11 @@ class TriageController {
} }
if (resultado) { if (resultado) {
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, resultado]); `, [maxMsgId, conversaId, resultado]);
await TriageController.sendEvolution(alias, instanciaId, numero, resultado); await TriageController.sendEvolution(alias, instanciaId, numero, resultado);
} }
@@ -577,11 +606,11 @@ class TriageController {
} else { } else {
logTriage('Boleto - NENHUM cliente ou dependente encontrado!'); logTriage('Boleto - NENHUM cliente ou dependente encontrado!');
var msgNaoEncontrado = 'Este número não está associado a nenhum contrato no sistema.\n\nPor favor, selecione um setor para que um atendente possa ajudá-lo a associar seu número.'; var msgNaoEncontrado = 'Este número não está associado a nenhum contrato no sistema.\n\nPor favor, selecione um setor para que um atendente possa ajudá-lo a associar seu número.';
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, msgNaoEncontrado]); `, [maxMsgId, conversaId, msgNaoEncontrado]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgNaoEncontrado); await TriageController.sendEvolution(alias, instanciaId, numero, msgNaoEncontrado);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = 'root' WHERE CON_CODIGO_ID = ?", [conversaId]); await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = 'root' WHERE CON_CODIGO_ID = ?", [conversaId]);
await TriageController.sendMenu(alias, conversaId, empresaId, instanciaId, numero); await TriageController.sendMenu(alias, conversaId, empresaId, instanciaId, numero);
@@ -603,11 +632,11 @@ class TriageController {
if (!titulos || titulos.length === 0) { if (!titulos || titulos.length === 0) {
var msg = 'Nenhum título em aberto encontrado.'; var msg = 'Nenhum título em aberto encontrado.';
logTriage('Boleto - NENHUM título em aberto'); logTriage('Boleto - NENHUM título em aberto');
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, msg]); `, [maxMsgId, conversaId, msg]);
await TriageController.sendEvolution(alias, instanciaId, numero, msg); await TriageController.sendEvolution(alias, instanciaId, numero, msg);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = 'root' WHERE CON_CODIGO_ID = ?", [conversaId]); await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = 'root' WHERE CON_CODIGO_ID = ?", [conversaId]);
await TriageController.sendMenu(alias, conversaId, empresaId, instanciaId, numero); await TriageController.sendMenu(alias, conversaId, empresaId, instanciaId, numero);
@@ -622,11 +651,11 @@ class TriageController {
}); });
msgBoleto += '\nResponda com o número desejado ou 0 para voltar.'; msgBoleto += '\nResponda com o número desejado ou 0 para voltar.';
const maxMsgId2 = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxMsgId2 = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId2[0]?.ID || 0) + 1, conversaId, msgBoleto]); `, [maxMsgId2, conversaId, msgBoleto]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgBoleto); await TriageController.sendEvolution(alias, instanciaId, numero, msgBoleto);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = 'boleto_lista_" + titularId + "' WHERE CON_CODIGO_ID = ?", [conversaId]); await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = 'boleto_lista_" + titularId + "' WHERE CON_CODIGO_ID = ?", [conversaId]);
@@ -650,11 +679,11 @@ class TriageController {
if (titulos.length === 0 || opcao > titulos.length) { if (titulos.length === 0 || opcao > titulos.length) {
const msg = 'Opção inválida. Tente novamente.'; const msg = 'Opção inválida. Tente novamente.';
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxId[0]?.ID || 0) + 1, conversaId, msg]); `, [maxId, conversaId, msg]);
await TriageController.sendEvolution(alias, instanciaId, numero, msg); await TriageController.sendEvolution(alias, instanciaId, numero, msg);
return; return;
} }
@@ -784,7 +813,7 @@ class TriageController {
// Envia PDF via Evolution // Envia PDF via Evolution
var fileName = matricula + ' - ' + fmtDt(t.CAR_DT_VENCIMENTO) + '.pdf'; var fileName = matricula + ' - ' + fmtDt(t.CAR_DT_VENCIMENTO) + '.pdf';
var mediaPayload = JSON.stringify({ var mediaPayload = JSON.stringify({
number: numero.replace(/\D/g, ''), number: _normalizarWhatsApp(numero),
mediatype: 'document', mediatype: 'document',
fileName: fileName, fileName: fileName,
caption: 'Boleto Sistema', caption: 'Boleto Sistema',
@@ -836,21 +865,21 @@ class TriageController {
textoBoleto += '\n💳 PIX disponível (cópia e cola)'; textoBoleto += '\n💳 PIX disponível (cópia e cola)';
} }
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxId[0]?.ID || 0) + 1, conversaId, textoBoleto]); `, [maxId, conversaId, textoBoleto]);
await TriageController.sendEvolution(alias, instanciaId, numero, textoBoleto); await TriageController.sendEvolution(alias, instanciaId, numero, textoBoleto);
} }
// Pergunta se quer continuar // Pergunta se quer continuar
var msgCont = '📄 Boleto enviado!\n\nDeseja continuar com o atendimento?\n\n1 - Sim\n2 - Não, finalizar'; var msgCont = '📄 Boleto enviado!\n\nDeseja continuar com o atendimento?\n\n1 - Sim\n2 - Não, finalizar';
const maxIdCont = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxIdCont = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxIdCont[0]?.ID || 0) + 1, conversaId, msgCont]); `, [maxIdCont, conversaId, msgCont]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgCont); await TriageController.sendEvolution(alias, instanciaId, numero, msgCont);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = 'boleto_pos_" + clienteId + "' WHERE CON_CODIGO_ID = ?", [conversaId]); await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = 'boleto_pos_" + clienteId + "' WHERE CON_CODIGO_ID = ?", [conversaId]);
@@ -887,20 +916,20 @@ class TriageController {
} }
var msgFinal = 'Atendimento finalizado. Obrigado pelo contato!'; var msgFinal = 'Atendimento finalizado. Obrigado pelo contato!';
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxId[0]?.ID || 0) + 1, conversaId, msgFinal]); `, [maxId, conversaId, msgFinal]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgFinal); await TriageController.sendEvolution(alias, instanciaId, numero, msgFinal);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_STATUS = 'F', CON_DT_FINAL = CURRENT_TIMESTAMP, CON_MENU_ESTADO = NULL WHERE CON_CODIGO_ID = ?", [conversaId]); await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_STATUS = 'F', CON_DT_FINAL = CURRENT_TIMESTAMP, CON_MENU_ESTADO = NULL WHERE CON_CODIGO_ID = ?", [conversaId]);
} else { } else {
var msgInv = 'Por favor, responda:\n\n1 - Sim\n2 - Não, finalizar'; var msgInv = 'Por favor, responda:\n\n1 - Sim\n2 - Não, finalizar';
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxId[0]?.ID || 0) + 1, conversaId, msgInv]); `, [maxId, conversaId, msgInv]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgInv); await TriageController.sendEvolution(alias, instanciaId, numero, msgInv);
} }
} catch(e) { } catch(e) {
@@ -914,11 +943,11 @@ class TriageController {
static async enviarMsgInvalida(alias, conversaId, empresaId, instanciaId, numero) { static async enviarMsgInvalida(alias, conversaId, empresaId, instanciaId, numero) {
try { try {
const msg = 'Opção inválida. Tente novamente.'; const msg = 'Opção inválida. Tente novamente.';
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxId[0]?.ID || 0) + 1, conversaId, msg]); `, [maxId, conversaId, msg]);
await TriageController.sendEvolution(alias, instanciaId, numero, msg); await TriageController.sendEvolution(alias, instanciaId, numero, msg);
// Reenvia o menu atual // Reenvia o menu atual
@@ -947,11 +976,11 @@ class TriageController {
static async enviarMsgTexto(alias, conversaId, empresaId, instanciaId, numero, texto, menuPaiId, equipeId) { static async enviarMsgTexto(alias, conversaId, empresaId, instanciaId, numero, texto, menuPaiId, equipeId) {
try { try {
if (texto) { if (texto) {
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS'); const maxId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_DT_ENVIO) INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', CURRENT_TIMESTAMP) VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxId[0]?.ID || 0) + 1, conversaId, texto]); `, [maxId, conversaId, texto]);
await TriageController.sendEvolution(alias, instanciaId, numero, texto); await TriageController.sendEvolution(alias, instanciaId, numero, texto);
} }
@@ -1026,47 +1055,8 @@ class TriageController {
const inst = await db.query(alias, const inst = await db.query(alias,
"SELECT * FROM CHATC2_INSTANCIAS WHERE INS_CODIGO_ID = ? AND INS_SITUACAO = 'A'", [instanciaId]); "SELECT * FROM CHATC2_INSTANCIAS WHERE INS_CODIGO_ID = ? AND INS_SITUACAO = 'A'", [instanciaId]);
if (inst.length === 0) return; if (inst.length === 0) return;
const url = ((inst[0].INS_URL || '').trim()).replace(/\/+$/, ''); const evolution = require('../services/evolutionService');
const apiKey = (inst[0].INS_API_KEY || '').trim(); await evolution.sendText(inst[0], numero, texto);
const instanceName = (inst[0].INS_INSTANCE_NAME || '').trim();
if (!url || !apiKey || !instanceName) return;
const numeroLimpo = numero.replace(/\D/g, '');
const payload = JSON.stringify({ number: numeroLimpo, text: texto || '', delay: 0 });
const endpoint = '/message/sendText/' + encodeURIComponent(instanceName);
const parsedUrl = new URL(url + endpoint);
const lib = parsedUrl.protocol === 'https:' ? https : http;
await new Promise(function(resolve, reject) {
const req = lib.request({
hostname: parsedUrl.hostname,
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
path: parsedUrl.pathname,
method: 'POST',
timeout: 15000,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
'apikey': apiKey,
}
}, function(res) {
var d = '';
res.on('data', function(c) { d += c; });
res.on('end', function() {
if (res.statusCode >= 400) {
console.error('[Triagem Evolution] Erro HTTP', res.statusCode, d.substring(0, 100));
}
resolve();
});
});
req.on('error', function(err) {
console.error('[Triagem Evolution] Erro:', err.message);
resolve();
});
req.on('timeout', function() { req.destroy(); resolve(); });
req.write(payload);
req.end();
});
} catch(e) { } catch(e) {
console.error('[Triagem Evolution] Erro ao enviar:', e.message); console.error('[Triagem Evolution] Erro ao enviar:', e.message);
} }
+77 -617
View File
@@ -1,618 +1,45 @@
/** /**
* CAMADA DE BANCO DE DADOS (arquivo único) * CAMADA DE BANCO DE DADOS (dispatcher)
* ======================================== * ========================================
* Concentra tudo que diz respeito a banco de dados em UM lugar:
* *
* 1) CONFIGURAÇÃO aliases, drivers, conexões estáticas e customizadas * API pública usada por todo o sistema. Delega para:
* 2) DRIVER FIREBIRD * - config/connections.js aliases, configuração
* 3) DRIVER POSTGRES (pool + tradutor de SQL + schema) * - drivers/firebird.js operações Firebird puras
* 4) DISPATCHER API pública usada por todo o sistema
* *
* Suporta múltiplos drivers ('postgres' padrão, 'firebird' legado). Cada * Todos os controllers importam deste arquivo. A API permanece
* conexão declara o campo `driver`. Conexões adicionadas em runtime (via API * idêntica apenas a implementação foi modularizada.
* /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'); const connections = require('./config/connections');
const firebird = require('./drivers/firebird');
// ============================================================ // ============================================================
// 1) CONFIGURAÇÃO // 1) DISPATCHER — API pública
// ============================================================ // ============================================================
const CUSTOM_DB_PATH = path.resolve(__dirname, 'databases_custom.json');
// Conexões dinâmicas salvas em arquivo JSON function getConn(alias) {
var customDatabases = {}; const config = connections.getConfig(alias);
try { return { config, driver: firebird };
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 = []) { function query(alias, sql, params = []) {
let d; let d;
try { d = getDriver(alias); } catch (err) { return Promise.reject(err); } try { d = getConn(alias); } catch (err) { return Promise.reject(err); }
return d.driver.query(d.config, sql, params).catch((err) => { throw new Error(`[${alias}] ${err.message}`); }); return d.driver.query(d.config, sql, params)
.catch((err) => { throw new Error(`[${alias}] ${err.message}`); });
} }
function execute(alias, sql, params = []) { function execute(alias, sql, params = []) {
let d; let d;
try { d = getDriver(alias); } catch (err) { return Promise.reject(err); } try { d = getConn(alias); } catch (err) { return Promise.reject(err); }
return d.driver.execute(d.config, sql, params).catch((err) => { throw new Error(`[${alias}] ${err.message}`); }); return d.driver.execute(d.config, sql, params)
.catch((err) => { throw new Error(`[${alias}] ${err.message}`); });
} }
async function testConnection(alias) { async function testConnection(alias) {
try { try {
const { config, driver } = getDriver(alias); const { config } = getConn(alias);
await driver.testConnection(config); await firebird.testConnection(config);
return true; return true;
} catch (err) { } catch (err) {
console.error(`[${alias}] Falha na conexão:`, err.message); console.error(`[${alias}] Falha na conexão:`, err.message);
@@ -622,44 +49,73 @@ async function testConnection(alias) {
async function testAllConnections() { async function testAllConnections() {
const results = {}; const results = {};
for (const alias of listAliases()) { for (const alias of connections.listAliases()) {
results[alias] = await testConnection(alias); results[alias] = await testConnection(alias);
} }
return results; return results;
} }
function listTables(alias) { function listTables(alias) {
const { config, driver } = getDriver(alias); const { config } = getConn(alias);
return driver.listTables(config); return firebird.listTables(config);
} }
function tableInfo(alias, tableName) { function tableInfo(alias, tableName) {
const { config, driver } = getDriver(alias); const { config } = getConn(alias);
return driver.tableInfo(config, tableName); return firebird.tableInfo(config, tableName);
} }
function tableExists(alias, tableName) { function tableExists(alias, tableName) {
const { config, driver } = getDriver(alias); const { config } = getConn(alias);
return driver.tableExists(config, tableName); return firebird.tableExists(config, tableName);
} }
function columnExists(alias, tableName, columnName) { function columnExists(alias, tableName, columnName) {
const { config, driver } = getDriver(alias); const { config } = getConn(alias);
return driver.columnExists(config, tableName, columnName); return firebird.columnExists(config, tableName, columnName);
} }
/** Nome do driver de um alias (ex.: 'postgres'). */
function driverOf(alias) { function driverOf(alias) {
return getConfig(alias).driver; return connections.driverOf(alias);
} }
/** Encerra os pools de todos os drivers (shutdown gracioso). */
async function closeAll() { async function closeAll() {
await Promise.all(Object.values(drivers).map((d) => d.close && d.close())); await firebird.close();
} }
// ============================================================
// 2) GERADORES DE ID (GEN_ID) — thread-safe
// ============================================================
const GENERATORS = [
'GEN_CHATC2_CONVERSAS',
'GEN_CONVERSAS_MENSAGENS',
'GEN_MENSAGENS_ATENDIMENTOS',
'GEN_CHATC2_EQUIPES',
'GEN_CHATC2_ETIQUETAS',
'GEN_CHATC2_INSTANCIAS',
'GEN_CHATC2_MENUS_EMPRESA',
'GEN_CHATC2_CSAT_AVALIACOES',
'GEN_CHATC2_MOTIVOS_ATENDIMENTO',
'GEN_CHATC2_AGENDAMENTOS',
'GEN_MENSAGEM_CLIENTES_ID',
];
async function nextId(alias, generatorName) {
if (!GENERATORS.includes(generatorName)) {
throw new Error('Generator inválido: ' + generatorName);
}
const result = await query(alias,
'SELECT GEN_ID(' + generatorName + ', 1) AS NEW_ID FROM RDB$DATABASE');
return result[0].NEW_ID;
}
// ============================================================
// 3) EXPORTS — API completa (compatível com versão anterior)
// ============================================================
module.exports = { module.exports = {
// Acesso a dados // Operações de dados
query, query,
execute, execute,
testConnection, testConnection,
@@ -670,13 +126,17 @@ module.exports = {
columnExists, columnExists,
driverOf, driverOf,
closeAll, closeAll,
// Configuração / aliases nextId,
databases, GENERATORS,
DRIVERS, poolStats: firebird.poolStats,
DEFAULT_DRIVER, // Configuração / aliases (delegados)
getConfig, databases: connections.databases,
listAliases, customDatabases: connections.customDatabases,
listAllDatabases, DRIVERS: ['firebird'],
addDatabase, DEFAULT_DRIVER: 'firebird',
removeDatabase, getConfig: connections.getConfig,
listAliases: connections.listAliases,
listAllDatabases: connections.listAllDatabases,
addDatabase: connections.addDatabase,
removeDatabase: connections.removeDatabase,
}; };
+323
View File
@@ -0,0 +1,323 @@
/**
* Driver Firebird com connection pooling.
*
* Mantém um pool de conexões pré-abertas por configuração de banco.
* Elimina o overhead de attach/detach a cada query (~18ms por query).
*
* API pública idêntica à versão anterior transparente para os controllers.
*/
const Firebird = require('node-firebird');
// ============================================================
// Configuração do Pool
// ============================================================
const POOL_SIZE = parseInt(process.env.FB_POOL_SIZE, 10) || 5;
const POOL_MAX = POOL_SIZE * 3; // máximo de conexões simultâneas
// Um pool por config (key = host:port:database)
const pools = new Map();
function configKey(config) {
return `${config.host}:${config.port}:${config.database}`;
}
// ============================================================
// Pool de Conexões
// ============================================================
class Pool {
constructor(config, size) {
this.config = config;
this.size = size;
this.maxSize = POOL_MAX;
this.available = [];
this.busy = 0;
this.waiting = [];
this.ready = false;
this.initPromise = null;
}
async _init() {
if (this.ready) return;
if (this.initPromise) return this.initPromise;
this.initPromise = (async () => {
const errors = [];
for (let i = 0; i < this.size; i++) {
try {
const db = await this._attach();
this.available.push(db);
} catch (e) {
errors.push(e.message);
}
}
if (this.available.length === 0) {
throw new Error(`Pool: falha ao abrir conexões: ${errors.join('; ')}`);
}
this.ready = true;
console.log(`[Pool] ${this.available.length} conexões abertas para ${configKey(this.config)}`);
})();
return this.initPromise;
}
_attach() {
return new Promise((resolve, reject) => {
Firebird.attach(this.config, (err, db) => {
if (err) return reject(err);
resolve(db);
});
});
}
async acquire() {
if (!this.ready) await this._init();
// Conexão disponível no pool
if (this.available.length > 0) {
this.busy++;
return this.available.pop();
}
// Pool esgotado mas dentro do limite máximo: abre nova
if (this.busy < this.maxSize) {
try {
const db = await this._attach();
this.busy++;
return db;
} catch (e) {
console.error('[Pool] Falha ao abrir conexão extra:', e.message);
throw e;
}
}
// Limite máximo atingido: entra na fila de espera
return new Promise((resolve) => {
this.waiting.push(resolve);
});
}
release(db) {
if (this.waiting.length > 0) {
// Entrega para o próximo da fila
const resolve = this.waiting.shift();
resolve(db);
} else {
// Devolve ao pool se ainda há espaço
if (this.available.length + this.busy <= this.maxSize) {
this.available.push(db);
} else {
try { db.detach(); } catch (_) {}
}
this.busy--;
}
}
async close() {
this.ready = false;
const all = [...this.available];
this.available = [];
for (const db of all) {
try { db.detach(); } catch (_) {}
}
}
}
function getPool(config) {
const key = configKey(config);
if (!pools.has(key)) {
pools.set(key, new Pool(config, POOL_SIZE));
}
return pools.get(key);
}
// ============================================================
// Leitura de BLOBs
// ============================================================
function readBlob(blobFunc) {
return new Promise((resolve, reject) => {
if (typeof blobFunc !== 'function') return resolve(blobFunc);
blobFunc((err, name, emitter) => {
if (err) return reject(err);
if (!emitter || typeof emitter.on !== 'function') return resolve(null);
const chunks = [];
let total = 0;
emitter.on('data', (c) => { chunks.push(c); total += c.length; });
emitter.on('end', () => resolve(Buffer.concat(chunks, total)));
emitter.on('error', reject);
});
});
}
// ============================================================
// Query (SELECT) — via pool
// ============================================================
function query(config, sql, params = []) {
return new Promise((resolve, reject) => {
getPool(config).acquire().then((db) => {
const allRows = [];
db.sequentially(sql, params, (row) => {
const keys = Object.keys(row);
const blobPromises = keys.map((key) => {
const val = row[key];
if (typeof val === 'function') {
return readBlob(val).then((data) => { row[key] = data; });
}
return Promise.resolve();
});
return Promise.all(blobPromises).then(() => { allRows.push(row); });
}, (queryErr) => {
getPool(config).release(db);
if (queryErr) return reject(new Error(`Erro na consulta (firebird): ${queryErr.message}`));
resolve(allRows);
});
}).catch((err) => {
reject(new Error(`Erro ao conectar (firebird): ${err.message}`));
});
});
}
// ============================================================
// Execute (INSERT/UPDATE/DELETE/DDL) — via pool
// ============================================================
function execute(config, sql, params = []) {
return new Promise((resolve, reject) => {
getPool(config).acquire().then((db) => {
const pool = getPool(config);
db.transaction(Firebird.ISOLATION_READ_COMMITTED, (transErr, transaction) => {
if (transErr) {
pool.release(db);
return reject(new Error(`Erro ao iniciar transação (firebird): ${transErr.message}`));
}
transaction.query(sql, params, (queryErr, result) => {
if (queryErr) {
try { transaction.rollback(); } catch (_) {}
pool.release(db);
return reject(new Error(`Erro na execução (firebird): ${queryErr.message}`));
}
transaction.commit((commitErr) => {
pool.release(db);
if (commitErr) return reject(new Error(`Erro ao commitar (firebird): ${commitErr.message}`));
resolve({ affectedRows: result ? result.length : 0, result });
});
});
});
}).catch((err) => {
reject(new Error(`Erro ao conectar (firebird): ${err.message}`));
});
});
}
// ============================================================
// Teste de conexão (usa pool)
// ============================================================
async function testConnection(config) {
await query(config, 'SELECT 1 FROM RDB$DATABASE');
return true;
}
// ============================================================
// Introspecção
// ============================================================
async function listTables(config) {
const rows = await query(config, `
SELECT TRIM(RDB$RELATION_NAME) AS TABLE_NAME
FROM RDB$RELATIONS
WHERE RDB$SYSTEM_FLAG = 0 AND RDB$RELATION_TYPE = 0
ORDER BY RDB$RELATION_NAME
`);
return rows.map((r) => (r.TABLE_NAME || '').trim()).filter(Boolean);
}
async function tableInfo(config, tableName) {
const rows = await query(config, `
SELECT
rf.RDB$FIELD_NAME AS COLUMN_NAME,
rf.RDB$FIELD_POSITION AS ORDINAL_POSITION,
CASE f.RDB$FIELD_TYPE
WHEN 7 THEN 'SMALLINT' WHEN 8 THEN 'INTEGER' WHEN 16 THEN 'BIGINT'
WHEN 9 THEN 'QUAD' WHEN 10 THEN 'FLOAT' WHEN 27 THEN 'DOUBLE PRECISION'
WHEN 12 THEN 'DATE' WHEN 13 THEN 'TIME' WHEN 35 THEN 'TIMESTAMP'
WHEN 37 THEN 'VARCHAR' WHEN 40 THEN 'CSTRING' WHEN 45 THEN 'BLOB_ID'
WHEN 261 THEN 'BLOB' WHEN 14 THEN 'CHAR' WHEN 41 THEN 'NUMERIC'
ELSE 'UNKNOWN'
END AS DATA_TYPE,
f.RDB$FIELD_LENGTH AS FIELD_LENGTH,
f.RDB$FIELD_SCALE AS FIELD_SCALE,
f.RDB$FIELD_PRECISION AS FIELD_PRECISION,
rf.RDB$NULL_FLAG AS NULL_FLAG
FROM RDB$RELATION_FIELDS rf
INNER JOIN RDB$FIELDS f ON rf.RDB$FIELD_SOURCE = f.RDB$FIELD_NAME
WHERE rf.RDB$RELATION_NAME = ?
ORDER BY rf.RDB$FIELD_POSITION
`, [String(tableName).toUpperCase()]);
return rows.map((row) => ({
name: (row.COLUMN_NAME || '').trim(),
position: row.ORDINAL_POSITION,
type: (row.DATA_TYPE || '').trim(),
length: row.FIELD_LENGTH,
precision: row.FIELD_PRECISION,
scale: row.FIELD_SCALE,
nullable: row.NULL_FLAG !== 1,
}));
}
async function tableExists(config, tableName) {
const r = await query(config,
"SELECT COUNT(*) AS CT FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = ?",
[String(tableName).toUpperCase()]);
return (r[0] && r[0].CT > 0) || false;
}
async function columnExists(config, tableName, columnName) {
const r = await query(config,
"SELECT COUNT(*) AS CT FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = ? AND RDB$FIELD_NAME = ?",
[String(tableName).toUpperCase(), String(columnName).toUpperCase()]);
return (r[0] && r[0].CT > 0) || false;
}
/**
* Encerra todos os pools de conexão.
*/
async function close() {
const promises = [];
for (const pool of pools.values()) {
promises.push(pool.close().catch(() => {}));
}
await Promise.all(promises);
pools.clear();
}
/**
* Retorna estatísticas dos pools para debug.
*/
function poolStats() {
const result = {};
for (const [key, pool] of pools) {
result[key] = {
available: pool.available.length,
busy: pool.busy,
waiting: pool.waiting.length,
maxSize: pool.maxSize,
};
}
return result;
}
module.exports = {
query,
execute,
testConnection,
listTables,
tableInfo,
tableExists,
columnExists,
close,
poolStats,
};
+26 -2
View File
@@ -43,6 +43,30 @@ async function authenticateToken(req, res, next) {
if (tokenSource !== 'usu_token') { if (tokenSource !== 'usu_token') {
try { try {
const decoded = jwt.verify(token, authConfig.secret, { issuer: authConfig.issuer }); const decoded = jwt.verify(token, authConfig.secret, { issuer: authConfig.issuer });
// Normaliza as empresas para número (tokens antigos podem ter strings,
// o que quebra comparações do tipo empresas.includes(2))
if (Array.isArray(decoded.empresas)) {
decoded.empresas = decoded.empresas
.map(function (e) { return Number(e); })
.filter(function (n) { return !isNaN(n); });
}
// Se o JWT não tem o array empresas (tokens legados), tenta carregar do BD
if (!Array.isArray(decoded.empresas) || decoded.empresas.length === 0) {
const alias = req.params.alias;
if (alias && decoded.id) {
try {
const empresas = await db.query(alias,
'SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA WHERE USE_USUARIO_ID = ?',
[decoded.id]
);
decoded.empresas = empresas.map(e => Number(e.USE_EMPRESA_ID)).filter(n => !isNaN(n));
} catch (e) {
decoded.empresas = [];
}
} else {
decoded.empresas = [];
}
}
req.user = decoded; req.user = decoded;
req.authType = 'jwt'; req.authType = 'jwt';
return next(); return next();
@@ -83,7 +107,7 @@ async function authenticateToken(req, res, next) {
FROM USUARIOS FROM USUARIOS
WHERE USU_TOKEN = ? WHERE USU_TOKEN = ?
AND USU_STATUS = 'A' AND USU_STATUS = 'A'
AND COALESCE(USU_ACESSO_WEB, 0) = 1`, AND COALESCE(USU_ACESSO_WEB, '0') IN ('1', 'S')`,
[token] [token]
); );
@@ -103,7 +127,7 @@ async function authenticateToken(req, res, next) {
'SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA WHERE USE_USUARIO_ID = ?', 'SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA WHERE USE_USUARIO_ID = ?',
[user.USU_CODIGO_ID] [user.USU_CODIGO_ID]
); );
empresasIds = empresas.map(e => e.USE_EMPRESA_ID); empresasIds = empresas.map(e => Number(e.USE_EMPRESA_ID)).filter(n => !isNaN(n));
} catch (e) { } catch (e) {
// Se a tabela não existir, ignora // Se a tabela não existir, ignora
} }
+56
View File
@@ -0,0 +1,56 @@
/**
* Presença de atendentes (em memória).
*
* Mantém um mapa simples de "última atividade" por (alias + usuário). As páginas
* autenticadas (chat, dashboard) enviam um ping periódico; consideramos
* "disponível" quem teve atividade nos últimos TTL_MS milissegundos.
*
* É em memória de propósito: não altera o schema do banco e funciona bem para
* um único processo Node. Em cluster/multi-processo seria preciso um store
* compartilhado (Redis), mas isso foge do escopo atual.
*/
const TTL_MS = 2 * 60 * 1000; // 2 minutos sem ping => desconectado
// chave: alias + '|' + usuarioId => timestamp (ms) do último ping
const ultimoPing = new Map();
function chave(alias, usuarioId) {
return String(alias) + '|' + String(usuarioId);
}
/** Registra atividade do usuário. */
function registrar(alias, usuarioId) {
if (!alias || usuarioId == null) return;
ultimoPing.set(chave(alias, usuarioId), Date.now());
}
/** Retorna true se o usuário teve atividade recente (online). */
function estaOnline(alias, usuarioId) {
const t = ultimoPing.get(chave(alias, usuarioId));
return !!t && (Date.now() - t) <= TTL_MS;
}
/** Conjunto (Set) de IDs de usuários online para um alias. */
function idsOnline(alias) {
const agora = Date.now();
const prefixo = String(alias) + '|';
const set = new Set();
for (const [k, t] of ultimoPing.entries()) {
if (k.startsWith(prefixo) && (agora - t) <= TTL_MS) {
set.add(Number(k.slice(prefixo.length)));
}
}
return set;
}
// Limpeza periódica de entradas expiradas (evita crescimento indefinido)
const intervalo = setInterval(function () {
const agora = Date.now();
for (const [k, t] of ultimoPing.entries()) {
if (agora - t > TTL_MS * 5) ultimoPing.delete(k);
}
}, TTL_MS);
if (intervalo.unref) intervalo.unref();
module.exports = { registrar, estaOnline, idsOnline, TTL_MS };
+63 -21
View File
@@ -6,30 +6,28 @@
<title>Conversas - Chatc2</title> <title>Conversas - Chatc2</title>
<link rel="stylesheet" href="/css/main.css"> <link rel="stylesheet" href="/css/main.css">
<style> <style>
body { background:#f3f4f6; display:flex; min-height:100vh; } body { background:var(--surface-3); display:flex; min-height:100vh; }
.sidebar-nav .admin-only { display:none; } .sidebar-nav .admin-only { display:none; }
.main { flex:1; display:flex; flex-direction:column; min-width:0; } .main { flex:1; display:flex; flex-direction:column; min-width:0; }
.container { flex:1; padding:24px; overflow-y:auto; } .container { flex:1; padding:24px; overflow-y:auto; }
.filters { display:flex; gap:10px; margin-bottom:20px; flex-wrap:wrap; align-items:center; } .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, .filters input { padding:9px 14px; border:2px solid var(--border); border-radius:8px; font-size:13px; outline:none; background:#fff; color:var(--text-secondary); transition:border-color .15s; }
.filters select:focus, .filters input:focus { border-color:#667eea; } .filters select:focus, .filters input:focus { border-color:var(--primary); }
.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 { padding:9px 16px; background:var(--primary); color:#fff; border:none; border-radius:8px; font-size:13px; font-weight:600; cursor:pointer; transition:background .15s; }
.filters button:hover { background:#5a67d8; } .filters button:hover { background:var(--primary-dark); }
.status-badge { display:inline-block; padding:3px 10px; border-radius:10px; font-size:11px; font-weight:600; } .status-badge { display:inline-block; padding:3px 10px; border-radius:10px; font-size:11px; font-weight:600; }
.status-E { background:#fef3c7; color:#92400e; } .status-E { background:var(--warning-bg); color:var(--warning-text); }
.status-A { background:#d1fae5; color:#065f46; } .status-A { background:var(--success-bg); color:var(--success-text); }
.status-F { background:#f3f4f6; color:#6b7280; } .status-F { background:var(--surface-3); color:var(--text-muted); }
.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 { padding:5px 12px; border:none; border-radius:6px; background:var(--primary); color:#fff; font-size:12px; font-weight:600; cursor:pointer; text-decoration:none; transition:background .15s; display:inline-block; }
.atender-btn:hover { background:#5a67d8; } .atender-btn:hover { background:var(--primary-dark); }
.loading { text-align:center; padding:40px; color:#9ca3af; } .loading { text-align:center; padding:40px; color:var(--text-faint); }
</style> </style>
<link rel="stylesheet" href="/css/dark-mode.css"> <link rel="stylesheet" href="/css/dark-mode.css">
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?' Claro':'🌙 Escuro'});} <script>(function(){var m=localStorage.getItem("chatc2_dark_mode_manual");var d=m!==null?m==="true":window.matchMedia("(prefers-color-scheme: dark)").matches;if(d)document.documentElement.className="dark"})()</script>
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> </head>
<body> <body>
<aside class="sidebar"> <aside class="sidebar" role="navigation" aria-label="Navegação principal">
<div class="sidebar-brand"><div class="logo">C2</div><div><h2>Chatc2</h2><span id="sidebarAlias">-</span></div></div> <div class="sidebar-brand"><div class="logo">C2</div><div><h2>Chatc2</h2><span id="sidebarAlias">-</span></div></div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<div class="nav-label">Principal</div> <div class="nav-label">Principal</div>
@@ -41,30 +39,35 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<a href="#" id="navRoutes" class="admin-only"><span class="icon">📡</span> Rotas</a> <a href="#" id="navRoutes" class="admin-only"><span class="icon">📡</span> Rotas</a>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button> <button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px" aria-label="Alternar modo escuro">🌙 Escuro</button>
<a onclick="logout()"><span class="icon">🚪</span> Sair</a></div> <a onclick="logout()" aria-label="Sair do sistema"><span class="icon">🚪</span> Sair</a></div>
</aside> </aside>
<div class="main"> <main class="main" id="main-content">
<div class="topbar"> <div class="topbar">
<span class="topbar-title">💬 Todas as Conversas</span> <span class="topbar-title">💬 Todas as Conversas</span>
<span id="totalInfo" style="font-size:13px;color:#6b7280"></span> <span id="totalInfo" style="font-size:13px;color:#6b7280"></span>
</div> </div>
<div class="container"> <div class="container">
<div class="filters"> <div class="filters">
<label for="filterStatus" class="sr-only">Status</label>
<select id="filterStatus" onchange="carregar()"> <select id="filterStatus" onchange="carregar()">
<option value="A,E">Em aberto</option> <option value="A,E">Em aberto</option>
<option value="E">Em espera</option> <option value="E">Em espera</option>
<option value="A">Em atendimento</option> <option value="A">Em atendimento</option>
<option value="F">Finalizadas</option> <option value="F">Finalizadas</option>
<option value="A,E,F">Todas</option> <option value="A,E,F">Todas</option>
<option value="scheduled">📅 Agendadas</option>
</select> </select>
<input type="date" id="filterAgData" onchange="carregar()" style="display:none;padding:6px 10px;border:2px solid var(--border);border-radius:8px;font-size:13px;background:var(--surface-2);color:var(--text-primary)">
<label for="filterAtribuicao" class="sr-only">Atribuição</label>
<select id="filterAtribuicao" onchange="carregar()"> <select id="filterAtribuicao" onchange="carregar()">
<option value="">Todas</option> <option value="">Todas</option>
<option value="me">Minhas conversas</option> <option value="me">Minhas conversas</option>
<option value="sem">Sem atendente</option> <option value="sem">Sem atendente</option>
</select> </select>
<label for="buscaContato" class="sr-only">Buscar contato</label>
<input type="text" id="buscaContato" placeholder="Buscar por nome ou número..." onkeyup="if(event.key==='Enter')carregar()"> <input type="text" id="buscaContato" placeholder="Buscar por nome ou número..." onkeyup="if(event.key==='Enter')carregar()">
<button onclick="carregar()">🔄 Atualizar</button> <button onclick="carregar()" aria-label="Atualizar lista">🔄 Atualizar</button>
</div> </div>
<div class="card" id="tabelaContainer"> <div class="card" id="tabelaContainer">
<div class="loading"><div class="spinner"></div><p style="margin-top:8px">Carregando conversas...</p></div> <div class="loading"><div class="spinner"></div><p style="margin-top:8px">Carregando conversas...</p></div>
@@ -111,6 +114,39 @@ window.carregar = async function() {
container.innerHTML = '<div class="loading"><div class="spinner"></div><p style="margin-top:8px">Carregando conversas...</p></div>'; 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 status = document.getElementById('filterStatus').value;
// Mostra/esconde seletor de data para agendadas
var agDataInput = document.getElementById('filterAgData');
if (status === 'scheduled') {
agDataInput.style.display = 'inline-block';
if (!agDataInput.value) agDataInput.value = new Date().toISOString().split('T')[0];
} else {
agDataInput.style.display = 'none';
}
// Agendadas: usa API de schedules
if (status === 'scheduled') {
var dataSel = agDataInput.value;
var dataFmt = dataSel ? dataSel.split('-').reverse().join('/') : 'hoje';
const agData = await api('/schedules?empresaId=' + empresaId + '&data=' + dataSel);
if (!agData.success) { container.innerHTML = '<p>Erro ao carregar</p>'; return; }
if (!agData.data || agData.data.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="icon">📅</div><p>Nenhum agendamento para ' + dataFmt + '</p></div>';
return;
}
container.innerHTML = '<table><thead><tr><th>Contato</th><th>Número</th><th>Data</th><th>Hora</th><th>Anotação</th></tr></thead><tbody>' +
agData.data.map(function(a) {
var d = a.data ? a.data.split('-').reverse().join('/') : '-';
var h = a.hora ? a.hora.substring(0,5) : '-';
return '<tr><td><strong>' + (a.nomeContato || a.numero || '-') + '</strong></td>' +
'<td>' + (a.numero || '-') + '</td>' +
'<td>' + d + '</td>' +
'<td>' + h + '</td>' +
'<td style="font-size:12px;color:var(--text-muted)">' + (a.nota || '-') + '</td></tr>';
}).join('') + '</tbody></table>';
return;
}
const atrib = document.getElementById('filterAtribuicao').value; const atrib = document.getElementById('filterAtribuicao').value;
let busca = document.getElementById('buscaContato').value.trim(); let busca = document.getElementById('buscaContato').value.trim();
@@ -151,7 +187,7 @@ window.carregar = async function() {
convs.forEach(function(c) { convs.forEach(function(c) {
const statusLabel = c.status === 'E' ? '🟡 Espera' : c.status === 'A' ? '🟢 Atendimento' : '⚫ Finalizado'; const statusLabel = c.status === 'E' ? '🟡 Espera' : c.status === 'A' ? '🟢 Atendimento' : '⚫ Finalizado';
const labelsHtml = (c.labels || []).map(function(l) { 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>'; return '<span style="display:inline-block;padding:0 6px;border-radius:4px;background:'+(l.cor||'#667eea')+';color:#fff;font-size:10px;margin:1px">'+(l.nome||'')+'</span>';
}).join(''); }).join('');
const ultima = (c.ultimaMsg || '').substring(0, 60); const ultima = (c.ultimaMsg || '').substring(0, 60);
const usuarioNome = c.usuarioId ? 'Usuário ' + c.usuarioId : '-'; const usuarioNome = c.usuarioId ? 'Usuário ' + c.usuarioId : '-';
@@ -178,5 +214,11 @@ setInterval(carregar, 10000);
})(); })();
</script> </script>
<script src="/js/dark-mode.js"></script> <script src="/js/dark-mode.js"></script>
<!-- impeccable-live-start -->
<script src="http://localhost:8400/live.js"></script>
<!-- impeccable-live-end -->
<!-- Bottom Tab Bar (mobile) -->
<script src="/js/tabbar.js"></script>
</body> </body>
</html> </html>
+1054 -118
View File
File diff suppressed because it is too large Load Diff
+246 -97
View File
@@ -6,46 +6,97 @@
<title>Detalhes do Cliente - Chatc2</title> <title>Detalhes do Cliente - Chatc2</title>
<link rel="stylesheet" href="/css/main.css"> <link rel="stylesheet" href="/css/main.css">
<style> <style>
body { background:#f3f4f6; display:flex; min-height:100vh; } body { background:var(--surface-3); display:flex; min-height:100vh; }
.sidebar-nav .admin-only { display:none; } .sidebar-nav .admin-only { display:none; }
.main { flex:1; display:flex; flex-direction:column; min-width:0; } .main { flex:1; display:flex; flex-direction:column; min-width:0; }
.container { flex:1; padding:24px; overflow-y:auto; } .container { flex:1; padding:24px; overflow-y:auto; }
.loading { text-align:center; padding:80px 20px; color:#9ca3af; } .loading { text-align:center; padding:80px 20px; color:var(--text-faint); }
.error-box { background:#fef2f2; border:1px solid #fecaca; border-radius:12px; padding:32px; text-align:center; color:#991b1b; } .error-box { background:var(--danger-bg); border:1px solid #fecaca; border-radius:12px; padding:32px; text-align:center; color:var(--danger-text); }
.error-box .icon { font-size:48px; margin-bottom:12px; } .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-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 var(--surface-3); }
.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-avatar { width:68px; height:68px; border-radius:50%; background:linear-gradient(135deg,var(--primary),#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 h1 { font-size:22px; font-weight:800; color:var(--text-primary); margin-bottom:4px; letter-spacing:-0.3px; }
.client-header-info .matricula { font-size:14px; color:#6b7280; } .client-header-info .matricula { font-size:14px; color:var(--text-muted); }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:18px; margin-bottom:20px; } .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; } .card h3 { font-size:12px; text-transform:uppercase; letter-spacing:0.07em; color:var(--text-faint); margin-bottom:14px; font-weight:700; }
.field { margin-bottom:12px; } .field { margin-bottom:12px; }
.field:last-child { margin-bottom:0; } .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 .label { font-size:11px; text-transform:uppercase; letter-spacing:0.06em; color:var(--text-faint); margin-bottom:3px; font-weight:600; }
.field .value { font-size:15px; color:#111827; font-weight:500; } .field .value { font-size:15px; color:var(--text-primary); font-weight:500; }
.carnes-section { margin-top:20px; } .carnes-section { margin-top:20px; }
.carnes-filters { display:flex; gap:10px; margin-bottom:16px; flex-wrap:wrap; align-items:center; } .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 { display:flex; align-items:center; gap:6px; font-size:13px; color:var(--text-secondary); cursor:pointer; padding:7px 14px; border-radius:8px; border:1px solid var(--border); background:#fff; transition:all 0.15s; user-select:none; font-weight:500; }
.carnes-filters label:hover { border-color:#667eea; color:#667eea; } .carnes-filters label:hover { border-color:var(--primary); color:var(--primary); }
.carnes-filters label.filtro-ativo { border-color:#667eea; background:#eef2ff; color:#667eea; font-weight:600; } .carnes-filters label.filtro-ativo { border-color:var(--primary); background:#eef2ff; color:var(--primary); font-weight:600; }
.carnes-filters label input { accent-color:#667eea; } .carnes-filters label input { accent-color:var(--primary); }
table { font-size:13px; } table { font-size:13px; }
.valor { text-align:right; font-family:'SF Mono',monospace; } .valor { text-align:right; font-family:'SF Mono',monospace; }
.centro { text-align:center; } .centro { text-align:center; }
.sit-badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:600; } .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; } .vencido-dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--danger); 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 { margin-right:14px; padding:7px 14px; background:var(--surface-3); border:1px solid var(--border); border-radius:8px; font-size:13px; font-weight:500; cursor:pointer; color:var(--text-secondary); text-decoration:none; transition:all .15s; display:inline-flex; align-items:center; gap:4px; }
.back-btn:hover { background:#e5e7eb; border-color:#d1d5db; } .back-btn:hover { background:var(--border); border-color:#d1d5db; }
.empty-msg { text-align:center; padding:32px; color:#9ca3af; font-size:14px; } .empty-msg { text-align:center; padding:32px; color:var(--text-faint); font-size:14px; }
/* ===== ABAS ===== */
.aba-btn {
flex: 1; padding: 10px; border: none;
font-size: 13px; cursor: pointer;
transition: all .15s;
}
.aba-btn.ativo { background: var(--primary); color: #fff; font-weight: 600; }
.aba-btn.inativo { background: var(--surface-3); color: var(--text-secondary); font-weight: 400; }
.aba-btn.inativo:hover { background: var(--border); }
/* ===== SUB-ABAS (Convalescentes) ===== */
.subaba-btn {
flex: 1; padding: 6px 10px; border: none;
font-size: 11px; cursor: pointer;
transition: all .15s;
}
.subaba-btn.ativo { background: var(--primary); color: #fff; font-weight: 600; }
.subaba-btn.inativo { background: var(--surface-3); color: var(--text-secondary); }
/* ===== BADGES CONVALESCENTES ===== */
.conv-badge {
display: inline-block; padding: 2px 8px; border-radius: 10px;
font-size: 11px; font-weight: 600;
}
.conv-badge.devolvido { background: var(--success-bg); color: var(--success-text); }
.conv-badge.entregue { background: var(--warning-bg); color: var(--warning-text); }
.conv-badge.ativo { background: #dbeafe; color: #1e40af; }
.conv-badge.cancelado { background: var(--danger-bg); color: var(--danger-text); }
/* ===== BOTÃO AÇÃO PRIMÁRIA NA TOPBAR ===== */
.btn-acao-topbar {
display: inline-flex; align-items: center; gap: 4px;
padding: 7px 16px; border: none; border-radius: 8px;
background: var(--primary); color: #fff;
font-size: 13px; font-weight: 600; cursor: pointer;
text-decoration: none; transition: all .15s;
}
.btn-acao-topbar:hover { background: var(--primary-dark); }
/* ===== FEEDBACK INLINE ===== */
.feedback-msg {
padding: 8px 14px; border-radius: 6px; font-size: 13px;
margin-top: 8px; display: none;
}
.feedback-msg.sucesso { background: var(--success-bg); color: var(--success-text); display: block; }
.feedback-msg.erro { background: var(--danger-bg); color: var(--danger-text); display: block; }
/* ===== CARD CONVALESCENTE ===== */
.conv-card {
border: 1px solid var(--border); border-radius: 8px;
padding: 16px; margin-bottom: 12px;
}
</style> </style>
<link rel="stylesheet" href="/css/dark-mode.css"> <link rel="stylesheet" href="/css/dark-mode.css">
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?' Claro':'🌙 Escuro'});} <script>(function(){var m=localStorage.getItem("chatc2_dark_mode_manual");var d=m!==null?m==="true":window.matchMedia("(prefers-color-scheme: dark)").matches;if(d)document.documentElement.className="dark"})()</script>
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> </head>
<body> <body>
<aside class="sidebar"> <aside class="sidebar" role="navigation" aria-label="Navegação principal">
<div class="sidebar-brand"> <div class="sidebar-brand">
<div class="logo">C2</div> <div class="logo">C2</div>
<div><h2>Chatc2</h2><span id="sidebarAlias">-</span></div> <div><h2>Chatc2</h2><span id="sidebarAlias">-</span></div>
@@ -60,16 +111,16 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<a href="#" id="navRoutesDetail" class="admin-only"><span class="icon">📡</span> Rotas</a> <a href="#" id="navRoutesDetail" class="admin-only"><span class="icon">📡</span> Rotas</a>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button> <button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px" aria-label="Alternar modo escuro">🌙 Escuro</button>
<a onclick="logout()"><span class="icon">🚪</span> Sair</a> <a onclick="logout()" aria-label="Sair do sistema"><span class="icon">🚪</span> Sair</a>
</div> </div>
</aside> </aside>
<div class="main"> <main class="main" id="main-content">
<div class="topbar"> <div class="topbar">
<a href="#" class="back-btn" id="backBtn">← Voltar</a> <a href="#" class="back-btn" id="backBtn">← Voltar</a>
<span class="topbar-title">Detalhes do Cliente</span> <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> <a href="#" class="btn-acao-topbar" id="btnIniciarConv" style="margin-left:auto">💬 Iniciar Conversa</a>
</div> </div>
<div class="container" id="container"> <div class="container" id="container">
<div class="loading" id="loadingState"> <div class="loading" id="loadingState">
@@ -80,18 +131,18 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
</div> </div>
<!-- Modal Nova Conversa --> <!-- 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 class="modal-overlay" id="modalNovaConvDet">
<div style="background:#fff;border-radius:12px;padding:24px;width:90%;max-width:450px"> <div class="modal" style="max-width:450px">
<h3 style="margin-bottom:16px">💬 Iniciar Conversa</h3> <h3>💬 Iniciar Conversa</h3>
<input type="hidden" id="convClienteIdDet"> <input type="hidden" id="convClienteIdDet">
<input type="hidden" id="convEmpresaIdDet"> <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 for="convNumeroDet">Número</label><input type="text" id="convNumeroDet" readonly></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 for="convNomeDet">Cliente</label><input type="text" id="convNomeDet" readonly></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 for="convInstanciaDet">Instância WhatsApp</label><select id="convInstanciaDet"></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 class="form-group"><label for="convMensagemDet">Mensagem inicial</label><textarea id="convMensagemDet" rows="3" placeholder="Digite a mensagem que será enviada..."></textarea></div>
<div style="display:flex;gap:8px;justify-content:flex-end"> <div class="modal-footer">
<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 class="btn-secondary" onclick="document.getElementById('modalNovaConvDet').classList.remove('show')">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> <button class="btn-primary" onclick="iniciarConversaDet()" id="btnEnviarConv">💬 Enviar e Abrir Chat</button>
</div> </div>
</div> </div>
</div> </div>
@@ -209,9 +260,16 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
var sitLabel = c.situacao ? c.situacao.descricao : 'Desconhecido'; var sitLabel = c.situacao ? c.situacao.descricao : 'Desconhecido';
var iniciais = c.nome ? c.nome.split(' ').map(function(s) { return s[0]; }).slice(0,2).join('').toUpperCase() : '?'; var iniciais = c.nome ? c.nome.split(' ').map(function(s) { return s[0]; }).slice(0,2).join('').toUpperCase() : '?';
var avatarConteudo = iniciais;
if (c.foto) {
var srcFoto = c.foto;
if (srcFoto.indexOf('data:') !== 0 && srcFoto.length > 50) srcFoto = 'data:image/jpeg;base64,' + srcFoto;
avatarConteudo = '<img src="' + srcFoto + '" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:50%">';
}
container.innerHTML = container.innerHTML =
'<div class="client-header">' + '<div class="client-header">' +
'<div class="client-avatar">' + iniciais + '</div>' + '<div class="client-avatar">' + avatarConteudo + '</div>' +
'<div class="client-header-info">' + '<div class="client-header-info">' +
'<h1>' + (c.nome || '-') + '</h1>' + '<h1>' + (c.nome || '-') + '</h1>' +
'<div class="matricula">Matrícula: ' + (c.matricula || '-') + '</div>' + '<div class="matricula">Matrícula: ' + (c.matricula || '-') + '</div>' +
@@ -220,17 +278,17 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
'</div>' + '</div>' +
'<div class="grid">' + '<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="card"><h3>📞 Contato <button onclick="editarContato()" style="float:right;padding:2px 8px;border:1px solid var(--border);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">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">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 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">' + '<div id="editContatoForm" style="display:none;margin-top:12px;padding:12px;background:var(--surface-2);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="email" id="editEmail" placeholder="Email" style="width:100%;padding:8px;border:1px solid var(--border);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="editCelular" placeholder="Celular" style="width:100%;padding:8px;border:1px solid var(--border);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">' + '<input type="text" id="editTelefone" placeholder="Telefone" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;font-size:13px;margin-bottom:8px">' +
'<div style="display:flex;gap: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="salvarContato()" style="padding:6px 14px;background:var(--primary);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>' + '<button onclick="cancelarEditarContato()" style="padding:6px 14px;border:1px solid var(--border);border-radius:6px;background:#fff;cursor:pointer;font-size:12px">Cancelar</button>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
@@ -251,11 +309,11 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
'</div>' + '</div>' +
// ABAS: Conversas | Títulos | Dependentes | Convalescentes // 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)">' + '<div style="display:flex;gap:0;margin-bottom:16px;background:var(--surface);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 class="aba-btn ativo" onclick="mudarAba(\'conversas\')" id="abaConversas">💬 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 class="aba-btn inativo" onclick="mudarAba(\'titulos\')" id="abaTitulos">📄 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 class="aba-btn inativo" onclick="mudarAba(\'dependentes\')" id="abaDependentes">👥 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>' + '<button class="aba-btn inativo" onclick="mudarAba(\'convalescentes\')" id="abaConvalescentes">🛏️ Convalescentes</button>' +
'</div>' + '</div>' +
'<div id="conteudoAba">' + '<div id="conteudoAba">' +
// CONVERSAS (padrão - primeira aba) // CONVERSAS (padrão - primeira aba)
@@ -303,25 +361,24 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
var cC = document.getElementById('conteudoConvalescentes'); var cC = document.getElementById('conteudoConvalescentes');
var btns = [btnV, btnT, btnD, btnC]; var btns = [btnV, btnT, btnD, btnC];
var contents = [cV, cT, cD, cC]; var contents = [cV, cT, cD, cC];
btns.forEach(function(b) { if (b) { b.style.background = '#f3f4f6'; b.style.color = '#374151'; b.style.fontWeight = '400'; } }); btns.forEach(function(b) { if (b) { b.className = 'aba-btn inativo'; } });
contents.forEach(function(c) { if (c) c.style.display = 'none'; }); contents.forEach(function(c) { if (c) c.style.display = 'none'; });
var activeBtn, activeContent;
if (aba === 'conversas') { if (aba === 'conversas') {
btnV.style.background = '#667eea'; btnV.style.color = '#fff'; btnV.style.fontWeight = '600'; activeBtn = btnV; activeContent = cV;
cV.style.display = 'block';
carregarConversas(); carregarConversas();
} else if (aba === 'titulos') { } else if (aba === 'titulos') {
btnT.style.background = '#667eea'; btnT.style.color = '#fff'; btnT.style.fontWeight = '600'; activeBtn = btnT; activeContent = cT;
cT.style.display = 'block';
loadCarnes(1); loadCarnes(1);
} else if (aba === 'dependentes') { } else if (aba === 'dependentes') {
btnD.style.background = '#667eea'; btnD.style.color = '#fff'; btnD.style.fontWeight = '600'; activeBtn = btnD; activeContent = cD;
cD.style.display = 'block';
carregarDependentes(); carregarDependentes();
} else if (aba === 'convalescentes') { } else if (aba === 'convalescentes') {
btnC.style.background = '#667eea'; btnC.style.color = '#fff'; btnC.style.fontWeight = '600'; activeBtn = btnC; activeContent = cC;
cC.style.display = 'block';
carregarConvalescentes(); carregarConvalescentes();
} }
if (activeBtn) activeBtn.className = 'aba-btn ativo';
if (activeContent) activeContent.style.display = 'block';
}; };
async function carregarConversas() { async function carregarConversas() {
@@ -353,7 +410,7 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
'<td style="font-size:12px">' + statusLabel + '</td>' + '<td style="font-size:12px">' + statusLabel + '</td>' +
'<td style="font-size:12px">' + usuario + '</td>' + '<td style="font-size:12px">' + usuario + '</td>' +
'<td style="font-size:12px">' + equipe + '</td>' + '<td style="font-size:12px">' + equipe + '</td>' +
'<td><span style="color:#667eea;font-size:12px">Abrir →</span></td>' + '<td><span style="color:var(--primary);font-size:12px">Abrir →</span></td>' +
'</tr>'; '</tr>';
}); });
html += '</tbody></table></div>'; html += '</tbody></table></div>';
@@ -382,7 +439,7 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
var telEdit = d.telefone || ''; var telEdit = d.telefone || '';
html += '<tr><td><strong>' + d.nome + '</strong></td><td>' + getParentescoLabel(d.parentesco) + '</td><td>' + html += '<tr><td><strong>' + d.nome + '</strong></td><td>' + getParentescoLabel(d.parentesco) + '</td><td>' +
'<span id="depTel_' + d.id + '">' + (telEdit || '-') + '</span> ' + '<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>'; '<button onclick="editarTelDep(' + d.id + ')" data-tel="' + telEdit.replace(/"/g,'&quot;') + '" style="padding:1px 6px;border:1px solid var(--border);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>'; html += '</tbody></table></div>';
area.innerHTML = html; area.innerHTML = html;
@@ -406,34 +463,34 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
var html = ''; var html = '';
data.data.forEach(function(c) { data.data.forEach(function(c) {
var sitLabel = c.situacao || '-'; var sitLabel = c.situacao || '-';
var sitBadge = c.situacao === 'D' ? '<span class="sit-badge" style="background:#d1fae5;color:#065f46">Devolvido</span>' : var sitBadge = c.situacao === 'D' ? '<span class="conv-badge devolvido">Devolvido</span>' :
c.situacao === 'E' ? '<span class="sit-badge" style="background:#fef3c7;color:#92400e">Entregue</span>' : c.situacao === 'E' ? '<span class="conv-badge entregue">Entregue</span>' :
c.situacao === 'A' ? '<span class="sit-badge" style="background:#dbeafe;color:#1e40af">Ativo</span>' : c.situacao === 'A' ? '<span class="conv-badge ativo">Ativo</span>' :
c.situacao === 'C' ? '<span class="sit-badge" style="background:#fef2f2;color:#991b1b">Cancelado</span>' : c.situacao === 'C' ? '<span class="conv-badge cancelado">Cancelado</span>' :
'<span class="sit-badge" style="background:#f3f4f6;color:#6b7280">' + c.situacao + '</span>'; '<span class="conv-badge" style="background:var(--surface-3);color:var(--text-muted)">' + c.situacao + '</span>';
html += '<div style="border:1px solid #e5e7eb;border-radius:8px;padding:16px;margin-bottom:12px">' + html += '<div class="conv-card">' +
'<div style="display:flex;justify-content:space-between;align-items:center;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>' + '<h4 style="margin:0;font-size:15px">🛏️ Convalescente #' + c.id + '</h4>' +
sitBadge + sitBadge +
'</div>' + '</div>' +
'<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;font-size:13px;margin-bottom:12px">' + '<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:var(--text-faint)">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:var(--text-faint)">Data Saída:</span> ' + fmtDate(c.dtSaida) + '</div>' +
'<div><span style="color:#9ca3af">Previsão Retorno:</span> ' + fmtDate(c.dtPrevisaoRetorno) + '</div>' + '<div><span style="color:var(--text-faint)">Previsão Retorno:</span> ' + fmtDate(c.dtPrevisaoRetorno) + '</div>' +
(c.dtCancelou ? '<div><span style="color:#9ca3af">Data Cancelamento:</span> ' + fmtDate(c.dtCancelou) + '</div>' : '') + (c.dtCancelou ? '<div><span style="color:var(--text-faint)">Data Cancelamento:</span> ' + fmtDate(c.dtCancelou) + '</div>' : '') +
'<div><span style="color:#9ca3af">Cadastrado por:</span> ' + (c.usuarioNome || '-') + '</div>' + '<div><span style="color:var(--text-faint)">Cadastrado por:</span> ' + (c.usuarioNome || '-') + '</div>' +
'</div>'; '</div>';
// SUB-ABAS: Boletos | Itens // SUB-ABAS: Boletos | Itens
var temCarnes = c.carnes && c.carnes.length > 0; var temCarnes = c.carnes && c.carnes.length > 0;
var temItens = c.itens && c.itens.length > 0; var temItens = c.itens && c.itens.length > 0;
if (temCarnes || temItens) { if (temCarnes || temItens) {
html += '<div style="margin-top:12px;padding-top:12px;border-top:1px solid #e5e7eb">' + html += '<div style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border)">' +
'<div style="display:flex;gap:0;margin-bottom:8px;border-radius:6px;overflow:hidden">'; '<div style="display:flex;gap:0;margin-bottom:8px;border-radius:6px;overflow:hidden">';
if (temCarnes) { 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>'; html += '<button class="subaba-btn ativo" onclick="mudarSubaba(\'carnes_' + c.id + '\',this)" id="subCarnes_' + c.id + '">📄 Boletos (' + c.carnes.length + ')</button>';
} }
if (temItens) { 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 += '<button class="subaba-btn ' + (temCarnes ? 'inativo' : 'ativo') + '" onclick="mudarSubaba(\'itens_' + c.id + '\',this)" id="subItens_' + c.id + '">📦 Itens (' + c.itens.length + ')</button>';
} }
html += '</div>'; html += '</div>';
// Conteudo Boletos // Conteudo Boletos
@@ -452,8 +509,8 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
} else { } else {
html += '<div id="conteudoCarnes_' + c.id + '" style="display:none"></div>'; html += '<div id="conteudoCarnes_' + c.id + '" style="display:none"></div>';
} }
// Conteudo Itens // Conteudo Itens (oculto por padrão quando há boletos — Boletos é a aba ativa)
html += '<div id="conteudoItens_' + c.id + '" style="' + (temCarnes && !temItens ? 'display:none' : '') + ';overflow-x:auto">'; html += '<div id="conteudoItens_' + c.id + '" style="' + (temCarnes ? 'display:none;' : '') + 'overflow-x:auto">';
if (temItens) { if (temItens) {
html += '<table style="font-size:12px"><thead><tr>' + html += '<table style="font-size:12px"><thead><tr>' +
'<th>Produto</th><th style="text-align:center">Quantidade</th>' + '<th>Produto</th><th style="text-align:center">Quantidade</th>' +
@@ -482,8 +539,8 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
window.mudarSubaba = function(id, btn) { window.mudarSubaba = function(id, btn) {
var container = btn.parentElement.parentElement; var container = btn.parentElement.parentElement;
var botoes = container.querySelectorAll('button[id^="sub"]'); var botoes = container.querySelectorAll('button[id^="sub"]');
botoes.forEach(function(b) { b.style.background = '#f3f4f6'; b.style.color = '#374151'; }); botoes.forEach(function(b) { b.className = 'subaba-btn inativo'; });
btn.style.background = '#667eea'; btn.style.color = '#fff'; btn.className = 'subaba-btn ativo';
var prefixo = id.split('_')[0]; var prefixo = id.split('_')[0];
var covId = id.split('_')[1]; var covId = id.split('_')[1];
var cCarnes = document.getElementById('conteudoCarnes_' + covId); var cCarnes = document.getElementById('conteudoCarnes_' + covId);
@@ -492,19 +549,64 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
if (cItens) cItens.style.display = prefixo === 'itens' ? '' : 'none'; if (cItens) cItens.style.display = prefixo === 'itens' ? '' : 'none';
}; };
// Edição INLINE do telefone do dependente (sem prompt/alert)
window.editarTelDep = function(id) { window.editarTelDep = function(id) {
var btn = document.querySelector('button[onclick="editarTelDep(' + id + ')"]'); var span = document.getElementById('depTel_' + id);
var telAtual = btn ? (btn.getAttribute('data-tel') || '') : ''; if (!span || span.getAttribute('data-editing') === '1') return;
var novo = prompt('Editar telefone do dependente:', telAtual || ''); var btnEdit = document.querySelector('button[onclick="editarTelDep(' + id + ')"]');
if (novo === null || novo.trim() === telAtual) return; var telAtual = btnEdit ? (btnEdit.getAttribute('data-tel') || '') : '';
span.setAttribute('data-editing', '1');
span.setAttribute('data-original', span.innerHTML);
if (btnEdit) btnEdit.style.display = 'none';
span.innerHTML =
'<input type="text" id="depTelInput_' + id + '" value="' + telAtual.replace(/"/g, '&quot;') + '" style="width:120px;padding:2px 6px;border:1px solid #667eea;border-radius:4px;font-size:12px;outline:none">' +
' <button onclick="confirmarTelDep(' + id + ')" title="Confirmar" style="padding:1px 7px;border:1px solid #059669;border-radius:4px;background:#059669;color:#fff;cursor:pointer;font-size:11px"></button>' +
' <button onclick="cancelarTelDep(' + id + ')" title="Cancelar" style="padding:1px 7px;border:1px solid var(--border);border-radius:4px;background:#fff;cursor:pointer;font-size:11px"></button>';
var inp = document.getElementById('depTelInput_' + id);
if (inp) {
inp.focus();
inp.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); confirmarTelDep(id); }
else if (e.key === 'Escape') { cancelarTelDep(id); }
});
}
};
window.cancelarTelDep = function(id) {
var span = document.getElementById('depTel_' + id);
if (!span) return;
span.innerHTML = span.getAttribute('data-original') || '-';
span.setAttribute('data-editing', '');
var btnEdit = document.querySelector('button[onclick="editarTelDep(' + id + ')"]');
if (btnEdit) btnEdit.style.display = '';
};
window.confirmarTelDep = function(id) {
var inp = document.getElementById('depTelInput_' + id);
var span = document.getElementById('depTel_' + id);
if (!inp || !span) return;
var novo = (inp.value || '').trim();
var btnEdit = document.querySelector('button[onclick="editarTelDep(' + id + ')"]');
inp.disabled = true;
fetch('/api/' + alias + '/dependents/' + id + '/phone', { fetch('/api/' + alias + '/dependents/' + id + '/phone', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ telefone: novo.trim() }) body: JSON.stringify({ telefone: novo })
}).then(function(r) { return r.json(); }).then(function(d) { }).then(function(r) { return r.json(); }).then(function(d) {
if (d.success) { document.getElementById('depTel_' + id).textContent = novo.trim(); alert('Telefone atualizado!'); } if (d.success) {
else alert('Erro: ' + d.error); span.setAttribute('data-editing', '');
}).catch(function(e) { alert('Erro: ' + e.message); }); span.textContent = novo || '-';
if (btnEdit) { btnEdit.setAttribute('data-tel', novo); btnEdit.style.display = ''; }
} else {
inp.disabled = false;
inp.style.borderColor = '#ef4444';
inp.title = d.error || 'Erro ao salvar';
}
}).catch(function(e) {
inp.disabled = false;
inp.style.borderColor = '#ef4444';
inp.title = e.message;
});
}; };
// ============================================================ // ============================================================
@@ -535,6 +637,28 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
return tipos; return tipos;
} }
// Editar data de agendamento de cobrança
window.editarAgendamento = function(carneId, el) {
var dataAtual = el.textContent.trim();
var novaData = prompt('Nova data de agendamento (AAAA-MM-DD):', dataAtual !== '-' ? dataAtual : '');
if (novaData === null) return; // cancelou
if (!novaData.trim()) {
novaData = null; // limpar agendamento
}
fetch('/api/' + alias + '/clients/' + id_cliente + '/carne/' + carneId + '/agendamento', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ dataAgendamento: novaData })
}).then(function(r) { return r.json(); }).then(function(data) {
if (data.success) {
el.textContent = novaData ? novaData.split('-').reverse().join('/') : '-';
} else {
alert(data.error || 'Erro ao atualizar');
}
}).catch(function() { alert('Erro de conexão'); });
};
// Torna global para os onclick dos botões de paginação // Torna global para os onclick dos botões de paginação
window.loadCarnes = function(page) { window.loadCarnes = function(page) {
if (page) carnesPage = page; if (page) carnesPage = page;
@@ -586,7 +710,7 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
for (var i = 0; i < data.data.length; i++) { for (var i = 0; i < data.data.length; i++) {
var c = data.data[i]; var c = data.data[i];
var bg = c.situacao.color || '#f3f4f6'; var bg = c.situacao.color || '#f3f4f6';
var tc = c.situacao.textColor || '#6b7280'; var tc = c.situacao.textColor || 'var(--text-muted)';
var vencDot = c.vencido ? '<span class="vencido-dot"></span>' : ''; var vencDot = c.vencido ? '<span class="vencido-dot"></span>' : '';
html += '<tr>' + html += '<tr>' +
@@ -595,7 +719,9 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
'<td>' + vencDot + fmtDate(c.vencimento) + '</td>' + '<td>' + vencDot + fmtDate(c.vencimento) + '</td>' +
'<td class="valor">' + fmtMoney(c.valorParcela) + '</td>' + '<td class="valor">' + fmtMoney(c.valorParcela) + '</td>' +
'<td>' + fmtDate(c.dataPagamento) + '</td>' + '<td>' + fmtDate(c.dataPagamento) + '</td>' +
'<td style="font-size:11px">' + fmtDate(c.agendamentoCobranca) + '</td>' + '<td style="font-size:11px">' +
'<span class="agendamento-cell" onclick="editarAgendamento(' + c.id + ',this)" title="Clique para alterar" style="cursor:pointer;border-bottom:1px dashed #9ca3af">' +
fmtDate(c.agendamentoCobranca) + '</span></td>' +
'<td>' + (c.nossoNumero || '-') + '</td>' + '<td>' + (c.nossoNumero || '-') + '</td>' +
'<td class="centro">' + (c.parcela || '-') + '/' + (c.totalParcelas || '-') + '</td>' + '<td class="centro">' + (c.parcela || '-') + '/' + (c.totalParcelas || '-') + '</td>' +
'</tr>'; '</tr>';
@@ -653,7 +779,10 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
if (celular) data.celular = celular; if (celular) data.celular = celular;
if (telefone) data.telefone = telefone; if (telefone) data.telefone = telefone;
if (Object.keys(data).length === 0) { alert('Preencha ao menos um campo'); return; } if (Object.keys(data).length === 0) {
mostrarFeedback('Preencha ao menos um campo', 'erro');
return;
}
try { try {
var r = await fetch('/api/' + alias + '/clients/' + clienteId, { var r = await fetch('/api/' + alias + '/clients/' + clienteId, {
@@ -663,15 +792,29 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
}); });
var d = await r.json(); var d = await r.json();
if (d.success) { if (d.success) {
alert('Dados atualizados!'); mostrarFeedback('Dados atualizados com sucesso!', 'sucesso');
document.getElementById('editContatoForm').style.display = 'none'; document.getElementById('editContatoForm').style.display = 'none';
loadClient(); // Recarrega os dados loadClient();
} else { } else {
alert('Erro: ' + d.error); mostrarFeedback('Erro: ' + d.error, 'erro');
} }
} catch(e) { alert('Erro: ' + e.message); } } catch(e) { mostrarFeedback('Erro: ' + e.message, 'erro'); }
}; };
function mostrarFeedback(msg, tipo) {
var fb = document.getElementById('editFeedback');
if (!fb) {
fb = document.createElement('div');
fb.id = 'editFeedback';
fb.className = 'feedback-msg';
var form = document.getElementById('editContatoForm');
form.parentNode.insertBefore(fb, form.nextSibling);
}
fb.textContent = msg;
fb.className = 'feedback-msg ' + tipo;
setTimeout(function() { if (fb) fb.className = 'feedback-msg'; }, 3000);
}
// ===== MODAL NOVA CONVERSA ===== // ===== MODAL NOVA CONVERSA =====
window.abrirModalConversa = async function(empresaId, clienteId, nome, celular) { window.abrirModalConversa = async function(empresaId, clienteId, nome, celular) {
document.getElementById('convEmpresaIdDet').value = empresaId; document.getElementById('convEmpresaIdDet').value = empresaId;
@@ -698,7 +841,7 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
sel.innerHTML = '<option value="">Erro ao carregar</option>'; sel.innerHTML = '<option value="">Erro ao carregar</option>';
} }
document.getElementById('modalNovaConvDet').style.display = 'flex'; document.getElementById('modalNovaConvDet').classList.add('show');
}; };
window.iniciarConversaDet = async function() { window.iniciarConversaDet = async function() {
@@ -720,7 +863,7 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
}); });
var d = await r.json(); var d = await r.json();
if (d.success) { if (d.success) {
document.getElementById('modalNovaConvDet').style.display = 'none'; document.getElementById('modalNovaConvDet').classList.remove('show');
window.location.href = '/app/' + alias + '/company/' + empresaId + '/conversation/' + d.data.id; window.location.href = '/app/' + alias + '/company/' + empresaId + '/conversation/' + d.data.id;
} else { } else {
alert('Erro: ' + d.error); alert('Erro: ' + d.error);
@@ -729,7 +872,7 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
}; };
// Fechar modal ao clicar fora // Fechar modal ao clicar fora
document.getElementById('modalNovaConvDet').onclick = function(e) { if (e.target === this) this.style.display = 'none'; }; document.getElementById('modalNovaConvDet').onclick = function(e) { if (e.target === this) this.classList.remove('show'); };
// ---- Iniciar ---- // ---- Iniciar ----
loadClient(); loadClient();
@@ -737,5 +880,11 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
})(); })();
</script> </script>
<script src="/js/dark-mode.js"></script> <script src="/js/dark-mode.js"></script>
<!-- impeccable-live-start -->
<script src="http://localhost:8400/live.js"></script>
<!-- impeccable-live-end -->
<!-- Bottom Tab Bar (mobile) -->
<script src="/js/tabbar.js"></script>
</body> </body>
</html> </html>
+54 -33
View File
@@ -6,18 +6,16 @@
<title>Clientes - Chatc2</title> <title>Clientes - Chatc2</title>
<link rel="stylesheet" href="/css/main.css"> <link rel="stylesheet" href="/css/main.css">
<style> <style>
body { background: #f3f4f6; display: flex; min-height: 100vh; } body { background: var(--surface-3); display: flex; min-height: 100vh; }
.sidebar-nav .admin-only { display: none; } .sidebar-nav .admin-only { display: none; }
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; } .main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.container { flex: 1; padding: 24px; overflow-y: auto; } .container { flex: 1; padding: 24px; overflow-y: auto; }
</style> </style>
<link rel="stylesheet" href="/css/dark-mode.css"> <link rel="stylesheet" href="/css/dark-mode.css">
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?' Claro':'🌙 Escuro'});} <script>(function(){var m=localStorage.getItem("chatc2_dark_mode_manual");var d=m!==null?m==="true":window.matchMedia("(prefers-color-scheme: dark)").matches;if(d)document.documentElement.className="dark"})()</script>
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> </head>
<body> <body>
<aside class="sidebar"> <aside class="sidebar" role="navigation" aria-label="Navegação principal">
<div class="sidebar-brand"> <div class="sidebar-brand">
<div class="logo">C2</div> <div class="logo">C2</div>
<div><h2>Chatc2</h2><span id="sidebarAlias">lajedo</span></div> <div><h2>Chatc2</h2><span id="sidebarAlias">lajedo</span></div>
@@ -32,12 +30,12 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<a href="#" id="navRoutes" class="admin-only"><span class="icon">📡</span> Rotas</a> <a href="#" id="navRoutes" class="admin-only"><span class="icon">📡</span> Rotas</a>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button> <button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px" aria-label="Alternar modo escuro">🌙 Escuro</button>
<a onclick="logout()"><span class="icon">🚪</span> Sair</a> <a onclick="logout()" aria-label="Sair do sistema"><span class="icon">🚪</span> Sair</a>
</div> </div>
</aside> </aside>
<div class="main"> <main class="main" id="main-content">
<div class="topbar"> <div class="topbar">
<span class="topbar-title">👥 Clientes</span> <span class="topbar-title">👥 Clientes</span>
<div class="user-info"> <div class="user-info">
@@ -47,12 +45,12 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
</div> </div>
<div class="container"> <div class="container">
<div class="search-bar"> <div class="search-bar" role="search">
<select id="empresaSelect"> <select id="empresaSelect">
<option value="">Todas as empresas</option> <option value="">Todas as empresas</option>
</select> </select>
<input type="text" id="searchInput" placeholder="Buscar por nome, matrícula ou CPF..." autofocus /> <label for="searchInput" class="sr-only">Buscar</label><input type="text" id="searchInput" placeholder="Buscar por nome, matrícula ou CPF..." autofocus />
<button id="btnSearch">🔍 Buscar</button> <button id="btnSearch" aria-label="Buscar">🔍 Buscar</button>
<span class="total-info" id="totalInfo"></span> <span class="total-info" id="totalInfo"></span>
</div> </div>
@@ -249,28 +247,35 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
// === INICIAR === // === INICIAR ===
loadEmpresas().then(() => fetchClients(1, '', '')); loadEmpresas().then(() => fetchClients(1, '', ''));
</script> </script>
<script src="/js/dark-mode.js"></script>
</body>
</html>
<!-- Modal Nova Conversa --> <!-- 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 class="modal-overlay" id="modalNovaConv">
<div style="background:#fff;border-radius:12px;padding:24px;width:90%;max-width:450px"> <div class="modal" style="max-width:450px">
<h3 style="margin-bottom:16px">💬 Iniciar Conversa</h3> <h3>💬 Iniciar Conversa</h3>
<input type="hidden" id="convClienteId"> <input type="hidden" id="convClienteId">
<input type="hidden" id="convEmpresaId"> <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 for="convNumero">Número</label><input type="text" id="convNumero" readonly></div>
<div class="form-group"><label>Cliente</label><input type="text" id="convNome" readonly style="background:#f9fafb"></div> <div class="form-group"><label for="convNome">Cliente</label><input type="text" id="convNome" readonly></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 for="convInstancia">Instância WhatsApp</label><select id="convInstancia"></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 class="form-group"><label for="convMensagem">Mensagem inicial</label><textarea id="convMensagem" rows="3" placeholder="Digite a mensagem que será enviada..."></textarea></div>
<div style="display:flex;gap:8px;justify-content:flex-end"> <div id="convFeedback" class="feedback-msg"></div>
<button class="btn" onclick="fecharModalConv()" style="padding:8px 16px;border:1px solid #d1d5db;border-radius:8px;background:#fff;cursor:pointer">Cancelar</button> <div class="modal-footer">
<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> <button class="btn-secondary" onclick="fecharModalConv()">Cancelar</button>
<button class="btn-primary" onclick="iniciarConversa()">💬 Enviar e Abrir Chat</button>
</div> </div>
</div> </div>
</div> </div>
<script> <script>
// ===== FECHAR MODAIS COM ESC =====
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay.show').forEach(function(m) {
m.classList.remove('show');
});
}
});
// ===== NOVA CONVERSA ===== // ===== NOVA CONVERSA =====
var modalConvEl = document.getElementById('modalNovaConv'); var modalConvEl = document.getElementById('modalNovaConv');
@@ -280,8 +285,8 @@ window.abrirModalConversa = async function(empresaId, clienteId, nome, celular)
document.getElementById('convNumero').value = celular || ''; document.getElementById('convNumero').value = celular || '';
document.getElementById('convNome').value = nome || ''; document.getElementById('convNome').value = nome || '';
document.getElementById('convMensagem').value = 'Olá ' + (nome || '') + '! Tudo bem? Como podemos ajudar?'; document.getElementById('convMensagem').value = 'Olá ' + (nome || '') + '! Tudo bem? Como podemos ajudar?';
document.getElementById('convFeedback').className = 'feedback-msg';
// Carregar instâncias
var sel = document.getElementById('convInstancia'); var sel = document.getElementById('convInstancia');
sel.innerHTML = '<option value="">Carregando...</option>'; sel.innerHTML = '<option value="">Carregando...</option>';
try { try {
@@ -300,11 +305,11 @@ window.abrirModalConversa = async function(empresaId, clienteId, nome, celular)
sel.innerHTML = '<option value="">Erro ao carregar</option>'; sel.innerHTML = '<option value="">Erro ao carregar</option>';
} }
modalConvEl.style.display = 'flex'; modalConvEl.classList.add('show');
}; };
window.fecharModalConv = function() { window.fecharModalConv = function() {
modalConvEl.style.display = 'none'; modalConvEl.classList.remove('show');
}; };
window.iniciarConversa = async function() { window.iniciarConversa = async function() {
@@ -316,11 +321,10 @@ window.iniciarConversa = async function() {
var clienteId = document.getElementById('convClienteId').value; var clienteId = document.getElementById('convClienteId').value;
if (!numero || !mensagem) { if (!numero || !mensagem) {
alert('Informe o número e a mensagem'); mostrarConvFeedback('Informe o número e a mensagem', 'erro');
return; return;
} }
// Adicionar código do país se necessário
var numCompleto = numero.startsWith('55') ? numero : '55' + numero; var numCompleto = numero.startsWith('55') ? numero : '55' + numero;
try { try {
@@ -334,13 +338,30 @@ window.iniciarConversa = async function() {
fecharModalConv(); fecharModalConv();
window.location.href = '/app/' + alias + '/company/' + empresaId + '/conversation/' + d.data.id; window.location.href = '/app/' + alias + '/company/' + empresaId + '/conversation/' + d.data.id;
} else { } else {
alert('Erro: ' + d.error); mostrarConvFeedback('Erro: ' + d.error, 'erro');
} }
} catch(e) { } catch(e) {
alert('Erro de conexão: ' + e.message); mostrarConvFeedback('Erro de conexão: ' + e.message, 'erro');
} }
}; };
// Fechar modal ao clicar fora function mostrarConvFeedback(msg, tipo) {
modalConvEl.addEventListener('click', function(e) { if (e.target === this) this.style.display = 'none'; }); var fb = document.getElementById('convFeedback');
fb.textContent = msg;
fb.className = 'feedback-msg ' + tipo;
setTimeout(function() { fb.className = 'feedback-msg'; }, 4000);
}
modalConvEl.addEventListener('click', function(e) { if (e.target === this) this.classList.remove('show'); });
</script>
<script src="/js/dark-mode.js"></script>
<!-- impeccable-live-start -->
<script src="http://localhost:8400/live.js"></script>
<!-- impeccable-live-end -->
<!-- Bottom Tab Bar (mobile) -->
<script src="/js/tabbar.js"></script>
</body>
</html>
</script> </script>
+192 -80
View File
@@ -3,102 +3,187 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Avalie seu Atendimento</title> <title>Avalie seu Atendimento - Chatc2</title>
<style> <style>
:root {
--primary: #667eea;
--primary-dark: #5a67d8;
--secondary: #764ba2;
--surface: #ffffff;
--surface-2: #f9fafb;
--surface-3: #f3f4f6;
--border: #e5e7eb;
--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;
}
* { margin:0; padding:0; box-sizing:border-box; } * { margin:0; padding:0; box-sizing:border-box; }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -webkit-font-smoothing: antialiased;
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
background-size: 400% 400%;
animation: gradientShift 12s ease infinite;
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 20px; padding: 20px;
color: var(--text-primary);
} }
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.container { .container {
background: #fff; background: var(--surface);
border-radius: 20px; border-radius: 20px;
padding: 40px; padding: 44px 40px 36px;
max-width: 480px; max-width: 440px;
width: 100%; width: 100%;
text-align: center; text-align: center;
box-shadow: 0 20px 60px rgba(0,0,0,0.15); box-shadow: 0 32px 80px rgba(0,0,0,0.28), 0 0 0 1px rgba(255,255,255,0.1);
} }
.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 { .logo {
display: flex; width: 76px; height: 76px;
background: linear-gradient(135deg, var(--primary), var(--secondary));
border-radius: 20px;
display: inline-flex;
align-items: center;
justify-content: center; justify-content: center;
gap: 8px; margin-bottom: 18px;
font-size: 32px;
color: #fff;
}
h1 {
font-size: 24px; font-weight: 800;
color: var(--text-primary);
margin-bottom: 6px;
letter-spacing: -0.5px;
}
.subtitle {
font-size: 14px; color: var(--text-muted);
margin-bottom: 32px;
line-height: 1.5;
}
/* Stars */
.stars {
display: flex; justify-content: center; gap: 8px;
margin-bottom: 32px; margin-bottom: 32px;
direction: rtl;
} }
.stars input { display: none; } .stars input { display: none; }
.stars label { .stars label {
font-size: 48px; font-size: 48px; cursor: pointer;
cursor: pointer;
color: #d1d5db; color: #d1d5db;
transition: color .2s, transform .15s; transition: color .2s, transform .15s;
user-select: none;
} }
.stars label:hover, .stars label:hover { transform: scale(1.15); }
.stars label:hover ~ label, .stars input:checked ~ label { color: var(--warning); }
.stars input:checked ~ label { .stars input:checked + label { color: var(--warning); }
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 { .rating-text {
font-size: 14px; font-size: 14px; color: var(--text-muted);
color: #6b7280; margin-bottom: 24px; min-height: 20px;
margin-bottom: 24px; font-weight: 500;
min-height: 20px;
} }
/* Textarea */
textarea {
width: 100%; padding: 10px 14px;
border: 2px solid var(--border);
border-radius: 8px;
font-size: 14px; font-family: inherit;
resize: vertical; min-height: 80px;
outline: none; background: var(--surface-2);
color: var(--text-primary);
transition: all .15s;
margin-bottom: 20px;
}
textarea:focus {
border-color: var(--primary);
background: var(--surface);
box-shadow: 0 0 0 3px rgba(102,126,234,0.12);
}
textarea::placeholder {
color: var(--text-faint);
}
/* Button */
.btn {
width: 100%; padding: 12px 20px;
background: var(--primary);
color: #fff;
border: none; border-radius: 8px;
font-size: 15px; font-weight: 600;
cursor: pointer;
transition: all .15s;
font-family: inherit;
}
.btn:hover { background: var(--primary-dark); }
.btn:active { transform: scale(0.98); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
/* Success */
.success { display: none; } .success { display: none; }
.success .icon { font-size: 64px; margin-bottom: 16px; } .success .icon { font-size: 64px; margin-bottom: 16px; }
.success h2 { color: #059669; margin-bottom: 8px; } .success h2 { color: var(--success); margin-bottom: 8px; font-size: 20px; font-weight: 700; }
.success p { color: #6b7280; } .success p { color: var(--text-muted); font-size: 14px; }
/* Error */
.erro { .erro {
color: #ef4444; background: var(--danger-bg); color: var(--danger-text);
font-size: 14px; padding: 8px 14px; border-radius: 6px;
margin-top: 12px; font-size: 13px; margin-top: 12px;
display: none; display: none; font-weight: 500;
}
/* ===== DARK MODE ===== */
body.dark-mode { background: linear-gradient(135deg, #1e1b4b 0%, #312e81 50%, #4c1d95 100%); }
body.dark-mode .container {
background: #16213e;
box-shadow: 0 32px 80px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.05);
color: #e0e0e0;
}
body.dark-mode h1 { color: #e0e0e0; }
body.dark-mode .subtitle { color: #9ca3af; }
body.dark-mode .rating-text { color: #9ca3af; }
body.dark-mode textarea {
background: #1a1a2e; border-color: #0f3460;
color: #e0e0e0;
}
body.dark-mode textarea:focus {
border-color: #818cf8;
background: #1a1a2e;
box-shadow: 0 0 0 3px rgba(129,140,248,0.15);
}
body.dark-mode textarea::placeholder { color: #6b7280; }
body.dark-mode .erro { background: #3b1515; color: #fca5a5; }
body.dark-mode .success h2 { color: #4ade80; }
body.dark-mode .success p { color: #9ca3af; }
/* Responsivo */
@media (max-width: 480px) {
.container { padding: 32px 24px 28px; border-radius: 16px; }
h1 { font-size: 20px; }
.stars label { font-size: 40px; }
} }
</style> </style>
</head> </head>
@@ -135,7 +220,13 @@ textarea:focus { border-color: #667eea; }
</div> </div>
<script> <script>
var alias, conversaId, empresaId; (function(){
var alias, conversaId, empresaId, nota = 0;
// Detecta dark mode
var m = localStorage.getItem('chatc2_dark_mode_manual');
var d = m !== null ? m === 'true' : window.matchMedia('(prefers-color-scheme: dark)').matches;
if (d) document.body.classList.add('dark-mode');
function getParams() { function getParams() {
var params = new URLSearchParams(window.location.search); var params = new URLSearchParams(window.location.search);
@@ -144,27 +235,45 @@ function getParams() {
empresaId = params.get('empresa'); empresaId = params.get('empresa');
if (!alias || !conversaId || !empresaId) { if (!alias || !conversaId || !empresaId) {
document.getElementById('erro').textContent = 'Link inválido. Entre em contato conosco.'; var erro = document.getElementById('erro');
document.getElementById('erro').style.display = 'block'; erro.textContent = 'Link inválido. Contate o suporte.';
document.querySelector('.stars').style.display = 'none'; erro.style.display = 'block';
document.querySelector('textarea').style.display = 'none'; document.getElementById('starContainer').style.display = 'none';
document.getElementById('comentario').style.display = 'none';
document.getElementById('btnEnviar').style.display = 'none'; document.getElementById('btnEnviar').style.display = 'none';
document.getElementById('ratingText').style.display = 'none';
} }
} }
var nota = 0;
document.querySelectorAll('.stars input').forEach(function(input) { document.querySelectorAll('.stars input').forEach(function(input) {
input.addEventListener('change', function() { input.addEventListener('change', function() {
nota = parseInt(this.value); nota = parseInt(this.value);
var textos = ['', 'Péssimo', 'Ruim', 'Regular', 'Bom', 'Excelente!']; var textos = ['', 'Péssimo 😞', 'Ruim 😕', 'Regular 😐', 'Bom 😊', 'Excelente! 🤩'];
document.getElementById('ratingText').textContent = textos[nota] || ''; document.getElementById('ratingText').textContent = textos[nota] || '';
}); });
}); });
async function enviar() { // Verifica se ja foi avaliado
async function verificarAvaliacao() {
if (!alias || !conversaId) return;
try {
var resp = await fetch('/api/' + alias + '/csat/check?conversa=' + conversaId);
var data = await resp.json();
if (data.success && data.avaliado) {
document.getElementById('starContainer').style.display = 'none';
document.getElementById('comentario').style.display = 'none';
document.getElementById('btnEnviar').style.display = 'none';
document.getElementById('ratingText').style.display = 'none';
document.getElementById('success').style.display = 'block';
}
} catch(e) { /* ignora erro de rede */ }
}
window.enviar = async function() {
if (nota === 0) { if (nota === 0) {
document.getElementById('erro').textContent = 'Selecione uma avaliação de 1 a 5 estrelas.'; var erro = document.getElementById('erro');
document.getElementById('erro').style.display = 'block'; erro.textContent = 'Selecione uma avaliação de 1 a 5 estrelas.';
erro.style.display = 'block';
return; return;
} }
@@ -186,8 +295,8 @@ async function enviar() {
}); });
var data = await resp.json(); var data = await resp.json();
if (data.success) { if (data.success) {
document.getElementById('app').querySelector('.stars').style.display = 'none'; document.getElementById('starContainer').style.display = 'none';
document.querySelector('textarea').style.display = 'none'; document.getElementById('comentario').style.display = 'none';
document.getElementById('btnEnviar').style.display = 'none'; document.getElementById('btnEnviar').style.display = 'none';
document.getElementById('ratingText').style.display = 'none'; document.getElementById('ratingText').style.display = 'none';
document.getElementById('success').style.display = 'block'; document.getElementById('success').style.display = 'block';
@@ -195,14 +304,17 @@ async function enviar() {
throw new Error(data.error || 'Erro ao enviar'); throw new Error(data.error || 'Erro ao enviar');
} }
} catch(e) { } catch(e) {
document.getElementById('erro').textContent = 'Erro ao enviar: ' + e.message; var erro = document.getElementById('erro');
document.getElementById('erro').style.display = 'block'; erro.textContent = 'Erro ao enviar: ' + e.message;
erro.style.display = 'block';
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Enviar Avaliação'; btn.textContent = 'Enviar Avaliação';
} }
} };
getParams(); getParams();
verificarAvaliacao();
})();
</script> </script>
</body> </body>
</html> </html>
File diff suppressed because it is too large Load Diff
+142 -5
View File
@@ -110,7 +110,7 @@ body {
.sidebar-nav::-webkit-scrollbar { width: 3px; } .sidebar-nav::-webkit-scrollbar { width: 3px; }
.sidebar-nav::-webkit-scrollbar-track { background: transparent; } .sidebar-nav::-webkit-scrollbar-track { background: transparent; }
.sidebar-nav::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; } .sidebar-nav::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; }
.sidebar-nav .nav-label { .sidebar-nav .nav-label {
font-size: 10px; font-size: 10px;
@@ -482,7 +482,7 @@ tr:last-child td { border-bottom: none; }
.modal-overlay.show { display: flex; } .modal-overlay.show { display: flex; }
.modal-box { .modal-box, .modal {
background: var(--surface); background: var(--surface);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 28px; padding: 28px;
@@ -497,14 +497,14 @@ tr:last-child td { border-bottom: none; }
to { opacity: 1; transform: scale(1) translateY(0); } to { opacity: 1; transform: scale(1) translateY(0); }
} }
.modal-box h3 { .modal-box h3, .modal h3 {
margin-bottom: 18px; margin-bottom: 18px;
font-size: 17px; font-size: 17px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
} }
.modal-footer { .modal-footer, .modal .modal-footer {
display: flex; display: flex;
gap: 8px; gap: 8px;
justify-content: flex-end; justify-content: flex-end;
@@ -644,5 +644,142 @@ tr:last-child td { border-bottom: none; }
/* ===== SCROLLBAR CUSTOMIZADA ===== */ /* ===== SCROLLBAR CUSTOMIZADA ===== */
::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 3px; } ::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #9ca3af; } ::-webkit-scrollbar-thumb:hover { background: #9ca3af; }
/* ===== SCREEN READER ONLY ===== */
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
white-space: nowrap;
border: 0;
}
/* =====================================================
RESPONSIVO (tablet / celular)
===================================================== */
@media (max-width: 1024px) {
.container { padding: 18px; }
.search-bar select { min-width: 150px; }
}
@media (max-width: 768px) {
/* A sidebar lateral vira uma barra horizontal no topo */
body { flex-direction: column; }
.sidebar {
width: 100%;
flex-direction: row;
align-items: center;
overflow-x: auto;
}
.sidebar-brand {
border-bottom: none;
padding: 10px 14px;
flex-shrink: 0;
}
.sidebar-brand span { display: none; }
.sidebar-brand h2 { font-size: 15px; }
.sidebar-nav {
flex-direction: row;
align-items: center;
padding: 6px;
gap: 2px;
overflow-x: auto;
overflow-y: hidden;
flex: 1;
}
.sidebar-nav .nav-label { display: none; }
.sidebar-nav a {
white-space: nowrap;
padding: 8px 12px;
margin-bottom: 0;
font-size: 13px;
}
.sidebar-footer {
display: flex;
flex-direction: row;
align-items: center;
border-top: none;
padding: 6px;
flex-shrink: 0;
}
.sidebar-footer .dark-mode-toggle {
width: auto;
margin: 0 4px 0 0;
white-space: nowrap;
}
.sidebar-footer a { padding: 8px 10px; white-space: nowrap; }
/* Conteúdo principal */
.container { padding: 14px; }
.topbar { padding: 12px 16px; flex-wrap: wrap; gap: 8px; }
.topbar-title { font-size: 15px; }
.user-info { gap: 8px; }
.card { padding: 16px; border-radius: var(--radius-md); }
/* Tabelas: rolagem horizontal em vez de espremer */
.table-wrapper { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.table-wrapper table { min-width: 560px; }
/* Busca/filtros empilham e ocupam a largura */
.search-bar { gap: 8px; }
.search-bar input,
.search-bar select,
.search-bar button { width: 100%; min-width: 0; }
.search-bar .total-info { margin-left: 0; }
/* Modal quase tela cheia */
.modal-box { width: 94%; padding: 20px; max-height: 90vh; overflow-y: auto; }
}
@media (max-width: 480px) {
.container { padding: 10px; }
.card { padding: 13px; }
.btn { padding: 9px 14px; font-size: 13px; }
.info-grid { grid-template-columns: 1fr; }
}
/* ===== BOTTOM TAB BAR (MOBILE ≤768px) ===== */
@media (max-width: 768px) {
.sidebar { display: none !important; }
.main { width: 100%; padding-bottom: 64px; }
/* Barra inferior fixa */
.tabbar {
display: flex !important;
position: fixed;
bottom: 0; left: 0; right: 0;
height: 62px;
background: var(--surface);
border-top: 1px solid var(--border);
z-index: 1000;
box-shadow: 0 -2px 12px rgba(0,0,0,0.06);
padding-bottom: env(safe-area-inset-bottom);
}
.tabbar a {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
text-decoration: none;
color: var(--text-faint);
font-size: 10px;
font-weight: 500;
transition: color .15s;
-webkit-tap-highlight-color: transparent;
}
.tabbar a .tab-icon { font-size: 22px; line-height: 1; }
.tabbar a.active { color: var(--primary); font-weight: 600; }
.topbar { padding: 10px 14px; }
.topbar-title { font-size: 15px; }
/* Chat: espaço para tabbar no mobile */
.chat-center { padding-bottom: 70px; }
.sidebar-left { padding-bottom: 70px; }
}
+200 -110
View File
@@ -6,18 +6,54 @@
<title>Dashboard - Chatc2</title> <title>Dashboard - Chatc2</title>
<link rel="stylesheet" href="/css/main.css"> <link rel="stylesheet" href="/css/main.css">
<style> <style>
body { background: #f3f4f6; display: flex; min-height: 100vh; } body { background: var(--surface-3); display: flex; min-height: 100vh; }
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; } .main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.container { flex: 1; padding: 24px; overflow-y: auto; } .container { flex: 1; padding: 24px; overflow-y: auto; }
.section-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); margin: 4px 0 12px; }
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; margin-bottom: 24px; }
.stat-card { background: #fff; border-radius: 12px; padding: 18px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); border-left: 4px solid var(--text-faint); }
.stat-card .num { font-size: 32px; font-weight: 800; color: var(--text-primary); line-height: 1; }
.stat-card .lbl { font-size: 13px; color: var(--text-muted); margin-top: 6px; font-weight: 600; }
.stat-card .desc { font-size: 11px; color: var(--text-faint); margin-top: 4px; }
.stat-card.abertas { border-left-color: #10b981; }
.stat-card.naoatendidas { border-left-color: var(--danger); }
.stat-card.naoatribuidas { border-left-color: #f59e0b; }
.stat-card.pendentes { border-left-color: #6366f1; }
.stat-card.agendadas-card { border-left-color: #8b5cf6; }
.stat-card.disponiveis { border-left-color: #10b981; }
.stat-card.desconectados { border-left-color: var(--text-faint); }
.card { background:#fff; border-radius:12px; padding:18px; box-shadow:0 1px 3px rgba(0,0,0,0.08); margin-bottom:24px; }
.atendente-row { display:flex; align-items:center; gap:8px; padding:7px 0; border-bottom:1px solid var(--surface-3); font-size:14px; }
.atendente-row:last-child { border-bottom:none; }
.dot { width:9px; height:9px; border-radius:50%; flex-shrink:0; }
.dot.on { background:#10b981; box-shadow:0 0 0 3px rgba(16,185,129,.18); }
.dot.off { background:#cbd5e1; }
.atendente-row .st { margin-left:auto; font-size:12px; color:var(--text-muted); }
/* Heatmap (estilo GitHub) */
.heatmap-scroll { overflow-x:auto; padding-bottom:6px; }
.heatmap { display:flex; gap:3px; }
.hm-col { display:flex; flex-direction:column; gap:3px; }
.hm-cell { width:12px; height:12px; border-radius:2px; background:#ebedf0; }
.hm-cell.l1 { background:#9be9a8; } .hm-cell.l2 { background:#40c463; }
.hm-cell.l3 { background:#30a14e; } .hm-cell.l4 { background:#216e39; }
.hm-months { display:flex; gap:3px; font-size:10px; color:var(--text-faint); margin-bottom:4px; height:12px; }
.hm-legend { display:flex; align-items:center; gap:4px; font-size:11px; color:var(--text-faint); margin-top:8px; justify-content:flex-end; }
body.dark-mode .hm-cell { background:#1e293b; }
body.dark-mode .hm-cell.l1 { background:#0e4429; }
body.dark-mode .hm-cell.l2 { background:#006d32; }
body.dark-mode .hm-cell.l3 { background:#26a641; }
body.dark-mode .hm-cell.l4 { background:#39d353; }
body.dark-mode .stat-card { background: #16213e !important; box-shadow: none !important; }
body.dark-mode .stat-card .num { color: #e0e0e0 !important; }
body.dark-mode .stat-card .lbl { color: #c0c0c0 !important; }
body.dark-mode .section-title { color: #9ca3af !important; }
</style> </style>
<link rel="stylesheet" href="/css/dark-mode.css"> <link rel="stylesheet" href="/css/dark-mode.css">
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?' Claro':'🌙 Escuro'});} <script>(function(){var m=localStorage.getItem("chatc2_dark_mode_manual");var d=m!==null?m==="true":window.matchMedia("(prefers-color-scheme: dark)").matches;if(d)document.documentElement.className="dark"})()</script>
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> </head>
<body> <body>
<!-- SIDEBAR --> <!-- SIDEBAR -->
<aside class="sidebar"> <aside class="sidebar" role="navigation" aria-label="Navegação principal">
<div class="sidebar-brand"> <div class="sidebar-brand">
<div class="logo">C2</div> <div class="logo">C2</div>
<div> <div>
@@ -27,34 +63,22 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<div class="nav-label">Principal</div> <div class="nav-label">Principal</div>
<a href="#" class="active" id="navDashboard"> <a href="#" class="active" id="navDashboard"><span class="icon">📊</span> Dashboard</a>
<span class="icon">📊</span> Dashboard <a href="#" id="navClients"><span class="icon">👥</span> Clientes</a>
</a> <a href="#" id="navChat"><span class="icon">💬</span> Conversas</a>
<a href="#" id="navClients">
<span class="icon">👥</span> Clientes
</a>
<a href="#" id="navChat">
<span class="icon">💬</span> Conversas
</a>
<div class="nav-label" id="adminLabel" style="display:none">Administrador</div> <div class="nav-label" id="adminLabel" style="display:none">Administrador</div>
<a href="#" id="navConfig" style="display:none"> <a href="#" id="navConfig" style="display:none"><span class="icon">⚙️</span> Configurações</a>
<span class="icon">⚙️</span> Configurações
</a>
<a href="#" id="navAllConvs" style="display:none"><span class="icon">💬</span> Todas Conversas</a> <a href="#" id="navAllConvs" style="display:none"><span class="icon">💬</span> Todas Conversas</a>
<a href="#" id="navRoutes" style="display:none"> <a href="#" id="navRoutes" style="display:none"><span class="icon">📡</span> Rotas</a>
<span class="icon">📡</span> Rotas
</a>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button> <button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px" aria-label="Alternar modo escuro">🌙 Escuro</button>
<a onclick="logout()"> <a onclick="logout()" aria-label="Sair do sistema"><span class="icon">🚪</span> Sair</a>
<span class="icon">🚪</span> Sair
</a>
</div> </div>
</aside> </aside>
<!-- MAIN --> <!-- MAIN -->
<div class="main"> <main class="main" id="main-content">
<div class="topbar"> <div class="topbar">
<span class="topbar-title">Dashboard</span> <span class="topbar-title">Dashboard</span>
<div class="user-info"> <div class="user-info">
@@ -64,125 +88,191 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
</div> </div>
<div class="container"> <div class="container">
<div class="card"> <div class="section-title">Conversas</div>
<h3>👤 Dados do Usuário</h3> <div class="stat-grid">
<div class="info-grid"> <div class="stat-card abertas"><div class="num" id="cAbertas"></div><div class="lbl">Abertas</div><div class="desc">Com atendente e equipe</div></div>
<div class="info-item"> <div class="stat-card naoatendidas"><div class="num" id="cNaoAtendidas"></div><div class="lbl">Não Atendidas</div><div class="desc">Aguardando resposta do atendente</div></div>
<div class="label">ID</div> <div class="stat-card naoatribuidas"><div class="num" id="cNaoAtribuidas"></div><div class="lbl">Não Atribuídas</div><div class="desc">Sem equipe e sem atendente</div></div>
<div class="value" id="userId">-</div> <div class="stat-card pendentes"><div class="num" id="cPendentes"></div><div class="lbl">Pendentes</div><div class="desc">Somente com equipe</div></div>
</div> <div class="stat-card agendadas-card"><div class="num" id="cAgendadas"></div><div class="lbl">📅 Agendadas Hoje</div><div class="desc">Retornos para hoje</div></div>
<div class="info-item">
<div class="label">Nome</div>
<div class="value" id="userNameDisplay">-</div>
</div>
<div class="info-item">
<div class="label">Login</div>
<div class="value" id="userLogin">-</div>
</div>
<div class="info-item">
<div class="label">Email</div>
<div class="value" id="userEmail">-</div>
</div>
<div class="info-item">
<div class="label">Nível</div>
<div class="value" id="userNivel">-</div>
</div>
<div class="info-item">
<div class="label">Tipo</div>
<div class="value" id="userTipo">-</div>
</div>
<div class="info-item">
<div class="label">Autenticação</div>
<div class="value" id="userAuthType">-</div>
</div>
</div>
</div> </div>
<div class="section-title">Atendentes</div>
<div class="stat-grid">
<div class="stat-card disponiveis"><div class="num" id="aDisponiveis"></div><div class="lbl">Disponíveis</div><div class="desc">Acessando a plataforma</div></div>
<div class="stat-card desconectados"><div class="num" id="aDesconectados"></div><div class="lbl">Desconectados</div><div class="desc">Fora da plataforma</div></div>
</div>
<div class="card"> <div class="card">
<h3>🔑 Token de Acesso</h3> <div class="section-title" style="margin-top:0">Situação dos atendentes</div>
<button class="btn-copy" onclick="showToken()" id="btnShowToken" style="margin-bottom:8px">Mostrar Token</button> <div id="atendentesLista"><div style="color:#9ca3af;font-size:13px">Carregando...</div></div>
<div class="token-box" id="tokenDisplay" style="display:none"></div> </div>
<button class="btn-copy" onclick="copyToken()" id="btnCopyToken" style="display:none">Copiar Token</button>
<div class="section-title">Tráfego das Conversas</div>
<div class="card">
<div style="font-size:13px;color:#6b7280;margin-bottom:12px" id="trafegoResumo">Conversas iniciadas nos últimos 12 meses</div>
<div class="heatmap-scroll">
<div class="hm-months" id="hmMonths"></div>
<div class="heatmap" id="heatmap"></div>
</div>
<div class="hm-legend">
Menos
<span class="hm-cell"></span><span class="hm-cell l1"></span><span class="hm-cell l2"></span><span class="hm-cell l3"></span><span class="hm-cell l4"></span>
Mais
</div>
</div> </div>
</div> </div>
</div> </div>
<script> <script>
// Extrai alias da URL var pathParts = window.location.pathname.split('/');
const pathParts = window.location.pathname.split('/'); var alias = pathParts[2] || localStorage.getItem('chatc2_alias') || 'lajedo';
const alias = pathParts[2] || localStorage.getItem('chatc2_alias') || 'lajedo';
localStorage.setItem('chatc2_alias', alias); localStorage.setItem('chatc2_alias', alias);
const token = localStorage.getItem('chatc2_token'); var token = localStorage.getItem('chatc2_token');
const user = JSON.parse(localStorage.getItem('chatc2_user') || '{}'); var user = JSON.parse(localStorage.getItem('chatc2_user') || '{}');
if (!token) { if (!token) { window.location.href = '/app/' + alias + '/login'; }
window.location.href = '/app/' + alias + '/login';
}
// Sidebar - mostra opções de admin se for Gerente var tipoChat = user.tipoChat || 'A';
const tipoChat = user.tipoChat || 'A';
if (tipoChat === 'G') { if (tipoChat === 'G') {
document.getElementById('adminLabel').style.display = ''; document.getElementById('adminLabel').style.display = '';
document.getElementById('navConfig').style.display = ''; document.getElementById('navConfig').style.display = '';
document.getElementById('navAllConvs').style.display = '';
document.getElementById('navRoutes').style.display = ''; document.getElementById('navRoutes').style.display = '';
} else { } else {
// Agente não dashboard - redireciona para conversas // Agente não acessa o dashboard — vai direto para as conversas
window.location.href = '/app/' + alias + '/company/' + (user.empresas?.[0] || 1) + '/conversation/0'; window.location.href = '/app/' + alias + '/company/' + (user.empresas && user.empresas[0] || 1) + '/conversation/0';
} }
document.getElementById('sidebarAlias').textContent = alias; document.getElementById('sidebarAlias').textContent = alias;
document.getElementById('userId').textContent = user.id || '-';
document.getElementById('userNameDisplay').textContent = user.nome || '-';
document.getElementById('userName').textContent = user.nome || '-'; document.getElementById('userName').textContent = user.nome || '-';
document.getElementById('userLogin').textContent = user.login || '-';
document.getElementById('userEmail').textContent = user.email || '-';
document.getElementById('userNivel').textContent = user.nivelId || '-';
document.getElementById('userTipo').textContent = user.tipo || '-';
document.getElementById('userAuthType').textContent = user.authType || 'jwt';
// Token foi carregado, mas fica oculto até clicar em Mostrar Token
// Navegação sidebar
function navClick(e, url) {
e.preventDefault();
window.location.href = url;
}
function navClick(e, url) { e.preventDefault(); window.location.href = url; }
document.getElementById('navDashboard').onclick = function(e) { navClick(e, '/app/' + alias + '/dashboard'); }; document.getElementById('navDashboard').onclick = function(e) { navClick(e, '/app/' + alias + '/dashboard'); };
document.getElementById('navClients').onclick = function(e) { navClick(e, '/app/' + alias + '/clients'); }; document.getElementById('navClients').onclick = function(e) { navClick(e, '/app/' + alias + '/clients'); };
document.getElementById('navChat').onclick = function(e) { navClick(e, '/app/' + alias + '/company/' + (user.empresas?.[0] || 1) + '/conversation/0'); }; document.getElementById('navChat').onclick = function(e) { navClick(e, '/app/' + alias + '/company/' + (user.empresas && user.empresas[0] || 1) + '/conversation/0'); };
document.getElementById('navConfig').onclick = function(e) { navClick(e, '/app/' + alias + '/settings'); }; document.getElementById('navConfig').onclick = function(e) { navClick(e, '/app/' + alias + '/settings'); };
document.getElementById('navAllConvs').onclick = function(e) { navClick(e, '/app/' + alias + '/conversations/all'); };
document.getElementById('navRoutes').onclick = function(e) { navClick(e, '/app/' + alias + '/routes'); }; document.getElementById('navRoutes').onclick = function(e) { navClick(e, '/app/' + alias + '/routes'); };
function copyToken() {
navigator.clipboard.writeText(token).then(() => {
alert('Token copiado!');
});
}
function logout() { function logout() {
localStorage.removeItem('chatc2_token'); ['chatc2_token','chatc2_alias','chatc2_user'].forEach(function(k){ localStorage.removeItem(k); });
localStorage.removeItem('chatc2_alias');
localStorage.removeItem('chatc2_user');
window.location.href = '/app/' + alias + '/login'; window.location.href = '/app/' + alias + '/login';
} }
function showToken() { function esc(s){ return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
const display = document.getElementById('tokenDisplay');
const btnShow = document.getElementById('btnShowToken'); // ===== Carrega estatísticas =====
const btnCopy = document.getElementById('btnCopyToken'); async function carregarStats() {
if (display.style.display === 'none') { try {
display.textContent = token; var r = await (await fetch('/api/' + alias + '/dashboard/stats', {
display.style.display = 'block'; headers: { 'Authorization': 'Bearer ' + token }
btnShow.textContent = 'Ocultar Token'; })).json();
btnCopy.style.display = 'inline-block'; if (!r.success) return;
} else { var d = r.data;
display.style.display = 'none'; document.getElementById('cAbertas').textContent = d.conversas.abertas;
btnShow.textContent = 'Mostrar Token'; document.getElementById('cNaoAtendidas').textContent = d.conversas.naoAtendidas;
btnCopy.style.display = 'none'; document.getElementById('cNaoAtribuidas').textContent = d.conversas.naoAtribuidas;
document.getElementById('cPendentes').textContent = d.conversas.pendentes;
document.getElementById('cAgendadas').textContent = d.conversas.agendadas || 0;
document.getElementById('aDisponiveis').textContent = d.atendentes.disponiveis;
document.getElementById('aDesconectados').textContent = d.atendentes.desconectados;
renderAtendentes(d.atendentes.lista);
renderHeatmap(d.trafego);
} catch(e) {}
}
function renderAtendentes(lista) {
var el = document.getElementById('atendentesLista');
if (!lista || lista.length === 0) {
el.innerHTML = '<div style="color:#9ca3af;font-size:13px">Nenhum atendente cadastrado.</div>';
return;
} }
// Online primeiro
lista.sort(function(a,b){ return (b.online?1:0) - (a.online?1:0) || a.nome.localeCompare(b.nome); });
el.innerHTML = lista.map(function(a){
return '<div class="atendente-row">' +
'<span class="dot ' + (a.online ? 'on' : 'off') + '"></span>' +
'<span>' + esc(a.nome) + '</span>' +
'<span class="st">' + (a.online ? 'Disponível' : 'Desconectado') + '</span>' +
'</div>';
}).join('');
}
// ===== Heatmap estilo GitHub =====
function nivel(n) {
if (!n) return '';
if (n <= 2) return 'l1';
if (n <= 5) return 'l2';
if (n <= 10) return 'l3';
return 'l4';
}
function renderHeatmap(trafego) {
var mapa = {}; var totalGeral = 0;
(trafego || []).forEach(function(t){ mapa[t.dia] = t.total; totalGeral += t.total; });
var hoje = new Date(); hoje.setHours(0,0,0,0);
var inicio = new Date(hoje); inicio.setDate(inicio.getDate() - 364);
inicio.setDate(inicio.getDate() - inicio.getDay()); // alinha ao domingo
var meses = ['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez'];
var grid = document.getElementById('heatmap');
var monthsBar = document.getElementById('hmMonths');
grid.innerHTML = ''; monthsBar.innerHTML = '';
var cursor = new Date(inicio);
var ultimoMes = -1;
while (cursor <= hoje) {
var col = document.createElement('div');
col.className = 'hm-col';
var mesDaColuna = cursor.getMonth();
// rótulo do mês quando muda no topo da coluna
var lbl = document.createElement('div');
lbl.style.width = '12px';
lbl.style.fontSize = '10px';
lbl.style.color = '#9ca3af';
if (mesDaColuna !== ultimoMes) { lbl.textContent = meses[mesDaColuna]; ultimoMes = mesDaColuna; }
else { lbl.innerHTML = '&nbsp;'; }
monthsBar.appendChild(lbl);
for (var dia = 0; dia < 7; dia++) {
var cell = document.createElement('div');
var iso = cursor.toISOString().split('T')[0];
if (cursor > hoje) { cell.className = 'hm-cell'; cell.style.visibility = 'hidden'; }
else {
var n = mapa[iso] || 0;
cell.className = 'hm-cell ' + nivel(n);
cell.title = iso.split('-').reverse().join('/') + ': ' + n + ' conversa' + (n === 1 ? '' : 's');
}
col.appendChild(cell);
cursor.setDate(cursor.getDate() + 1);
}
grid.appendChild(col);
}
document.getElementById('trafegoResumo').textContent =
totalGeral + ' conversa' + (totalGeral === 1 ? '' : 's') + ' iniciada' + (totalGeral === 1 ? '' : 's') + ' nos últimos 12 meses';
}
// ===== Presença + atualização periódica =====
function pingPresenca() {
fetch('/api/' + alias + '/dashboard/ping', {
method: 'POST', headers: { 'Authorization': 'Bearer ' + token }
}).catch(function(){});
}
if (tipoChat === 'G') {
pingPresenca();
carregarStats();
setInterval(pingPresenca, 45000);
setInterval(carregarStats, 30000);
} }
</script> </script>
<script src="/js/dark-mode.js"></script> <script src="/js/dark-mode.js"></script>
<!-- impeccable-live-start -->
<script src="http://localhost:8400/live.js"></script>
<!-- impeccable-live-end -->
<!-- Bottom Tab Bar (mobile) -->
<script src="/js/tabbar.js"></script>
</body> </body>
</html> </html>
+41 -38
View File
@@ -1,44 +1,47 @@
// Dark Mode Controller - Only handles initialization/persistence // Dark Mode Controller — Unified for all Chatc2 pages
(function() { (function() {
if (typeof window.darkModeToggle !== 'function') { var osPrefersDark = window.matchMedia('(prefers-color-scheme: dark)');
// Ensure function exists (inline script in head should have defined it) var manualOverride = localStorage.getItem('chatc2_dark_mode_manual');
window.darkModeToggle = function() {
var el = document.body; function getInitialTheme() {
if (!el) return; if (manualOverride !== null) return manualOverride === 'true';
var isDark = localStorage.getItem('chatc2_dark_mode') === 'true'; return osPrefersDark.matches;
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 function applyTheme(enable, isManual) {
if (localStorage.getItem('chatc2_dark_mode') === 'true') { if (document.body) {
if (document.readyState === 'loading') { document.body.classList.toggle('dark-mode', enable);
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';
});
} }
localStorage.setItem('chatc2_dark_mode', enable ? 'true' : 'false');
if (isManual) {
localStorage.setItem('chatc2_dark_mode_manual', enable ? 'true' : 'false');
}
document.querySelectorAll('.dark-mode-toggle').forEach(function(btn) {
btn.innerHTML = enable ? '☀️ Claro' : '🌙 Escuro';
});
} }
window.darkModeToggle = function() {
var isDark = localStorage.getItem('chatc2_dark_mode') === 'true';
applyTheme(!isDark, true);
};
window.darkModeApply = function(enable) { applyTheme(enable, false); };
window.darkModeIsDark = function() {
return localStorage.getItem('chatc2_dark_mode') === 'true';
};
// Reage a mudanças no SO (só sem override manual)
osPrefersDark.addEventListener('change', function(e) {
if (localStorage.getItem('chatc2_dark_mode_manual') === null) {
applyTheme(e.matches, false);
}
});
// Aplica imediatamente (sem esperar DOMContentLoaded)
// Sincroniza html.dark (setado pelo script inline no <head>) com body.dark-mode
var htmlDark = document.documentElement.classList.contains('dark');
applyTheme(htmlDark || getInitialTheme(), false);
document.documentElement.classList.remove('dark');
})(); })();
+94
View File
@@ -0,0 +1,94 @@
/**
* Notificações Desktop Chatc2
*
* Incluir em páginas que precisam de notificações (chat, dashboard):
* <script src="/js/notifications.js"></script>
*
* Uso:
* Chatc2Notify.requestPermission(); // pede permissão ao usuário
* Chatc2Notify.show('Nova mensagem', 'Cliente enviou uma mensagem');
*
* Respeita: permissão do navegador, foco da janela (não notifica se está focada).
*/
(function() {
var permitido = false;
var inicializado = false;
function init() {
if (inicializado) return;
inicializado = true;
// Verifica suporte
if (!('Notification' in window)) return;
// Verifica permissão atual
if (Notification.permission === 'granted') {
permitido = true;
} else if (Notification.permission === 'denied') {
permitido = false;
}
}
window.Chatc2Notify = {
/** Pede permissão ao usuário (deve ser chamado após interação do usuário). */
requestPermission: function() {
init();
if (!('Notification' in window)) return;
if (Notification.permission === 'granted') {
permitido = true;
return;
}
if (Notification.permission === 'denied') return;
Notification.requestPermission().then(function(permission) {
permitido = permission === 'granted';
});
},
/**
* Exibe uma notificação.
* dispara se: permissão concedida E janela não está focada.
*/
show: function(title, body, icon) {
init();
if (!permitido) return;
if (document.hasFocus && document.hasFocus()) return; // já está olhando
try {
var n = new Notification(title, {
body: body || '',
icon: icon || '/favicon.ico',
tag: 'chatc2',
});
// Clica na notificação → foca a janela
n.onclick = function() {
window.focus();
n.close();
};
// Auto-fecha após 5s
setTimeout(function() { n.close(); }, 5000);
} catch(e) {
// Ignora erros (ex: Service Worker não registrado)
}
},
/** Retorna true se notificações estão disponíveis. */
isSupported: function() {
init();
return 'Notification' in window;
},
/** Retorna true se permissão está concedida. */
isPermitted: function() {
init();
return permitido;
},
};
// Inicializa ao carregar
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+42
View File
@@ -0,0 +1,42 @@
/**
* Bottom Tab Bar Chatc2 (Mobile)
*
* Em dispositivos móveis (768px), renderiza uma barra de navegação
* inferior fixa com ícones. A sidebar desktop é ocultada.
*
* Incluir antes do </body>:
* <script src="/js/tabbar.js"></script>
*/
(function() {
var alias = (window.location.pathname.split('/')[2]) || localStorage.getItem('chatc2_alias') || 'novo_local';
var user = JSON.parse(localStorage.getItem('chatc2_user') || '{}');
var empresaId = user.empresas ? user.empresas[0] : 1;
var isGerente = (user.tipoChat || '') === 'G';
// Determina qual aba está ativa baseado na URL
var path = window.location.pathname;
var activeTab = 'chat';
if (path.includes('/dashboard')) activeTab = 'dash';
else if (path.includes('/clients')) activeTab = 'clients';
else if (path.includes('/settings')) activeTab = 'config';
else if (path.includes('/routes')) activeTab = 'config';
var tabs = [
{ id: 'dash', icon: '📊', label: 'Dash', href: '/app/' + alias + '/dashboard', admin: true },
{ id: 'clients', icon: '👥', label: 'Clientes', href: '/app/' + alias + '/clients', admin: false },
{ id: 'chat', icon: '💬', label: 'Chat', href: '/app/' + alias + '/company/' + empresaId + '/conversation/0', admin: false },
{ id: 'config', icon: '⚙️', label: 'Config', href: '/app/' + alias + '/settings', admin: true },
];
var html = '<nav class="tabbar" style="display:none">';
for (var i = 0; i < tabs.length; i++) {
var t = tabs[i];
if (t.admin && !isGerente) continue;
var cls = t.id === activeTab ? ' class="active"' : '';
html += '<a href="' + t.href + '"' + cls + '><span class="tab-icon">' + t.icon + '</span>' + t.label + '</a>';
}
html += '</nav>';
// Insere no body
document.body.insertAdjacentHTML('beforeend', html);
})();
+70
View File
@@ -0,0 +1,70 @@
/**
* Toast notifications Chatc2
*
* Incluir em páginas que precisam de feedback visual:
* <script src="/js/toast.js"></script>
*
* Uso:
* Chatc2Toast.success('Mensagem enviada!');
* Chatc2Toast.error('Erro ao carregar conversas');
* Chatc2Toast.warn('Conexão lenta...');
*/
(function() {
var container = null;
function ensureContainer() {
if (container) return container;
container = document.createElement('div');
container.id = 'chatc2-toast-container';
container.style.cssText = 'position:fixed;top:20px;right:20px;z-index:99999;display:flex;flex-direction:column;gap:8px;pointer-events:none;';
document.body.appendChild(container);
return container;
}
function show(message, type) {
var c = ensureContainer();
var toast = document.createElement('div');
var colors = {
success: { bg: '#d1fae5', color: '#065f46', border: '#6ee7b7' },
error: { bg: '#fef2f2', color: '#991b1b', border: '#fca5a5' },
warn: { bg: '#fef3c7', color: '#92400e', border: '#fcd34d' },
info: { bg: '#ede9fe', color: '#5b21b6', border: '#c4b5fd' },
};
var col = colors[type] || colors.info;
toast.style.cssText = 'pointer-events:auto;padding:10px 16px;border-radius:8px;font-size:13px;font-weight:500;' +
'max-width:380px;box-shadow:0 4px 16px rgba(0,0,0,0.12);animation:chatc2ToastIn 0.25s ease;' +
'background:' + col.bg + ';color:' + col.color + ';border-left:4px solid ' + col.border + ';';
toast.textContent = message;
c.appendChild(toast);
// Auto-remove
var duration = type === 'error' ? 6000 : 3500;
setTimeout(function() {
toast.style.opacity = '0';
toast.style.transform = 'translateX(20px)';
toast.style.transition = 'all 0.2s ease';
setTimeout(function() {
if (toast.parentNode) toast.parentNode.removeChild(toast);
}, 200);
}, duration);
}
// Inject keyframe
if (!document.getElementById('chatc2-toast-style')) {
var style = document.createElement('style');
style.id = 'chatc2-toast-style';
style.textContent = '@keyframes chatc2ToastIn{from{opacity:0;transform:translateX(20px)}to{opacity:1;transform:translateX(0)}}';
document.head.appendChild(style);
}
window.Chatc2Toast = {
success: function(msg) { show(msg, 'success'); },
error: function(msg) { show(msg, 'error'); },
warn: function(msg) { show(msg, 'warn'); },
info: function(msg) { show(msg, 'info'); },
};
})();
+20 -19
View File
@@ -7,7 +7,7 @@
<link rel="stylesheet" href="/css/main.css"> <link rel="stylesheet" href="/css/main.css">
<style> <style>
body { body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); background: linear-gradient(135deg, var(--primary) 0%, #764ba2 50%, #f093fb 100%);
background-size: 400% 400%; background-size: 400% 400%;
animation: gradientShift 12s ease infinite; animation: gradientShift 12s ease infinite;
min-height: 100vh; min-height: 100vh;
@@ -40,7 +40,7 @@
.login-header .logo { .login-header .logo {
width: 76px; width: 76px;
height: 76px; height: 76px;
background: linear-gradient(135deg, #667eea, #764ba2); background: linear-gradient(135deg, var(--primary), #764ba2);
border-radius: 20px; border-radius: 20px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -63,13 +63,13 @@
.login-header h1 { .login-header h1 {
font-size: 24px; font-size: 24px;
font-weight: 800; font-weight: 800;
color: #111827; color: var(--text-primary);
margin-bottom: 6px; margin-bottom: 6px;
letter-spacing: -0.5px; letter-spacing: -0.5px;
} }
.login-header p { .login-header p {
color: #6b7280; color: var(--text-muted);
font-size: 14px; font-size: 14px;
} }
@@ -81,31 +81,31 @@
display: block; display: block;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #374151; color: var(--text-secondary);
margin-bottom: 7px; margin-bottom: 7px;
} }
.form-group input { .form-group input {
width: 100%; width: 100%;
padding: 12px 16px; padding: 12px 16px;
border: 2px solid #e5e7eb; border: 2px solid var(--border);
border-radius: 11px; border-radius: 11px;
font-size: 15px; font-size: 15px;
transition: all 0.15s; transition: all 0.15s;
outline: none; outline: none;
background: #f9fafb; background: var(--surface-2);
color: #111827; color: var(--text-primary);
} }
.form-group input:focus { .form-group input:focus {
border-color: #667eea; border-color: var(--primary);
background: #fff; background: #fff;
box-shadow: 0 0 0 4px rgba(102,126,234,0.12); box-shadow: 0 0 0 4px rgba(102,126,234,0.12);
} }
.form-group input.error { .form-group input.error {
border-color: #ef4444; border-color: var(--danger);
background: #fef2f2; background: var(--danger-bg);
} }
.password-wrapper { position: relative; } .password-wrapper { position: relative; }
@@ -119,17 +119,17 @@
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
color: #9ca3af; color: var(--text-faint);
font-size: 18px; font-size: 18px;
padding: 4px; padding: 4px;
transition: color 0.15s; transition: color 0.15s;
} }
.toggle-password:hover { color: #6b7280; } .toggle-password:hover { color: var(--text-muted); }
.btn-login { .btn-login {
width: 100%; width: 100%;
padding: 14px; padding: 14px;
background: linear-gradient(135deg, #667eea, #764ba2); background: linear-gradient(135deg, var(--primary), #764ba2);
color: #fff; color: #fff;
border: none; border: none;
border-radius: 11px; border-radius: 11px;
@@ -145,8 +145,8 @@
.btn-login:disabled { opacity: 0.6; cursor: not-allowed; } .btn-login:disabled { opacity: 0.6; cursor: not-allowed; }
.error-message { .error-message {
background: #fef2f2; background: var(--danger-bg);
color: #dc2626; color: var(--danger-text);
padding: 12px 16px; padding: 12px 16px;
border-radius: 10px; border-radius: 10px;
font-size: 13px; font-size: 13px;
@@ -196,9 +196,7 @@
} }
</style> </style>
<link rel="stylesheet" href="/css/dark-mode.css"> <link rel="stylesheet" href="/css/dark-mode.css">
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?' Claro':'🌙 Escuro'});} <script>(function(){var m=localStorage.getItem("chatc2_dark_mode_manual");var d=m!==null?m==="true":window.matchMedia("(prefers-color-scheme: dark)").matches;if(d)document.documentElement.className="dark"})()</script>
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> </head>
<body> <body>
<div class="login-container"> <div class="login-container">
@@ -355,5 +353,8 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<button class="dark-mode-toggle" onclick="darkModeToggle()">🌙 Escuro</button> <button class="dark-mode-toggle" onclick="darkModeToggle()">🌙 Escuro</button>
</div> </div>
<script src="/js/dark-mode.js"></script> <script src="/js/dark-mode.js"></script>
<!-- impeccable-live-start -->
<script src="http://localhost:8400/live.js"></script>
<!-- impeccable-live-end -->
</body> </body>
</html> </html>
+43 -39
View File
@@ -6,89 +6,87 @@
<title>Rotas - Chatc2 API</title> <title>Rotas - Chatc2 API</title>
<link rel="stylesheet" href="/css/main.css"> <link rel="stylesheet" href="/css/main.css">
<style> <style>
body { background:#f3f4f6; display:flex; min-height:100vh; } body { background:var(--surface-3); display:flex; min-height:100vh; }
.sidebar-nav .admin-only { display:none; } .sidebar-nav .admin-only { display:none; }
.main { flex:1; display:flex; flex-direction:column; min-width:0; } .main { flex:1; display:flex; flex-direction:column; min-width:0; }
.container { flex:1; padding:24px; overflow-y:auto; } .container { flex:1; padding:24px; overflow-y:auto; }
/* Barra de busca/filtros */ /* Barra de busca/filtros */
.search-box { margin-bottom:18px; display:flex; gap:10px; flex-wrap:wrap; align-items:center; } .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 { flex:1; min-width:220px; padding:10px 16px; border:2px solid var(--border); border-radius:8px; font-size:14px; outline:none; transition:border-color .15s; background:#fff; color:var(--text-primary); }
.search-box input:focus { border-color:#667eea; } .search-box input:focus { border-color:var(--primary); }
.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 { padding:10px 12px; border:2px solid var(--border); border-radius:8px; font-size:13px; outline:none; background:#fff; cursor:pointer; color:var(--text-secondary); }
.search-box select:focus { border-color:#667eea; } .search-box select:focus { border-color:var(--primary); }
.btn-ghost { padding:9px 14px; border:2px solid #e5e7eb; border-radius:8px; background:#fff; cursor:pointer; font-size:13px; color:#374151; } .btn-ghost { padding:9px 14px; border:2px solid var(--border); border-radius:8px; background:#fff; cursor:pointer; font-size:13px; color:var(--text-secondary); }
.btn-ghost:hover { background:#f3f4f6; } .btn-ghost:hover { background:var(--surface-3); }
/* Legenda */ /* Legenda */
.legenda { display:flex; gap:16px; flex-wrap:wrap; font-size:12px; color:#6b7280; margin-bottom:16px; align-items:center; } .legenda { display:flex; gap:16px; flex-wrap:wrap; font-size:12px; color:var(--text-muted); margin-bottom:16px; align-items:center; }
.legenda .pill { display:inline-flex; align-items:center; gap:5px; } .legenda .pill { display:inline-flex; align-items:center; gap:5px; }
/* Categoria */ /* Categoria */
.cat-group { margin-bottom:26px; } .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 { display:flex; align-items:center; gap:8px; font-size:15px; font-weight:700; color:var(--text-secondary); margin:0 0 10px; }
.cat-title .count { font-size:11px; background:#e5e7eb; color:#6b7280; padding:2px 8px; border-radius:10px; font-weight:600; } .cat-title .count { font-size:11px; background:var(--border); color:var(--text-muted); padding:2px 8px; border-radius:8px; font-weight:600; }
/* Card de rota (expansível) */ /* Card de rota (expansível) */
.route { background:#fff; border:1px solid #e5e7eb; border-radius:10px; margin-bottom:8px; overflow:hidden; } .route { background:#fff; border:1px solid var(--border); border-radius:8px; margin-bottom:8px; overflow:hidden; }
.route[open] { box-shadow:0 1px 6px rgba(0,0,0,.06); border-color:#d1d5db; } .route[open] { box-shadow:0 1px 6px rgba(0,0,0,.06); border-color:var(--border); }
.route-head { list-style:none; cursor:pointer; display:flex; align-items:center; gap:10px; padding:12px 14px; flex-wrap:wrap; } .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::-webkit-details-marker { display:none; }
.route-head:hover { background:#fafafa; } .route-head:hover { background:var(--surface-2); }
.route[open] .route-head { border-bottom:1px solid #f0f0f0; background:#fafbff; } .route[open] .route-head { border-bottom:1px solid #f0f0f0; background:#fafbff; }
.chev { color:#9ca3af; font-size:11px; transition:transform .15s; } .chev { color:var(--text-faint); font-size:11px; transition:transform .15s; }
.route[open] .chev { transform:rotate(90deg); } .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 { 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.get { background:var(--success); }
.method.post { background:#2563eb; } .method.post { background:#2563eb; }
.method.put { background:#d97706; } .method.put { background:#d97706; }
.method.delete { background:#dc2626; } .method.delete { background:var(--danger-text); }
.method.patch { background:#7c3aed; } .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 { font-family:'SF Mono',Monaco,'Cascadia Code',monospace; font-size:13px; color:var(--text-primary); word-break:break-all; }
.route-path .ph { color:#7c3aed; font-style:normal; } .route-path .ph { color:#7c3aed; font-style:normal; }
.route-summary-desc { color:#6b7280; font-size:12.5px; flex:1 1 100%; margin-left:64px; } .route-summary-desc { color:var(--text-muted); 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-badge { font-size:10.5px; font-weight:600; padding:2px 8px; border-radius:20px; white-space:nowrap; }
.auth-yes { background:#fef3c7; color:#92400e; } .auth-yes { background:var(--warning-bg); color:var(--warning-text); }
.auth-no { background:#d1fae5; color:#065f46; } .auth-no { background:var(--success-bg); color:var(--success-text); }
/* Detalhe expandido */ /* Detalhe expandido */
.route-detail { padding:14px 16px 16px; } .route-detail { padding:14px 16px 16px; }
.block { margin-top:14px; } .block { margin-top:14px; }
.block:first-child { margin-top:0; } .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; } .block-title { font-size:12px; font-weight:700; color:var(--text-muted); 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 { 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 th { text-align:left; font-size:10.5px; text-transform:uppercase; color:var(--text-faint); padding:4px 10px; border-bottom:1px solid var(--border); font-weight:600; }
.param-table td { padding:6px 10px; border-bottom:1px solid #f3f4f6; vertical-align:top; color:#374151; } .param-table td { padding:6px 10px; border-bottom:1px solid var(--surface-3); vertical-align:top; color:var(--text-secondary); }
.param-table td.pname { font-family:'SF Mono',monospace; color:#1f2937; font-weight:600; white-space:nowrap; } .param-table td.pname { font-family:'SF Mono',monospace; color:var(--text-primary); font-weight:600; white-space:nowrap; }
.tag { font-size:10px; font-weight:700; padding:2px 7px; border-radius:12px; 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.req { background:#fee2e2; color:#b91c1c; }
.tag.opt { background:#f3f4f6; color:#6b7280; } .tag.opt { background:var(--surface-3); color:var(--text-muted); }
.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-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; } .code-curl { background:var(--text-primary); 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 { font-size:12px; color:var(--text-muted); margin-top:7px; }
.req-note code { background:#f3f4f6; padding:1px 6px; border-radius:4px; color:#b91c1c; font-weight:600; } .req-note code { background:var(--surface-3); 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; } .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 { padding:3px 9px; border:none; border-radius:5px; background:var(--border); cursor:pointer; font-size:11px; color:var(--text-secondary); }
.copy:hover { background:#d1d5db; } .copy:hover { background:#d1d5db; }
.copy-light { background:rgba(255,255,255,.12); color:#e2e8f0; } .copy-light { background:rgba(255,255,255,.12); color:#e2e8f0; }
.copy-light:hover { background:rgba(255,255,255,.24); } .copy-light:hover { background:rgba(255,255,255,.24); }
.empty { text-align:center; padding:50px; color:#9ca3af; font-size:14px; } .empty { text-align:center; padding:50px; color:var(--text-faint); font-size:14px; }
</style> </style>
<link rel="stylesheet" href="/css/dark-mode.css"> <link rel="stylesheet" href="/css/dark-mode.css">
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?' Claro':'🌙 Escuro'});} <script>(function(){var m=localStorage.getItem("chatc2_dark_mode_manual");var d=m!==null?m==="true":window.matchMedia("(prefers-color-scheme: dark)").matches;if(d)document.documentElement.className="dark"})()</script>
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> </head>
<body> <body>
<aside class="sidebar"> <aside class="sidebar" role="navigation" aria-label="Navegação principal">
<div class="sidebar-brand"> <div class="sidebar-brand">
<div class="logo">C2</div> <div class="logo">C2</div>
<div><h2>Chatc2</h2><span>API Routes</span></div> <div><h2>Chatc2</h2><span>API Routes</span></div>
@@ -103,18 +101,18 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<a href="#" class="active admin-only" id="navRoutesActive"><span class="icon">📡</span> Rotas</a> <a href="#" class="active admin-only" id="navRoutesActive"><span class="icon">📡</span> Rotas</a>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button> <button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px" aria-label="Alternar modo escuro">🌙 Escuro</button>
<a onclick="logout()"><span class="icon">🚪</span> Sair</a> <a onclick="logout()" aria-label="Sair do sistema"><span class="icon">🚪</span> Sair</a>
</div> </div>
</aside> </aside>
<div class="main"> <main class="main" id="main-content">
<div class="topbar"> <div class="topbar">
<span class="topbar-title">📡 Rotas da API</span> <span class="topbar-title">📡 Rotas da API</span>
<span id="totalRotas" style="font-size:13px;color:#6b7280"></span> <span id="totalRotas" style="font-size:13px;color:#6b7280"></span>
</div> </div>
<div class="container"> <div class="container">
<div class="search-box"> <div class="search-box">
<input type="text" id="searchInput" placeholder="Buscar por método, path ou descrição..." oninput="filtrarRotas()"> <label for="searchInput" class="sr-only">Buscar</label><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="filterCat" onchange="filtrarRotas()"><option value="">Todas as categorias</option></select>
<select id="filterMethod" onchange="filtrarRotas()"> <select id="filterMethod" onchange="filtrarRotas()">
<option value="">Todos os métodos</option> <option value="">Todos os métodos</option>
@@ -332,5 +330,11 @@ carregarRotas();
})(); })();
</script> </script>
<script src="/js/dark-mode.js"></script> <script src="/js/dark-mode.js"></script>
<!-- impeccable-live-start -->
<script src="http://localhost:8400/live.js"></script>
<!-- impeccable-live-end -->
<!-- Bottom Tab Bar (mobile) -->
<script src="/js/tabbar.js"></script>
</body> </body>
</html> </html>
+412 -135
View File
@@ -6,44 +6,64 @@
<title>Configurações - Chatc2</title> <title>Configurações - Chatc2</title>
<link rel="stylesheet" href="/css/main.css"> <link rel="stylesheet" href="/css/main.css">
<style> <style>
body { background:#f3f4f6; display:flex; min-height:100vh; } body { background:var(--surface-3); display:flex; min-height:100vh; }
.sidebar-nav .admin-only { display:none; } .sidebar-nav .admin-only { display:none; }
.main { flex:1; display:flex; flex-direction:column; min-width:0; } .main { flex:1; display:flex; flex-direction:column; min-width:0; }
.container { flex:1; padding:24px; overflow-y:auto; } .container { flex:1; padding:24px; overflow-y:auto; }
.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 { 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 var(--surface-3); }
.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 { flex:1; padding:13px 16px; border:none; background:#fff; font-size:14px; cursor:pointer; border-bottom:2px solid transparent; transition:all .15s; color:var(--text-muted); font-weight:500; }
.tabs button:hover { background:#f9fafb; color:#374151; } .tabs button:hover { background:var(--surface-2); color:var(--text-secondary); }
.tabs button.active { border-bottom-color:#667eea; color:#667eea; font-weight:700; background:#fafbff; } .tabs button.active { border-bottom-color:var(--primary); color:var(--primary); font-weight:700; background:#fafbff; }
.tab-content { display:none; } .tab-content { display:none; }
.tab-content.active { display:block; } .tab-content.active { display:block; }
.form-group { margin-bottom:16px; } .form-group { margin-bottom:16px; }
.form-group label { display:block; font-size:13px; font-weight:600; color:#374151; margin-bottom:5px; } .form-group label { display:block; font-size:13px; font-weight:600; color:var(--text-secondary); 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, .form-group select, .form-group textarea { width:100%; padding:10px 14px; border:2px solid var(--border); border-radius:8px; font-size:14px; outline:none; background:var(--surface-2); color:var(--text-primary); 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 input:focus, .form-group select:focus, .form-group textarea:focus { border-color:var(--primary); background:#fff; box-shadow:0 0 0 3px rgba(102,126,234,0.1); }
.form-group textarea { min-height:80px; resize:vertical; } .form-group textarea { min-height:80px; resize:vertical; }
.form-group .toggle { display:flex; align-items:center; gap:8px; } .form-group .toggle { display:flex; align-items:center; gap:8px; }
.form-group .toggle input { width:auto; } .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 { 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 { background:var(--primary); color:#fff; }
.btn-primary:hover { background:#5a67d8; } .btn-primary:hover { background:var(--primary-dark); }
.btn-danger { background:#ef4444; color:#fff; } .btn-secondary {
display: inline-flex; align-items: center; gap: 6px;
padding: 10px 20px; border: 1px solid var(--border);
border-radius: 8px; font-size: 14px; font-weight: 600;
cursor: pointer; transition: all .15s;
background: var(--surface); color: var(--text-secondary);
}
.btn-secondary:hover { background: var(--surface-2); border-color: #d1d5db; }
.btn-danger { background:var(--danger); color:#fff; }
.btn-danger:hover { background:#dc2626; } .btn-danger:hover { background:#dc2626; }
.btn-danger:disabled { opacity:0.5; cursor:not-allowed; background:var(--danger); }
.btn-sm { padding:6px 12px; font-size:12px; border-radius:6px; } .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 { display:inline-block; padding:2px 9px; border-radius:10px; font-size:11px; font-weight:600; }
.badge-success { background:#d1fae5; color:#065f46; } .badge-success { background:var(--success-bg); color:var(--success-text); }
.badge-danger { background:#fef2f2; color:#991b1b; } .badge-danger { background:var(--danger-bg); color:var(--danger-text); }
.user-info { display:flex; align-items:center; gap:14px; } .user-info { display:flex; align-items:center; gap:14px; }
.user-name { font-size:14px; font-weight:500; color:#374151; } .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 { display:inline-flex; align-items:center; gap:5px; padding:4px 12px; border-radius:20px; font-size:12px; font-weight:600; }
.status-badge.online { background:#d1fae5; color:#065f46; } .status-badge.online { background:var(--success-bg); color:var(--success-text); }
.feedback-msg {
padding: 8px 14px; border-radius: 6px; font-size: 13px;
margin-bottom: 8px; display: none;
}
.feedback-msg.sucesso { background: var(--success-bg); color: var(--success-text); display: block; }
.feedback-msg.erro { background: var(--danger-bg); color: var(--danger-text); display: block; }
/* Responsivo: abas rolam horizontalmente no celular */
@media (max-width: 768px) {
.tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.tabs button { flex: 0 0 auto; white-space: nowrap; padding: 12px 14px; font-size: 13px; }
}
</style> </style>
<link rel="stylesheet" href="/css/dark-mode.css"> <link rel="stylesheet" href="/css/dark-mode.css">
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?' Claro':'🌙 Escuro'});} <script>(function(){var m=localStorage.getItem("chatc2_dark_mode_manual");var d=m!==null?m==="true":window.matchMedia("(prefers-color-scheme: dark)").matches;if(d)document.documentElement.className="dark"})()</script>
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> </head>
<body> <body>
<aside class="sidebar"> <aside class="sidebar" role="navigation" aria-label="Navegação principal">
<div class="sidebar-brand"> <div class="sidebar-brand">
<div class="logo">C2</div> <div class="logo">C2</div>
<div><h2>Chatc2</h2><span id="sidebarAlias">-</span></div> <div><h2>Chatc2</h2><span id="sidebarAlias">-</span></div>
@@ -58,11 +78,11 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<a href="#" id="navRoutesSet" class="admin-only"><span class="icon">📡</span> Rotas</a> <a href="#" id="navRoutesSet" class="admin-only"><span class="icon">📡</span> Rotas</a>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button> <button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px" aria-label="Alternar modo escuro">🌙 Escuro</button>
<a onclick="logout()"><span class="icon">🚪</span> Sair</a></div> <a onclick="logout()" aria-label="Sair do sistema"><span class="icon">🚪</span> Sair</a></div>
</aside> </aside>
<div class="main"> <main class="main" id="main-content">
<div class="topbar"><span class="topbar-title">⚙️ Configurações</span></div> <span class="topbar-title">⚙️ Configurações</span></div>
<div class="container"> <div class="container">
<div class="tabs"> <div class="tabs">
<button class="active" onclick="ativarAba('equipe',this)">👥 Equipe</button> <button class="active" onclick="ativarAba('equipe',this)">👥 Equipe</button>
@@ -78,12 +98,34 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3 style="margin:0">📋 Fluxo de Atendimento</h3> <h3 style="margin:0">📋 Fluxo de Atendimento</h3>
</div> </div>
<p style="color:#6b7280;font-size:13px;margin-bottom:16px"> <p style="color:var(--text-muted);font-size:13px;margin-bottom:16px">
Configure os submenus que aparecem quando o cliente seleciona uma equipe. Configure os submenus que aparecem quando o cliente seleciona uma equipe.
As opções iniciais são sempre as <strong>Equipes</strong> cadastradas, 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. com a opção de <strong>Segunda via de Boleto</strong> no final.
</p> </p>
<div id="menusList"><p style="color:#9ca3af">Carregando...</p></div> <div id="menusList"><p style="color:var(--text-faint)">Carregando...</p></div>
</div>
<div class="card" id="cardResolucao">
<h3 style="margin:0 0 8px">🧩 Fluxo de Resolução</h3>
<p style="color:var(--text-muted);font-size:13px;margin-bottom:16px">
Defina os motivos de atendimento e como o atendente registra a resolução ao finalizar a conversa.
</p>
<h4 style="margin:8px 0">Motivo do Atendimento</h4>
<div style="display:flex;gap:8px;margin-bottom:8px">
<input type="text" id="novoMotivo" placeholder="Cadastrar novo motivo..." style="flex:1;padding:8px;border:2px solid #e5e7eb;border-radius:8px;font-size:13px" onkeydown="if(event.key==='Enter')adicionarMotivo()">
<button class="btn btn-primary btn-sm" onclick="adicionarMotivo()">+ Adicionar</button>
</div>
<div id="motivosList" style="margin-bottom:12px"><p style="color:var(--text-faint);font-size:13px">Carregando...</p></div>
<div class="form-group"><div class="toggle"><input type="checkbox" id="flgMotivoVis"> <label for="flgMotivoVis">Visualizar Motivo na tela de conversa</label></div></div>
<div class="form-group"><div class="toggle"><input type="checkbox" id="flgMotivoObr"> <label for="flgMotivoObr">Obrigatório preencher (só finaliza com motivo)</label></div></div>
<h4 style="margin:16px 0 8px">Resolução do Atendimento</h4>
<div class="form-group"><div class="toggle"><input type="checkbox" id="flgResolVis"> <label for="flgResolVis">Visualizar Resolução na tela de conversa</label></div></div>
<div class="form-group"><div class="toggle"><input type="checkbox" id="flgResolObr"> <label for="flgResolObr">Obrigatório preencher (só finaliza com resolução)</label></div></div>
<div id="resolucaoFeedback" class="feedback-msg"></div><button class="btn btn-primary" onclick="salvarResolucao()">💾 Salvar Fluxo de Resolução</button>
</div> </div>
</div> </div>
@@ -94,11 +136,11 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<h3 style="margin:0">Equipes</h3> <h3 style="margin:0">Equipes</h3>
<button class="btn btn-primary btn-sm" onclick="mostrarModalEquipe()">+ Nova Equipe</button> <button class="btn btn-primary btn-sm" onclick="mostrarModalEquipe()">+ Nova Equipe</button>
</div> </div>
<div id="equipesList"><p style="color:#9ca3af">Carregando...</p></div> <div id="equipesList"><p style="color:var(--text-faint)">Carregando...</p></div>
</div> </div>
<div class="card"> <div class="card">
<h3>Usuários da Empresa</h3> <h3>Usuários da Empresa</h3>
<div id="usuariosList"><p style="color:#9ca3af">Carregando...</p></div> <div id="usuariosList"><p style="color:var(--text-faint)">Carregando...</p></div>
</div> </div>
</div> </div>
@@ -106,7 +148,7 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<div class="tab-content" id="tabEmpresa"> <div class="tab-content" id="tabEmpresa">
<div class="card"> <div class="card">
<h3>Configurações da Empresa</h3> <h3>Configurações da Empresa</h3>
<div id="configForm"><p style="color:#9ca3af">Carregando...</p></div> <div id="configForm"><p style="color:var(--text-faint)">Carregando...</p></div>
</div> </div>
</div> </div>
@@ -117,7 +159,7 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<h3 style="margin:0">Etiquetas</h3> <h3 style="margin:0">Etiquetas</h3>
<button class="btn btn-primary btn-sm" onclick="mostrarModalEtiqueta()">+ Nova Etiqueta</button> <button class="btn btn-primary btn-sm" onclick="mostrarModalEtiqueta()">+ Nova Etiqueta</button>
</div> </div>
<div id="etiquetasList"><p style="color:#9ca3af">Carregando...</p></div> <div id="etiquetasList"><p style="color:var(--text-faint)">Carregando...</p></div>
</div> </div>
</div> </div>
@@ -128,61 +170,84 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<h3 style="margin:0">📱 Conexões WhatsApp</h3> <h3 style="margin:0">📱 Conexões WhatsApp</h3>
<button class="btn btn-primary btn-sm" onclick="mostrarModalConexao()">+ Nova Conexão</button> <button class="btn btn-primary btn-sm" onclick="mostrarModalConexao()">+ Nova Conexão</button>
</div> </div>
<div id="conexoesList"><p style="color:#9ca3af">Carregando...</p></div> <div id="conexoesList"><p style="color:var(--text-faint)">Carregando...</p></div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Modal Equipe --> <!-- 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 class="modal-overlay" id="modalEquipe">
<div style="background:#fff;border-radius:12px;padding:24px;width:90%;max-width:400px"> <div class="modal-box" style="max-width:480px">
<h3 id="modalEquipeTitle">Nova Equipe</h3> <h3 id="modalEquipeTitle">Nova Equipe</h3>
<input type="hidden" id="editEquipeId"> <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">
<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> <label for="equipeNome">Nome da Equipe</label>
<div class="form-group"><label>Membros</label><div id="equipeMembros"></div></div> <input type="text" id="equipeNome" placeholder="Ex: Atendimento">
<div style="display:flex;gap:8px;justify-content:flex-end"> </div>
<button class="btn" onclick="fecharModal('modalEquipe')" style="background:#f3f4f6">Cancelar</button> <div class="form-group">
<button class="btn btn-primary" onclick="salvarEquipe()">Salvar</button> <label for="equipeOrdem">Ordem</label>
<div style="display:flex;align-items:center;gap:8px">
<input type="number" id="equipeOrdem" value="0" min="0" style="width:80px">
<span style="font-size:12px;color:var(--text-faint)">Menor = aparece primeiro</span>
</div>
</div>
<div class="form-group">
<label for="equipeMensagem">Mensagem automática</label>
<textarea id="equipeMensagem" rows="2" placeholder="Mensagem enviada quando o cliente escolhe esta equipe..."></textarea>
<span style="font-size:11px;color:var(--text-faint);margin-top:4px;display:block">Opcional. Se preenchida, será enviada ao cliente após escolher esta opção.</span>
</div>
<div class="form-group">
<label for="equipeEtiqueta">Etiqueta automática</label>
<select id="equipeEtiqueta"><option value="">Nenhuma</option></select>
<span style="font-size:11px;color:var(--text-faint);margin-top:4px;display:block">Opcional. Etiqueta adicionada automaticamente à conversa.</span>
</div>
<div class="form-group">
<label>Membros</label>
<div id="equipeMembros"></div>
</div>
<div id="equipeFeedback" class="feedback-msg"></div>
<div class="modal-footer">
<button class="btn-secondary" onclick="fecharModal('modalEquipe')">Cancelar</button>
<button class="btn-primary" onclick="salvarEquipe()">Salvar</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Modal Etiqueta --> <!-- 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 class="modal-overlay" id="modalEtiqueta">
<div style="background:#fff;border-radius:12px;padding:24px;width:90%;max-width:400px"> <div class="modal-box" style="max-width:400px">
<h3 id="modalEtiquetaTitle">Nova Etiqueta</h3> <h3 id="modalEtiquetaTitle">Nova Etiqueta</h3>
<input type="hidden" id="editEtiquetaId"> <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 for="etiquetaNome">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 class="form-group"><label for="etiquetaCor">Cor</label><input type="color" id="etiquetaCor" value="#667eea"></div>
<div style="display:flex;gap:8px;justify-content:flex-end"> <div id="etiquetaFeedback" class="feedback-msg"></div>
<button class="btn" onclick="fecharModal('modalEtiqueta')" style="background:#f3f4f6">Cancelar</button> <div class="modal-footer">
<button class="btn btn-primary" onclick="salvarEtiqueta()">Salvar</button> <button class="btn-secondary" onclick="fecharModal('modalEtiqueta')">Cancelar</button>
<button class="btn-primary" onclick="salvarEtiqueta()">Salvar</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Modal Menu --> <!-- 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 class="modal-overlay" id="modalMenu">
<div style="background:#fff;border-radius:12px;padding:24px;width:90%;max-width:500px"> <div class="modal-box" style="max-width:520px">
<h3 id="modalMenuTitle">Novo Submenu</h3> <h3 id="modalMenuTitle">Novo Submenu</h3>
<input type="hidden" id="editMenuId"> <input type="hidden" id="editMenuId">
<input type="hidden" id="editMenuEquipeId"> <input type="hidden" id="editMenuEquipeId">
<input type="hidden" id="editMenuPaiId"> <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" id="menuEquipeGroup"><label for="menuEquipe">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 for="menuTitulo">Título</label><input type="text" id="menuTitulo" placeholder="Ex: Alteração Cadastral"></div>
<div class="form-group"> <div class="form-group"><label for="menuTipo">Tipo</label>
<label>Tipo</label>
<select id="menuTipo" onchange="mudarTipoMenu()"> <select id="menuTipo" onchange="mudarTipoMenu()">
<option value="M">📂 Submenu (mostra mais opções)</option> <option value="M">📂 Submenu (mostra mais opções)</option>
<option value="T">💬 Texto (envia mensagem fixa)</option> <option value="T">💬 Texto (envia mensagem fixa)</option>
<option value="R">⚙️ Ação (executa alteração via API)</option> <option value="R">⚙️ Ação (executa alteração via API)</option>
</select> </select>
</div> </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="menuTextoGroup" style="display:none"><label for="menuTexto">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"> <div class="form-group" id="menuRotaGroup" style="display:none">
<label>Ação (Rota)</label> <label for="menuAcaoRota">Ação (Rota)</label>
<select id="menuAcaoRota"> <select id="menuAcaoRota">
<option value="">Selecione uma ação...</option> <option value="">Selecione uma ação...</option>
<option value="listar_carnes">📄 Listar carnês / Boleto</option> <option value="listar_carnes">📄 Listar carnês / Boleto</option>
@@ -191,31 +256,41 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<option value="alterar_endereco">🏠 Alterar Endereço</option> <option value="alterar_endereco">🏠 Alterar Endereço</option>
<option value="info_cliente">️ Mostrar dados do cliente</option> <option value="info_cliente">️ Mostrar dados do cliente</option>
</select> </select>
<div style="margin-top:8px"><label>Pergunta ao cliente (se precisar de dados)</label> <div style="margin-top:8px"><label for="menuAcaoPrompt">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> <textarea id="menuAcaoPrompt" rows="2" placeholder="Ex: Informe seu novo e-mail:"></textarea></div>
</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 for="menuOrdem">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 for="menuPai">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 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 var(--border);border-radius:8px;min-height:36px"></div></div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px"> <div id="menuFeedback" class="feedback-msg"></div>
<button class="btn" onclick="fecharModal('modalMenu')" style="background:#f3f4f6">Cancelar</button> <div class="modal-footer">
<button class="btn btn-primary" onclick="salvarMenu()">Salvar</button> <button class="btn-secondary" onclick="fecharModal('modalMenu')">Cancelar</button>
<button class="btn-primary" onclick="salvarMenu()">Salvar</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Modal Conexão --> <!-- 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 class="modal-overlay" id="modalConexao">
<div style="background:#fff;border-radius:12px;padding:24px;width:90%;max-width:500px"> <div class="modal-box" style="max-width:520px">
<h3 id="modalConexaoTitle">Nova Conexão WhatsApp</h3> <h3 id="modalConexaoTitle">Nova Conexão WhatsApp</h3>
<input type="hidden" id="editConexaoId"> <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 for="conNome">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 for="conUrl">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 for="conApiKey">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 class="form-group"><label for="conInstance">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"> <div id="conexaoFeedback" class="feedback-msg"></div>
<button class="btn" onclick="fecharModal('modalConexao')" style="background:#f3f4f6">Cancelar</button> <div class="form-group" id="instanciasExistentesGroup" style="display:none">
<button class="btn btn-primary" onclick="salvarConexao()" id="btnSalvarConexao">Conectar</button> <label>Instâncias já conectadas no servidor</label>
<div id="instanciasExistentesList" style="max-height:150px;overflow-y:auto;border:2px solid var(--border);border-radius:8px;padding:8px">
<button class="btn-sm" onclick="buscarInstanciasExistentes()" style="width:100%;padding:8px;font-size:12px">🔍 Buscar instâncias no servidor</button>
</div>
<span style="font-size:11px;color:var(--text-faint)">Clique em uma instância existente para reutilizá-la.</span>
</div>
<div id="conexaoFeedback" class="feedback-msg"></div>
<div class="modal-footer">
<button class="btn-secondary" onclick="fecharModal('modalConexao')">Cancelar</button>
<button class="btn-primary" onclick="salvarConexao()" id="btnSalvarConexao">Conectar</button>
</div> </div>
</div> </div>
</div> </div>
@@ -223,6 +298,16 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<script> <script>
(function(){ (function(){
'use strict'; 'use strict';
// Fechar modais com Esc
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay.show, .modal.show').forEach(function(m) {
m.classList.remove('show');
});
}
});
const token = localStorage.getItem('chatc2_token'); const token = localStorage.getItem('chatc2_token');
const pathParts = window.location.pathname.split('/'); const pathParts = window.location.pathname.split('/');
const alias = pathParts[2] || localStorage.getItem('chatc2_alias') || 'lajedo'; const alias = pathParts[2] || localStorage.getItem('chatc2_alias') || 'lajedo';
@@ -265,20 +350,56 @@ window.ativarAba = function(aba, btn) {
document.getElementById('tab' + aba.charAt(0).toUpperCase() + aba.slice(1)).classList.add('active'); document.getElementById('tab' + aba.charAt(0).toUpperCase() + aba.slice(1)).classList.add('active');
}; };
// ===== MODAL DE CONFIRMAÇÃO =====
let _confirmarCallback = null;
window.mostrarConfirmacao = function(titulo, mensagem, nomeConfirmar, callback) {
document.getElementById('confirmarTitulo').textContent = titulo;
document.getElementById('confirmarMensagem').textContent = mensagem;
document.getElementById('confirmarInput').value = '';
document.getElementById('confirmarInput').placeholder = 'Digite: ' + nomeConfirmar;
document.getElementById('btnConfirmarExclusao').disabled = true;
_confirmarCallback = callback;
_initConfirmModal();
document.getElementById('modalConfirmar').classList.add('show');
document.getElementById('confirmarInput').focus();
};
// Event listeners do modal de confirmação (inicializados após DOM pronto)
function _initConfirmModal() {
var inp = document.getElementById('confirmarInput');
if (!inp || inp._confirmInit) return;
inp._confirmInit = true;
inp.addEventListener('input', function() {
var esperado = this.placeholder.replace('Digite: ', '');
document.getElementById('btnConfirmarExclusao').disabled = this.value.trim() !== esperado;
});
inp.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !document.getElementById('btnConfirmarExclusao').disabled) {
document.getElementById('btnConfirmarExclusao').click();
}
});
document.getElementById('btnConfirmarExclusao').addEventListener('click', function() {
if (_confirmarCallback) { _confirmarCallback(); _confirmarCallback = null; }
fecharModal('modalConfirmar');
});
}
// ===== EQUIPES ===== // ===== EQUIPES =====
async function carregarEquipes() { async function carregarEquipes() {
var data = await api('/teams?empresaId=' + empresaId); var data = await api('/teams?empresaId=' + empresaId);
var div = document.getElementById('equipesList'); var div = document.getElementById('equipesList');
if (!data.success || !data.data || data.data.length === 0) { if (!data.success || !data.data || data.data.length === 0) {
div.innerHTML = '<p style="color:#9ca3af">Nenhuma equipe cadastrada</p>'; div.innerHTML = '<p style="color:var(--text-faint)">Nenhuma equipe cadastrada</p>';
return; return;
} }
div.innerHTML = '<table><thead><tr><th>Ordem</th><th>Nome</th><th>Membros</th><th>Ações</th></tr></thead><tbody>' + 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) { data.data.map(function(eq) {
var membros = (eq.membros || []).map(function(m) { return m.nome; }).join(', ') || '-'; var membros = (eq.membros || []).map(function(m) { return m.nome; }).join(', ') || '-';
var mensagemEsc = (eq.mensagem || '').replace(/\\/g,'\\\\').replace(/'/g,"\\'");
return '<tr><td>' + (eq.ordem || 0) + '</td><td><strong>' + eq.nome + '</strong></td><td>' + membros + '</td><td>' + 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" onclick="editarEquipe(' + eq.id + ',' + (eq.ordem || 0) + ',\'' + eq.nome.replace(/'/g,"\\'") + '\',\'' + mensagemEsc + '\',' + (eq.etiquetaId || 'null') + ')" style="background:var(--surface-3);margin-right:4px">✏️</button>' +
'<button class="btn btn-sm btn-danger" onclick="excluirEquipe(' + eq.id + ')">🗑️</button></td></tr>'; '<button class="btn btn-sm btn-danger" onclick="excluirEquipe(' + eq.id + ',\'' + eq.nome.replace(/'/g,"\\'") + '\')">🗑️</button></td></tr>';
}).join('') + '</tbody></table>'; }).join('') + '</tbody></table>';
} }
@@ -286,24 +407,30 @@ window.mostrarModalEquipe = function() {
document.getElementById('editEquipeId').value = ''; document.getElementById('editEquipeId').value = '';
document.getElementById('equipeOrdem').value = 0; document.getElementById('equipeOrdem').value = 0;
document.getElementById('equipeNome').value = ''; document.getElementById('equipeNome').value = '';
document.getElementById('equipeMensagem').value = '';
document.getElementById('equipeEtiqueta').value = '';
document.getElementById('modalEquipeTitle').textContent = 'Nova Equipe'; document.getElementById('modalEquipeTitle').textContent = 'Nova Equipe';
carregarUsuariosCheckbox(); carregarUsuariosCheckbox();
document.getElementById('modalEquipe').style.display = 'flex'; carregarEtiquetasDropdown();
document.getElementById('modalEquipe').classList.add('show');
}; };
window.editarEquipe = function(id, ordem, nome) { window.editarEquipe = async function(id, ordem, nome, mensagem, etiquetaId) {
document.getElementById('editEquipeId').value = id; document.getElementById('editEquipeId').value = id;
document.getElementById('equipeOrdem').value = ordem; document.getElementById('equipeOrdem').value = ordem;
document.getElementById('equipeNome').value = nome; document.getElementById('equipeNome').value = nome;
document.getElementById('equipeMensagem').value = mensagem || '';
document.getElementById('equipeEtiqueta').value = etiquetaId || '';
document.getElementById('modalEquipeTitle').textContent = 'Editar Equipe'; document.getElementById('modalEquipeTitle').textContent = 'Editar Equipe';
await carregarEtiquetasDropdown();
carregarUsuariosCheckbox(id); carregarUsuariosCheckbox(id);
document.getElementById('modalEquipe').style.display = 'flex'; document.getElementById('modalEquipe').classList.add('show');
}; };
async function carregarUsuariosCheckbox(equipeId) { async function carregarUsuariosCheckbox(equipeId) {
var div = document.getElementById('equipeMembros'); var div = document.getElementById('equipeMembros');
var data = await api('/company/users?empresaId=' + empresaId); var data = await api('/company/users?empresaId=' + empresaId);
if (!data.success) { div.innerHTML = '<p style="color:#9ca3af">Erro ao carregar</p>'; return; } if (!data.success) { div.innerHTML = '<p style="color:var(--text-faint)">Erro ao carregar</p>'; return; }
var membrosAtuais = []; var membrosAtuais = [];
if (equipeId) { if (equipeId) {
@@ -318,27 +445,41 @@ async function carregarUsuariosCheckbox(equipeId) {
}).join(''); }).join('');
} }
async function carregarEtiquetasDropdown() {
var sel = document.getElementById('equipeEtiqueta');
var data = await api('/labels?empresaId=' + empresaId);
sel.innerHTML = '<option value="">Nenhuma</option>';
if (data.success && data.data) {
data.data.forEach(function(l) {
sel.innerHTML += '<option value="' + l.id + '">' + l.nome + '</option>';
});
}
}
window.salvarEquipe = async function() { window.salvarEquipe = async function() {
var id = document.getElementById('editEquipeId').value; var id = document.getElementById('editEquipeId').value;
var nome = document.getElementById('equipeNome').value.trim(); var nome = document.getElementById('equipeNome').value.trim();
var ordem = parseInt(document.getElementById('equipeOrdem').value) || 0; var ordem = parseInt(document.getElementById('equipeOrdem').value) || 0;
var membros = Array.from(document.querySelectorAll('#equipeMembros input:checked')).map(function(cb) { return parseInt(cb.value); }); var membros = Array.from(document.querySelectorAll('#equipeMembros input:checked')).map(function(cb) { return parseInt(cb.value); });
var mensagem = document.getElementById('equipeMensagem').value.trim();
var etiquetaId = document.getElementById('equipeEtiqueta').value || null;
if (!nome) return alert('Informe o nome da equipe'); if (!nome) { mostrarFeedbackModal('equipeFeedback', 'Informe o nome da equipe', 'erro'); return; }
if (id) { if (id) {
var r = await api('/teams/' + id, { method: 'PUT', body: JSON.stringify({ nome: nome, ordem: ordem, membros: membros }) }); var r = await api('/teams/' + id, { method: 'PUT', body: JSON.stringify({ nome: nome, ordem: ordem, membros: membros, mensagem: mensagem, etiquetaId: etiquetaId }) });
if (r.success) { fecharModal('modalEquipe'); carregarEquipes(); } if (r.success) { fecharModal('modalEquipe'); carregarEquipes(); }
} else { } else {
var r = await api('/teams', { method: 'POST', body: JSON.stringify({ nome: nome, ordem: ordem, membros: membros, empresaId: empresaId }) }); var r = await api('/teams', { method: 'POST', body: JSON.stringify({ nome: nome, ordem: ordem, membros: membros, empresaId: empresaId, mensagem: mensagem, etiquetaId: etiquetaId }) });
if (r.success) { fecharModal('modalEquipe'); carregarEquipes(); } if (r.success) { fecharModal('modalEquipe'); carregarEquipes(); }
} }
}; };
window.excluirEquipe = async function(id) { window.excluirEquipe = async function(id, nome) {
if (!confirm('Excluir esta equipe?')) return; mostrarConfirmacao('Excluir Equipe', 'Esta ação não pode ser desfeita. Todos os submenus vinculados serão perdidos.', nome || 'equipe', async function() {
var r = await api('/teams/' + id, { method: 'DELETE' }); var r = await api('/teams/' + id, { method: 'DELETE' });
if (r.success) carregarEquipes(); if (r.success) carregarEquipes();
});
}; };
// ===== USUÁRIOS ===== // ===== USUÁRIOS =====
@@ -346,7 +487,7 @@ async function carregarUsuarios() {
var data = await api('/company/users?empresaId=' + empresaId); var data = await api('/company/users?empresaId=' + empresaId);
var div = document.getElementById('usuariosList'); var div = document.getElementById('usuariosList');
if (!data.success || !data.data || data.data.length === 0) { if (!data.success || !data.data || data.data.length === 0) {
div.innerHTML = '<p style="color:#9ca3af">Nenhum usuário encontrado</p>'; div.innerHTML = '<p style="color:var(--text-faint)">Nenhum usuário encontrado</p>';
return; return;
} }
div.innerHTML = '<table><thead><tr><th>Nome</th><th>Login</th><th>Tipo Chat</th></tr></thead><tbody>' + div.innerHTML = '<table><thead><tr><th>Nome</th><th>Login</th><th>Tipo Chat</th></tr></thead><tbody>' +
@@ -365,7 +506,7 @@ async function carregarMenus() {
var div = document.getElementById('menusList'); var div = document.getElementById('menusList');
if (!eqData.success || !eqData.data || eqData.data.length === 0) { if (!eqData.success || !eqData.data || eqData.data.length === 0) {
div.innerHTML = '<p style="color:#9ca3af">Crie equipes primeiro para configurar os submenus.</p>'; div.innerHTML = '<p style="color:var(--text-faint)">Crie equipes primeiro para configurar os submenus.</p>';
return; return;
} }
@@ -376,8 +517,8 @@ async function carregarMenus() {
var menusEq = menusAgrupados.find(function(g) { return g.equipeId === eq.id; }); var menusEq = menusAgrupados.find(function(g) { return g.equipeId === eq.id; });
var temMenus = menusEq && menusEq.menus && menusEq.menus.length > 0; 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="margin-bottom:16px;border:1px solid var(--border);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 += '<div style="background:var(--surface-2);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 += '<span>👥 ' + eq.nome + '</span>';
html += '<button class="btn btn-sm" onclick="mostrarModalMenu(' + eq.id + ')" style="background:#e0e7ff">+ Submenu</button>'; html += '<button class="btn btn-sm" onclick="mostrarModalMenu(' + eq.id + ')" style="background:#e0e7ff">+ Submenu</button>';
html += '</div>'; html += '</div>';
@@ -385,7 +526,7 @@ async function carregarMenus() {
if (temMenus) { if (temMenus) {
html += '<div style="padding:4px 0">' + montarArvoreHtml(menusEq.menus, 0) + '</div>'; html += '<div style="padding:4px 0">' + montarArvoreHtml(menusEq.menus, 0) + '</div>';
} else { } else {
html += '<p style="padding:12px 14px;color:#9ca3af;font-size:13px;margin:0">Nenhum submenu configurado.</p>'; html += '<p style="padding:12px 14px;color:var(--text-faint);font-size:13px;margin:0">Nenhum submenu configurado.</p>';
} }
html += '</div>'; html += '</div>';
}); });
@@ -399,8 +540,8 @@ function montarArvoreHtml(menus, nivel) {
menus.forEach(function(m) { menus.forEach(function(m) {
var tipoIcon = m.tipo === 'M' ? '📂' : m.tipo === 'T' ? '💬' : m.tipo === 'R' ? '⚙️' : '❓'; 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 += '<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 += '<span style="flex:1;font-size:13px">' + tipoIcon + ' ' + m.titulo + ' <span style="color:var(--text-faint);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="mostrarModalMenu(' + m.equipeId + ',' + m.id + ')" style="background:var(--surface-3);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="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 += '<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>'; html += '</div>';
@@ -517,7 +658,7 @@ window.mostrarModalMenu = async function(equipeId, editMenuId, parentMenuId) {
} }
} }
document.getElementById('modalMenu').style.display = 'flex'; document.getElementById('modalMenu').classList.add('show');
}; };
window.mudarTipoMenu = function() { window.mudarTipoMenu = function() {
@@ -547,12 +688,12 @@ async function buscarEtiquetasDoMenu(menuId) {
function renderEtiquetasCheckbox(data, selecionadas) { function renderEtiquetasCheckbox(data, selecionadas) {
var container = document.getElementById('menuEtiquetasContainer'); var container = document.getElementById('menuEtiquetasContainer');
if (!data.success || !data.data || data.data.length === 0) { if (!data.success || !data.data || data.data.length === 0) {
container.innerHTML = '<span style="color:#9ca3af;font-size:12px">Nenhuma etiqueta cadastrada</span>'; container.innerHTML = '<span style="color:var(--text-faint);font-size:12px">Nenhuma etiqueta cadastrada</span>';
return; return;
} }
container.innerHTML = data.data.map(function(e) { container.innerHTML = data.data.map(function(e) {
var checked = selecionadas.includes(e.id) ? 'checked' : ''; 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">' + return '<label style="display:flex;align-items:center;gap:4px;padding:4px 8px;background:var(--surface-3);border-radius:6px;font-size:12px;cursor:pointer">' +
'<input type="checkbox" value="' + e.id + '" ' + checked + ' style="accent-color:#667eea"> ' + '<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> ' + '<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:' + (e.cor || '#667eea') + '"></span> ' +
e.nome + e.nome +
@@ -573,8 +714,8 @@ window.salvarMenu = async function() {
// Etiquetas selecionadas // 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(','); 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 (!titulo) { mostrarFeedbackModal('menuFeedback', 'Informe o título', 'erro'); return; }
if (!equipeId) return alert('Selecione a equipe'); if (!equipeId) { mostrarFeedbackModal('menuFeedback', 'Selecione a equipe', 'erro'); return; }
var body = { var body = {
empresaId: empresaId, empresaId: empresaId,
@@ -594,18 +735,19 @@ window.salvarMenu = async function() {
var r = await api('/menus/' + editId, { method: 'PUT', body: JSON.stringify(body) }); var r = await api('/menus/' + editId, { method: 'PUT', body: JSON.stringify(body) });
console.log('[Menu] Resposta save:', JSON.stringify(r)); console.log('[Menu] Resposta save:', JSON.stringify(r));
if (r.success) { fecharModal('modalMenu'); carregarMenus(); } if (r.success) { fecharModal('modalMenu'); carregarMenus(); }
else alert('Erro: ' + (r.error || 'desconhecido')); else mostrarFeedbackModal('menuFeedback', 'Erro: ' + (r.error || 'desconhecido'), 'erro');
} else { } else {
var r = await api('/menus', { method: 'POST', body: JSON.stringify(body) }); var r = await api('/menus', { method: 'POST', body: JSON.stringify(body) });
if (r.success) { fecharModal('modalMenu'); carregarMenus(); } if (r.success) { fecharModal('modalMenu'); carregarMenus(); }
else alert('Erro: ' + (r.error || 'desconhecido')); else mostrarFeedbackModal('menuFeedback', 'Erro: ' + (r.error || 'desconhecido'), 'erro');
} }
}; };
window.excluirMenu = async function(id, titulo) { window.excluirMenu = async function(id, titulo) {
if (!confirm('Excluir o submenu "' + titulo + '" e todos os seus sub-itens?')) return; mostrarConfirmacao('Excluir Submenu', 'O submenu "' + titulo + '" e todos os seus sub-itens serão removidos.', titulo, async function() {
var r = await api('/menus/' + id, { method: 'DELETE' }); var r = await api('/menus/' + id, { method: 'DELETE' });
if (r.success) carregarMenus(); if (r.success) carregarMenus();
});
}; };
// ===== ETIQUETAS ===== // ===== ETIQUETAS =====
@@ -613,14 +755,14 @@ async function carregarEtiquetas() {
var data = await api('/labels?empresaId=' + empresaId); var data = await api('/labels?empresaId=' + empresaId);
var div = document.getElementById('etiquetasList'); var div = document.getElementById('etiquetasList');
if (!data.success || !data.data || data.data.length === 0) { if (!data.success || !data.data || data.data.length === 0) {
div.innerHTML = '<p style="color:#9ca3af">Nenhuma etiqueta cadastrada</p>'; div.innerHTML = '<p style="color:var(--text-faint)">Nenhuma etiqueta cadastrada</p>';
return; return;
} }
div.innerHTML = '<table><thead><tr><th>Nome</th><th>Cor</th><th>Ações</th></tr></thead><tbody>' + div.innerHTML = '<table><thead><tr><th>Nome</th><th>Cor</th><th>Ações</th></tr></thead><tbody>' +
data.data.map(function(l) { 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>' + 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" onclick="editarEtiqueta(' + l.id + ',\'' + l.nome.replace(/'/g,"\\'") + '\',\'' + l.cor + '\')" style="background:var(--surface-3);margin-right:4px">✏️</button>' +
'<button class="btn btn-sm btn-danger" onclick="excluirEtiqueta(' + l.id + ')">🗑️</button></td></tr>'; '<button class="btn btn-sm btn-danger" onclick="excluirEtiqueta(' + l.id + ',\'' + l.nome.replace(/'/g,"\\'") + '\')">🗑️</button></td></tr>';
}).join('') + '</tbody></table>'; }).join('') + '</tbody></table>';
} }
@@ -629,7 +771,7 @@ window.mostrarModalEtiqueta = function() {
document.getElementById('etiquetaNome').value = ''; document.getElementById('etiquetaNome').value = '';
document.getElementById('etiquetaCor').value = '#667eea'; document.getElementById('etiquetaCor').value = '#667eea';
document.getElementById('modalEtiquetaTitle').textContent = 'Nova Etiqueta'; document.getElementById('modalEtiquetaTitle').textContent = 'Nova Etiqueta';
document.getElementById('modalEtiqueta').style.display = 'flex'; document.getElementById('modalEtiqueta').classList.add('show');
}; };
window.editarEtiqueta = function(id, nome, cor) { window.editarEtiqueta = function(id, nome, cor) {
@@ -637,14 +779,14 @@ window.editarEtiqueta = function(id, nome, cor) {
document.getElementById('etiquetaNome').value = nome; document.getElementById('etiquetaNome').value = nome;
document.getElementById('etiquetaCor').value = cor; document.getElementById('etiquetaCor').value = cor;
document.getElementById('modalEtiquetaTitle').textContent = 'Editar Etiqueta'; document.getElementById('modalEtiquetaTitle').textContent = 'Editar Etiqueta';
document.getElementById('modalEtiqueta').style.display = 'flex'; document.getElementById('modalEtiqueta').classList.add('show');
}; };
window.salvarEtiqueta = async function() { window.salvarEtiqueta = async function() {
var id = document.getElementById('editEtiquetaId').value; var id = document.getElementById('editEtiquetaId').value;
var nome = document.getElementById('etiquetaNome').value.trim(); var nome = document.getElementById('etiquetaNome').value.trim();
var cor = document.getElementById('etiquetaCor').value; var cor = document.getElementById('etiquetaCor').value;
if (!nome) return alert('Informe o nome'); if (!nome) { mostrarFeedbackModal('etiquetaFeedback', 'Informe o nome', 'erro'); return; }
if (id) { if (id) {
var r = await api('/labels/' + id, { method: 'PUT', body: JSON.stringify({ nome: nome, cor: cor }) }); var r = await api('/labels/' + id, { method: 'PUT', body: JSON.stringify({ nome: nome, cor: cor }) });
@@ -655,10 +797,11 @@ window.salvarEtiqueta = async function() {
} }
}; };
window.excluirEtiqueta = async function(id) { window.excluirEtiqueta = async function(id, nome) {
if (!confirm('Excluir esta etiqueta?')) return; mostrarConfirmacao('Excluir Etiqueta', 'A etiqueta será removida permanentemente.', nome, async function() {
var r = await api('/labels/' + id, { method: 'DELETE' }); var r = await api('/labels/' + id, { method: 'DELETE' });
if (r.success) carregarEtiquetas(); if (r.success) carregarEtiquetas();
});
}; };
// ===== CONFIGURAÇÕES EMPRESA ===== // ===== CONFIGURAÇÕES EMPRESA =====
@@ -674,15 +817,17 @@ async function carregarConfig() {
'<div class="form-group"><div class="toggle"><input type="checkbox" id="cfgSaudacao" ' + (cfg.saudacaoAtiva === 'S' ? 'checked' : '') + '> <label for="cfgSaudacao">Ativar saudação automática</label></div></div>' + '<div class="form-group"><div class="toggle"><input type="checkbox" id="cfgSaudacao" ' + (cfg.saudacaoAtiva === 'S' ? 'checked' : '') + '> <label for="cfgSaudacao">Ativar saudação automática</label></div></div>' +
'<div class="form-group"><label>Mensagem de Saudação</label><textarea id="cfgSaudacaoMsg">' + (cfg.saudacaoMensagem || '') + '</textarea></div>' + '<div class="form-group"><label>Mensagem de Saudação</label><textarea id="cfgSaudacaoMsg">' + (cfg.saudacaoMensagem || '') + '</textarea></div>' +
'<div class="form-group"><div class="toggle"><input type="checkbox" id="cfgNomeUser" ' + (cfg.enviarNomeUsuario === 'S' ? 'checked' : '') + '> <label for="cfgNomeUser">Mostrar nome do usuário nas mensagens ("Nome: Mensagem")</label></div></div>' + '<div class="form-group"><div class="toggle"><input type="checkbox" id="cfgNomeUser" ' + (cfg.enviarNomeUsuario === 'S' ? 'checked' : '') + '> <label for="cfgNomeUser">Mostrar nome do usuário nas mensagens ("Nome: Mensagem")</label></div></div>' +
'<div class="form-group"><div class="toggle"><input type="checkbox" id="cfgEnviarBoleto" ' + (cfg.enviarBoleto === 'S' ? 'checked' : '') + '> <label for="cfgEnviarBoleto">💳 Permitir enviar boleto na conversa (botão "Enviar boleto")</label></div></div>' +
'<div class="form-group"><div class="toggle"><input type="checkbox" id="cfgTriagem" ' + (cfg.triagemAtiva === 'S' ? 'checked' : '') + '> <label for="cfgTriagem">📋 Ativar fluxo de triagem (menu de opções)</label></div></div>' + '<div class="form-group"><div class="toggle"><input type="checkbox" id="cfgTriagem" ' + (cfg.triagemAtiva === 'S' ? 'checked' : '') + '> <label for="cfgTriagem">📋 Ativar fluxo de triagem (menu de opções)</label></div></div>' +
'<div class="form-group" id="triagemConfig" style="display:' + (cfg.triagemAtiva === 'S' ? 'block' : 'none') + ';padding:12px;background:#f9fafb;border-radius:8px;margin-bottom:12px">' + '<div class="form-group" id="triagemConfig" style="display:' + (cfg.triagemAtiva === 'S' ? 'block' : 'none') + ';padding:12px;background:var(--surface-2);border-radius:8px;margin-bottom:12px">' +
'<div class="form-group"><label>Mensagem de boas-vindas (use {EMPRESA} para o nome)</label><textarea id="cfgTriagemWelcome" rows="2" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:13px;resize:vertical">' + (cfg.triagemMsgWelcome || '') + '</textarea></div>' + '<div class="form-group"><label>Mensagem de boas-vindas (use {EMPRESA} para o nome)</label><textarea id="cfgTriagemWelcome" rows="2" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:13px;resize:vertical">' + (cfg.triagemMsgWelcome || '') + '</textarea></div>' +
'<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>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 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>' +
'<div class="form-group"><div class="toggle"><input type="checkbox" id="cfgResolucaoSalvarMSC" ' + (cfg.resolucaoSalvarMSC === 'S' ? 'checked' : '') + '> <label for="cfgResolucaoSalvarMSC">💾 Salvar resolução de atendimento na tabela MENSAGENS_CLIENTES (como aviso)</label></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"><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>' + '<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>'; '<div id="configFeedback" class="feedback-msg"></div><button class="btn btn-primary" onclick="salvarConfig()">💾 Salvar Configurações</button>';
// Evento para toggle da triagem // Evento para toggle da triagem
document.getElementById('cfgTriagem').addEventListener('change', function() { document.getElementById('cfgTriagem').addEventListener('change', function() {
@@ -711,26 +856,88 @@ window.salvarConfig = async function() {
saudacaoAtiva: document.getElementById('cfgSaudacao').checked ? 'S' : 'N', saudacaoAtiva: document.getElementById('cfgSaudacao').checked ? 'S' : 'N',
saudacaoMensagem: document.getElementById('cfgSaudacaoMsg').value, saudacaoMensagem: document.getElementById('cfgSaudacaoMsg').value,
enviarNomeUsuario: document.getElementById('cfgNomeUser').checked ? 'S' : 'N', enviarNomeUsuario: document.getElementById('cfgNomeUser').checked ? 'S' : 'N',
enviarBoleto: document.getElementById('cfgEnviarBoleto').checked ? 'S' : 'N',
triagemAtiva: document.getElementById('cfgTriagem').checked ? 'S' : 'N', triagemAtiva: document.getElementById('cfgTriagem').checked ? 'S' : 'N',
triagemMsgWelcome: document.getElementById('cfgTriagemWelcome').value, triagemMsgWelcome: document.getElementById('cfgTriagemWelcome').value,
triagemMsgAfter: document.getElementById('cfgTriagemAfter').value, triagemMsgAfter: document.getElementById('cfgTriagemAfter').value,
triagemBoletoNumero: document.getElementById('cfgTriagemBoletoNum').value, triagemBoletoNumero: document.getElementById('cfgTriagemBoletoNum').value,
csatAtivo: document.getElementById('cfgCsat').checked ? 'S' : 'N', csatAtivo: document.getElementById('cfgCsat').checked ? 'S' : 'N',
csatMensagem: document.getElementById('cfgCsatMsg').value, csatMensagem: document.getElementById('cfgCsatMsg').value,
resolucaoSalvarMSC: document.getElementById('cfgResolucaoSalvarMSC').checked ? 'S' : 'N',
}; };
var r = await api('/company/config', { method: 'POST', body: JSON.stringify(data) }); var r = await api('/company/config', { method: 'POST', body: JSON.stringify(data) });
if (r.success) alert('Configurações salvas com sucesso!'); if (r.success) mostrarFeedbackModal('configFeedback', 'Configurações salvas com sucesso!', 'sucesso');
}; };
// O addEventListener do cfgTriagem é adicionado dentro do carregarConfig() após criar o HTML // O addEventListener do cfgTriagem é adicionado dentro do carregarConfig() após criar o HTML
// ===== FLUXO DE RESOLUÇÃO =====
function escc(s){ return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
async function carregarResolucao() {
var md = await api('/motivos?empresaId=' + empresaId);
var div = document.getElementById('motivosList');
if (div && md.success) {
div.innerHTML = (md.data && md.data.length)
? md.data.map(function(m) {
return '<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 10px;border:1px solid var(--border);border-radius:6px;margin-bottom:4px">' +
'<span style="font-size:13px">' + escc(m.descricao) + '</span>' +
'<button class="btn btn-sm" style="color:#dc2626;background:none;border:none;cursor:pointer" onclick="removerMotivo(' + m.id + ',\'' + escc(m.descricao).replace(/'/g,"\\'") + '\')">🗑️</button></div>';
}).join('')
: '<p style="color:var(--text-faint);font-size:13px">Nenhum motivo cadastrado.</p>';
}
var cd = await api('/company/config?empresaId=' + empresaId);
if (cd.success) {
var s = function(id, v) { var el = document.getElementById(id); if (el) el.checked = v === 'S'; };
s('flgMotivoVis', cd.data.motivoVisualizar);
s('flgMotivoObr', cd.data.motivoObrigatorio);
s('flgResolVis', cd.data.resolucaoVisualizar);
s('flgResolObr', cd.data.resolucaoObrigatorio);
}
}
window.adicionarMotivo = async function() {
var inp = document.getElementById('novoMotivo');
var d = (inp.value || '').trim();
if (!d) return;
var r = await api('/motivos', { method: 'POST', body: JSON.stringify({ descricao: d, empresaId: empresaId }) });
if (r.success) { inp.value = ''; carregarResolucao(); } else mostrarFeedbackResolucao(r.error || 'Erro ao adicionar motivo', 'erro');
};
window.removerMotivo = async function(id, descricao) {
mostrarConfirmacao('Remover Motivo', 'O motivo será removido permanentemente.', descricao, async function() {
var r = await api('/motivos/' + id, { method: 'DELETE' });
if (r.success) carregarResolucao(); else mostrarFeedbackResolucao(r.error || 'Erro ao remover', 'erro');
});
};
function mostrarFeedbackResolucao(msg, tipo) {
var fb = document.getElementById('resolucaoFeedback');
if (!fb) return;
fb.textContent = msg;
fb.className = 'feedback-msg ' + tipo;
setTimeout(function() { fb.className = 'feedback-msg'; }, 4000);
}
window.salvarResolucao = async function() {
var body = {
empresaId: empresaId,
motivoVisualizar: document.getElementById('flgMotivoVis').checked ? 'S' : 'N',
motivoObrigatorio: document.getElementById('flgMotivoObr').checked ? 'S' : 'N',
resolucaoVisualizar: document.getElementById('flgResolVis').checked ? 'S' : 'N',
resolucaoObrigatorio: document.getElementById('flgResolObr').checked ? 'S' : 'N',
};
var r = await api('/company/resolucao-config', { method: 'POST', body: JSON.stringify(body) });
if (r.success) mostrarFeedbackModal('resolucaoFeedback', 'Fluxo de Resolução salvo!', 'sucesso'); else mostrarFeedbackModal('resolucaoFeedback', r.error || 'Erro ao salvar', 'erro');
};
// ===== CONEXÕES ===== // ===== CONEXÕES =====
async function carregarConexoes() { async function carregarConexoes() {
var data = await api('/evolution/instances?empresaId=' + empresaId); var data = await api('/evolution/instances?empresaId=' + empresaId);
var div = document.getElementById('conexoesList'); var div = document.getElementById('conexoesList');
if (!data.success || !data.data || data.data.length === 0) { if (!data.success || !data.data || data.data.length === 0) {
div.innerHTML = '<p style="color:#9ca3af">Nenhuma conexão cadastrada</p>'; div.innerHTML = '<p style="color:var(--text-faint)">Nenhuma conexão cadastrada</p>';
return; 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>' + 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>' +
@@ -740,9 +947,9 @@ async function carregarConexoes() {
ins.status === 'D' ? '<span class="badge badge-danger">⛔ Desconectado</span>' : '<span class="badge badge-danger">❌ ' + ins.status + '</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, "\\'"); 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>' + 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="editarConexao(' + ins.id + ')" style="background:var(--surface-3);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="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>'; '<button class="btn btn-sm" onclick="gerarQRCode(' + ins.id + ')" style="background:var(--surface-3)">📱 QR Code</button></td></tr>';
}).join('') + '</tbody></table>'; }).join('') + '</tbody></table>';
} }
@@ -754,7 +961,46 @@ window.mostrarModalConexao = function() {
document.getElementById('conInstance').value = ''; document.getElementById('conInstance').value = '';
document.getElementById('modalConexaoTitle').textContent = 'Nova Conexão WhatsApp'; document.getElementById('modalConexaoTitle').textContent = 'Nova Conexão WhatsApp';
document.getElementById('btnSalvarConexao').textContent = 'Conectar'; document.getElementById('btnSalvarConexao').textContent = 'Conectar';
document.getElementById('modalConexao').style.display = 'flex'; document.getElementById('modalConexao').classList.add('show');
document.getElementById('instanciasExistentesGroup').style.display = 'block';
document.getElementById('instanciasExistentesList').innerHTML = '<button class="btn-sm" onclick="buscarInstanciasExistentes()" style="width:100%;padding:8px;font-size:12px">🔍 Buscar instâncias no servidor</button>';
};
window.buscarInstanciasExistentes = async function() {
var list = document.getElementById('instanciasExistentesList');
var url = document.getElementById('conUrl').value.trim();
var apiKey = document.getElementById('conApiKey').value.trim();
if (!url || !apiKey) { list.innerHTML = '<span style="font-size:12px;color:var(--text-faint)">Preencha URL e API Key primeiro</span>'; return; }
list.innerHTML = '<span style="font-size:12px;color:var(--text-faint)">Buscando...</span>';
try {
var r = await fetch('/api/' + alias + '/evolution/fetch-instances', {
method: 'POST', body: JSON.stringify({ url: url, apiKey: apiKey }),
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }
});
var d = await r.json();
if (d.success && d.data && d.data.length > 0) {
list.innerHTML = d.data.map(function(i) {
var nm = (i.instanceName || i.name || '');
var st = i.connectionStatus === 'open' ? '🟢' : '🔴';
return '<div data-instance="' + nm.replace(/"/g, '&quot;') + '" class="instancia-existente" style="padding:6px 8px;border-radius:4px;cursor:pointer;font-size:12px;display:flex;align-items:center;gap:6px;border-bottom:1px solid var(--border);color:var(--text-primary)">' + st + ' <strong>' + nm + '</strong><span style="margin-left:auto;font-size:11px;color:var(--text-muted)">' + (i.connectionStatus || i.status || '-') + '</span></div>';
}).join('');
// Delegation: click em qualquer .instancia-existente
list.querySelectorAll('.instancia-existente').forEach(function(el) {
el.addEventListener('click', function() { reutilizarInstancia(this.dataset.instance); });
});
} else { list.innerHTML = '<span style="font-size:12px;color:var(--text-faint)">Nenhuma instância no servidor</span>'; }
} catch(e) { list.innerHTML = '<span style="font-size:12px;color:var(--danger)">Erro ao buscar</span>'; }
};
window.reutilizarInstancia = function(nm) {
document.getElementById('conInstance').value = nm;
var existente = document.getElementById('conInstanceExistente');
if (!existente) {
existente = document.createElement('span'); existente.id = 'conInstanceExistente';
existente.style.cssText = 'font-size:11px;color:#059669;display:block;margin-top:3px';
document.getElementById('conInstance').parentNode.appendChild(existente);
}
existente.textContent = '✓ Reutilizando: ' + nm;
}; };
window.gerarQRCode = async function(id) { window.gerarQRCode = async function(id) {
@@ -766,9 +1012,9 @@ window.gerarQRCode = async function(id) {
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">' + 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>' + '<h2 style="margin-bottom:20px">Escaneie o QR Code</h2>' +
'<img src="' + imgSrc + '" style="max-width:300px">' + '<img src="' + imgSrc + '" style="max-width:300px">' +
'<p style="margin-top:20px;color:#6b7280">Abra o WhatsApp no celular e escaneie</p></body></html>'); '<p style="margin-top:20px;color:var(--text-muted)">Abra o WhatsApp no celular e escaneie</p></body></html>');
} else { } else {
alert('Erro ao gerar QR Code: ' + JSON.stringify(r.data)); mostrarFeedbackModal('conexaoFeedback', 'Erro ao gerar QR Code', 'erro');
} }
}; };
@@ -776,9 +1022,9 @@ window.gerarQRCode = async function(id) {
window.editarConexao = async function(id) { window.editarConexao = async function(id) {
// Busca dados atuais da API // Busca dados atuais da API
var data = await api('/evolution/instances?empresaId=' + empresaId); var data = await api('/evolution/instances?empresaId=' + empresaId);
if (!data.success) return alert('Erro ao carregar dados'); if (!data.success) { mostrarFeedbackModal('conexaoFeedback', 'Erro ao carregar dados', 'erro'); return; }
var ins = data.data.find(function(i) { return i.id === id; }); var ins = data.data.find(function(i) { return i.id === id; });
if (!ins) return alert('Instância não encontrada'); if (!ins) { mostrarFeedbackModal('conexaoFeedback', 'Instância não encontrada', 'erro'); return; }
document.getElementById('editConexaoId').value = id; document.getElementById('editConexaoId').value = id;
document.getElementById('conNome').value = ins.nome; document.getElementById('conNome').value = ins.nome;
@@ -787,18 +1033,16 @@ window.editarConexao = async function(id) {
document.getElementById('conInstance').value = ins.instanceName; document.getElementById('conInstance').value = ins.instanceName;
document.getElementById('modalConexaoTitle').textContent = 'Editar Conexão WhatsApp'; document.getElementById('modalConexaoTitle').textContent = 'Editar Conexão WhatsApp';
document.getElementById('btnSalvarConexao').textContent = 'Salvar'; document.getElementById('btnSalvarConexao').textContent = 'Salvar';
document.getElementById('modalConexao').style.display = 'flex'; document.getElementById('modalConexao').classList.add('show');
}; };
// ===== EXCLUIR CONEXÃO ===== // ===== EXCLUIR CONEXÃO =====
window.excluirConexao = async function(id, nome) { window.excluirConexao = async function(id, nome) {
if (!confirm('Excluir a conexão "' + nome + '"?')) return; mostrarConfirmacao('Excluir Conexao', 'A conexao WhatsApp "' + nome + '" sera removida.', nome, async function() {
var r = await api('/evolution/instances/' + id, { method: 'DELETE' }); var r = await api('/evolution/instances/' + id, { method: 'DELETE' });
if (r.success) { if (r.success) carregarConexoes();
carregarConexoes(); else mostrarFeedbackModal('conexaoFeedback', 'Erro: ' + r.error, 'erro');
} else { });
alert('Erro: ' + r.error);
}
}; };
window.salvarConexao = async function() { window.salvarConexao = async function() {
@@ -810,8 +1054,9 @@ window.salvarConexao = async function() {
INS_INSTANCE_NAME: document.getElementById('conInstance').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) if (!data.INS_NOME || !data.INS_URL || !data.INS_API_KEY || !data.INS_INSTANCE_NAME) {
return alert('Preencha todos os campos'); mostrarFeedbackModal('conexaoFeedback', 'Preencha todos os campos', 'erro'); return;
}
var r; var r;
if (editId) { if (editId) {
@@ -831,16 +1076,24 @@ window.salvarConexao = async function() {
carregarConexoes(); carregarConexoes();
if (!editId && r.data && r.data.id) gerarQRCode(r.data.id); if (!editId && r.data && r.data.id) gerarQRCode(r.data.id);
} else { } else {
alert('Erro: ' + r.error); mostrarFeedbackModal('conexaoFeedback', 'Erro: ' + r.error, 'erro');
} }
}; };
// ===== MODAIS ===== // ===== MODAIS =====
window.fecharModal = function(id) { document.getElementById(id).style.display = 'none'; }; window.fecharModal = function(id) { document.getElementById(id).classList.remove('show'); };
function mostrarFeedbackModal(elId, msg, tipo) {
var fb = document.getElementById(elId);
if (!fb) return;
fb.textContent = msg;
fb.className = 'feedback-msg ' + tipo;
setTimeout(function() { fb.className = 'feedback-msg'; }, 4000);
}
// Fechar modal ao clicar fora // Fechar modal ao clicar fora
document.querySelectorAll('.modal-overlay').forEach(function(el) { document.querySelectorAll('.modal-overlay').forEach(function(el) {
el.addEventListener('click', function(e) { if (e.target === this) this.style.display = 'none'; }); el.addEventListener('click', function(e) { if (e.target === this) this.classList.remove('show'); });
}); });
// ===== INICIAR ===== // ===== INICIAR =====
@@ -850,9 +1103,33 @@ carregarMenus();
carregarEtiquetas(); carregarEtiquetas();
carregarConexoes(); carregarConexoes();
carregarConfig(); carregarConfig();
carregarResolucao();
})(); })();
</script> </script>
<!-- Modal Confirmação de Exclusão -->
<div class="modal-overlay" id="modalConfirmar">
<div class="modal-box" style="max-width:420px">
<h3 id="confirmarTitulo">Confirmar exclusão</h3>
<p id="confirmarMensagem" style="font-size:14px;color:var(--text-secondary);margin-bottom:14px;line-height:1.5"></p>
<div class="form-group">
<label for="confirmarInput" style="font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:5px">Digite o nome para confirmar:</label>
<input type="text" id="confirmarInput" placeholder="Digite exatamente o nome..." style="width:100%;padding:10px 14px;border:2px solid var(--border);border-radius:8px;font-size:14px;outline:none;background:var(--surface-2);color:var(--text-primary);transition:all .15s" onfocus="this.style.borderColor='var(--primary)';this.style.background='var(--surface)'" onblur="this.style.borderColor='var(--border)';this.style.background='var(--surface-2)'">
</div>
<div id="confirmarFeedback" class="feedback-msg"></div>
<div class="modal-footer">
<button class="btn-secondary" onclick="fecharModal('modalConfirmar')">Cancelar</button>
<button class="btn-danger" id="btnConfirmarExclusao" disabled>Excluir</button>
</div>
</div>
</div>
<script src="/js/dark-mode.js"></script> <script src="/js/dark-mode.js"></script>
<!-- impeccable-live-start -->
<script src="http://localhost:8400/live.js"></script>
<!-- impeccable-live-end -->
<!-- Bottom Tab Bar (mobile) -->
<script src="/js/tabbar.js"></script>
</body> </body>
</html> </html>
+6
View File
@@ -17,6 +17,12 @@ router.post('/:alias/conversations/:id/assign', authenticateToken, ChatControlle
router.post('/:alias/conversations/:id/assign-team', authenticateToken, ChatController.assignTeam); router.post('/:alias/conversations/:id/assign-team', authenticateToken, ChatController.assignTeam);
router.post('/:alias/conversations/:id/labels', authenticateToken, ChatController.toggleLabel); router.post('/:alias/conversations/:id/labels', authenticateToken, ChatController.toggleLabel);
router.post('/:alias/conversations/:id/link-client', authenticateToken, ChatController.linkClient); router.post('/:alias/conversations/:id/link-client', authenticateToken, ChatController.linkClient);
router.get('/:alias/conversations/:id/boletos', authenticateToken, ChatController.getBoletos);
router.post('/:alias/conversations/:id/send-boleto', authenticateToken, ChatController.sendBoleto);
router.post('/:alias/conversations/:id/schedule', authenticateToken, ChatController.scheduleConversation);
router.get('/:alias/schedules', authenticateToken, ChatController.listSchedules);
router.post('/:alias/schedules/:id/reopen', authenticateToken, ChatController.reopenSchedule);
router.get('/:alias/search', authenticateToken, ChatController.search);
router.get('/:alias/media/:mediaId', ChatController.getMedia); router.get('/:alias/media/:mediaId', ChatController.getMedia);
module.exports = router; module.exports = router;
+7
View File
@@ -27,12 +27,19 @@ router.delete('/:alias/labels/:id', ConfigController.deleteLabel);
router.get('/:alias/company/config', ConfigController.getCompanyConfig); router.get('/:alias/company/config', ConfigController.getCompanyConfig);
router.post('/:alias/company/config', ConfigController.saveCompanyConfig); router.post('/:alias/company/config', ConfigController.saveCompanyConfig);
// Fluxo de Resolução (motivos + flags)
router.post('/:alias/company/resolucao-config', ConfigController.saveResolucaoConfig);
router.get('/:alias/motivos', ConfigController.listMotivos);
router.post('/:alias/motivos', ConfigController.createMotivo);
router.delete('/:alias/motivos/:id', ConfigController.deleteMotivo);
// Evolution API // Evolution API
router.get('/:alias/evolution/instances', EvolutionController.listInstances); router.get('/:alias/evolution/instances', EvolutionController.listInstances);
router.post('/:alias/evolution/connect', EvolutionController.connect); router.post('/:alias/evolution/connect', EvolutionController.connect);
router.put('/:alias/evolution/instances/:id', EvolutionController.updateInstance); router.put('/:alias/evolution/instances/:id', EvolutionController.updateInstance);
router.delete('/:alias/evolution/instances/:id', EvolutionController.deleteInstance); router.delete('/:alias/evolution/instances/:id', EvolutionController.deleteInstance);
router.post('/:alias/evolution/qrcode/:id', EvolutionController.generateQRCode); router.post('/:alias/evolution/qrcode/:id', EvolutionController.generateQRCode);
router.post('/:alias/evolution/fetch-instances', EvolutionController.fetchInstances);
router.post('/:alias/evolution/refresh-photo/:conversaId', EvolutionController.refreshPhoto); router.post('/:alias/evolution/refresh-photo/:conversaId', EvolutionController.refreshPhoto);
module.exports = router; module.exports = router;
+10
View File
@@ -0,0 +1,10 @@
const express = require('express');
const router = express.Router();
const DashboardController = require('../controllers/dashboardController');
const authenticateToken = require('../middlewares/auth');
// Presença (qualquer usuário autenticado) e estatísticas (gerente).
router.post('/:alias/dashboard/ping', authenticateToken, DashboardController.ping);
router.get('/:alias/dashboard/stats', authenticateToken, DashboardController.stats);
module.exports = router;
+3
View File
@@ -63,4 +63,7 @@ router.get('/:alias/clients/:id/convalescentes', ClientController.clientConvales
// Historico de conversas do cliente (inclui dependentes) // Historico de conversas do cliente (inclui dependentes)
router.get('/:alias/clients/:id/conversations', ClientController.clientConversations); router.get('/:alias/clients/:id/conversations', ClientController.clientConversations);
// Atualizar data de agendamento de cobrança
router.put('/:alias/clients/:clienteId/carne/:carneId/agendamento', authenticateToken, ClientController.atualizarAgendamento);
module.exports = router; module.exports = router;
+20
View File
@@ -7,8 +7,11 @@ const chatRoutes = require('./chatRoutes');
const configRoutes = require('./configRoutes'); const configRoutes = require('./configRoutes');
const menuRoutes = require('./menuRoutes'); const menuRoutes = require('./menuRoutes');
const databaseRoutes = require('./databaseRoutes'); const databaseRoutes = require('./databaseRoutes');
const dashboardRoutes = require('./dashboardRoutes');
const RoutesController = require('../controllers/routesController'); const RoutesController = require('../controllers/routesController');
const EvolutionController = require('../controllers/evolutionController'); const EvolutionController = require('../controllers/evolutionController');
const authenticateToken = require('../middlewares/auth');
const metrics = require('../services/metrics');
// Webhook Evolution (sem auth - DEVE vir antes de qualquer middleware de auth) // Webhook Evolution (sem auth - DEVE vir antes de qualquer middleware de auth)
// Suporta URL com alias (ex: /api/lajedo/webhook/evolution) ou sem alias // Suporta URL com alias (ex: /api/lajedo/webhook/evolution) ou sem alias
@@ -23,8 +26,24 @@ router.get('/api/:alias/webhook/ping', EvolutionController.webhookPing);
router.get('/api/:alias/media/:mediaId', require('../controllers/chatController').getMedia); router.get('/api/:alias/media/:mediaId', require('../controllers/chatController').getMedia);
// CSAT (sem auth - formulário público de avaliação) // CSAT (sem auth - formulário público de avaliação)
router.get('/api/:alias/csat/check', require('../controllers/chatController').csatCheck);
router.post('/api/:alias/csat/avaliar', require('../controllers/chatController').csatAvaliar); router.post('/api/:alias/csat/avaliar', require('../controllers/chatController').csatAvaliar);
// Métricas (apenas gerentes)
router.get('/api/:alias/metrics', authenticateToken, (req, res) => {
if (String(req.user?.tipoChat || req.user?.tipo || '').trim() !== 'G') {
return res.status(403).json({ success: false, error: 'Apenas gerentes.' });
}
res.json({
success: true,
uptimeSeconds: Math.floor(process.uptime()),
memory: process.memoryUsage(),
metrics: metrics.getAll(),
pool: require('../database').poolStats(),
timestamp: new Date().toISOString(),
});
});
// Rota de descoberta de rotas (sem auth) // Rota de descoberta de rotas (sem auth)
router.get('/api/routes', RoutesController.list); router.get('/api/routes', RoutesController.list);
@@ -34,6 +53,7 @@ router.use('/api', chatRoutes);
router.use('/api', configRoutes); router.use('/api', configRoutes);
router.use('/api', menuRoutes); router.use('/api', menuRoutes);
router.use('/api', databaseRoutes); router.use('/api', databaseRoutes);
router.use('/api', dashboardRoutes);
// Rotas do aplicativo (proteção aplicada internamente) // Rotas do aplicativo (proteção aplicada internamente)
router.use('/app', authRoutes); router.use('/app', authRoutes);
+10
View File
@@ -24,6 +24,16 @@ async function start() {
console.log('\n⚠️ Alguns bancos não puderam ser conectados. Verifique as configurações.'); console.log('\n⚠️ Alguns bancos não puderam ser conectados. Verifique as configurações.');
} }
// Executa migrações pendentes automaticamente (idempotente, seguro)
console.log('');
const migrationRunner = require('../scripts/migrationRunner');
for (const alias of listAliases()) {
try { await migrationRunner.executarMigracoes(alias); } catch (e) {
console.log(` ⚠️ Migrações para "${alias}" ignoradas: ${e.message.substring(0, 80)}`);
}
}
console.log('');
const server = app.listen(PORT, () => { const server = app.listen(PORT, () => {
console.log(`\n🚀 API Chatc2 rodando em http://localhost:${PORT}`); console.log(`\n🚀 API Chatc2 rodando em http://localhost:${PORT}`);
console.log(`📋 Aliases disponíveis: ${listAliases().join(', ')}`); console.log(`📋 Aliases disponíveis: ${listAliases().join(', ')}`);
+308
View File
@@ -0,0 +1,308 @@
/**
* Serviço de geração e envio de boletos.
*
* Centraliza a montagem do payload, chamada à API externa (cobpagweb.com.br)
* e envio do PDF via Evolution API. Antes duplicado entre triageController
* e chatController.
*/
const db = require('../database');
const evolution = require('./evolutionService');
const https = require('https');
// ============================================================
// Helpers de formatação
// ============================================================
function fmtCpf(cpf) {
if (!cpf) return '';
const d = String(cpf).replace(/\D/g, '');
return d.length === 11 ? d.slice(0, 3) + '.' + d.slice(3, 6) + '.' + d.slice(6, 9) + '-' + d.slice(9) : cpf;
}
function fmtData(dt) {
if (!dt) return null;
if (typeof dt === 'string') return dt.split('T')[0];
if (dt instanceof Date) return dt.toISOString().split('T')[0];
return String(dt);
}
function fmtDataBR(dt) {
if (!dt) return '-';
try {
let s = fmtData(dt);
if (s) {
const partes = s.split('-');
if (partes.length === 3) return partes[2] + '/' + partes[1] + '/' + partes[0];
}
} catch (_) {}
return '-';
}
function fmtValor(v) {
const n = Number(v);
if (isNaN(n)) return '';
return 'R$ ' + n.toFixed(2).replace('.', ',');
}
// ============================================================
// Montagem do payload para API de boleto
// ============================================================
/**
* Monta o payload completo para envio à API de geração de boleto (cobpagweb.com.br).
* Busca dados do cliente, carnê e empresa no banco.
*
* @param {string} alias - Alias do banco
* @param {number} clienteId - ID do cliente
* @param {number} carneId - ID do carnê (título)
* @returns {Promise<{payload: object, matricula: string, vencimento: string}|null>}
*/
async function montarPayloadBoleto(alias, clienteId, carneId) {
// Busca cliente
const cli = await db.query(alias, `
SELECT CLI_CODIGO_ID, CLI_NOME, CLI_NOME_FANTASIA, CLI_CPF, CLI_ENDERECO,
CLI_BAIRRO, CLI_CEP, CLI_CIDADES_ID, CLI_MATRICULA, CLI_EMPRESA_ID
FROM CLIENTES WHERE CLI_CODIGO_ID = ?
`, [clienteId]);
if (cli.length === 0) return null;
const matricula = (cli[0].CLI_MATRICULA || '').trim() || '000000';
// Busca carnê
const car = await db.query(alias, `
SELECT CAR_CODIGO_ID, CAR_DT_VENCIMENTO, CAR_VALOR_PARCELA
FROM CARNES WHERE CAR_CODIGO_ID = ? AND CAR_CLIENTE_ID = ?
`, [carneId, clienteId]);
if (car.length === 0) return null;
// Busca detalhes do carnê (código de barras, linha digitável, etc.)
const det = await db.query(alias, `
SELECT CAR_CODIGO_BARRAS, CAR_AGEN_COD_CEDENTE, CAR_NUM_BANCARIO,
CAR_DT_CADASTRO, CAR_DT_PROCESSAMENTO, CAR_NUMERO_DOCUMENTO,
CAR_LINHA_DIGITAVEL, CAR_NOSSO_NUMERO, CAR_PIX_QRCODE
FROM CARNES WHERE CAR_CODIGO_ID = ?
`, [carneId]);
// Busca cidade
let cidadeNome = '', cidadeUf = '';
if (cli[0].CLI_CIDADES_ID) {
const cid = await db.query(alias,
'SELECT CID_NOME, CID_UF FROM CIDADES WHERE CID_CODIGO_ID = ?',
[cli[0].CLI_CIDADES_ID]);
if (cid.length > 0) {
cidadeNome = (cid[0].CID_NOME || '').trim();
cidadeUf = (cid[0].CID_UF || '').trim();
}
}
// Busca dados da empresa
let empresaBoleto = null;
if (cli[0].CLI_EMPRESA_ID) {
const empData = await db.query(alias, `
SELECT EMP_NOME, EMP_CNPJ, EMP_FOTO
FROM EMPRESAS WHERE EMP_CODIGO_ID = ?
`, [cli[0].CLI_EMPRESA_ID]);
if (empData.length > 0) {
let fotoBase64 = null;
if (empData[0].EMP_FOTO) {
try {
const buf = empData[0].EMP_FOTO;
if (Buffer.isBuffer(buf)) fotoBase64 = buf.toString('base64');
else if (typeof buf === 'string') fotoBase64 = buf;
} catch (_) {}
}
empresaBoleto = {
EMP_NOME: (empData[0].EMP_NOME || '').trim(),
EMP_CNPJ: (empData[0].EMP_CNPJ || '').trim(),
EMP_FOTO: fotoBase64,
};
}
}
const payload = {
empresa: empresaBoleto,
Cliente: {
CLI_NOME_FANTASIA: (cli[0].CLI_NOME_FANTASIA || cli[0].CLI_NOME || '').trim(),
CLI_CPF_CNPJ: fmtCpf(cli[0].CLI_CPF),
CLI_ENDERECO: (cli[0].CLI_ENDERECO || '').trim(),
CLI_BAIRRO: (cli[0].CLI_BAIRRO || '').trim(),
CLI_CEP: (cli[0].CLI_CEP || '').trim(),
CLI_CIDADE: cidadeNome,
CLI_UF: cidadeUf,
},
Carnes: [{
CAR_CODIGO_BARRAS: (det[0]?.CAR_CODIGO_BARRAS || '').trim(),
CAR_DT_VENCIMENTO: fmtData(car[0].CAR_DT_VENCIMENTO),
CAR_AGEN_COD_CEDENTE: (det[0]?.CAR_AGEN_COD_CEDENTE || '').trim(),
CAR_NUM_BANCARIO: (det[0]?.CAR_NUM_BANCARIO || '').trim(),
CAR_DT_CADASTRO: fmtData(det[0]?.CAR_DT_CADASTRO),
CAR_DT_PROCESSAMENTO: fmtData(det[0]?.CAR_DT_PROCESSAMENTO),
CAR_NUMERO_DOCUMENTO: (det[0]?.CAR_NUMERO_DOCUMENTO || '').trim(),
CAR_VALOR_PARCELA: car[0].CAR_VALOR_PARCELA,
CAR_LINHA_DIGITAVEL: (det[0]?.CAR_LINHA_DIGITAVEL || '').trim(),
CAR_NOSSO_NUMERO: (det[0]?.CAR_NOSSO_NUMERO || '').trim(),
CAR_PIX_QRCODE: (det[0]?.CAR_PIX_QRCODE || '').trim(),
}],
};
return {
payload,
matricula,
vencimento: fmtData(car[0].CAR_DT_VENCIMENTO),
valor: car[0].CAR_VALOR_PARCELA,
linhaDigitavel: (det[0]?.CAR_LINHA_DIGITAVEL || '').trim(),
pixQrcode: (det[0]?.CAR_PIX_QRCODE || '').trim(),
codigoBarras: (det[0]?.CAR_CODIGO_BARRAS || '').trim(),
};
}
// ============================================================
// Geração do PDF via API externa
// ============================================================
/**
* Gera o PDF do boleto via API externa (cobpagweb.com.br).
*
* @param {object} payload - Payload montado por montarPayloadBoleto()
* @returns {Promise<string|null>} PDF em base64 ou null se falhar
*/
function gerarPDF(payload) {
return new Promise((resolve) => {
const body = JSON.stringify(payload);
const url = new URL('https://cobpagweb.com.br/boleto/cliente/index.php?base64');
const req = https.request({
hostname: url.hostname,
port: 443,
path: url.pathname + url.search,
method: 'POST',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
}
}, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const buf = Buffer.concat(chunks);
try {
const parsed = JSON.parse(buf.toString());
const pdf = parsed.base64 || parsed.pdf || parsed.data;
if (pdf && pdf.length > 100) {
resolve(pdf);
} else {
console.log('[Boleto] API retornou resposta sem PDF válido');
resolve(null);
}
} catch (_) {
resolve(null);
}
});
});
req.on('error', (err) => {
console.error('[Boleto] Erro na API:', err.message);
resolve(null);
});
req.on('timeout', () => { req.destroy(); resolve(null); });
req.write(body);
req.end();
});
}
// ============================================================
// Envio do boleto (PDF ou texto)
// ============================================================
/**
* Gera e envia boleto em PDF para o cliente via Evolution API.
*
* @param {string} alias - Alias do banco
* @param {object} instancia - Objeto da instância Evolution
* @param {string} numero - Número do destinatário
* @param {number} clienteId - ID do cliente
* @param {number} carneId - ID do carnê (título)
* @returns {Promise<{sucesso: boolean, pdfEnviado: boolean}>}
*/
async function gerarEnviarBoletoPDF(alias, instancia, numero, clienteId, carneId) {
const dados = await montarPayloadBoleto(alias, clienteId, carneId);
if (!dados) return { sucesso: false, pdfEnviado: false };
const pdfBase64 = await gerarPDF(dados.payload);
if (pdfBase64) {
const fileName = dados.matricula + ' - ' + dados.vencimento + '.pdf';
try {
await evolution.sendMedia(instancia, numero, 'document', pdfBase64, fileName);
return { sucesso: true, pdfEnviado: true };
} catch (err) {
console.error('[Boleto] Erro ao enviar PDF:', err.message);
}
}
// Fallback: envia como texto
const texto = montarTextoBoleto(dados);
if (texto) {
await evolution.sendText(instancia, numero, texto);
return { sucesso: true, pdfEnviado: false };
}
return { sucesso: false, pdfEnviado: false };
}
/**
* Monta o texto do boleto (usado como fallback quando PDF não é gerado).
*
* @param {object} dados - Dados retornados por montarPayloadBoleto()
* @returns {string}
*/
function montarTextoBoleto(dados) {
if (!dados) return '';
let texto = '📄 *Boleto - Dados para pagamento*\n\n';
texto += '💵 Valor: ' + fmtValor(dados.valor) + '\n';
texto += '📅 Vencimento: ' + fmtDataBR(dados.vencimento) + '\n';
if (dados.codigoBarras) {
texto += '\n🔢 *Código de Barras:*\n' + dados.codigoBarras + '\n';
}
if (dados.linhaDigitavel) {
texto += '\n📝 *Linha Digitável:*\n' + dados.linhaDigitavel + '\n';
}
if (dados.pixQrcode) {
texto += '\n💳 PIX disponível (cópia e cola)';
}
return texto;
}
/**
* Monta texto resumido do boleto para envio manual pelo atendente.
* Usa formato mais enxuto (usado em chatController.sendBoleto).
*
* @param {object} carne - Linha da tabela CARNES (pelo menos: CAR_VALOR_PARCELA, CAR_DT_VENCIMENTO, CAR_NUMERO_PARCELA, CAR_NUMERO_TOTAL_PARCELAS)
* @param {string} linha - Linha digitável ou código de barras
* @param {string} pix - PIX copia-e-cola
* @returns {string}
*/
function montarTextoBoletoResumido(carne, linha, pix) {
let texto = '📄 *Boleto*';
if (carne.CAR_NUMERO_PARCELA) {
texto += ' - Parcela ' + carne.CAR_NUMERO_PARCELA +
(carne.CAR_NUMERO_TOTAL_PARCELAS ? '/' + carne.CAR_NUMERO_TOTAL_PARCELAS : '');
}
texto += '\n';
if (carne.CAR_VALOR_PARCELA != null) texto += '\n💰 Valor: ' + fmtValor(carne.CAR_VALOR_PARCELA);
if (carne.CAR_DT_VENCIMENTO) texto += '\n📅 Vencimento: ' + fmtDataBR(carne.CAR_DT_VENCIMENTO);
if (linha) texto += '\n\n*Linha digitável:*\n' + linha;
if (pix) texto += '\n\n*PIX (copia e cola):*\n' + pix;
return texto;
}
module.exports = {
montarPayloadBoleto,
gerarPDF,
gerarEnviarBoletoPDF,
montarTextoBoleto,
montarTextoBoletoResumido,
fmtDataBR,
fmtValor,
};
+47
View File
@@ -0,0 +1,47 @@
/**
* Serviço de configuração da empresa (sem cache).
*
* direto do banco sem TTL, sem stale data.
* As flags CFE_* são consultadas a cada operação, então refletem
* imediatamente qualquer alteração salva em Settings.
*/
const db = require('../database');
/**
* Retorna a configuração completa de uma empresa (direto do banco).
*
* @param {string} alias
* @param {number} empresaId
* @returns {Promise<object>}
*/
async function getConfig(alias, empresaId) {
await garantirRegistro(alias, empresaId);
const result = await db.query(alias,
'SELECT * FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?',
[empresaId]);
return result[0] || {};
}
/** No-op: não há cache para invalidar */
function invalidarConfig() {}
async function garantirRegistro(alias, empresaId) {
try {
const existe = await db.query(alias,
'SELECT COUNT(*) AS CT FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?',
[empresaId]);
if (existe[0]?.CT === 0) {
await db.execute(alias, `
INSERT INTO CHATC2_CONFIGURACOES_EMPRESA (CFE_EMPRESA_ID)
VALUES (?)
`, [empresaId]);
}
} catch (e) {
if (!e.message.includes('unknown') && !e.message.includes('não encontrado')) {
console.error('[ConfigService] Erro ao garantir registro:', e.message.substring(0, 100));
}
}
}
module.exports = { getConfig, invalidarConfig };
+224
View File
@@ -0,0 +1,224 @@
/**
* Serviço unificado de comunicação com Evolution API.
*
* Centraliza TODAS as chamadas HTTP para a Evolution API que antes estavam
* duplicadas em chatController, triageController e evolutionController.
*
* Uso:
* const evolution = require('../services/evolutionService');
* await evolution.sendText(instancia, numero, texto);
* await evolution.sendMedia(instancia, numero, tipo, base64, nome);
* const result = await evolution.request(url, apiKey, endpoint, 'POST', body);
*/
const https = require('https');
const http = require('http');
// ============================================================
// Função interna: requisição HTTP de baixo nível
// ============================================================
function _request(baseUrl, apiKey, endpoint, method, body) {
const urlClean = (baseUrl || '').replace(/\/+$/, '');
if (!urlClean || !apiKey) return Promise.reject(new Error('URL ou API Key não configurados'));
const parsedUrl = new URL(urlClean + endpoint);
const lib = parsedUrl.protocol === 'https:' ? https : http;
const data = body ? JSON.stringify(body) : '';
return new Promise((resolve, reject) => {
const options = {
hostname: parsedUrl.hostname,
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
path: parsedUrl.pathname + parsedUrl.search,
method: method || 'GET',
timeout: 30000,
headers: { 'Content-Type': 'application/json', 'apikey': apiKey },
};
if (data) options.headers['Content-Length'] = Buffer.byteLength(data);
const req = lib.request(options, (resHttp) => {
let respBody = '';
resHttp.on('data', chunk => respBody += chunk);
resHttp.on('end', () => {
try {
const parsed = JSON.parse(respBody);
parsed._httpStatus = resHttp.statusCode;
if (resHttp.statusCode >= 400) {
reject(Object.assign(
new Error(parsed.error || parsed.response?.message?.[0] || 'HTTP ' + resHttp.statusCode),
{ _httpStatus: resHttp.statusCode, response: parsed }
));
} else {
resolve(parsed);
}
} catch (e) {
if (resHttp.statusCode >= 400) {
reject(Object.assign(new Error('HTTP ' + resHttp.statusCode), { _httpStatus: resHttp.statusCode, raw: respBody }));
} else {
resolve({ raw: respBody, _httpStatus: resHttp.statusCode });
}
}
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
if (data) req.write(data);
req.end();
});
}
// ============================================================
// Função auxiliar: normaliza número para formato WhatsApp
// ============================================================
function _normalizarNumero(numero) {
let n = String(numero || '').replace(/\D/g, '');
if (!n) return '';
// Adiciona DDI Brasil (55) se não tiver código de país
if (!n.startsWith('55') && !n.startsWith('1') && !n.startsWith('3') &&
!n.startsWith('4') && !n.startsWith('6') && !n.startsWith('7') &&
!n.startsWith('8') && !n.startsWith('9')) {
n = '55' + n;
}
// Corrige celular sem o 9º dígito: DDD(2) + 8 dígitos → DDD(2) + 9 + 8 dígitos
if (n.startsWith('55')) {
const resto = n.slice(2);
if (resto.length === 10) {
n = '55' + resto.slice(0, 2) + '9' + resto.slice(2);
}
}
return n;
}
function _extrairCredenciais(instancia) {
return {
url: ((instancia.INS_URL || '').trim()).replace(/\/+$/, ''),
apiKey: (instancia.INS_API_KEY || '').trim(),
instanceName: (instancia.INS_INSTANCE_NAME || '').trim(),
};
}
// ============================================================
// API PÚBLICA
// ============================================================
/**
* Requisição HTTP genérica para a Evolution API.
* Usada para operações avançadas: QR Code, fetchInstances, restart, fetchProfile, etc.
*
* @param {string} baseUrl - URL base da Evolution (ex: https://evo.empresa.com)
* @param {string} apiKey - API Key da Evolution
* @param {string} endpoint - Caminho (ex: /instance/fetchInstances)
* @param {string} method - GET, POST, PUT, DELETE
* @param {object} [body] - Corpo da requisição (opcional)
* @returns {Promise<object>} Resposta parseada (JSON) ou { raw, _httpStatus }
*/
async function request(baseUrl, apiKey, endpoint, method, body) {
return _request(baseUrl, apiKey, endpoint, method, body);
}
/**
* Envia mensagem de texto via Evolution API.
*
* @param {object} instancia - Objeto da instância (linha do banco: INS_URL, INS_API_KEY, INS_INSTANCE_NAME)
* @param {string} numero - Número do destinatário (com ou sem formatação)
* @param {string} texto - Texto da mensagem
* @returns {Promise<void>}
*/
async function sendText(instancia, numero, texto) {
const { url, apiKey, instanceName } = _extrairCredenciais(instancia);
if (!url || !apiKey || !instanceName || !numero) return;
const numeroLimpo = numero.replace(/\D/g, '');
const endpoint = '/message/sendText/' + encodeURIComponent(instanceName);
const payload = { number: numeroLimpo, text: texto || '', delay: 0 };
try {
await _request(url, apiKey, endpoint, 'POST', payload);
} catch (err) {
console.error('[Evolution] Erro ao enviar texto:', err.message?.substring(0, 100));
}
}
/**
* Envia mídia (imagem, áudio, vídeo, documento) via Evolution API.
*
* @param {object} instancia - Objeto da instância
* @param {string} numero - Número do destinatário
* @param {string} tipo - 'image', 'audio', 'video', 'document'
* @param {string} midiaBase64 - Conteúdo em base64
* @param {string} [nomeArquivo] - Nome do arquivo
* @returns {Promise<void>}
*/
async function sendMedia(instancia, numero, tipo, midiaBase64, nomeArquivo) {
const { url, apiKey, instanceName } = _extrairCredenciais(instancia);
if (!url || !apiKey || !instanceName || !numero || !midiaBase64) return;
const numeroLimpo = _normalizarNumero(numero);
const fileName = nomeArquivo || 'arquivo';
let endpoint, payload;
if (tipo === 'audio') {
// Envia como mensagem de voz (PTT) via sendWhatsAppAudio
endpoint = '/message/sendWhatsAppAudio/' + encodeURIComponent(instanceName);
payload = { number: numeroLimpo, audio: midiaBase64, encoding: true };
console.log('[Evolution] Enviando áudio PTT, tamanho base64:', midiaBase64.length);
} else if (tipo === 'image') {
endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName);
payload = { number: numeroLimpo, mediatype: 'image', media: midiaBase64, fileName };
} else if (tipo === 'video') {
endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName);
payload = { number: numeroLimpo, mediatype: 'video', media: midiaBase64, fileName };
} else {
// document e outros tipos
endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName);
payload = { number: numeroLimpo, mediatype: 'document', media: midiaBase64, fileName };
}
try {
const result = await _request(url, apiKey, endpoint, 'POST', payload);
console.log('[Evolution] Mídia enviada, status:', result._httpStatus);
return result;
} catch (err) {
console.error('[Evolution] Erro ao enviar mídia:', err.message?.substring(0, 100));
throw err;
}
}
/**
* Baixa mídia da Evolution API via GET /chat/getMedia/.
* Usado no webhook para buscar áudio/imagem/vídeo/documento recebidos.
*
* @param {string} baseUrl - URL base da Evolution
* @param {string} apiKey - API Key
* @param {string} instanceName - Nome da instância
* @param {string} messageKey - ID da mensagem (data.key.id)
* @returns {Promise<Buffer|null>}
*/
async function downloadMedia(baseUrl, apiKey, instanceName, messageKey) {
if (!baseUrl || !apiKey || !instanceName || !messageKey) return null;
const urlClean = baseUrl.replace(/\/+$/, '');
const getUrl = urlClean + '/chat/getMedia/' + encodeURIComponent(instanceName) + '/' + encodeURIComponent(messageKey);
return new Promise((resolve) => {
const u = new URL(getUrl);
const lib = u.protocol === 'https:' ? https : http;
const req = lib.get(getUrl, { timeout: 20000, headers: { 'apikey': apiKey } }, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const full = Buffer.concat(chunks);
if (res.statusCode !== 200) {
console.log('[Evolution] downloadMedia HTTP ' + res.statusCode + ': ' + full.toString('utf8').substring(0, 100));
resolve(null);
} else {
resolve(full);
}
});
});
req.on('error', () => resolve(null));
req.on('timeout', () => { req.destroy(); resolve(null); });
});
}
module.exports = { request, sendText, sendMedia, downloadMedia };
+64
View File
@@ -0,0 +1,64 @@
/**
* Serviço de logging estruturado.
*
* Substitui console.log por logs com níveis, timestamps ISO e saída
* simultânea para stdout (desenvolvimento) e arquivo (produção).
*
* Uso:
* const log = require('../services/logger');
* log.info('chat', 'Mensagem enviada', { conversaId: 123 });
* log.warn('webhook', 'Falha ao baixar mídia', { erro: err.message });
* log.error('evolution', 'Timeout', err);
*/
const fs = require('fs');
const path = require('path');
const LOG_DIR = path.join(__dirname, '../../logs');
if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
const currentLevel = process.env.LOG_LEVEL ? (LEVELS[process.env.LOG_LEVEL] ?? 1) : 1;
function formatar(level, modulo, mensagem, extra) {
const ts = new Date().toISOString();
let linha = `[${ts}] [${level.toUpperCase()}] [${modulo}] ${mensagem}`;
if (extra !== undefined) {
if (extra instanceof Error) {
linha += ' | ' + extra.message;
if (extra.stack && level === 'error') linha += '\n' + extra.stack;
} else if (typeof extra === 'object') {
linha += ' | ' + JSON.stringify(extra);
} else {
linha += ' | ' + String(extra);
}
}
return linha;
}
function escrever(level, modulo, mensagem, extra) {
if (LEVELS[level] < currentLevel) return;
const linha = formatar(level, modulo, mensagem, extra);
// Stdout (colorido se terminal interativo)
const cores = { error: '\x1b[31m', warn: '\x1b[33m', info: '\x1b[36m', debug: '\x1b[90m' };
const reset = '\x1b[0m';
if (process.stdout.isTTY) {
console.log(cores[level] + linha + reset);
} else {
console.log(linha);
}
// Arquivo (sempre, sem cores)
try {
const hoje = new Date().toISOString().slice(0, 10);
fs.appendFileSync(path.join(LOG_DIR, `app_${hoje}.log`), linha + '\n', 'utf8');
} catch (_) {}
}
module.exports = {
debug: (mod, msg, extra) => escrever('debug', mod, msg, extra),
info: (mod, msg, extra) => escrever('info', mod, msg, extra),
warn: (mod, msg, extra) => escrever('warn', mod, msg, extra),
error: (mod, msg, extra) => escrever('error', mod, msg, extra),
};
+252
View File
@@ -0,0 +1,252 @@
/**
* Serviço de manipulação de mídia.
*
* Centraliza compressão de vídeo, conversão de áudio, validação de headers
* e descriptografia de mídia do WhatsApp. Antes espalhado entre
* chatController e evolutionController.
*/
const { execFile } = require('child_process');
const fs = require('fs');
const path = require('path');
const ffmpegPath = require('ffmpeg-static');
const crypto = require('crypto');
const UPLOAD_DIR = path.join(__dirname, '../../uploads/audio');
// Garante que o diretório de uploads existe
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
// ============================================================
// Compressão de Vídeo
// ============================================================
/**
* Comprime vídeo para tamanho aceitável pelo WhatsApp/Evolution API.
* Reduz bitrate e resolução se necessário (alvo: ~10MB).
*
* @param {Buffer} inputBuffer - Buffer do vídeo original
* @returns {Promise<Buffer>} Buffer do vídeo comprimido
*/
function compressVideo(inputBuffer) {
return new Promise((resolve, reject) => {
const inputPath = path.join(UPLOAD_DIR, 'video_in_' + Date.now() + '.mp4');
const outputPath = path.join(UPLOAD_DIR, 'video_out_' + Date.now() + '.mp4');
fs.writeFileSync(inputPath, inputBuffer);
const inputSizeMB = (inputBuffer.length / (1024 * 1024)).toFixed(1);
const targetBitrate = inputBuffer.length < 10 * 1024 * 1024 ? '1M' : '500k';
console.log('[Video] Comprimindo de ' + inputSizeMB + 'MB, bitrate alvo: ' + targetBitrate);
execFile(ffmpegPath, [
'-y',
'-i', inputPath,
'-c:v', 'libx264',
'-b:v', targetBitrate,
'-c:a', 'aac',
'-b:a', '64k',
'-vf', 'scale=720:-2',
'-movflags', '+faststart',
'-preset', 'fast',
'-maxrate', '1M',
'-bufsize', '2M',
outputPath
], { timeout: 120000 }, (err) => {
try { fs.unlinkSync(inputPath); } catch (_) {}
if (err) {
try { fs.unlinkSync(outputPath); } catch (_) {}
console.error('[Video] Erro na compressão:', err.message.substring(0, 100));
return reject(err);
}
const outputBuffer = fs.readFileSync(outputPath);
try { fs.unlinkSync(outputPath); } catch (_) {}
const outputSizeMB = (outputBuffer.length / (1024 * 1024)).toFixed(1);
console.log('[Video] Compressão concluída: ' + inputSizeMB + 'MB -> ' + outputSizeMB + 'MB');
resolve(outputBuffer);
});
});
}
// ============================================================
// Conversão de Áudio
// ============================================================
/**
* Converte buffer de áudio WebM para Ogg Opus usando ffmpeg.
*
* @param {Buffer} inputBuffer - Buffer do áudio WebM
* @returns {Promise<Buffer>} Buffer Ogg Opus
*/
function convertWebmToOgg(inputBuffer) {
return new Promise((resolve, reject) => {
const inputPath = path.join(UPLOAD_DIR, 'input_' + Date.now() + '.webm');
const outputPath = path.join(UPLOAD_DIR, 'output_' + Date.now() + '.ogg');
fs.writeFileSync(inputPath, inputBuffer);
execFile(ffmpegPath, [
'-y',
'-i', inputPath,
'-c:a', 'libopus',
'-b:a', '16k',
'-ar', '16000',
'-ac', '1',
outputPath
], { timeout: 30000 }, (err) => {
try { fs.unlinkSync(inputPath); } catch (_) {}
if (err) {
try { fs.unlinkSync(outputPath); } catch (_) {}
return reject(err);
}
const outputBuffer = fs.readFileSync(outputPath);
try { fs.unlinkSync(outputPath); } catch (_) {}
resolve(outputBuffer);
});
});
}
// ============================================================
// Validação de Headers de Mídia
// ============================================================
/**
* Valida se um buffer tem cabeçalho de arquivo de mídia válido.
* Defesa: rejeita JSON, HTML ou dados criptografados salvos por engano.
*
* @param {Buffer} buf
* @param {string} [mimeType] - MIME type opcional para validação extra
* @returns {boolean}
*/
function validarHeaderMidia(buf, mimeType) {
if (!buf || buf.length < 4) return false;
const headerHex = buf.slice(0, 4).toString('hex');
// Headers de áudio conhecidos
const audioHeaders = ['4f676753', // OggS (ogg/opus)
'52494646', // RIFF (wav)
'66747970', // ftyp (mp4/m4a)
'494433', // ID3 (mp3)
'1a45dfa3', // WebM/Matroska EBML
'fffb', 'fff3', 'fffa', 'fff2']; // MP3 frames
for (const vh of audioHeaders) {
if (headerHex.startsWith(vh)) return true;
}
// Imagens
if (mimeType && mimeType.startsWith('image/')) {
if (headerHex.startsWith('ffd8ff') || // JPEG
headerHex.startsWith('89504e47') || // PNG
headerHex.startsWith('47494638')) // GIF
return true;
}
// Documentos (ZIP/Office)
if (mimeType && mimeType.startsWith('application/')) {
if (headerHex.startsWith('504b')) return true; // PK (ZIP)
}
return false;
}
// ============================================================
// Descriptografia de Mídia WhatsApp
// ============================================================
/**
* Descriptografa mídia do WhatsApp usando a mediaKey do webhook.
* Algoritmo: AES-256-CBC + HMAC-SHA256 (padrão Signal/WhatsApp).
*
* @param {Buffer} encryptedBuffer - Dados criptografados (inclui 10 bytes HMAC no final)
* @param {string} mediaKeyBase64 - Chave em base64 (do webhook)
* @param {string} mediaType - 'audio', 'image', 'video', 'document'
* @returns {Buffer|null} Dados descriptografados ou null se falhar
*/
function decryptWhatsAppMedia(encryptedBuffer, mediaKeyBase64, mediaType) {
try {
const mediaKey = Buffer.from(mediaKeyBase64, 'base64');
if (mediaKey.length !== 32) {
console.log('[Decrypt] mediaKey tamanho inválido:', mediaKey.length);
return null;
}
// Tipo -> info string para HKDF
const infoMap = {
'image': 'WhatsApp Image Keys',
'video': 'WhatsApp Video Keys',
'audio': 'WhatsApp Audio Keys',
'document': 'WhatsApp Document Keys'
};
const info = Buffer.from(infoMap[mediaType] || 'WhatsApp Audio Keys');
// Passo 1: HKDF Extract
const salt = Buffer.alloc(32, 0);
const prk = crypto.createHmac('sha256', salt).update(mediaKey).digest();
// Passo 2: HKDF Expand para 112 bytes
const hashLen = 32;
const numBlocks = Math.ceil(112 / hashLen);
const blocks = [];
let prev = Buffer.alloc(0);
for (let i = 0; i < numBlocks; i++) {
const hmac = crypto.createHmac('sha256', prk);
hmac.update(prev);
hmac.update(info);
hmac.update(Buffer.from([i + 1]));
prev = hmac.digest();
blocks.push(prev);
}
const expanded = Buffer.concat(blocks).slice(0, 112);
if (!expanded || expanded.length < 80) {
console.log('[Decrypt] HKDF produziu buffer muito curto:', expanded ? expanded.length : 0);
return null;
}
const cipherKey = expanded.slice(0, 32);
const macKey = expanded.slice(32, 64);
const iv = expanded.slice(64, 80);
// Passo 3: Arquivo = dados criptografados (n-10 bytes) + HMAC (últimos 10 bytes)
if (encryptedBuffer.length < 10) {
console.log('[Decrypt] Buffer muito curto:', encryptedBuffer.length);
return null;
}
const fileData = encryptedBuffer.slice(0, -10);
const expectedMac = encryptedBuffer.slice(-10);
// Passo 4: Verifica HMAC
const hmacVerify = crypto.createHmac('sha256', macKey);
hmacVerify.update(fileData);
const computedMac = hmacVerify.digest().slice(0, 10);
if (Buffer.compare(computedMac, expectedMac) !== 0) {
console.log('[Decrypt] HMAC inválido - dados corrompidos, chave errada, ou mídia expirada');
return null;
}
// Passo 5: Descriptografa AES-256-CBC
const decipher = crypto.createDecipheriv('aes-256-cbc', cipherKey, iv);
decipher.setAutoPadding(true);
const decrypted = Buffer.concat([decipher.update(fileData), decipher.final()]);
console.log('[Decrypt] Sucesso!', encryptedBuffer.length, 'bytes ->', decrypted.length, 'bytes');
return decrypted;
} catch (e) {
console.log('[Decrypt] Erro:', e.message);
return null;
}
}
module.exports = {
compressVideo,
convertWebmToOgg,
validarHeaderMidia,
decryptWhatsAppMedia,
};
+41
View File
@@ -0,0 +1,41 @@
/**
* Coletor de métricas em memória.
*
* Contadores simples para monitorar saúde da aplicação.
* Exposto via GET /api/:alias/metrics (apenas gerentes).
*
* Uso:
* const metrics = require('../services/metrics');
* metrics.incr('webhook.mensagens_recebidas');
* metrics.incr('chat.mensagens_enviadas');
*/
const counters = new Map();
function incr(name, delta = 1) {
const current = counters.get(name) || 0;
counters.set(name, current + delta);
}
function get(name) {
return counters.get(name) || 0;
}
function getAll() {
const result = {};
for (const [k, v] of counters) result[k] = v;
return result;
}
function reset() {
counters.clear();
}
// Limpa contadores de taxa a cada minuto (opcional)
setInterval(() => {
for (const [k] of counters) {
if (k.startsWith('rate.')) counters.delete(k);
}
}, 60000).unref();
module.exports = { incr, get, getAll, reset };