Compare commits

..

19 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
52 changed files with 6684 additions and 2398 deletions
+8
View File
@@ -37,3 +37,11 @@ EXTERNAL_URL=https://atendchat.assantos.app.br
# Token de verificacao do webhook Evolution. Se definido, a Evolution deve
# enviar este valor no header apikey (ou x-webhook-token). Vazio = sem checagem.
# WEBHOOK_TOKEN=
# ------------------------------------------------------------
# Administracao
# ------------------------------------------------------------
# Token de segundo fator para endpoints de SQL direto (/api/:alias/query e
# /api/:alias/execute). Deve ser enviado no header X-Admin-Token.
# Gere um token forte: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# ADMIN_TOKEN=
+7
View File
@@ -17,6 +17,13 @@ pgdata/
*.log
_inspect*.js
whisper/
logs/
tmp/
uploads/
# Ferramentas
.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)
+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',
},
}],
};
+13
View File
@@ -14,6 +14,7 @@
"dotenv": "^16.4.7",
"express": "^5.2.1",
"ffmpeg-static": "^5.3.0",
"helmet": "^8.2.0",
"jsonwebtoken": "^9.0.3",
"node-firebird": "^2.3.1"
}
@@ -526,6 +527,18 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz",
"integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"url": "https://github.com/sponsors/EvanHahn"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+6
View File
@@ -6,6 +6,11 @@
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"pm2:start": "pm2 start ecosystem.config.js",
"pm2:stop": "pm2 stop chatc2",
"pm2:restart": "pm2 restart chatc2",
"pm2:logs": "pm2 logs chatc2",
"pm2:status": "pm2 status",
"postinstall": "node scripts/postinstall.js"
},
"keywords": [
@@ -21,6 +26,7 @@
"dotenv": "^16.4.7",
"express": "^5.2.1",
"ffmpeg-static": "^5.3.0",
"helmet": "^8.2.0",
"jsonwebtoken": "^9.0.3",
"node-firebird": "^2.3.1"
}
+12 -476
View File
@@ -1,488 +1,24 @@
/**
* ARQUIVO DE MIGRAÇÕES DO BANCO DE DADOS
* ========================================
*
* Este script contém TODAS as alterações estruturais feitas no banco de dados
* desde a criação inicial do projeto.
*
* USO:
* node scripts/migracoes.js [alias]
*
* Exemplo:
* node scripts/migracoes.js lajedo
* node scripts/migracoes.js novo
*
* IMPORTANTE:
* - Cada migração tem um ID único e só executa uma vez
* - O controle é feito via tabela CHATC2_CONTROLE_MIGRACOES
* - Para adicionar NOVA migração, adicione um novo objeto no array MIGRACOES
* - Nunca remova ou altere migrações já existentes (apenas adicione novas)
* Script CLI de migrações.
*
* Uso: node scripts/migracoes.js [alias]
*
* As migrações também rodam automaticamente no boot do servidor.
* Este script existe para execução manual/diagnóstico.
*/
const db = require('../src/database');
const migrationRunner = require('./migrationRunner');
// Este script aplica DDL no dialeto Firebird (BLOB SUB_TYPE, etc.).
// Alias padrão: novo_local.
const alias = process.argv[2] || 'novo_local';
// ============================================================
// CONTROLE DE MIGRAÇÕES
// ============================================================
async function garantirControle() {
try {
await db.execute(alias, `
CREATE TABLE CHATC2_CONTROLE_MIGRACOES (
MIG_ID INTEGER NOT NULL PRIMARY KEY,
MIG_DESCRICAO VARCHAR(200),
MIG_DATA_EXECUCAO TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
MIG_STATUS CHAR(1) DEFAULT 'A'
)
`);
console.log('✅ Tabela CHATC2_CONTROLE_MIGRACOES criada');
} catch(e) {
if (!e.message.includes('already exists')) {
console.log('⚠️ CHATC2_CONTROLE_MIGRACOES:', e.message.substring(0, 100));
}
}
}
async function migracaoJaExecutada(id) {
try {
var r = await db.query(alias,
'SELECT COUNT(*) AS CT FROM CHATC2_CONTROLE_MIGRACOES WHERE MIG_ID = ?', [id]);
return r[0]?.CT > 0;
} catch(e) {
return false;
}
}
async function registrarMigracao(id, descricao) {
try {
await db.execute(alias,
'INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (?, ?)',
[id, descricao]);
} catch(e) {
console.log('⚠️ Erro ao registrar migração:', e.message.substring(0, 80));
}
}
// ============================================================
// LISTA DE MIGRAÇÕES
// ============================================================
// SEMPRE adicione NOVAS migrações no FINAL do array, com ID sequencial.
// NUNCA remova ou modifique migrações já existentes.
const MIGRACOES = [
// ----------------------------------------------------------
// MIGRAÇÃO 1: Campo USU_TIPO_CHAT em USUARIOS
// ----------------------------------------------------------
{
id: 1,
descricao: 'Adicionar USU_TIPO_CHAT em USUARIOS (A=Atendente, G=Gerente)',
sql: [
`ALTER TABLE USUARIOS ADD USU_TIPO_CHAT CHAR(1) DEFAULT 'A'`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 2: Tabela CHATC2_INSTANCIAS (Evolution API)
// ----------------------------------------------------------
{
id: 2,
descricao: 'Criar tabela CHATC2_INSTANCIAS para conexões WhatsApp',
sql: [
`CREATE TABLE CHATC2_INSTANCIAS (
INS_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
INS_EMPRESA_ID INTEGER NOT NULL,
INS_NOME VARCHAR(100),
INS_URL VARCHAR(500),
INS_API_KEY VARCHAR(500),
INS_INSTANCE_NAME VARCHAR(100),
INS_STATUS CHAR(1) DEFAULT 'D',
INS_QR_CODE BLOB SUB_TYPE 0,
INS_DT_CONEXAO TIMESTAMP,
INS_DT_CADASTRO TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INS_SITUACAO CHAR(1) DEFAULT 'A'
)`,
`CREATE SEQUENCE GEN_INSTANCIAS`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 3: Tabela CHATC2_EQUIPES
// ----------------------------------------------------------
{
id: 3,
descricao: 'Criar tabela CHATC2_EQUIPES',
sql: [
`CREATE TABLE CHATC2_EQUIPES (
EQU_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
EQU_EMPRESA_ID INTEGER NOT NULL,
EQU_NOME VARCHAR(100),
EQU_ORDEM INTEGER DEFAULT 0,
EQU_SITUACAO CHAR(1) DEFAULT 'A'
)`,
`CREATE SEQUENCE GEN_EQUIPES`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 4: Tabela CHATC2_USU_EQUIPES
// ----------------------------------------------------------
{
id: 4,
descricao: 'Criar tabela CHATC2_USU_EQUIPES (relação N:N)',
sql: [
`CREATE TABLE CHATC2_USU_EQUIPES (
EQU_EQUIPE_ID INTEGER NOT NULL,
EQU_USUARIO_ID INTEGER NOT NULL,
PRIMARY KEY (EQU_EQUIPE_ID, EQU_USUARIO_ID)
)`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 5: Tabela CHATC2_ETIQUETAS
// ----------------------------------------------------------
{
id: 5,
descricao: 'Criar tabela CHATC2_ETIQUETAS',
sql: [
`CREATE TABLE CHATC2_ETIQUETAS (
ETI_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
ETI_EMPRESA_ID INTEGER NOT NULL,
ETI_NOME VARCHAR(50),
ETI_COR VARCHAR(7) DEFAULT '#667eea',
ETI_SITUACAO CHAR(1) DEFAULT 'A'
)`,
`CREATE SEQUENCE GEN_ETIQUETAS`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 6: Tabela CHATC2_CONVERSAS
// ----------------------------------------------------------
{
id: 6,
descricao: 'Criar tabela CHATC2_CONVERSAS',
sql: [
`CREATE TABLE CHATC2_CONVERSAS (
CON_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
CON_EMPRESA_ID INTEGER NOT NULL,
CON_INSTANCIA_ID INTEGER,
CON_CLIENTE_ID INTEGER,
CON_NUMERO VARCHAR(20),
CON_NOME_CONTATO VARCHAR(100),
CON_STATUS CHAR(1) DEFAULT 'E',
CON_USUARIO_ID INTEGER,
CON_EQUIPE_ID INTEGER,
CON_DT_INICIO TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CON_DT_FINAL TIMESTAMP,
CON_DT_ULTIMA_MSG TIMESTAMP,
CON_SAUDACAO_ENVIADA CHAR(1) DEFAULT 'N',
CON_CSAT_ENVIADO CHAR(1) DEFAULT 'N',
CON_PRIMEIRA_MSG TIMESTAMP,
CON_ORIGEM VARCHAR(20) DEFAULT 'whatsapp',
CON_SITUACAO CHAR(1) DEFAULT 'A'
)`,
`CREATE SEQUENCE GEN_CONVERSAS`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 7: Tabela CHATC2_CONVERSAS_MENSAGENS
// ----------------------------------------------------------
{
id: 7,
descricao: 'Criar tabela CHATC2_CONVERSAS_MENSAGENS',
sql: [
`CREATE TABLE CHATC2_CONVERSAS_MENSAGENS (
CME_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
CME_CONVERSA_ID INTEGER NOT NULL,
CME_REMETENTE CHAR(1) DEFAULT 'C',
CME_USUARIO_ID INTEGER,
CME_MENSAGEM BLOB SUB_TYPE 1,
CME_TEXTO VARCHAR(4000),
CME_TIPO VARCHAR(20) DEFAULT 'text',
CME_DT_ENVIO TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CME_PRIVADA CHAR(1) DEFAULT 'N',
CME_LIDA CHAR(1) DEFAULT 'N',
CME_MIDIA_ID INTEGER,
CME_SITUACAO CHAR(1) DEFAULT 'A'
)`,
`CREATE SEQUENCE GEN_CONVERSAS_MENSAGENS`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 8: Tabela CHATC2_MENSAGENS_ATENDIMENTOS (blob mídias)
// ----------------------------------------------------------
{
id: 8,
descricao: 'Criar tabela CHATC2_MENSAGENS_ATENDIMENTOS para mídias',
sql: [
`CREATE TABLE CHATC2_MENSAGENS_ATENDIMENTOS (
MAT_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
MAT_CONVERSA_ID INTEGER,
MAT_MENSAGEM_ID INTEGER,
MAT_NOME_ARQUIVO VARCHAR(255),
MAT_TIPO_ARQUIVO VARCHAR(50),
MAT_ARQUIVO BLOB SUB_TYPE 0,
MAT_DT_CADASTRO TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
MAT_SITUACAO CHAR(1) DEFAULT 'A'
)`,
`CREATE SEQUENCE GEN_MENSAGENS_ATENDIMENTO`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 9: Tabela CHATC2_CONVERSAS_ETIQUETAS
// ----------------------------------------------------------
{
id: 9,
descricao: 'Criar tabela CHATC2_CONVERSAS_ETIQUETAS',
sql: [
`CREATE TABLE CHATC2_CONVERSAS_ETIQUETAS (
CET_CONVERSA_ID INTEGER NOT NULL,
CET_ETIQUETA_ID INTEGER NOT NULL,
PRIMARY KEY (CET_CONVERSA_ID, CET_ETIQUETA_ID)
)`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 10: Tabela CHATC2_CSAT_AVALIACOES
// ----------------------------------------------------------
{
id: 10,
descricao: 'Criar tabela CHATC2_CSAT_AVALIACOES',
sql: [
`CREATE TABLE CHATC2_CSAT_AVALIACOES (
CSA_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
CSA_CONVERSA_ID INTEGER,
CSA_EMPRESA_ID INTEGER,
CSA_CLIENTE_ID INTEGER,
CSA_NOTA INTEGER,
CSA_COMENTARIO VARCHAR(500),
CSA_DT_AVALIACAO TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE SEQUENCE GEN_CSAT_AVALIACOES`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 11: Tabela CHATC2_CONFIGURACOES_EMPRESA (base)
// ----------------------------------------------------------
{
id: 11,
descricao: 'Criar tabela CHATC2_CONFIGURACOES_EMPRESA (base)',
sql: [
`CREATE TABLE CHATC2_CONFIGURACOES_EMPRESA (
CFE_EMPRESA_ID INTEGER NOT NULL PRIMARY KEY,
CFE_INSTANCIA_PADRAO_ID INTEGER,
CFE_FOTO_CELULAR CHAR(1) DEFAULT 'N',
CFE_SAUDACAO_ATIVA CHAR(1) DEFAULT 'S',
CFE_SAUDACAO_MENSAGEM VARCHAR(500),
CFE_CSAT_ATIVO CHAR(1) DEFAULT 'N',
CFE_CSAT_MENSAGEM VARCHAR(500) DEFAULT 'Avalie seu atendimento de 1 a 5 estrelas'
)`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 12: EQU_ORDEM em CHATC2_EQUIPES
// ----------------------------------------------------------
{
id: 12,
descricao: 'Adicionar EQU_ORDEM em CHATC2_EQUIPES (ordenação)',
sql: [
`ALTER TABLE CHATC2_EQUIPES ADD EQU_ORDEM INTEGER DEFAULT 0`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 13: Campos de triagem em CHATC2_CONFIGURACOES_EMPRESA
// ----------------------------------------------------------
{
id: 13,
descricao: 'Adicionar campos de triagem em CHATC2_CONFIGURACOES_EMPRESA',
sql: [
`ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_ENVIAR_NOME_USUARIO CHAR(1) DEFAULT 'N'`,
`ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_TRIAGEM_ATIVA CHAR(1) DEFAULT 'N'`,
`ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_TRIAGEM_MSG_WELCOME VARCHAR(500)`,
`ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_TRIAGEM_MSG_AFTER VARCHAR(500)`,
`ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_TRIAGEM_BOLETO_NUMERO VARCHAR(10) DEFAULT '0'`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 14: CON_MENU_ESTADO em CHATC2_CONVERSAS
// ----------------------------------------------------------
{
id: 14,
descricao: 'Adicionar CON_MENU_ESTADO em CHATC2_CONVERSAS (navegação triagem)',
sql: [
`ALTER TABLE CHATC2_CONVERSAS ADD CON_MENU_ESTADO VARCHAR(100)`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 15: CON_ETIQUETAS_DESC em CHATC2_CONVERSAS
// ----------------------------------------------------------
{
id: 15,
descricao: 'Adicionar CON_ETIQUETAS_DESC em CHATC2_CONVERSAS (estado legado)',
sql: [
`ALTER TABLE CHATC2_CONVERSAS ADD CON_ETIQUETAS_DESC VARCHAR(500)`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 16: Tabela CHATC2_MENUS_EMPRESA (fluxo personalizado)
// ----------------------------------------------------------
{
id: 16,
descricao: 'Criar tabela CHATC2_MENUS_EMPRESA para fluxo de atendimento personalizado',
sql: [
`CREATE TABLE CHATC2_MENUS_EMPRESA (
MNE_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
MNE_EMPRESA_ID INTEGER,
MNE_EQUIPE_ID INTEGER,
MNE_MENU_PAI_ID INTEGER,
MNE_ORDEM INTEGER DEFAULT 0,
MNE_TITULO VARCHAR(100),
MNE_TIPO CHAR(1) DEFAULT 'M',
MNE_TEXTO BLOB SUB_TYPE TEXT,
MNE_ACAO_ROTA VARCHAR(100),
MNE_ACAO_METODO VARCHAR(10),
MNE_ACAO_PROMPT VARCHAR(300),
MNE_SITUACAO CHAR(1) DEFAULT 'A'
)`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 17: CON_USUARIO_NOME, CON_EQUIPE_NOME, CON_ETIQUETAS_DESC em CHATC2_CONVERSAS
// ----------------------------------------------------------
{
id: 17,
descricao: 'Adicionar CON_USUARIO_NOME, CON_EQUIPE_NOME, CON_ETIQUETAS_DESC em CHATC2_CONVERSAS',
sql: [
`ALTER TABLE CHATC2_CONVERSAS ADD CON_USUARIO_NOME VARCHAR(100)`,
`ALTER TABLE CHATC2_CONVERSAS ADD CON_EQUIPE_NOME VARCHAR(100)`,
`ALTER TABLE CHATC2_CONVERSAS ADD CON_ETIQUETAS_DESC VARCHAR(500)`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 18: Etiquetas nos menus de fluxo
// ----------------------------------------------------------
{
id: 18,
descricao: 'Adicionar MNE_ETIQUETA_IDS em CHATC2_MENUS_EMPRESA (etiquetas por etapa do fluxo)',
sql: [
`ALTER TABLE CHATC2_MENUS_EMPRESA ADD MNE_ETIQUETA_IDS VARCHAR(200)`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 19: Transcrição de áudio em CHATC2_MENSAGENS_ATENDIMENTOS
// ----------------------------------------------------------
{
id: 19,
descricao: 'Adicionar MAT_TRANSCRICAO em CHATC2_MENSAGENS_ATENDIMENTOS (transcricao de audio)',
sql: [
`ALTER TABLE CHATC2_MENSAGENS_ATENDIMENTOS ADD MAT_TRANSCRICAO BLOB SUB_TYPE TEXT`,
],
},
// ============================================================
// >>> ADICIONE NOVAS MIGRAÇÕES AQUI <<<
// ============================================================
//
// Exemplo:
// {
// id: 19,
// descricao: 'Descrição clara do que esta migração faz',
// sql: [
// `ALTER TABLE EXEMPLO ADD NOVO_CAMPO VARCHAR(100)`,
// `CREATE TABLE NOVA_TABELA ( ... )`,
// ],
// },
//
];
// ============================================================
// EXECUTOR DE MIGRAÇÕES
// ============================================================
async function main() {
(async () => {
console.log(`\n=== MIGRAÇÕES DE BANCO DE DADOS ===`);
console.log(`Alias: ${alias}\n`);
// Blindagem: a DDL aqui é Firebird. Se o alias for Postgres (banco externo,
// gerenciado fora), não há o que aplicar — sai sem erro.
let driver;
try { driver = db.driverOf(alias); }
catch (e) { console.error('❌', e.message); process.exit(1); }
if (driver !== 'firebird') {
console.log(`⚠️ O alias "${alias}" usa o driver "${driver}". Este script aplica DDL Firebird.`);
console.log(' Para migrar um banco Firebird: node scripts/migracoes.js novo_local\n');
process.exit(0);
}
await garantirControle();
var executadas = 0;
var pendentes = 0;
for (var mig of MIGRACOES) {
var jaExecutou = await migracaoJaExecutada(mig.id);
if (jaExecutou) {
console.log(`⏭️ [${mig.id}] ${mig.descricao} — já executada`);
continue;
}
pendentes++;
console.log(`▶️ [${mig.id}] ${mig.descricao}...`);
var erros = 0;
for (var cmd of mig.sql) {
try {
await db.execute(alias, cmd);
console.log(`${cmd.substring(0, 80)}...`);
} catch(e) {
var msg = e.message;
// Firebird pode reportar "already exists" de várias formas
if (msg.includes('already exists') || msg.includes('Violation') || msg.includes('duplicate') || msg.includes('Unknow')) {
console.log(` ️ Já existe: ${cmd.substring(0, 60)}...`);
} else {
console.log(`${msg.substring(0, 120)}`);
erros++;
}
}
}
if (erros === 0) {
await registrarMigracao(mig.id, mig.descricao);
executadas++;
console.log(` ✅ Migração ${mig.id} concluída\n`);
} else {
console.log(` ⚠️ Migração ${mig.id} concluída com ${erros} erro(s)\n`);
}
}
console.log(`=== RESUMO ===`);
console.log(`Total de migrações: ${MIGRACOES.length}`);
console.log(`Executadas agora: ${executadas}`);
console.log(`Já executadas antes: ${MIGRACOES.length - pendentes}`);
console.log(`Pendentes (com erro): ${pendentes - executadas}`);
console.log(`\n✅ Finalizado!`);
const count = await migrationRunner.executarMigracoes(alias);
console.log(`\n${count} migração(ões) executada(s). Total: ${migrationRunner.MIGRACOES.length}.`);
process.exit(0);
}
main().catch(function(err) {
console.error('❌ Erro fatal:', err.message);
})().catch((err) => {
console.error('❌ Erro:', err.message);
process.exit(1);
});
+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 };
+8
View File
@@ -1,5 +1,6 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const path = require('path');
const routes = require('./routes');
const authenticateToken = require('./middlewares/auth');
@@ -7,6 +8,13 @@ const authenticateToken = require('./middlewares/auth');
const app = express();
// Middlewares
// Segurança: headers HTTP protetores (X-Content-Type-Options, X-Frame-Options, etc.)
// CSP configurado de forma permissiva para não quebrar inline scripts/styles existentes
app.use(helmet({
contentSecurityPolicy: false, // Desabilitado: app usa inline scripts/styles em várias páginas
crossOriginEmbedderPolicy: false, // Permite carregar recursos cross-origin (Evolution API, etc.)
}));
// CORS: restringível via CORS_ORIGINS (lista separada por vírgula no .env).
// Requisições same-origin e server-to-server (sem header Origin) sempre passam.
const corsOrigins = (process.env.CORS_ORIGINS || [process.env.LOCAL_URL, process.env.EXTERNAL_URL].filter(Boolean).join(','))
+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,
};
+431 -249
View File
@@ -1,63 +1,9 @@
const db = require('../database');
const { garantirEstrutura } = require('../resolucaoSetup');
const ffmpegPath = require('ffmpeg-static');
const { execFile } = require('child_process');
const path = require('path');
const fs = require('fs');
/**
* 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 video original
* @returns {Promise<Buffer>} Buffer do video comprimido
*/
function compressVideo(inputBuffer) {
return new Promise(function(resolve, reject) {
var tmpDir = path.join(__dirname, '../../uploads/audio');
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
var inputPath = path.join(tmpDir, 'video_in_' + Date.now() + '.mp4');
var outputPath = path.join(tmpDir, 'video_out_' + Date.now() + '.mp4');
fs.writeFileSync(inputPath, inputBuffer);
var inputSizeMB = (inputBuffer.length / (1024 * 1024)).toFixed(1);
// Se o video ja tem <= 10MB, nao precisa comprimir muito
var targetBitrate = inputBuffer.length < 10 * 1024 * 1024 ? '1M' : '500k';
console.log('[Video] Comprimindo video 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 }, function(err) {
try { fs.unlinkSync(inputPath); } catch(e) {}
if (err) {
try { fs.unlinkSync(outputPath); } catch(e) {}
console.error('[Video] Erro na compressao:', err.message.substring(0, 100));
return reject(err);
}
var outputBuffer = fs.readFileSync(outputPath);
try { fs.unlinkSync(outputPath); } catch(e) {}
var outputSizeMB = (outputBuffer.length / (1024 * 1024)).toFixed(1);
console.log('[Video] Compressao concluida: ' + inputSizeMB + 'MB -> ' + outputSizeMB + 'MB');
resolve(outputBuffer);
});
});
}
/**
* Converte buffer de áudio WebM para Ogg Opus usando ffmpeg
*/
@@ -181,70 +127,93 @@ class ChatController {
const result = await db.query(alias, `
SELECT c.*,
(SELECT CME_TEXTO FROM CHATC2_CONVERSAS_MENSAGENS WHERE CME_CONVERSA_ID = c.CON_CODIGO_ID ORDER BY CME_DT_ENVIO DESC FETCH FIRST 1 ROWS ONLY) AS ULTIMA_MSG,
(SELECT CME_TIPO FROM CHATC2_CONVERSAS_MENSAGENS WHERE CME_CONVERSA_ID = c.CON_CODIGO_ID ORDER BY CME_DT_ENVIO DESC FETCH FIRST 1 ROWS ONLY) AS ULTIMA_TIPO,
(SELECT CME_DT_ENVIO FROM CHATC2_CONVERSAS_MENSAGENS WHERE CME_CONVERSA_ID = c.CON_CODIGO_ID ORDER BY CME_DT_ENVIO DESC FETCH FIRST 1 ROWS ONLY) AS DT_ULTIMA_MSG
FROM CHATC2_CONVERSAS c
WHERE c.CON_EMPRESA_ID = ? AND c.CON_STATUS IN (${statusPh}) AND c.CON_SITUACAO = 'A' ${extraFilter}
ORDER BY c.CON_DT_ULTIMA_MSG DESC
`, params);
const conversations = await Promise.all(result.map(async (row) => {
// Busca etiquetas
const labels = await db.query(alias, `
SELECT e.ETI_CODIGO_ID, e.ETI_NOME, e.ETI_COR
FROM CHATC2_ETIQUETAS e INNER JOIN CHATC2_CONVERSAS_ETIQUETAS ce ON e.ETI_CODIGO_ID = ce.CET_ETIQUETA_ID
WHERE ce.CET_CONVERSA_ID = ? AND e.ETI_SITUACAO = 'A'
`, [row.CON_CODIGO_ID]);
// --- Busca em lote: etiquetas de todas as conversas (evita N+1 queries) ---
const conversaIds = result.map(r => r.CON_CODIGO_ID);
const labelsMap = {};
for (const cid of conversaIds) labelsMap[cid] = [];
// Busca foto do cliente se configurado
let foto = null;
if (row.CON_CLIENTE_ID) {
const config = await db.query(alias,
'SELECT CFE_FOTO_CELULAR FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]);
if (config.length > 0 && config[0].CFE_FOTO_CELULAR === 'S') {
const cli = await db.query(alias,
'SELECT CLI_FOTO, CLI_NOME_FOTO FROM CLIENTES WHERE CLI_CODIGO_ID = ?', [row.CON_CLIENTE_ID]);
if (cli.length > 0) {
var rawFoto = cli[0].CLI_FOTO;
if (rawFoto) {
if (Buffer.isBuffer(rawFoto)) {
var str = rawFoto.toString('utf8');
if (/^[A-Za-z0-9+/]+=*$/.test(str.slice(0, 30))) {
foto = str;
} else {
var prefixo = 'data:image/jpeg;base64,';
if (rawFoto[0] === 0x89 && rawFoto[1] === 0x50) prefixo = 'data:image/png;base64,';
else if (rawFoto[0] === 0x47 && rawFoto[1] === 0x49) prefixo = 'data:image/gif;base64,';
else if (rawFoto[0] === 0x52 && rawFoto[1] === 0x49) prefixo = 'data:image/webp;base64,';
foto = prefixo + rawFoto.toString('base64');
}
if (conversaIds.length > 0) {
const idsPlaceholders = conversaIds.map(() => '?').join(',');
const todasEtiquetas = await db.query(alias, `
SELECT ce.CET_CONVERSA_ID, e.ETI_CODIGO_ID, e.ETI_NOME, e.ETI_COR
FROM CHATC2_ETIQUETAS e
INNER JOIN CHATC2_CONVERSAS_ETIQUETAS ce ON e.ETI_CODIGO_ID = ce.CET_ETIQUETA_ID
WHERE ce.CET_CONVERSA_ID IN (${idsPlaceholders}) AND e.ETI_SITUACAO = 'A'
`, conversaIds);
for (const et of todasEtiquetas) {
if (!labelsMap[et.CET_CONVERSA_ID]) labelsMap[et.CET_CONVERSA_ID] = [];
labelsMap[et.CET_CONVERSA_ID].push({
id: et.ETI_CODIGO_ID, nome: (et.ETI_NOME || '').trim(), cor: (et.ETI_COR || '#667eea').trim()
});
}
}
// --- Busca em lote: fotos dos clientes (se config ativo) ---
const fotosMap = {};
let fotoCelularAtivo = false;
{
const config = await db.query(alias,
'SELECT CFE_FOTO_CELULAR FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]);
if (config.length > 0 && config[0].CFE_FOTO_CELULAR === 'S') {
fotoCelularAtivo = true;
const clienteIds = result.filter(r => r.CON_CLIENTE_ID).map(r => r.CON_CLIENTE_ID);
if (clienteIds.length > 0) {
const cliPlaceholders = clienteIds.map(() => '?').join(',');
const fotos = await db.query(alias,
'SELECT CLI_CODIGO_ID, CLI_FOTO, CLI_NOME_FOTO FROM CLIENTES WHERE CLI_CODIGO_ID IN (' + cliPlaceholders + ')',
clienteIds);
for (const f of fotos) {
const rawFoto = f.CLI_FOTO;
if (!rawFoto) continue;
let foto = null;
if (Buffer.isBuffer(rawFoto)) {
const str = rawFoto.toString('utf8');
if (/^[A-Za-z0-9+/]+=*$/.test(str.slice(0, 30))) {
foto = str;
} else {
foto = rawFoto;
let prefixo = 'data:image/jpeg;base64,';
if (rawFoto[0] === 0x89 && rawFoto[1] === 0x50) prefixo = 'data:image/png;base64,';
else if (rawFoto[0] === 0x47 && rawFoto[1] === 0x49) prefixo = 'data:image/gif;base64,';
else if (rawFoto[0] === 0x52 && rawFoto[1] === 0x49) prefixo = 'data:image/webp;base64,';
foto = prefixo + rawFoto.toString('base64');
}
} else {
foto = rawFoto;
}
fotosMap[f.CLI_CODIGO_ID] = foto;
}
}
}
}
return {
id: row.CON_CODIGO_ID,
empresaId: row.CON_EMPRESA_ID,
instanciaId: row.CON_INSTANCIA_ID,
clienteId: row.CON_CLIENTE_ID,
numero: (row.CON_NUMERO || '').trim(),
nomeContato: (row.CON_NOME_CONTATO || '').trim(),
status: (row.CON_STATUS || '').trim(),
usuarioId: row.CON_USUARIO_ID,
equipeId: row.CON_EQUIPE_ID,
dtInicio: row.CON_DT_INICIO,
dtFinal: row.CON_DT_FINAL,
ultimaMsg: row.ULTIMA_MSG || '',
dtUltimaMsg: row.DT_ULTIMA_MSG || row.CON_DT_ULTIMA_MSG,
saudacaoEnviada: (row.CON_SAUDACAO_ENVIADA || 'N').trim(),
csatEnviado: (row.CON_CSAT_ENVIADO || 'N').trim(),
primeiraMsg: row.CON_PRIMEIRA_MSG,
labels,
foto,
};
// --- Monta resultado final sem novas queries ---
const conversations = result.map((row) => ({
id: row.CON_CODIGO_ID,
empresaId: row.CON_EMPRESA_ID,
instanciaId: row.CON_INSTANCIA_ID,
clienteId: row.CON_CLIENTE_ID,
numero: (row.CON_NUMERO || '').trim(),
nomeContato: (row.CON_NOME_CONTATO || '').trim(),
status: (row.CON_STATUS || '').trim(),
usuarioId: row.CON_USUARIO_ID,
equipeId: row.CON_EQUIPE_ID,
dtInicio: row.CON_DT_INICIO,
dtFinal: row.CON_DT_FINAL,
ultimaMsg: row.ULTIMA_MSG || '',
ultimaTipo: (row.ULTIMA_TIPO || 'text').trim(),
dtUltimaMsg: row.DT_ULTIMA_MSG || row.CON_DT_ULTIMA_MSG,
saudacaoEnviada: (row.CON_SAUDACAO_ENVIADA || 'N').trim(),
csatEnviado: (row.CON_CSAT_ENVIADO || 'N').trim(),
primeiraMsg: row.CON_PRIMEIRA_MSG,
labels: labelsMap[row.CON_CODIGO_ID] || [],
foto: fotosMap[row.CON_CLIENTE_ID] || null,
}));
// Contagens para cada filtro
@@ -490,8 +459,8 @@ class ChatController {
FROM CHATC2_CONVERSAS_MENSAGENS
WHERE CME_CONVERSA_ID = ? AND CME_SITUACAO = 'A'
ORDER BY CME_DT_ENVIO DESC
OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY
`, [id]);
OFFSET ? ROWS FETCH NEXT ? ROWS ONLY
`, [id, offset, limit]);
var mapped = msgs.reverse().map(function(m) { return {
id: m.CME_CODIGO_ID,
@@ -551,14 +520,12 @@ class ChatController {
"UPDATE CHATC2_CONVERSAS SET CON_STATUS = 'A', CON_DT_FINAL = NULL WHERE CON_CODIGO_ID = ?", [id]);
}
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const newId = (maxId[0]?.ID || 0) + 1;
const newId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
let midiaId = null;
// Salva mídia se houver
if (midiaBase64) {
const maxMidia = await db.query(alias, 'SELECT MAX(MAT_CODIGO_ID) AS ID FROM CHATC2_MENSAGENS_ATENDIMENTOS');
midiaId = (maxMidia[0]?.ID || 0) + 1;
midiaId = await db.nextId(alias, 'GEN_MENSAGENS_ATENDIMENTOS');
// Para áudio: converte WebM → OGG antes de salvar no BD
// Assim o BD já tem o OGG e o envio para Evolution usa a mesma versão
@@ -618,6 +585,9 @@ class ChatController {
}
} catch(e) {}
// Processa placeholders: [CLIENTE], [CONTATO], [EMPRESAR], [EMPRESAF]
textoFinal = await ChatController.processarPlaceholders(alias, id, textoFinal);
// Insere mensagem no banco
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_USUARIO_ID, CME_TEXTO, CME_TIPO, CME_PRIVADA, CME_SITUACAO, CME_DT_ENVIO, CME_MIDIA_ID)
@@ -667,12 +637,11 @@ class ChatController {
return res.status(403).json({ success: false, error: 'Sem permissão.' });
// ===== Fluxo de Resolução: valida motivo/resolução conforme config =====
await garantirEstrutura(alias);
const motivoId = (req.body && req.body.motivoId) ? parseInt(req.body.motivoId, 10) : null;
const resolucao = (req.body && req.body.resolucao != null) ? String(req.body.resolucao).trim() : '';
const flagsRows = await db.query(alias,
'SELECT CFE_MOTIVO_VISUALIZAR, CFE_MOTIVO_OBRIGATORIO, CFE_RESOLUCAO_VISUALIZAR, CFE_RESOLUCAO_OBRIGATORIO FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?',
'SELECT CFE_MOTIVO_VISUALIZAR, CFE_MOTIVO_OBRIGATORIO, CFE_RESOLUCAO_VISUALIZAR, CFE_RESOLUCAO_OBRIGATORIO, CFE_RESOLUCAO_SALVAR_MSC FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?',
[c.CON_EMPRESA_ID]);
const fl = flagsRows[0] || {};
const isS = (v) => String(v || 'N').trim() === 'S';
@@ -719,7 +688,9 @@ class ChatController {
WHERE CON_CODIGO_ID = ?
`, [usuarioNome, equipeNome, etiquetasDesc, motivoId, resolucao || null, id]);
// CSAT
// CSAT — NÃO envia se a conversa for agendada (skipCsat = true)
const skipCsat = req.body && req.body.skipCsat === true;
if (!skipCsat) {
const config = await db.query(alias,
'SELECT CFE_CSAT_ATIVO, CFE_CSAT_MENSAGEM FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?',
[conv[0].CON_EMPRESA_ID]);
@@ -760,6 +731,21 @@ class ChatController {
console.log('[CSAT] NAO enviado - Evolution API pode estar offline');
}
}
} // fecha if (!skipCsat)
// Salva resolução na tabela MENSAGENS_CLIENTES como aviso (se configurado)
if (isS(fl.CFE_RESOLUCAO_SALVAR_MSC) && resolucao && c.CON_CLIENTE_ID) {
try {
const mscId = await db.nextId(alias, 'GEN_MENSAGEM_CLIENTES_ID');
await db.execute(alias,
`INSERT INTO MENSAGEM_CLIENTES (MSC_CODIGO_ID, MSC_CLIENTE_ID, MSC_USUARIO_ID, MSC_DT_MENSAGEM, MSC_AVISO, MSC_MENSAGEM)
VALUES (?, ?, ?, CURRENT_TIMESTAMP, 'S', ?)`,
[mscId, c.CON_CLIENTE_ID, c.CON_USUARIO_ID || null, resolucao]);
console.log('[Resolucao] Aviso salvo em MENSAGEM_CLIENTES para cliente', c.CON_CLIENTE_ID);
} catch (mscErr) {
console.error('[Resolucao] Erro ao salvar em MENSAGENS_CLIENTES:', mscErr.message);
}
}
res.json({ success: true, message: 'Conversa finalizada.' });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
@@ -773,7 +759,9 @@ class ChatController {
static async createConversation(req, res) {
try {
const { alias } = req.params;
const { empresaId, numero, nomeContato, mensagem, instanciaId, clienteId } = req.body;
const { empresaId, numero, mensagem, instanciaId } = req.body;
let clienteId = req.body.clienteId || null;
let nomeContato = req.body.nomeContato || '';
if (!empresaId || !numero || !mensagem) {
return res.status(400).json({ success: false, error: 'Campos obrigatórios: empresaId, numero, mensagem' });
@@ -787,6 +775,60 @@ class ChatController {
const numeroLimpo = numero.replace(/\D/g, '').substring(0, 15);
// Se não foi informado clienteId, tenta localizar pelo número de telefone
// Fluxo: cliente → dependente (titular) → número novo (sem vínculo)
let clienteEncontrado = null;
let nomeEncontrado = null;
if (!clienteId) {
// Variações do número para busca: completo, sem DDI (55), últimos 8 dígitos
const variantes = [numeroLimpo];
if (numeroLimpo.startsWith('55')) variantes.push(numeroLimpo.substring(2));
const ultimos8 = numeroLimpo.slice(-8);
if (!variantes.includes(ultimos8)) variantes.push(ultimos8);
// 1) Busca CLIENTES
for (const termo of variantes) {
const cli = await db.query(alias, `
SELECT CLI_CODIGO_ID, CLI_NOME
FROM CLIENTES
WHERE CLI_EMPRESA_ID = ?
AND (REPLACE(REPLACE(REPLACE(COALESCE(CLI_CELULAR,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE1,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE2,''),'-',''),'(',''),')','') LIKE '%' || ? || '%')
FETCH FIRST 1 ROWS ONLY
`, [empresaId, termo, termo, termo]);
if (cli.length > 0) {
clienteEncontrado = cli[0].CLI_CODIGO_ID;
nomeEncontrado = (cli[0].CLI_NOME || '').trim();
break;
}
}
// 2) Se não achou cliente, busca DEPENDENTES
if (!clienteEncontrado) {
for (const termo of variantes) {
const dep = await db.query(alias, `
SELECT d.DEPC_CLIENTE_ID AS TITULAR_ID, d.DEPC_NOME, c.CLI_NOME AS TITULAR_NOME
FROM DEPENDENTES_CLI d
LEFT JOIN CLIENTES c ON d.DEPC_CLIENTE_ID = c.CLI_CODIGO_ID
WHERE d.DEPC_EMPRESA_ID = ? AND d.DEPC_SITUACAO = 'A'
AND REPLACE(REPLACE(REPLACE(COALESCE(d.DEPC_TELEFONE,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
FETCH FIRST 1 ROWS ONLY
`, [empresaId, termo]);
if (dep.length > 0) {
clienteEncontrado = dep[0].TITULAR_ID;
nomeEncontrado = (dep[0].TITULAR_NOME || dep[0].DEPC_NOME || '').trim();
break;
}
}
}
if (clienteEncontrado) {
clienteId = clienteEncontrado;
if (!nomeContato || nomeContato === numeroLimpo) nomeContato = nomeEncontrado;
}
}
// Verifica se já existe conversa ABERTA ou EM ESPERA para este número
// Tenta com o número exato, e também com os últimos 8 dígitos
let existente = await db.query(alias, `
@@ -824,12 +866,12 @@ class ChatController {
}
// Adiciona mensagem na conversa existente
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const msgId = (maxMsgId[0]?.ID || 0) + 1;
const msgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
const textoProcessado = await ChatController.processarPlaceholders(alias, conversaId, mensagem);
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_USUARIO_ID, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'U', ?, ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [msgId, conversaId, req.user?.id, mensagem]);
`, [msgId, conversaId, req.user?.id, textoProcessado]);
await db.execute(alias,
'UPDATE CHATC2_CONVERSAS SET CON_DT_ULTIMA_MSG = CURRENT_TIMESTAMP WHERE CON_CODIGO_ID = ?',
@@ -839,15 +881,14 @@ class ChatController {
const resultado = await db.query(alias,
'SELECT * FROM CHATC2_INSTANCIAS WHERE INS_CODIGO_ID = ?', [instanciaId || null]);
if (resultado.length > 0) {
sendEvolutionMessage(resultado[0], numeroLimpo, mensagem, 'text', null).catch(function(){});
sendEvolutionMessage(resultado[0], numeroLimpo, textoProcessado, 'text', null).catch(function(){});
}
return res.json({ success: true, data: { id: conversaId, reutilizada: true } });
}
// Gera ID da nova conversa
const maxId = await db.query(alias, 'SELECT MAX(CON_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS');
const newId = (maxId[0]?.ID || 0) + 1;
const newId = await db.nextId(alias, 'GEN_CHATC2_CONVERSAS');
// Se não informou instância, pega a primeira ativa
let instId = instanciaId;
@@ -865,20 +906,20 @@ class ChatController {
`, [newId, empresaId, instId, numeroLimpo, nomeContato || numeroLimpo, clienteId || null]);
// Insere primeira mensagem
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const msgId = (maxMsgId[0]?.ID || 0) + 1;
const msgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
const textoProcessadoNew = await ChatController.processarPlaceholders(alias, newId, mensagem);
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_USUARIO_ID, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'U', ?, ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [msgId, newId, req.user?.id, mensagem]);
`, [msgId, newId, req.user?.id, textoProcessadoNew]);
// Envia via Evolution API
try {
const resultado = await db.query(alias,
'SELECT * FROM CHATC2_INSTANCIAS WHERE INS_CODIGO_ID = ?', [instId]);
if (resultado.length > 0) {
await sendEvolutionMessage(resultado[0], numeroLimpo, mensagem, 'text', null);
await sendEvolutionMessage(resultado[0], numeroLimpo, textoProcessadoNew, 'text', null);
}
} catch (evoErr) {
console.error('Erro ao enviar primeira mensagem via Evolution:', evoErr.message);
@@ -1279,8 +1320,7 @@ class ChatController {
}
// Salva mensagem do agente
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const newId = (maxId[0]?.ID || 0) + 1;
const newId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_USUARIO_ID, CME_TEXTO, CME_TIPO, CME_PRIVADA, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'U', ?, ?, 'text', 'N', 'A', CURRENT_TIMESTAMP)
@@ -1306,7 +1346,77 @@ class ChatController {
}
/**
* Recebe avaliação CSAT via formulário web
* Busca mensagens/conversas por termo de texto.
* GET /api/:alias/search?q=termo&empresaId=
*/
static async search(req, res) {
try {
const { alias } = req.params;
const q = (req.query.q || '').trim();
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
const limit = Math.min(parseInt(req.query.limit) || 30, 100);
if (!q || q.length < 2) {
return res.status(400).json({ success: false, error: 'Termo de busca muito curto (mínimo 2 caracteres).' });
}
if (!req.user?.empresas?.some(e => Number(e) === Number(empresaId))) {
return res.status(403).json({ success: false, error: 'Sem permissão.' });
}
const results = await db.query(alias, `
SELECT m.CME_CODIGO_ID, m.CME_CONVERSA_ID, m.CME_TEXTO, m.CME_TIPO,
m.CME_DT_ENVIO, m.CME_REMETENTE,
c.CON_NUMERO, c.CON_NOME_CONTATO, c.CON_STATUS
FROM CHATC2_CONVERSAS_MENSAGENS m
JOIN CHATC2_CONVERSAS c ON c.CON_CODIGO_ID = m.CME_CONVERSA_ID
WHERE c.CON_EMPRESA_ID = ?
AND c.CON_SITUACAO = 'A'
AND m.CME_SITUACAO = 'A'
AND m.CME_TEXTO CONTAINING ?
ORDER BY m.CME_DT_ENVIO DESC
FETCH FIRST ? ROWS ONLY
`, [empresaId, q, limit]);
res.json({
success: true,
data: results.map(r => ({
mensagemId: r.CME_CODIGO_ID,
conversaId: r.CME_CONVERSA_ID,
texto: (r.CME_TEXTO || '').substring(0, 120),
tipo: (r.CME_TIPO || 'text').trim(),
dtEnvio: r.CME_DT_ENVIO,
remetente: (r.CME_REMETENTE || '').trim(),
numero: (r.CON_NUMERO || '').trim(),
nomeContato: (r.CON_NOME_CONTATO || '').trim(),
status: (r.CON_STATUS || '').trim(),
})),
count: results.length,
});
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Recebe avaliação CSAT via formulário web
* POST /api/:alias/csat/avaliar
*/
static async csatCheck(req, res) {
try {
const { alias } = req.params;
const conversaId = parseInt(req.query.conversa);
if (!conversaId) return res.status(400).json({ success: false, error: 'conversa obrigatório.' });
const existe = await db.query(alias,
'SELECT CSA_NOTA, CSA_COMENTARIO FROM CHATC2_CSAT_AVALIACOES WHERE CSA_CONVERSA_ID = ?',
[conversaId]);
res.json({ success: true, avaliado: existe.length > 0 });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
}
/**
* Recebe avaliação CSAT via formulário web
* POST /api/:alias/csat/avaliar
*/
static async csatAvaliar(req, res) {
@@ -1329,8 +1439,7 @@ class ChatController {
}
// Salva avaliação
const maxId = await db.query(alias, 'SELECT MAX(CSA_CODIGO_ID) AS ID FROM CHATC2_CSAT_AVALIACOES');
const newId = (maxId[0]?.ID || 0) + 1;
const newId = await db.nextId(alias, 'GEN_CHATC2_CSAT_AVALIACOES');
await db.execute(alias,
'INSERT INTO CHATC2_CSAT_AVALIACOES (CSA_CODIGO_ID, CSA_CONVERSA_ID, CSA_EMPRESA_ID, CSA_CLIENTE_ID, CSA_NOTA, CSA_COMENTARIO) VALUES (?, ?, ?, ?, ?, ?)',
@@ -1348,143 +1457,216 @@ class ChatController {
res.status(500).json({ success: false, error: err.message });
}
}
}
// Função auxiliar para enviar mensagem via Evolution API
async function sendEvolutionMessage(instancia, numero, texto, tipo, midiaBase64, nomeArquivo) {
const url = ((instancia.INS_URL || '').trim()).replace(/\/$/, '');
const apiKey = (instancia.INS_API_KEY || '').trim();
const instanceName = (instancia.INS_INSTANCE_NAME || '').trim();
const numeroLimpo = numero.replace(/\D/g, '');
/**
* Substitui placeholders no texto da mensagem.
* [CLIENTE] → nome do cliente cadastrado (ou [CONTATO] se não tiver)
* [CONTATO] → nome do contato WhatsApp
* [EMPRESAR] → razão social da empresa
* [EMPRESAF] → nome fantasia da empresa
*/
static async processarPlaceholders(alias, conversaId, texto) {
if (!texto || (!texto.includes('[') && !texto.includes(']'))) return texto;
if (!url || !apiKey || !instanceName || !numeroLimpo) return { success: false, error: 'Parametros insuficientes' };
try {
const conv = await db.query(alias,
'SELECT CON_CLIENTE_ID, CON_NOME_CONTATO, CON_EMPRESA_ID FROM CHATC2_CONVERSAS WHERE CON_CODIGO_ID = ?',
[conversaId]);
if (conv.length === 0) return texto;
const https = require('https');
const http = require('http');
const contato = (conv[0].CON_NOME_CONTATO || '').trim();
const empresaId = conv[0].CON_EMPRESA_ID;
let payload;
let endpoint;
if (midiaBase64 && (tipo === 'image' || tipo === 'audio' || tipo === 'video' || tipo === 'document')) {
var mediaB64 = midiaBase64;
var fileName = nomeArquivo || 'arquivo';
if (tipo === 'audio') {
// A mídia pode já estar em OGG (convertida antes de salvar no BD)
// Se ainda for WebM, tenta converter agora como fallback
var jaEOgg = (fileName || '').toLowerCase().endsWith('.ogg');
if (!jaEOgg) {
try {
var inputBuffer = Buffer.from(mediaB64, 'base64');
if (inputBuffer.length > 100) {
var oggBuffer = await convertWebmToOgg(inputBuffer);
if (oggBuffer && oggBuffer.length > 100) {
mediaB64 = oggBuffer.toString('base64');
fileName = 'audio.ogg';
console.log('[Audio] Conversão fallback WebM→OGG:', inputBuffer.length, '→', oggBuffer.length);
}
}
} catch(convErr) {
console.error('[Audio] Conversão OGG falhou, mantendo como áudio:', convErr.message.substring(0, 100));
fileName = 'audio.webm';
// NÃO muda para 'document' - mantém como áudio
// Busca nome do cliente
let clienteNome = '';
if (conv[0].CON_CLIENTE_ID) {
const cli = await db.query(alias,
'SELECT CLI_NOME, CLI_NOME_FANTASIA FROM CLIENTES WHERE CLI_CODIGO_ID = ?',
[conv[0].CON_CLIENTE_ID]);
if (cli.length > 0) {
clienteNome = (cli[0].CLI_NOME_FANTASIA || cli[0].CLI_NOME || '').trim();
}
}
// Usa sendWhatsAppAudio para enviar como mensagem de voz (PTT)
endpoint = '/message/sendWhatsAppAudio/' + encodeURIComponent(instanceName);
payload = JSON.stringify({ number: numeroLimpo, audio: mediaB64, encoding: true });
console.log('[Evolution] Enviando via sendWhatsAppAudio, tamanho:', mediaB64.length, 'arquivo:', fileName);
} else if (tipo === 'image') {
endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName);
payload = JSON.stringify({ number: numeroLimpo, mediatype: 'image', media: mediaB64, fileName: fileName });
} else if (tipo === 'video') {
// Comprime video se for muito grande (> 5MB) para evitar rejeicao da Evolution API
try {
var videoBuf = Buffer.from(mediaB64, 'base64');
if (videoBuf.length > 5 * 1024 * 1024) {
console.log('[Video] Video grande detectado (' + (videoBuf.length / (1024 * 1024)).toFixed(1) + 'MB), comprimindo...');
var compressedBuf = await compressVideo(videoBuf);
if (compressedBuf && compressedBuf.length > 1000) {
mediaB64 = compressedBuf.toString('base64');
fileName = (fileName || 'video').replace(/\.\w+$/, '') + '_compressed.mp4';
console.log('[Video] Compressao OK, novo tamanho base64:', mediaB64.length);
}
// Busca dados da empresa
let empresaR = '', empresaF = '';
if (empresaId) {
const emp = await db.query(alias,
'SELECT EMP_NOME, EMP_NOME_FANTASIA FROM EMPRESAS WHERE EMP_CODIGO_ID = ?',
[empresaId]);
if (emp.length > 0) {
empresaR = (emp[0].EMP_NOME || '').trim();
empresaF = (emp[0].EMP_NOME_FANTASIA || emp[0].EMP_NOME || '').trim();
} else {
console.log('[Placeholder] Empresa nao encontrada para ID:', empresaId);
}
} catch(compressErr) {
console.error('[Video] Compressao falhou, enviando original:', compressErr.message.substring(0, 80));
} else {
console.log('[Placeholder] Conversa sem CON_EMPRESA_ID');
}
endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName);
payload = JSON.stringify({ number: numeroLimpo, mediatype: 'video', media: mediaB64, fileName: fileName });
} else if (tipo === 'document') {
// Para documentos que sao videos, comprime tambem se for grande
var isVideoDoc = (fileName || '').toLowerCase().match(/\.(mp4|mov|avi|mkv|webm|3gp|m4v)$/);
if (isVideoDoc) {
try {
var docBuf = Buffer.from(mediaB64, 'base64');
if (docBuf.length > 5 * 1024 * 1024) {
console.log('[Document] Video como documento detectado (' + (docBuf.length / (1024 * 1024)).toFixed(1) + 'MB), comprimindo...');
var compressedDocBuf = await compressVideo(docBuf);
if (compressedDocBuf && compressedDocBuf.length > 1000) {
mediaB64 = compressedDocBuf.toString('base64');
fileName = (fileName || 'video').replace(/\.\w+$/, '') + '_compressed.mp4';
console.log('[Document] Compressao OK, novo tamanho base64:', mediaB64.length);
}
}
} catch(compressErr) {
console.error('[Document] Compressao falhou, enviando original:', compressErr.message.substring(0, 80));
}
}
endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName);
payload = JSON.stringify({ number: numeroLimpo, mediatype: 'document', media: mediaB64, fileName: fileName });
// Substitui: [CLIENTE] usa nome do cadastro, senão cai para [CONTATO]
let result = texto;
result = result.replace(/\[CLIENTE\]/gi, clienteNome || contato || '');
result = result.replace(/\[CONTATO\]/gi, contato || '');
result = result.replace(/\[EMPRESAR\]/gi, empresaR || empresaF || '');
result = result.replace(/\[EMPRESAF\]/gi, empresaF || empresaR || '');
} else {
endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName);
payload = JSON.stringify({ number: numeroLimpo, mediatype: 'document', media: mediaB64, fileName: fileName });
console.log('[Placeholder] conversa:', conversaId, '| contato:', contato, '| cliente:', clienteNome, '| empresaR:', empresaR, '| empresaF:', empresaF, '| antes:', texto.substring(0, 80), '| depois:', result.substring(0, 80));
return result;
} catch (e) {
return texto; // fallback: retorna sem substituir
}
} else {
endpoint = '/message/sendText/' + encodeURIComponent(instanceName);
payload = JSON.stringify({ number: numeroLimpo, text: texto || '', delay: 0 });
}
const parsedUrl = new URL(url + endpoint);
const lib = parsedUrl.protocol === 'https:' ? https : http;
// ==================== AGENDAMENTOS ====================
return new Promise((resolve, reject) => {
const options = {
hostname: parsedUrl.hostname,
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
path: parsedUrl.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
'apikey': apiKey,
/**
* Agenda retorno para uma conversa.
* POST /api/:alias/conversations/:id/schedule
*/
static async scheduleConversation(req, res) {
try {
const { alias, id } = req.params;
const { data, hora, nota } = req.body;
if (!data) return res.status(400).json({ success: false, error: 'Data é obrigatória.' });
const chk = await ChatController.checarConversaEmpresa(alias, id, req);
if (chk.status) return res.status(chk.status).json({ success: false, error: 'Conversa não encontrada ou sem permissão.' });
const newId = await db.nextId(alias, 'GEN_CHATC2_AGENDAMENTOS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_AGENDAMENTOS (CAG_CODIGO_ID, CAG_CONVERSA_ID, CAG_EMPRESA_ID, CAG_DATA, CAG_HORA, CAG_NOTA, CAG_USUARIO_ID)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, [newId, id, chk.empresaId, data, hora || null, nota || null, req.user?.id]);
res.json({ success: true, data: { id: newId } });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Lista agendamentos de uma data (ou hoje).
* GET /api/:alias/schedules?data=2026-12-25&empresaId=
*/
static async listSchedules(req, res) {
try {
const { alias } = req.params;
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
const data = req.query.data || new Date().toISOString().split('T')[0];
if (!req.user?.empresas?.some(e => Number(e) === Number(empresaId))) {
return res.status(403).json({ success: false, error: 'Sem permissão.' });
}
};
const req = lib.request(options, (res) => {
let body = '';
res.on('data', c => body += c);
res.on('end', () => {
if (res.statusCode >= 400) {
console.error('[Evolution] Erro ao enviar mídia:', res.statusCode, body.substring(0, 200));
reject(new Error('HTTP ' + res.statusCode + ': ' + body.substring(0, 100)));
} else {
resolve(body);
}
const result = await db.query(alias, `
SELECT a.*, c.CON_NUMERO, c.CON_NOME_CONTATO, c.CON_CLIENTE_ID
FROM CHATC2_CONVERSAS_AGENDAMENTOS a
JOIN CHATC2_CONVERSAS c ON c.CON_CODIGO_ID = a.CAG_CONVERSA_ID
WHERE a.CAG_EMPRESA_ID = ? AND a.CAG_DATA = ? AND a.CAG_STATUS = 'P'
ORDER BY a.CAG_HORA NULLS LAST, a.CAG_DT_CRIACAO
`, [empresaId, data]);
res.json({
success: true,
data: result.map(r => ({
id: r.CAG_CODIGO_ID,
conversaId: r.CAG_CONVERSA_ID,
data: r.CAG_DATA,
hora: r.CAG_HORA,
nota: (r.CAG_NOTA || '').trim(),
numero: (r.CON_NUMERO || '').trim(),
nomeContato: (r.CON_NOME_CONTATO || '').trim(),
clienteId: r.CON_CLIENTE_ID,
status: (r.CAG_STATUS || '').trim(),
})),
count: result.length,
});
});
req.on('error', function(err) {
console.error('[Evolution] Erro na requisição:', err.message);
reject(err);
});
req.write(payload);
req.end();
});
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Cria nova conversa a partir de um agendamento.
* POST /api/:alias/schedules/:id/reopen
* Retorna a nova conversa e o texto sugerido — NÃO envia automaticamente.
*/
static async reopenSchedule(req, res) {
try {
const { alias, id } = req.params;
const ag = await db.query(alias,
`SELECT a.*, c.CON_NUMERO, c.CON_NOME_CONTATO, c.CON_CLIENTE_ID, c.CON_EMPRESA_ID, c.CON_INSTANCIA_ID
FROM CHATC2_CONVERSAS_AGENDAMENTOS a
JOIN CHATC2_CONVERSAS c ON c.CON_CODIGO_ID = a.CAG_CONVERSA_ID
WHERE a.CAG_CODIGO_ID = ?`, [id]);
if (ag.length === 0) return res.status(404).json({ success: false, error: 'Agendamento não encontrado.' });
const a = ag[0];
const numeroLimpo = (a.CON_NUMERO || '').replace(/\D/g, '').substring(0, 15);
const nomeContato = (a.CON_NOME_CONTATO || a.CON_NUMERO || '').trim();
const nota = (a.CAG_NOTA || '').trim();
let clienteId = a.CON_CLIENTE_ID || null;
// Se não tem cliente vinculado, busca pelo número
if (!clienteId) {
const variantes = [numeroLimpo];
if (numeroLimpo.startsWith('55')) variantes.push(numeroLimpo.substring(2));
variantes.push(numeroLimpo.slice(-8));
for (const termo of variantes) {
const cli = await db.query(alias, `
SELECT CLI_CODIGO_ID, CLI_NOME FROM CLIENTES
WHERE CLI_EMPRESA_ID = ?
AND (REPLACE(REPLACE(REPLACE(COALESCE(CLI_CELULAR,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE1,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE2,''),'-',''),'(',''),')','') LIKE '%' || ? || '%')
FETCH FIRST 1 ROWS ONLY
`, [a.CAG_EMPRESA_ID, termo, termo, termo]);
if (cli.length > 0) { clienteId = cli[0].CLI_CODIGO_ID; break; }
}
if (!clienteId) {
for (const termo of variantes) {
const dep = await db.query(alias, `
SELECT DEPC_CLIENTE_ID FROM DEPENDENTES_CLI
WHERE DEPC_EMPRESA_ID = ? AND DEPC_SITUACAO = 'A'
AND REPLACE(REPLACE(REPLACE(COALESCE(DEPC_TELEFONE,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
FETCH FIRST 1 ROWS ONLY
`, [a.CAG_EMPRESA_ID, termo]);
if (dep.length > 0) { clienteId = dep[0].DEPC_CLIENTE_ID; break; }
}
}
}
// Cria NOVA conversa
const newId = await db.nextId(alias, 'GEN_CHATC2_CONVERSAS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS (CON_CODIGO_ID, CON_EMPRESA_ID, CON_INSTANCIA_ID, CON_NUMERO,
CON_NOME_CONTATO, CON_CLIENTE_ID, CON_STATUS, CON_SITUACAO, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG)
VALUES (?, ?, ?, ?, ?, ?, 'A', 'A', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`, [newId, a.CAG_EMPRESA_ID, a.CON_INSTANCIA_ID, numeroLimpo, nomeContato, clienteId]);
// Monta texto sugerido (placeholders serão processados no frontend ao enviar)
const textoSugerido = nota
? '📅 *Retorno agendado*\n' + nota + '\n\nOlá [CLIENTE]! Conforme combinado, estou retornando o contato. Em que posso ajudar?'
: '👋 Olá [CLIENTE]! Conforme combinado, estou retornando o contato. Em que posso ajudar?';
// Marca agendamento como retomado
await db.execute(alias,
"UPDATE CHATC2_CONVERSAS_AGENDAMENTOS SET CAG_STATUS = 'R' WHERE CAG_CODIGO_ID = ?", [id]);
res.json({ success: true, conversaId: newId, textoSugerido: textoSugerido });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
}
// ponytail: delegado para evolutionService
const evolution = require('../services/evolutionService');
async function sendEvolutionMessage(instancia, numero, texto, tipo, midiaBase64, nomeArquivo) {
if (midiaBase64 && (tipo === 'image' || tipo === 'audio' || tipo === 'video' || tipo === 'document')) {
return evolution.sendMedia(instancia, numero, tipo, midiaBase64, nomeArquivo);
}
return evolution.sendText(instancia, numero, texto);
}
module.exports = ChatController;
+40 -4
View File
@@ -299,6 +299,7 @@ class ClientController {
const total = countResult[0].TOTAL;
// Busca paginada
const dataParams = params.concat([offset, limit]);
const dataSql = `
SELECT
c.CAR_CODIGO_ID,
@@ -315,10 +316,10 @@ class ClientController {
FROM CARNES c
${where}
ORDER BY c.CAR_DT_VENCIMENTO
OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY
OFFSET ? ROWS FETCH NEXT ? ROWS ONLY
`;
const result = await db.query(alias, dataSql, params);
const result = await db.query(alias, dataSql, dataParams);
const carnes = result.map(row => {
const situacao = row.CAR_SITUACAO;
@@ -464,6 +465,7 @@ class ClientController {
const total = countResult[0].TOTAL;
// Busca paginada
const dataParams = params.concat([offset, limit]);
const dataSql = `
SELECT
c.CLI_CODIGO_ID,
@@ -478,10 +480,10 @@ class ClientController {
FROM CLIENTES c
${where}
ORDER BY c.CLI_NOME
OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY
OFFSET ? ROWS FETCH NEXT ? ROWS ONLY
`;
const result = await db.query(alias, dataSql, params);
const result = await db.query(alias, dataSql, dataParams);
const clients = result.map(row => ({
id: row.CLI_CODIGO_ID,
@@ -1007,6 +1009,40 @@ class ClientController {
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;
+31 -21
View File
@@ -1,6 +1,6 @@
const db = require('../database');
const { isGerente } = require('../middlewares/roles');
const { garantirEstrutura } = require('../resolucaoSetup');
const configService = require('../services/configService');
class ConfigController {
// ==================== CHATC2_EQUIPES ====================
@@ -24,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() }));
}
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 }); }
}
@@ -36,11 +43,14 @@ class ConfigController {
const empresaId = req.user?.empresas?.[0] || req.body.empresaId;
if (!nome) return res.status(400).json({ success: false, error: 'Nome obrigatório.' });
const maxId = await db.query(alias, 'SELECT MAX(EQU_CODIGO_ID) AS ID FROM CHATC2_EQUIPES');
const newId = (maxId[0]?.ID || 0) + 1;
const newId = await db.nextId(alias, 'GEN_CHATC2_EQUIPES');
const ordem = req.body.ordem !== undefined ? req.body.ordem : 0;
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) {
for (const userId of membros) {
@@ -60,6 +70,8 @@ class ConfigController {
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.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) {
await db.execute(alias, 'DELETE FROM CHATC2_USU_EQUIPES WHERE EQU_EQUIPE_ID = ?', [id]);
@@ -119,8 +131,7 @@ class ConfigController {
const { nome, cor } = req.body;
const empresaId = req.user?.empresas?.[0];
const maxId = await db.query(alias, 'SELECT MAX(ETI_CODIGO_ID) AS ID FROM CHATC2_ETIQUETAS');
const newId = (maxId[0]?.ID || 0) + 1;
const newId = await db.nextId(alias, 'GEN_CHATC2_ETIQUETAS');
await db.execute(alias, 'INSERT INTO CHATC2_ETIQUETAS (ETI_CODIGO_ID, ETI_EMPRESA_ID, ETI_NOME, ETI_COR) VALUES (?, ?, ?, ?)', [newId, empresaId, nome, cor || '#667eea']);
res.json({ success: true, data: { id: newId } });
@@ -153,13 +164,12 @@ class ConfigController {
const { alias } = req.params;
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
await garantirEstrutura(alias);
let config = await db.query(alias,
'SELECT * FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]
);
const flagsPadrao = { motivoVisualizar: 'N', motivoObrigatorio: 'N', resolucaoVisualizar: 'N', resolucaoObrigatorio: 'N', enviarBoleto: 'N' };
const flagsPadrao = { motivoVisualizar: 'N', motivoObrigatorio: 'N', resolucaoVisualizar: 'N', resolucaoObrigatorio: 'N', enviarBoleto: 'N', resolucaoSalvarMSC: 'N' };
if (config.length === 0) {
const cfg = Object.assign({ empresaId, fotoCelular: 'N', saudacaoAtiva: 'S', saudacaoMensagem: '', csatAtivo: 'N', csatMensagem: '', enviarNomeUsuario: 'N', triagemAtiva: 'N', triagemMsgWelcome: '', triagemMsgAfter: '', triagemBoletoNumero: '0', instanciaPadraoId: null }, flagsPadrao);
@@ -185,6 +195,7 @@ class ConfigController {
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 }); }
@@ -196,7 +207,6 @@ class ConfigController {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem alterar o fluxo de resolução.' });
const { alias } = req.params;
await garantirEstrutura(alias);
const empresaId = req.body.empresaId || req.user?.empresas?.[0];
const sn = (v) => (v === 'S' || v === true ? 'S' : 'N');
const mv = sn(req.body.motivoVisualizar), mo = sn(req.body.motivoObrigatorio);
@@ -214,6 +224,7 @@ class ConfigController {
VALUES (?, ?, ?, ?, ?)`,
[empresaId, mv, mo, rv, ro]);
}
configService.invalidarConfig(alias, empresaId);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
@@ -222,7 +233,6 @@ class ConfigController {
static async listMotivos(req, res) {
try {
const { alias } = req.params;
await garantirEstrutura(alias);
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
const rows = await db.query(alias,
`SELECT MOT_CODIGO_ID, MOT_DESCRICAO FROM "CHATC2_MOTIVOS_ATENDIMENTO"
@@ -235,12 +245,10 @@ class ConfigController {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem cadastrar motivos.' });
const { alias } = req.params;
await garantirEstrutura(alias);
const descricao = (req.body.descricao || '').trim();
if (!descricao) return res.status(400).json({ success: false, error: 'Descrição obrigatória.' });
const empresaId = req.body.empresaId || req.user?.empresas?.[0];
const maxId = await db.query(alias, 'SELECT MAX(MOT_CODIGO_ID) AS ID FROM "CHATC2_MOTIVOS_ATENDIMENTO"');
const newId = (maxId[0]?.ID || 0) + 1;
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]);
@@ -252,7 +260,6 @@ class ConfigController {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem remover motivos.' });
const { alias, id } = req.params;
await garantirEstrutura(alias);
await db.execute(alias, `UPDATE "CHATC2_MOTIVOS_ATENDIMENTO" SET MOT_SITUACAO = 'I' WHERE MOT_CODIGO_ID = ?`, [id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
@@ -262,10 +269,10 @@ class ConfigController {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem alterar as configurações da empresa.' });
const { alias } = req.params;
await garantirEstrutura(alias);
const data = req.body;
const empresaId = data.empresaId || req.user?.empresas?.[0];
const enviarBoleto = (data.enviarBoleto === 'S' || data.enviarBoleto === true) ? 'S' : 'N';
const resolucaoSalvarMSC = (data.resolucaoSalvarMSC === 'S' || data.resolucaoSalvarMSC === true) ? 'S' : 'N';
// Upsert
const exists = await db.query(alias, 'SELECT COUNT(*) AS T FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]);
@@ -276,18 +283,21 @@ class ConfigController {
CFE_INSTANCIA_PADRAO_ID = ?, CFE_FOTO_CELULAR = ?, CFE_SAUDACAO_ATIVA = ?,
CFE_SAUDACAO_MENSAGEM = ?, CFE_CSAT_ATIVO = ?, CFE_CSAT_MENSAGEM = ?,
CFE_ENVIAR_NOME_USUARIO = ?, CFE_TRIAGEM_ATIVA = ?, CFE_TRIAGEM_MSG_WELCOME = ?,
CFE_TRIAGEM_MSG_AFTER = ?, CFE_TRIAGEM_BOLETO_NUMERO = ?, CFE_ENVIAR_BOLETO = ?
CFE_TRIAGEM_MSG_AFTER = ?, CFE_TRIAGEM_BOLETO_NUMERO = ?, CFE_ENVIAR_BOLETO = ?,
CFE_RESOLUCAO_SALVAR_MSC = ?
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', enviarBoleto, 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 {
await db.execute(alias, `
INSERT INTO CHATC2_CONFIGURACOES_EMPRESA (CFE_EMPRESA_ID, CFE_INSTANCIA_PADRAO_ID, CFE_FOTO_CELULAR,
CFE_SAUDACAO_ATIVA, CFE_SAUDACAO_MENSAGEM, CFE_CSAT_ATIVO, CFE_CSAT_MENSAGEM, CFE_ENVIAR_NOME_USUARIO,
CFE_TRIAGEM_ATIVA, CFE_TRIAGEM_MSG_WELCOME, CFE_TRIAGEM_MSG_AFTER, CFE_TRIAGEM_BOLETO_NUMERO, CFE_ENVIAR_BOLETO)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [empresaId, data.instanciaPadraoId || null, data.fotoCelular || 'N', data.saudacaoAtiva || 'S', data.saudacaoMensagem || '', data.csatAtivo || 'N', data.csatMensagem || '', data.enviarNomeUsuario || 'N', data.triagemAtiva || 'N', data.triagemMsgWelcome || '', data.triagemMsgAfter || '', data.triagemBoletoNumero || '0', enviarBoleto]);
CFE_TRIAGEM_ATIVA, CFE_TRIAGEM_MSG_WELCOME, CFE_TRIAGEM_MSG_AFTER, CFE_TRIAGEM_BOLETO_NUMERO, CFE_ENVIAR_BOLETO,
CFE_RESOLUCAO_SALVAR_MSC)
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 });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
+9 -4
View File
@@ -73,6 +73,12 @@ class DashboardController {
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
@@ -89,15 +95,13 @@ class DashboardController {
});
// ===== Tráfego das conversas (últimos 365 dias, por dia) =====
const desde = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
const desdeStr = desde.toISOString().split('T')[0];
const trafegoRows = await db.query(alias,
`SELECT CAST(COALESCE(CON_DT_INICIO, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG) AS DATE) AS DIA, COUNT(*) AS CT
FROM CHATC2_CONVERSAS
WHERE CON_EMPRESA_ID IN (${ph})
AND COALESCE(CON_DT_INICIO, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG) >= ?
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.concat([desdeStr]));
empresas);
const fmtDia = (d) => {
if (!d) return null;
@@ -115,6 +119,7 @@ class DashboardController {
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,
+243 -129
View File
@@ -3,6 +3,31 @@ const https = require('https');
const http = require('http');
const { listAliases } = db;
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
var cacheInstanciaAlias = {};
@@ -217,8 +242,7 @@ class EvolutionController {
return res.status(403).json({ success: false, error: 'Sem permissão para esta empresa.' });
}
const maxId = await db.query(alias, 'SELECT MAX(INS_CODIGO_ID) AS ID FROM CHATC2_INSTANCIAS');
const newId = (maxId[0]?.ID || 0) + 1;
const newId = await db.nextId(alias, 'GEN_CHATC2_INSTANCIAS');
// Tenta criar a instância na Evolution
let instanceCreated = false;
@@ -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
* GET /api/:alias/evolution/instances?empresaId=
@@ -532,17 +577,30 @@ class EvolutionController {
var u = new URL(fotoUrl);
var lib = u.protocol === 'https:' ? https : http;
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 = [];
imgRes.on('data', function(c) { chunks.push(c); });
imgRes.on('end', function() { resolve(Buffer.concat(chunks)); });
}).on('error', reject).on('timeout', function() { this.destroy(); reject(new Error('Timeout')); });
});
if (fotoBuffer && fotoBuffer.length > 100) {
// Valida buffer: tamanho realista (500B a 2MB) e header de imagem
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,
'UPDATE CLIENTES SET CLI_FOTO = ? WHERE CLI_CODIGO_ID = ?',
[fotoBuffer.toString('base64'), clienteId]);
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) {
console.error('[Foto] Erro ao baixar imagem:', (imgErr.message || '').substring(0, 120));
@@ -650,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) {
try {
const data = body.data || body;
@@ -698,7 +825,8 @@ async function processWebhook(alias, body) {
// Busca conversa existente pelo número exato ou pelos últimos 8 dígitos
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 = ?
AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A'
ORDER BY CON_DT_INICIO DESC`,
@@ -708,7 +836,8 @@ async function processWebhook(alias, body) {
if (conversations.length === 0) {
const ultimos8 = numero.slice(-8);
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 = ?
AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A'
ORDER BY CON_DT_INICIO DESC`,
@@ -719,80 +848,95 @@ async function processWebhook(alias, body) {
let conversa;
var isNovaConversa = false;
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;
const maxId = await db.query(alias, 'SELECT MAX(CON_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS');
const newId = (maxId[0]?.ID || 0) + 1;
const newId = await db.nextId(alias, 'GEN_CHATC2_CONVERSAS');
const nomeContato = data.pushName || data.notifyName || sender || '';
// Busca cliente pelo número em múltiplos campos de telefone
// Tenta com o número completo (com DDI 55) e também sem o DDI
// Busca cliente pelo número em TODOS os aliases (multi-banco)
let clienteId = null;
let nomeCliente = null;
// 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
var aliasEfetivo = alias; // alias onde o cliente foi encontrado (pode ser diferente)
const clientes = 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 = ?`,
[termo1, termo1, termo1, empresaId]
);
// 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() {});
const resultadoGlobal = await buscarClienteGlobal(numero, empresaId, alias);
if (resultadoGlobal) {
clienteId = resultadoGlobal.cliente.CLI_CODIGO_ID;
nomeCliente = (resultadoGlobal.cliente.CLI_NOME || '').trim();
aliasEfetivo = resultadoGlobal.alias;
// ponytail: CLI_FOTO desativado (causa erro no ERP)
// EvolutionController.atualizarFotoContato(aliasEfetivo, clienteId, numero, empresaId).catch(function() {});
}
// Usa nome do cliente do BD se encontrado, senão usa o nome do WhatsApp
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,
CON_NOME_CONTATO, CON_CLIENTE_ID, CON_STATUS, CON_SITUACAO, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG)
VALUES (?, ?, ?, ?, ?, ?, 'E', 'A', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`, [newId, empresaId, instanciaId, numero, nomeFinal, clienteId]);
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];
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 = ?',
[empresaId]
);
@@ -800,16 +944,16 @@ async function processWebhook(alias, body) {
if (config.length > 0 && config[0].CFE_SAUDACAO_ATIVA === 'S') {
const msgSaudacao = (config[0].CFE_SAUDACAO_MENSAGEM || '').trim();
if (msgSaudacao) {
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
await db.execute(alias, `
const saudacaoMsgId = await db.nextId(aliasEfetivo, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(aliasEfetivo, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversa.CON_CODIGO_ID, msgSaudacao]);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_SAUDACAO_ENVIADA = 'S' WHERE CON_CODIGO_ID = ?", [conversa.CON_CODIGO_ID]);
`, [saudacaoMsgId, conversa.CON_CODIGO_ID, msgSaudacao]);
await db.execute(aliasEfetivo, "UPDATE CHATC2_CONVERSAS SET CON_SAUDACAO_ENVIADA = 'S' WHERE CON_CODIGO_ID = ?", [conversa.CON_CODIGO_ID]);
// Envia saudação via Evolution API
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]);
if (inst.length > 0) {
await evolutionRequest(
@@ -827,72 +971,34 @@ async function processWebhook(alias, body) {
// Envia menu de triagem SEMPRE que for conversa nova
// (sendMenu internamente verifica se CFE_TRIAGEM_ATIVA = 'S')
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]);
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); }
} // fecha else (nova conversa)
} else {
conversa = conversations[0];
// Se a conversa existente não tem cliente vinculado, tenta vincular agora
if (!conversa.CON_CLIENTE_ID) {
const termo1 = numero;
const termo2 = numero.startsWith('55') ? numero.substring(2) : numero;
const clientes = 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 = ?`,
[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,
// Busca cliente em TODOS os aliases (multi-banco)
const resultadoGlobal = await buscarClienteGlobal(numero, empresaId, alias);
if (resultadoGlobal) {
var cliId = resultadoGlobal.cliente.CLI_CODIGO_ID;
const nomeCliente = (resultadoGlobal.cliente.CLI_NOME || '').trim();
var aliasEfetivo = resultadoGlobal.alias;
await db.execute(aliasEfetivo,
'UPDATE CHATC2_CONVERSAS SET CON_CLIENTE_ID = ?, CON_NOME_CONTATO = ? WHERE CON_CODIGO_ID = ?',
[cliId, nomeCliente, conversa.CON_CODIGO_ID]);
conversa.CON_CLIENTE_ID = cliId;
// Atualiza foto do contato
EvolutionController.atualizarFotoContato(alias, cliId, numero, empresaId).catch(function() {});
// ponytail: CLI_FOTO desativado
// 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]);
}
@@ -1167,8 +1273,7 @@ async function processWebhook(alias, body) {
var b64Salvar = mediaBuffer.toString('base64');
var mimeTypeSalvar = mimeType || (mediaTipo + '/' + ext);
var maxMidia = await db.query(alias, 'SELECT MAX(MAT_CODIGO_ID) AS ID FROM CHATC2_MENSAGENS_ATENDIMENTOS');
midiaId = (maxMidia[0]?.ID || 0) + 1;
midiaId = await db.nextId(alias, 'GEN_MENSAGENS_ATENDIMENTOS');
await db.execute(alias, `
INSERT INTO CHATC2_MENSAGENS_ATENDIMENTOS (MAT_CODIGO_ID, MAT_CONVERSA_ID, MAT_MENSAGEM_ID, MAT_NOME_ARQUIVO, MAT_TIPO_ARQUIVO, MAT_ARQUIVO)
@@ -1214,26 +1319,35 @@ async function processWebhook(alias, body) {
} // fim if (mediaInfo)
if (messageText || messageType !== 'conversation' || midiaId) {
var maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const webhookMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
if (midiaId) {
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO, CME_MIDIA_ID)
VALUES (?, ?, 'C', ?, ?, 'A', CURRENT_TIMESTAMP, ?)
`, [(maxMsgId[0]?.ID || 0) + 1, conversa.CON_CODIGO_ID, messageText, messageType, midiaId]);
`, [webhookMsgId, conversa.CON_CODIGO_ID, messageText, messageType, midiaId]);
} else {
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'C', ?, ?, 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversa.CON_CODIGO_ID, messageText, messageType]);
`, [webhookMsgId, conversa.CON_CODIGO_ID, messageText, messageType]);
}
// Processa resposta do cliente à triagem (se estiver em modo triagem)
// Só processa para conversas já existentes. Para conversas novas, a mensagem
// que disparou a criação foi enviada ANTES do menu, então não deve ser processada.
if (!isNovaConversa && messageText && messageType === 'conversation' && !data.key?.fromMe) {
TriageController.processResponse(alias, conversa.CON_CODIGO_ID, empresaId, instanciaId, numero, messageText)
.catch(function(e) { console.error('[Triagem] Erro ao processar resposta:', e.message); });
// Processa resposta do cliente à triagem (APENAS se conversa está em modo triagem)
// Condições: conversa existente, tem texto, é mensagem de conversa, não é do agente,
// E está em triagem (CON_MENU_ESTADO preenchido) E ainda não tem atendente+equipe.
var emTriagem = conversa.CON_MENU_ESTADO &&
(!conversa.CON_USUARIO_ID || !conversa.CON_EQUIPE_ID);
if (!isNovaConversa && messageText && messageType === 'conversation' && !data.key?.fromMe && emTriagem) {
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) {
+37 -5
View File
@@ -1,7 +1,23 @@
const db = require('../database');
const crypto = require('crypto');
const { listAliases } = db;
const { isGerente } = require('../middlewares/roles');
/**
* Valida o token de administração (segundo fator para SQL direto).
* Comparação em tempo constante para prevenir timing attacks.
*/
function validarAdminToken(req) {
const esperado = process.env.ADMIN_TOKEN;
if (!esperado) return false; // Se não configurado, ninguém acessa
const recebido = req.headers['x-admin-token'] || '';
try {
return crypto.timingSafeEqual(Buffer.from(recebido), Buffer.from(esperado));
} catch (_) {
return false;
}
}
class GenericController {
/**
* Executa uma consulta SQL genérica (SELECT)
@@ -15,6 +31,10 @@ class GenericController {
return res.status(403).json({ success: false, error: 'Apenas gerentes podem executar SQL direto.' });
}
if (!validarAdminToken(req)) {
return res.status(403).json({ success: false, error: 'Token de administração (X-Admin-Token) inválido ou não configurado.' });
}
if (!sql) {
return res.status(400).json({
success: false,
@@ -42,6 +62,10 @@ class GenericController {
return res.status(403).json({ success: false, error: 'Apenas gerentes podem executar SQL direto.' });
}
if (!validarAdminToken(req)) {
return res.status(403).json({ success: false, error: 'Token de administração (X-Admin-Token) inválido ou não configurado.' });
}
if (!sql) {
return res.status(400).json({
success: false,
@@ -94,23 +118,31 @@ class GenericController {
}
/**
* Health check do alias específico
* Health check completo do alias específico.
* Verifica: conectividade Firebird, uso de memória, uptime.
*/
static async healthCheck(req, res) {
try {
const { alias } = req.params;
const t0 = Date.now();
const isConnected = await db.testConnection(alias);
const pingMs = Date.now() - t0;
const mem = process.memoryUsage();
res.json({
success: true,
alias,
connected: isConnected,
pingMs,
uptimeSeconds: Math.floor(process.uptime()),
memory: {
rssMB: Math.round(mem.rss / 1024 / 1024),
heapUsedMB: Math.round(mem.heapUsed / 1024 / 1024),
heapTotalMB: Math.round(mem.heapTotal / 1024 / 1024),
},
timestamp: new Date().toISOString(),
});
} catch (err) {
res.status(500).json({
success: false,
error: err.message,
});
res.status(500).json({ success: false, error: err.message });
}
}
+1 -2
View File
@@ -172,8 +172,7 @@ class MenuController {
await MenuController.garantirTabela(alias);
const maxId = await db.query(alias, 'SELECT MAX(MNE_CODIGO_ID) AS ID FROM CHATC2_MENUS_EMPRESA');
const newId = (maxId[0]?.ID || 0) + 1;
const newId = await db.nextId(alias, 'GEN_CHATC2_MENUS_EMPRESA');
// Se tem menuPaiId, o equipeId é herdado do pai
var equipeIdFinal = menuPaiId ? null : (equipeId || null);
+67 -77
View File
@@ -7,7 +7,6 @@ const fs = require('fs');
const path = require('path');
const db = require('../database');
const https = require('https');
const http = require('http');
function logTriage(msg) {
try {
@@ -18,6 +17,16 @@ function logTriage(msg) {
} 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 {
/**
* Envia o menu principal (equipes + boleto)
@@ -37,7 +46,7 @@ class TriageController {
const boletoNum = String(cfg.CFE_TRIAGEM_BOLETO_NUMERO || '0').trim();
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]
);
@@ -70,11 +79,11 @@ class TriageController {
}
// Salva mensagem do sistema
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'triagem', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, menuText]);
`, [maxMsgId, conversaId, menuText]);
// Envia via Evolution
await TriageController.sendEvolution(alias, instanciaId, numero, menuText);
@@ -119,11 +128,11 @@ class TriageController {
menuText += '\n0 - Voltar';
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'triagem', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, menuText]);
`, [maxMsgId, conversaId, menuText]);
await TriageController.sendEvolution(alias, instanciaId, numero, menuText);
// Salva estado
@@ -224,7 +233,7 @@ class TriageController {
if (estado === 'root' || estado === '') {
logTriage('7-processResponse root - resposta: ' + JSON.stringify(resposta) + ' | boletoNum: ' + JSON.stringify(boletoNum));
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]
);
@@ -238,17 +247,37 @@ class TriageController {
[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
var temSubmenus = await TriageController.enviarSubmenu(alias, conversaId, empresaId, instanciaId, numero, null, eq.EQU_CODIGO_ID);
if (!temSubmenus) {
// Sem submenus: envia msgAfter e finaliza triagem
if (msgAfter) {
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, msgAfter]);
`, [maxMsgId, conversaId, msgAfter]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgAfter);
}
// Remove estado de triagem (conversa fica com equipe atribuída)
@@ -375,11 +404,11 @@ class TriageController {
// Texto: envia mensagem fixa e FINALIZA (não mostra menu novamente)
var textoMsg = menuEscolhido.MNE_TEXTO ? menuEscolhido.MNE_TEXTO.toString() : '';
if (textoMsg) {
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, textoMsg]);
`, [maxMsgId, conversaId, textoMsg]);
await TriageController.sendEvolution(alias, instanciaId, numero, textoMsg);
}
// Finaliza triagem (não mostra menu novamente)
@@ -394,11 +423,11 @@ class TriageController {
msgPrompt = msgPrompt.replace(/{NOME}/g, 'Cliente');
msgPrompt = msgPrompt.replace(/{EMPRESA}/g, '');
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, msgPrompt]);
`, [maxMsgId, conversaId, msgPrompt]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgPrompt);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = 'coleta_" + menuEscolhido.MNE_CODIGO_ID + "' WHERE CON_CODIGO_ID = ?", [conversaId]);
@@ -519,11 +548,11 @@ class TriageController {
}
if (resultado) {
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, resultado]);
`, [maxMsgId, conversaId, resultado]);
await TriageController.sendEvolution(alias, instanciaId, numero, resultado);
}
@@ -577,11 +606,11 @@ class TriageController {
} else {
logTriage('Boleto - NENHUM cliente ou dependente encontrado!');
var msgNaoEncontrado = 'Este número não está associado a nenhum contrato no sistema.\n\nPor favor, selecione um setor para que um atendente possa ajudá-lo a associar seu número.';
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, msgNaoEncontrado]);
`, [maxMsgId, conversaId, msgNaoEncontrado]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgNaoEncontrado);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = 'root' WHERE CON_CODIGO_ID = ?", [conversaId]);
await TriageController.sendMenu(alias, conversaId, empresaId, instanciaId, numero);
@@ -603,11 +632,11 @@ class TriageController {
if (!titulos || titulos.length === 0) {
var msg = 'Nenhum título em aberto encontrado.';
logTriage('Boleto - NENHUM título em aberto');
const maxMsgId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxMsgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId[0]?.ID || 0) + 1, conversaId, msg]);
`, [maxMsgId, conversaId, msg]);
await TriageController.sendEvolution(alias, instanciaId, numero, msg);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = 'root' WHERE CON_CODIGO_ID = ?", [conversaId]);
await TriageController.sendMenu(alias, conversaId, empresaId, instanciaId, numero);
@@ -622,11 +651,11 @@ class TriageController {
});
msgBoleto += '\nResponda com o número desejado ou 0 para voltar.';
const maxMsgId2 = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxMsgId2 = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxMsgId2[0]?.ID || 0) + 1, conversaId, msgBoleto]);
`, [maxMsgId2, conversaId, msgBoleto]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgBoleto);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = 'boleto_lista_" + titularId + "' WHERE CON_CODIGO_ID = ?", [conversaId]);
@@ -650,11 +679,11 @@ class TriageController {
if (titulos.length === 0 || opcao > titulos.length) {
const msg = 'Opção inválida. Tente novamente.';
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxId[0]?.ID || 0) + 1, conversaId, msg]);
`, [maxId, conversaId, msg]);
await TriageController.sendEvolution(alias, instanciaId, numero, msg);
return;
}
@@ -784,7 +813,7 @@ class TriageController {
// Envia PDF via Evolution
var fileName = matricula + ' - ' + fmtDt(t.CAR_DT_VENCIMENTO) + '.pdf';
var mediaPayload = JSON.stringify({
number: numero.replace(/\D/g, ''),
number: _normalizarWhatsApp(numero),
mediatype: 'document',
fileName: fileName,
caption: 'Boleto Sistema',
@@ -836,21 +865,21 @@ class TriageController {
textoBoleto += '\n💳 PIX disponível (cópia e cola)';
}
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxId[0]?.ID || 0) + 1, conversaId, textoBoleto]);
`, [maxId, conversaId, textoBoleto]);
await TriageController.sendEvolution(alias, instanciaId, numero, textoBoleto);
}
// Pergunta se quer continuar
var msgCont = '📄 Boleto enviado!\n\nDeseja continuar com o atendimento?\n\n1 - Sim\n2 - Não, finalizar';
const maxIdCont = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxIdCont = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxIdCont[0]?.ID || 0) + 1, conversaId, msgCont]);
`, [maxIdCont, conversaId, msgCont]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgCont);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_MENU_ESTADO = 'boleto_pos_" + clienteId + "' WHERE CON_CODIGO_ID = ?", [conversaId]);
@@ -887,20 +916,20 @@ class TriageController {
}
var msgFinal = 'Atendimento finalizado. Obrigado pelo contato!';
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxId[0]?.ID || 0) + 1, conversaId, msgFinal]);
`, [maxId, conversaId, msgFinal]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgFinal);
await db.execute(alias, "UPDATE CHATC2_CONVERSAS SET CON_STATUS = 'F', CON_DT_FINAL = CURRENT_TIMESTAMP, CON_MENU_ESTADO = NULL WHERE CON_CODIGO_ID = ?", [conversaId]);
} else {
var msgInv = 'Por favor, responda:\n\n1 - Sim\n2 - Não, finalizar';
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxId[0]?.ID || 0) + 1, conversaId, msgInv]);
`, [maxId, conversaId, msgInv]);
await TriageController.sendEvolution(alias, instanciaId, numero, msgInv);
}
} catch(e) {
@@ -914,11 +943,11 @@ class TriageController {
static async enviarMsgInvalida(alias, conversaId, empresaId, instanciaId, numero) {
try {
const msg = 'Opção inválida. Tente novamente.';
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxId[0]?.ID || 0) + 1, conversaId, msg]);
`, [maxId, conversaId, msg]);
await TriageController.sendEvolution(alias, instanciaId, numero, msg);
// Reenvia o menu atual
@@ -947,11 +976,11 @@ class TriageController {
static async enviarMsgTexto(alias, conversaId, empresaId, instanciaId, numero, texto, menuPaiId, equipeId) {
try {
if (texto) {
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const maxId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'S', ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [(maxId[0]?.ID || 0) + 1, conversaId, texto]);
`, [maxId, conversaId, texto]);
await TriageController.sendEvolution(alias, instanciaId, numero, texto);
}
@@ -1026,47 +1055,8 @@ class TriageController {
const inst = await db.query(alias,
"SELECT * FROM CHATC2_INSTANCIAS WHERE INS_CODIGO_ID = ? AND INS_SITUACAO = 'A'", [instanciaId]);
if (inst.length === 0) return;
const url = ((inst[0].INS_URL || '').trim()).replace(/\/+$/, '');
const apiKey = (inst[0].INS_API_KEY || '').trim();
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();
});
const evolution = require('../services/evolutionService');
await evolution.sendText(inst[0], numero, texto);
} catch(e) {
console.error('[Triagem Evolution] Erro ao enviar:', e.message);
}
+69 -322
View File
@@ -1,331 +1,45 @@
/**
* CAMADA DE BANCO DE DADOS (arquivo único)
* CAMADA DE BANCO DE DADOS (dispatcher)
* ========================================
* Concentra tudo que diz respeito a banco de dados em UM lugar:
*
* 1) CONFIGURAÇÃO aliases, drivers, conexões estáticas e customizadas
* 2) DRIVER FIREBIRD
* 3) DISPATCHER API pública usada por todo o sistema
* API pública usada por todo o sistema. Delega para:
* - config/connections.js aliases, configuração
* - drivers/firebird.js operações Firebird puras
*
* Suporte ao banco Firebird. Cada conexão declara o campo `driver`.
* Conexões adicionadas em runtime (via API /api/databases) são persistidas
* em databases_custom.json (ao lado deste arquivo).
* Todos os controllers importam deste arquivo. A API permanece
* idêntica apenas a implementação foi modularizada.
*/
const path = require('path');
const fs = require('fs');
const connections = require('./config/connections');
const firebird = require('./drivers/firebird');
// ============================================================
// 1) CONFIGURAÇÃO
// 1) DISPATCHER — API pública
// ============================================================
const CUSTOM_DB_PATH = path.resolve(__dirname, 'databases_custom.json');
// Conexões dinâmicas salvas em arquivo JSON
var customDatabases = {};
try {
if (fs.existsSync(CUSTOM_DB_PATH)) {
customDatabases = JSON.parse(fs.readFileSync(CUSTOM_DB_PATH, 'utf8'));
}
} catch (e) {
console.error('[Databases] Erro ao carregar databases_custom.json:', e.message);
}
const DRIVERS = ['firebird'];
const DEFAULT_DRIVER = 'firebird';
// Valores padrão para Firebird
const DRIVER_DEFAULTS = {
firebird: {
host: 'localhost',
port: 3050,
user: 'SYSDBA',
password: 'masterkey',
encoding: 'UTF-8',
lowercase_keys: false,
pageSize: 4096,
wireCrypt: 1, // WIRE_CRYPT_ENABLE
},
};
/**
* Conexões estáticas.
* - `novo_local` Firebird (informe o CAMINHO do arquivo .FDB em `database`)
* Sobrescrevível pelo .env (DB_*).
*/
const databases = {
novo_local: {
driver: 'firebird',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT, 10) || 3050,
// CAMINHO do arquivo .FDB: defina DB_DATABASE no .env (absoluto) ou ajuste
// o path.resolve abaixo. Ex.: path.resolve(__dirname, '../db/NOVO.FDB').
database: process.env.DB_DATABASE || path.resolve(__dirname, '../db/NOVO.FDB'),
user: process.env.DB_USER || 'SYSDBA',
password: process.env.DB_PASSWORD || 'masterkey',
encoding: process.env.DB_ENCODING || 'UTF-8',
lowercase_keys: false,
pageSize: 4096,
wireCrypt: 1, // WIRE_CRYPT_ENABLE
},
};
function salvarCustomDatabases() {
try {
fs.writeFileSync(CUSTOM_DB_PATH, JSON.stringify(customDatabases, null, 2), 'utf8');
return true;
} catch (e) {
console.error('[Databases] Erro ao salvar databases_custom.json:', e.message);
return false;
}
}
/** Normaliza uma config bruta aplicando o driver e seus defaults. */
function normalize(raw) {
const d = DRIVER_DEFAULTS['firebird'];
return {
driver: 'firebird',
host: raw.host || d.host,
port: raw.port || d.port,
database: raw.database,
user: raw.user || d.user,
password: raw.password || d.password,
encoding: raw.encoding || d.encoding,
lowercase_keys: raw.lowercase_keys || d.lowercase_keys,
role: raw.role || null,
pageSize: raw.pageSize || d.pageSize,
wireCrypt: raw.wireCrypt !== undefined ? raw.wireCrypt : d.wireCrypt,
};
}
/** Configuração normalizada (inclui `driver`) de um alias. */
function getConfig(alias) {
if (!alias) throw new Error('Alias do banco não informado.');
const aliasLower = alias.toLowerCase();
const raw = customDatabases[aliasLower] || databases[aliasLower];
if (!raw) {
throw new Error(`Alias "${alias}" não encontrado. Aliases disponíveis: ${listAliases().join(', ')}`);
}
return normalize(raw);
}
/** Lista de aliases disponíveis. */
function listAliases() {
return Object.keys(databases).concat(
Object.keys(customDatabases).filter((a) => !databases[a])
);
}
/** Todos os aliases com suas configs (custom + estáticas). */
function listAllDatabases() {
var result = {};
Object.keys(databases).forEach(function (alias) {
result[alias] = Object.assign({}, databases[alias], { _tipo: 'estatico' });
});
Object.keys(customDatabases).forEach(function (alias) {
result[alias] = Object.assign({}, customDatabases[alias], { _tipo: 'custom' });
});
return result;
}
/** Adiciona/atualiza uma conexão customizada. */
function addDatabase(alias, config) {
if (!alias || !config || !config.database) {
throw new Error('Alias e database são obrigatórios.');
}
var aliasLower = alias.toLowerCase().replace(/[^a-z0-9_]/g, '_');
var stored = { driver: 'firebird' };
['host', 'port', 'database', 'user', 'password',
'encoding', 'lowercase_keys', 'role', 'pageSize', 'wireCrypt']
.forEach(function (k) {
if (config[k] !== undefined && config[k] !== null && config[k] !== '') {
stored[k] = config[k];
}
});
customDatabases[aliasLower] = stored;
return salvarCustomDatabases();
}
/** Remove uma conexão customizada. */
function removeDatabase(alias) {
if (!alias) return false;
var aliasLower = alias.toLowerCase();
if (!customDatabases[aliasLower]) return false;
delete customDatabases[aliasLower];
return salvarCustomDatabases();
}
// ============================================================
// 2) DRIVER FIREBIRD
// ============================================================
const Firebird = require('node-firebird');
function readBlob(blobFunc) {
return new Promise(function (resolve, reject) {
if (typeof blobFunc !== 'function') return resolve(blobFunc);
blobFunc(function (err, name, emitter) {
if (err) return reject(err);
if (!emitter || typeof emitter.on !== 'function') return resolve(null);
var chunks = [];
var total = 0;
emitter.on('data', function (c) { chunks.push(c); total += c.length; });
emitter.on('end', function () { resolve(Buffer.concat(chunks, total)); });
emitter.on('error', reject);
});
});
}
function firebirdQuery(config, sql, params = []) {
return new Promise((resolve, reject) => {
Firebird.attach(config, (err, db) => {
if (err) return reject(new Error(`Erro ao conectar (firebird): ${err.message}`));
var allRows = [];
db.sequentially(sql, params, function (row) {
var keys = Object.keys(row);
var blobPromises = keys.map(function (key) {
var val = row[key];
if (typeof val === 'function') {
return readBlob(val).then(function (data) { row[key] = data; });
}
return Promise.resolve();
});
return Promise.all(blobPromises).then(function () { allRows.push(row); });
}, function (queryErr) {
db.detach();
if (queryErr) return reject(new Error(`Erro na consulta (firebird): ${queryErr.message}`));
resolve(allRows);
});
});
});
}
function firebirdExecute(config, sql, params = []) {
return new Promise((resolve, reject) => {
Firebird.attach(config, (err, db) => {
if (err) return reject(new Error(`Erro ao conectar (firebird): ${err.message}`));
db.transaction(Firebird.ISOLATION_READ_COMMITTED, (transErr, transaction) => {
if (transErr) {
db.detach();
return reject(new Error(`Erro ao iniciar transação (firebird): ${transErr.message}`));
}
transaction.query(sql, params, (queryErr, result) => {
if (queryErr) {
transaction.rollback();
db.detach();
return reject(new Error(`Erro na execução (firebird): ${queryErr.message}`));
}
transaction.commit((commitErr) => {
db.detach();
if (commitErr) return reject(new Error(`Erro ao commitar (firebird): ${commitErr.message}`));
resolve({ affectedRows: result ? result.length : 0, result });
});
});
});
});
});
}
async function firebirdTestConnection(config) {
await firebirdQuery(config, 'SELECT 1 FROM RDB$DATABASE');
return true;
}
async function firebirdListTables(config) {
const rows = await firebirdQuery(config, `
SELECT TRIM(RDB$RELATION_NAME) AS TABLE_NAME
FROM RDB$RELATIONS
WHERE RDB$SYSTEM_FLAG = 0 AND RDB$RELATION_TYPE = 0
ORDER BY RDB$RELATION_NAME
`);
return rows.map((r) => (r.TABLE_NAME || '').trim()).filter(Boolean);
}
async function firebirdTableInfo(config, tableName) {
const rows = await firebirdQuery(config, `
SELECT
rf.RDB$FIELD_NAME AS COLUMN_NAME,
rf.RDB$FIELD_POSITION AS ORDINAL_POSITION,
CASE f.RDB$FIELD_TYPE
WHEN 7 THEN 'SMALLINT' WHEN 8 THEN 'INTEGER' WHEN 16 THEN 'BIGINT'
WHEN 9 THEN 'QUAD' WHEN 10 THEN 'FLOAT' WHEN 27 THEN 'DOUBLE PRECISION'
WHEN 12 THEN 'DATE' WHEN 13 THEN 'TIME' WHEN 35 THEN 'TIMESTAMP'
WHEN 37 THEN 'VARCHAR' WHEN 40 THEN 'CSTRING' WHEN 45 THEN 'BLOB_ID'
WHEN 261 THEN 'BLOB' WHEN 14 THEN 'CHAR' WHEN 41 THEN 'NUMERIC'
ELSE 'UNKNOWN'
END AS DATA_TYPE,
f.RDB$FIELD_LENGTH AS FIELD_LENGTH,
f.RDB$FIELD_SCALE AS FIELD_SCALE,
f.RDB$FIELD_PRECISION AS FIELD_PRECISION,
rf.RDB$NULL_FLAG AS NULL_FLAG
FROM RDB$RELATION_FIELDS rf
INNER JOIN RDB$FIELDS f ON rf.RDB$FIELD_SOURCE = f.RDB$FIELD_NAME
WHERE rf.RDB$RELATION_NAME = ?
ORDER BY rf.RDB$FIELD_POSITION
`, [String(tableName).toUpperCase()]);
return rows.map((row) => ({
name: (row.COLUMN_NAME || '').trim(),
position: row.ORDINAL_POSITION,
type: (row.DATA_TYPE || '').trim(),
length: row.FIELD_LENGTH,
precision: row.FIELD_PRECISION,
scale: row.FIELD_SCALE,
nullable: row.NULL_FLAG !== 1,
}));
}
async function firebirdTableExists(config, tableName) {
const r = await firebirdQuery(config,
"SELECT COUNT(*) AS CT FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = ?",
[String(tableName).toUpperCase()]);
return (r[0] && r[0].CT > 0) || false;
}
async function firebirdColumnExists(config, tableName, columnName) {
const r = await firebirdQuery(config,
"SELECT COUNT(*) AS CT FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = ? AND RDB$FIELD_NAME = ?",
[String(tableName).toUpperCase(), String(columnName).toUpperCase()]);
return (r[0] && r[0].CT > 0) || false;
}
async function firebirdClose() { /* Firebird abre conexão por consulta; nada a encerrar */ }
// ============================================================
// 3) DISPATCHER — API pública
// ============================================================
const driver = {
query: firebirdQuery,
execute: firebirdExecute,
testConnection: firebirdTestConnection,
listTables: firebirdListTables,
tableInfo: firebirdTableInfo,
tableExists: firebirdTableExists,
columnExists: firebirdColumnExists,
close: firebirdClose,
};
function getConn(alias) {
const config = getConfig(alias);
return { config, driver };
const config = connections.getConfig(alias);
return { config, driver: firebird };
}
function query(alias, sql, params = []) {
let d;
try { d = getConn(alias); } catch (err) { return Promise.reject(err); }
return d.driver.query(d.config, sql, params).catch((err) => { throw new Error(`[${alias}] ${err.message}`); });
return d.driver.query(d.config, sql, params)
.catch((err) => { throw new Error(`[${alias}] ${err.message}`); });
}
function execute(alias, sql, params = []) {
let d;
try { d = getConn(alias); } catch (err) { return Promise.reject(err); }
return d.driver.execute(d.config, sql, params).catch((err) => { throw new Error(`[${alias}] ${err.message}`); });
return d.driver.execute(d.config, sql, params)
.catch((err) => { throw new Error(`[${alias}] ${err.message}`); });
}
async function testConnection(alias) {
try {
const { config } = getConn(alias);
await driver.testConnection(config);
await firebird.testConnection(config);
return true;
} catch (err) {
console.error(`[${alias}] Falha na conexão:`, err.message);
@@ -335,7 +49,7 @@ async function testConnection(alias) {
async function testAllConnections() {
const results = {};
for (const alias of listAliases()) {
for (const alias of connections.listAliases()) {
results[alias] = await testConnection(alias);
}
return results;
@@ -343,36 +57,65 @@ async function testAllConnections() {
function listTables(alias) {
const { config } = getConn(alias);
return driver.listTables(config);
return firebird.listTables(config);
}
function tableInfo(alias, tableName) {
const { config } = getConn(alias);
return driver.tableInfo(config, tableName);
return firebird.tableInfo(config, tableName);
}
function tableExists(alias, tableName) {
const { config } = getConn(alias);
return driver.tableExists(config, tableName);
return firebird.tableExists(config, tableName);
}
function columnExists(alias, tableName, columnName) {
const { config } = getConn(alias);
return driver.columnExists(config, tableName, columnName);
return firebird.columnExists(config, tableName, columnName);
}
/** Nome do driver de um alias (sempre 'firebird'). */
function driverOf(alias) {
return 'firebird';
return connections.driverOf(alias);
}
/** Encerra recursos (noop para Firebird). */
async function closeAll() {
await driver.close();
await firebird.close();
}
// ============================================================
// 2) GERADORES DE ID (GEN_ID) — thread-safe
// ============================================================
const GENERATORS = [
'GEN_CHATC2_CONVERSAS',
'GEN_CONVERSAS_MENSAGENS',
'GEN_MENSAGENS_ATENDIMENTOS',
'GEN_CHATC2_EQUIPES',
'GEN_CHATC2_ETIQUETAS',
'GEN_CHATC2_INSTANCIAS',
'GEN_CHATC2_MENUS_EMPRESA',
'GEN_CHATC2_CSAT_AVALIACOES',
'GEN_CHATC2_MOTIVOS_ATENDIMENTO',
'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 = {
// Acesso a dados
// Operações de dados
query,
execute,
testConnection,
@@ -383,13 +126,17 @@ module.exports = {
columnExists,
driverOf,
closeAll,
// Configuração / aliases
databases,
DRIVERS,
DEFAULT_DRIVER,
getConfig,
listAliases,
listAllDatabases,
addDatabase,
removeDatabase,
nextId,
GENERATORS,
poolStats: firebird.poolStats,
// Configuração / aliases (delegados)
databases: connections.databases,
customDatabases: connections.customDatabases,
DRIVERS: ['firebird'],
DEFAULT_DRIVER: 'firebird',
getConfig: connections.getConfig,
listAliases: connections.listAliases,
listAllDatabases: connections.listAllDatabases,
addDatabase: connections.addDatabase,
removeDatabase: connections.removeDatabase,
};
+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,
};
+63 -21
View File
@@ -6,30 +6,28 @@
<title>Conversas - Chatc2</title>
<link rel="stylesheet" href="/css/main.css">
<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; }
.main { flex:1; display:flex; flex-direction:column; min-width:0; }
.container { flex:1; padding:24px; overflow-y:auto; }
.filters { display:flex; gap:10px; margin-bottom:20px; flex-wrap:wrap; align-items:center; }
.filters select, .filters input { padding:9px 14px; border:2px solid #e5e7eb; border-radius:8px; font-size:13px; outline:none; background:#fff; color:#374151; transition:border-color .15s; }
.filters select:focus, .filters input:focus { border-color:#667eea; }
.filters button { padding:9px 16px; background:#667eea; color:#fff; border:none; border-radius:8px; font-size:13px; font-weight:600; cursor:pointer; transition:background .15s; }
.filters button:hover { background:#5a67d8; }
.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:var(--primary); }
.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:var(--primary-dark); }
.status-badge { display:inline-block; padding:3px 10px; border-radius:10px; font-size:11px; font-weight:600; }
.status-E { background:#fef3c7; color:#92400e; }
.status-A { background:#d1fae5; color:#065f46; }
.status-F { background:#f3f4f6; color:#6b7280; }
.atender-btn { padding:5px 12px; border:none; border-radius:6px; background:#667eea; color:#fff; font-size:12px; font-weight:600; cursor:pointer; text-decoration:none; transition:background .15s; display:inline-block; }
.atender-btn:hover { background:#5a67d8; }
.loading { text-align:center; padding:40px; color:#9ca3af; }
.status-E { background:var(--warning-bg); color:var(--warning-text); }
.status-A { background:var(--success-bg); color:var(--success-text); }
.status-F { background:var(--surface-3); color:var(--text-muted); }
.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:var(--primary-dark); }
.loading { text-align:center; padding:40px; color:var(--text-faint); }
</style>
<link rel="stylesheet" href="/css/dark-mode.css">
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?' Claro':'🌙 Escuro'});}
window.darkModeApply=function(a){var e=document.body;if(!e)return;e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});};
window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')==='true';};</script>
<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>
</head>
<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>
<nav class="sidebar-nav">
<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>
</nav>
<div class="sidebar-footer">
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button>
<a onclick="logout()"><span class="icon">🚪</span> Sair</a></div>
<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()" aria-label="Sair do sistema"><span class="icon">🚪</span> Sair</a></div>
</aside>
<div class="main">
<main class="main" id="main-content">
<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>
</div>
<div class="container">
<div class="filters">
<label for="filterStatus" class="sr-only">Status</label>
<select id="filterStatus" onchange="carregar()">
<option value="A,E">Em aberto</option>
<option value="E">Em espera</option>
<option value="A">Em atendimento</option>
<option value="F">Finalizadas</option>
<option value="A,E,F">Todas</option>
<option value="scheduled">📅 Agendadas</option>
</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()">
<option value="">Todas</option>
<option value="me">Minhas conversas</option>
<option value="sem">Sem atendente</option>
</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()">
<button onclick="carregar()">🔄 Atualizar</button>
<button onclick="carregar()" aria-label="Atualizar lista">🔄 Atualizar</button>
</div>
<div class="card" id="tabelaContainer">
<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>';
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;
let busca = document.getElementById('buscaContato').value.trim();
@@ -151,7 +187,7 @@ window.carregar = async function() {
convs.forEach(function(c) {
const statusLabel = c.status === 'E' ? '🟡 Espera' : c.status === 'A' ? '🟢 Atendimento' : '⚫ Finalizado';
const labelsHtml = (c.labels || []).map(function(l) {
return '<span style="display:inline-block;padding:0 6px;border-radius:3px;background:'+(l.cor||'#667eea')+';color:#fff;font-size:10px;margin:1px">'+(l.nome||'')+'</span>';
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('');
const ultima = (c.ultimaMsg || '').substring(0, 60);
const usuarioNome = c.usuarioId ? 'Usuário ' + c.usuarioId : '-';
@@ -178,5 +214,11 @@ setInterval(carregar, 10000);
})();
</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>
+829 -126
View File
File diff suppressed because it is too large Load Diff
+184 -87
View File
@@ -6,46 +6,97 @@
<title>Detalhes do Cliente - Chatc2</title>
<link rel="stylesheet" href="/css/main.css">
<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; }
.main { flex:1; display:flex; flex-direction:column; min-width:0; }
.container { flex:1; padding:24px; overflow-y:auto; }
.loading { text-align:center; padding:80px 20px; color:#9ca3af; }
.error-box { background:#fef2f2; border:1px solid #fecaca; border-radius:12px; padding:32px; text-align:center; color:#991b1b; }
.loading { text-align:center; padding:80px 20px; color:var(--text-faint); }
.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; }
.client-header { background:#fff; border-radius:14px; padding:24px; box-shadow:0 1px 4px rgba(0,0,0,0.08); margin-bottom:20px; display:flex; align-items:center; gap:20px; border:1px solid #f3f4f6; }
.client-avatar { width:68px; height:68px; border-radius:50%; background:linear-gradient(135deg,#667eea,#764ba2); display:flex; align-items:center; justify-content:center; color:#fff; font-size:26px; font-weight:800; flex-shrink:0; box-shadow:0 4px 14px rgba(102,126,234,0.3); }
.client-header-info h1 { font-size:22px; font-weight:800; color:#111827; margin-bottom:4px; letter-spacing:-0.3px; }
.client-header-info .matricula { font-size:14px; color:#6b7280; }
.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,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:var(--text-primary); margin-bottom:4px; letter-spacing:-0.3px; }
.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; }
.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:last-child { margin-bottom:0; }
.field .label { font-size:11px; text-transform:uppercase; letter-spacing:0.06em; color:#9ca3af; margin-bottom:3px; font-weight:600; }
.field .value { font-size:15px; color:#111827; font-weight:500; }
.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:var(--text-primary); font-weight:500; }
.carnes-section { margin-top:20px; }
.carnes-filters { display:flex; gap:10px; margin-bottom:16px; flex-wrap:wrap; align-items:center; }
.carnes-filters label { display:flex; align-items:center; gap:6px; font-size:13px; color:#374151; cursor:pointer; padding:7px 14px; border-radius:8px; border:1px solid #e5e7eb; background:#fff; transition:all 0.15s; user-select:none; font-weight:500; }
.carnes-filters label:hover { border-color:#667eea; color:#667eea; }
.carnes-filters label.filtro-ativo { border-color:#667eea; background:#eef2ff; color:#667eea; font-weight:600; }
.carnes-filters label input { accent-color:#667eea; }
.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:var(--primary); color:var(--primary); }
.carnes-filters label.filtro-ativo { border-color:var(--primary); background:#eef2ff; color:var(--primary); font-weight:600; }
.carnes-filters label input { accent-color:var(--primary); }
table { font-size:13px; }
.valor { text-align:right; font-family:'SF Mono',monospace; }
.centro { text-align:center; }
.sit-badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:600; }
.vencido-dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:#ef4444; margin-right:4px; }
.back-btn { margin-right:14px; padding:7px 14px; background:#f3f4f6; border:1px solid #e5e7eb; border-radius:8px; font-size:13px; font-weight:500; cursor:pointer; color:#374151; text-decoration:none; transition:all .15s; display:inline-flex; align-items:center; gap:4px; }
.back-btn:hover { background:#e5e7eb; border-color:#d1d5db; }
.empty-msg { text-align:center; padding:32px; color:#9ca3af; font-size:14px; }
.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: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:var(--border); border-color:#d1d5db; }
.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>
<link rel="stylesheet" href="/css/dark-mode.css">
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?' Claro':'🌙 Escuro'});}
window.darkModeApply=function(a){var e=document.body;if(!e)return;e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});};
window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')==='true';};</script>
<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>
</head>
<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>
@@ -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>
</nav>
<div class="sidebar-footer">
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button>
<a onclick="logout()"><span class="icon">🚪</span> Sair</a>
<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()" aria-label="Sair do sistema"><span class="icon">🚪</span> Sair</a>
</div>
</aside>
<div class="main">
<main class="main" id="main-content">
<div class="topbar">
<a href="#" class="back-btn" id="backBtn">← Voltar</a>
<span class="topbar-title">Detalhes do Cliente</span>
<a href="#" class="back-btn" id="btnIniciarConv" style="font-size:13px;margin-left:auto" title="Iniciar conversa WhatsApp">💬 Iniciar Conversa</a>
<span class="topbar-title">Detalhes do Cliente</span>
<a href="#" class="btn-acao-topbar" id="btnIniciarConv" style="margin-left:auto">💬 Iniciar Conversa</a>
</div>
<div class="container" id="container">
<div class="loading" id="loadingState">
@@ -80,18 +131,18 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
</div>
<!-- Modal Nova Conversa -->
<div class="modal-overlay" id="modalNovaConvDet" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;z-index:1000">
<div style="background:#fff;border-radius:12px;padding:24px;width:90%;max-width:450px">
<h3 style="margin-bottom:16px">💬 Iniciar Conversa</h3>
<div class="modal-overlay" id="modalNovaConvDet">
<div class="modal" style="max-width:450px">
<h3>💬 Iniciar Conversa</h3>
<input type="hidden" id="convClienteIdDet">
<input type="hidden" id="convEmpresaIdDet">
<div class="form-group"><label>Número</label><input type="text" id="convNumeroDet" readonly style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:14px;background:#f9fafb;margin-bottom:12px"></div>
<div class="form-group"><label>Cliente</label><input type="text" id="convNomeDet" readonly style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:14px;background:#f9fafb;margin-bottom:12px"></div>
<div class="form-group"><label>Instância WhatsApp</label><select id="convInstanciaDet" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:14px;margin-bottom:12px"></select></div>
<div class="form-group"><label>Mensagem inicial</label><textarea id="convMensagemDet" rows="3" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:14px;resize:vertical;margin-bottom:12px" placeholder="Digite a mensagem que será enviada..."></textarea></div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button onclick="document.getElementById('modalNovaConvDet').style.display='none'" style="padding:8px 16px;border:1px solid #d1d5db;border-radius:8px;background:#fff;cursor:pointer">Cancelar</button>
<button onclick="iniciarConversaDet()" style="padding:8px 16px;background:#667eea;color:#fff;border:none;border-radius:8px;cursor:pointer">💬 Enviar e Abrir Chat</button>
<div class="form-group"><label for="convNumeroDet">Número</label><input type="text" id="convNumeroDet" readonly></div>
<div class="form-group"><label for="convNomeDet">Cliente</label><input type="text" id="convNomeDet" readonly></div>
<div class="form-group"><label for="convInstanciaDet">Instância WhatsApp</label><select id="convInstanciaDet"></select></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 class="modal-footer">
<button class="btn-secondary" onclick="document.getElementById('modalNovaConvDet').classList.remove('show')">Cancelar</button>
<button class="btn-primary" onclick="iniciarConversaDet()" id="btnEnviarConv">💬 Enviar e Abrir Chat</button>
</div>
</div>
</div>
@@ -227,17 +278,17 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
'</div>' +
'<div class="grid">' +
'<div class="card"><h3>📞 Contato <button onclick="editarContato()" style="float:right;padding:2px 8px;border:1px solid #d1d5db;border-radius:4px;background:#fff;cursor:pointer;font-size:11px">✏️ Editar</button></h3>' +
'<div class="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">Celular</div><div class="value" id="campoCelular">' + (c.celular && c.celular.formatado ? c.celular.formatado : c.celular && c.celular.original ? c.celular.original : '-') + '</div></div>' +
'<div class="field"><div class="label">Telefone</div><div class="value" id="campoTelefone">' + (c.telefone || '-') + '</div></div>' +
'<div id="editContatoForm" style="display:none;margin-top:12px;padding:12px;background:#f9fafb;border-radius:8px">' +
'<input type="email" id="editEmail" placeholder="Email" style="width:100%;padding:8px;border:1px solid #e5e7eb;border-radius:6px;font-size:13px;margin-bottom:8px">' +
'<input type="text" id="editCelular" placeholder="Celular" style="width:100%;padding:8px;border:1px solid #e5e7eb;border-radius:6px;font-size:13px;margin-bottom:8px">' +
'<input type="text" id="editTelefone" placeholder="Telefone" style="width:100%;padding:8px;border:1px solid #e5e7eb;border-radius:6px;font-size:13px;margin-bottom:8px">' +
'<div 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 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 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 var(--border);border-radius:6px;font-size:13px;margin-bottom:8px">' +
'<div style="display:flex;gap:8px">' +
'<button onclick="salvarContato()" style="padding:6px 14px;background:#667eea;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:12px">💾 Salvar</button>' +
'<button onclick="cancelarEditarContato()" style="padding:6px 14px;border:1px solid #d1d5db;border-radius:6px;background:#fff;cursor:pointer;font-size:12px">Cancelar</button>' +
'<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 var(--border);border-radius:6px;background:#fff;cursor:pointer;font-size:12px">Cancelar</button>' +
'</div>' +
'</div>' +
'</div>' +
@@ -258,11 +309,11 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
'</div>' +
// ABAS: Conversas | Títulos | Dependentes | Convalescentes
'<div style="display:flex;gap:0;margin-bottom:16px;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.08)">' +
'<button class="aba-ativa" onclick="mudarAba(\'conversas\')" id="abaConversas" style="flex:1;padding:10px;border:none;background:#667eea;color:#fff;font-size:13px;font-weight:600;cursor:pointer">💬 Conversas</button>' +
'<button onclick="mudarAba(\'titulos\')" id="abaTitulos" style="flex:1;padding:10px;border:none;background:#f3f4f6;color:#374151;font-size:13px;cursor:pointer">📄 Títulos</button>' +
'<button onclick="mudarAba(\'dependentes\')" id="abaDependentes" style="flex:1;padding:10px;border:none;background:#f3f4f6;color:#374151;font-size:13px;cursor:pointer">👥 Dependentes</button>' +
'<button onclick="mudarAba(\'convalescentes\')" id="abaConvalescentes" style="flex:1;padding:10px;border:none;background:#f3f4f6;color:#374151;font-size:13px;cursor:pointer">🛏️ Convalescentes</button>' +
'<div 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-btn ativo" onclick="mudarAba(\'conversas\')" id="abaConversas">💬 Conversas</button>' +
'<button class="aba-btn inativo" onclick="mudarAba(\'titulos\')" id="abaTitulos">📄 Títulos</button>' +
'<button class="aba-btn inativo" onclick="mudarAba(\'dependentes\')" id="abaDependentes">👥 Dependentes</button>' +
'<button class="aba-btn inativo" onclick="mudarAba(\'convalescentes\')" id="abaConvalescentes">🛏️ Convalescentes</button>' +
'</div>' +
'<div id="conteudoAba">' +
// CONVERSAS (padrão - primeira aba)
@@ -310,25 +361,24 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
var cC = document.getElementById('conteudoConvalescentes');
var btns = [btnV, btnT, btnD, btnC];
var contents = [cV, cT, cD, cC];
btns.forEach(function(b) { if (b) { b.style.background = '#f3f4f6'; b.style.color = '#374151'; b.style.fontWeight = '400'; } });
btns.forEach(function(b) { if (b) { b.className = 'aba-btn inativo'; } });
contents.forEach(function(c) { if (c) c.style.display = 'none'; });
var activeBtn, activeContent;
if (aba === 'conversas') {
btnV.style.background = '#667eea'; btnV.style.color = '#fff'; btnV.style.fontWeight = '600';
cV.style.display = 'block';
activeBtn = btnV; activeContent = cV;
carregarConversas();
} else if (aba === 'titulos') {
btnT.style.background = '#667eea'; btnT.style.color = '#fff'; btnT.style.fontWeight = '600';
cT.style.display = 'block';
activeBtn = btnT; activeContent = cT;
loadCarnes(1);
} else if (aba === 'dependentes') {
btnD.style.background = '#667eea'; btnD.style.color = '#fff'; btnD.style.fontWeight = '600';
cD.style.display = 'block';
activeBtn = btnD; activeContent = cD;
carregarDependentes();
} else if (aba === 'convalescentes') {
btnC.style.background = '#667eea'; btnC.style.color = '#fff'; btnC.style.fontWeight = '600';
cC.style.display = 'block';
activeBtn = btnC; activeContent = cC;
carregarConvalescentes();
}
if (activeBtn) activeBtn.className = 'aba-btn ativo';
if (activeContent) activeContent.style.display = 'block';
};
async function carregarConversas() {
@@ -360,7 +410,7 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
'<td style="font-size:12px">' + statusLabel + '</td>' +
'<td style="font-size:12px">' + usuario + '</td>' +
'<td style="font-size:12px">' + equipe + '</td>' +
'<td><span style="color:#667eea;font-size:12px">Abrir →</span></td>' +
'<td><span style="color:var(--primary);font-size:12px">Abrir →</span></td>' +
'</tr>';
});
html += '</tbody></table></div>';
@@ -389,7 +439,7 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
var telEdit = d.telefone || '';
html += '<tr><td><strong>' + d.nome + '</strong></td><td>' + getParentescoLabel(d.parentesco) + '</td><td>' +
'<span id="depTel_' + d.id + '">' + (telEdit || '-') + '</span> ' +
'<button onclick="editarTelDep(' + d.id + ')" data-tel="' + telEdit.replace(/"/g,'&quot;') + '" style="padding:1px 6px;border:1px solid #d1d5db;border-radius:4px;background:#fff;cursor:pointer;font-size:10px">✏️</button></td><td>' + adicional + '</td><td style="text-align:right;font-family:monospace">R$ ' + (d.valorContribuicao / 100).toFixed(2) + '</td><td>' + sit + '</td></tr>';
'<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>';
area.innerHTML = html;
@@ -413,34 +463,34 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
var html = '';
data.data.forEach(function(c) {
var sitLabel = c.situacao || '-';
var sitBadge = c.situacao === 'D' ? '<span class="sit-badge" style="background:#d1fae5;color:#065f46">Devolvido</span>' :
c.situacao === 'E' ? '<span class="sit-badge" style="background:#fef3c7;color:#92400e">Entregue</span>' :
c.situacao === 'A' ? '<span class="sit-badge" style="background:#dbeafe;color:#1e40af">Ativo</span>' :
c.situacao === 'C' ? '<span class="sit-badge" style="background:#fef2f2;color:#991b1b">Cancelado</span>' :
'<span class="sit-badge" style="background:#f3f4f6;color:#6b7280">' + c.situacao + '</span>';
html += '<div style="border:1px solid #e5e7eb;border-radius:8px;padding:16px;margin-bottom:12px">' +
var sitBadge = c.situacao === 'D' ? '<span class="conv-badge devolvido">Devolvido</span>' :
c.situacao === 'E' ? '<span class="conv-badge entregue">Entregue</span>' :
c.situacao === 'A' ? '<span class="conv-badge ativo">Ativo</span>' :
c.situacao === 'C' ? '<span class="conv-badge cancelado">Cancelado</span>' :
'<span class="conv-badge" style="background:var(--surface-3);color:var(--text-muted)">' + c.situacao + '</span>';
html += '<div class="conv-card">' +
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">' +
'<h4 style="margin:0;font-size:15px">🛏️ Convalescente #' + c.id + '</h4>' +
sitBadge +
'</div>' +
'<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;font-size:13px;margin-bottom:12px">' +
'<div><span style="color:#9ca3af">Valor Total:</span> <strong>R$ ' + (c.valorTotal || 0).toFixed(2) + '</strong></div>' +
'<div><span style="color:#9ca3af">Data Saída:</span> ' + fmtDate(c.dtSaida) + '</div>' +
'<div><span style="color:#9ca3af">Previsão Retorno:</span> ' + fmtDate(c.dtPrevisaoRetorno) + '</div>' +
(c.dtCancelou ? '<div><span style="color:#9ca3af">Data Cancelamento:</span> ' + fmtDate(c.dtCancelou) + '</div>' : '') +
'<div><span style="color:#9ca3af">Cadastrado por:</span> ' + (c.usuarioNome || '-') + '</div>' +
'<div><span style="color:var(--text-faint)">Valor Total:</span> <strong>R$ ' + (c.valorTotal || 0).toFixed(2) + '</strong></div>' +
'<div><span style="color:var(--text-faint)">Data Saída:</span> ' + fmtDate(c.dtSaida) + '</div>' +
'<div><span style="color:var(--text-faint)">Previsão Retorno:</span> ' + fmtDate(c.dtPrevisaoRetorno) + '</div>' +
(c.dtCancelou ? '<div><span style="color:var(--text-faint)">Data Cancelamento:</span> ' + fmtDate(c.dtCancelou) + '</div>' : '') +
'<div><span style="color:var(--text-faint)">Cadastrado por:</span> ' + (c.usuarioNome || '-') + '</div>' +
'</div>';
// SUB-ABAS: Boletos | Itens
var temCarnes = c.carnes && c.carnes.length > 0;
var temItens = c.itens && c.itens.length > 0;
if (temCarnes || temItens) {
html += '<div style="margin-top:12px;padding-top:12px;border-top:1px solid #e5e7eb">' +
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">';
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) {
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>';
// Conteudo Boletos
@@ -489,8 +539,8 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
window.mudarSubaba = function(id, btn) {
var container = btn.parentElement.parentElement;
var botoes = container.querySelectorAll('button[id^="sub"]');
botoes.forEach(function(b) { b.style.background = '#f3f4f6'; b.style.color = '#374151'; });
btn.style.background = '#667eea'; btn.style.color = '#fff';
botoes.forEach(function(b) { b.className = 'subaba-btn inativo'; });
btn.className = 'subaba-btn ativo';
var prefixo = id.split('_')[0];
var covId = id.split('_')[1];
var cCarnes = document.getElementById('conteudoCarnes_' + covId);
@@ -511,7 +561,7 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
span.innerHTML =
'<input type="text" id="depTelInput_' + id + '" value="' + telAtual.replace(/"/g, '&quot;') + '" style="width:120px;padding:2px 6px;border:1px solid #667eea;border-radius:4px;font-size:12px;outline:none">' +
' <button onclick="confirmarTelDep(' + id + ')" title="Confirmar" style="padding:1px 7px;border:1px solid #059669;border-radius:4px;background:#059669;color:#fff;cursor:pointer;font-size:11px"></button>' +
' <button onclick="cancelarTelDep(' + id + ')" title="Cancelar" style="padding:1px 7px;border:1px solid #d1d5db;border-radius:4px;background:#fff;cursor:pointer;font-size:11px"></button>';
' <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();
@@ -587,6 +637,28 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
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
window.loadCarnes = function(page) {
if (page) carnesPage = page;
@@ -638,7 +710,7 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
for (var i = 0; i < data.data.length; i++) {
var c = data.data[i];
var bg = c.situacao.color || '#f3f4f6';
var tc = c.situacao.textColor || '#6b7280';
var tc = c.situacao.textColor || 'var(--text-muted)';
var vencDot = c.vencido ? '<span class="vencido-dot"></span>' : '';
html += '<tr>' +
@@ -647,7 +719,9 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
'<td>' + vencDot + fmtDate(c.vencimento) + '</td>' +
'<td class="valor">' + fmtMoney(c.valorParcela) + '</td>' +
'<td>' + fmtDate(c.dataPagamento) + '</td>' +
'<td style="font-size:11px">' + fmtDate(c.agendamentoCobranca) + '</td>' +
'<td 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 class="centro">' + (c.parcela || '-') + '/' + (c.totalParcelas || '-') + '</td>' +
'</tr>';
@@ -705,7 +779,10 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
if (celular) data.celular = celular;
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 {
var r = await fetch('/api/' + alias + '/clients/' + clienteId, {
@@ -715,15 +792,29 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
});
var d = await r.json();
if (d.success) {
alert('Dados atualizados!');
mostrarFeedback('Dados atualizados com sucesso!', 'sucesso');
document.getElementById('editContatoForm').style.display = 'none';
loadClient(); // Recarrega os dados
loadClient();
} 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 =====
window.abrirModalConversa = async function(empresaId, clienteId, nome, celular) {
document.getElementById('convEmpresaIdDet').value = empresaId;
@@ -750,7 +841,7 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
sel.innerHTML = '<option value="">Erro ao carregar</option>';
}
document.getElementById('modalNovaConvDet').style.display = 'flex';
document.getElementById('modalNovaConvDet').classList.add('show');
};
window.iniciarConversaDet = async function() {
@@ -772,7 +863,7 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
});
var d = await r.json();
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;
} else {
alert('Erro: ' + d.error);
@@ -781,7 +872,7 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
};
// 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 ----
loadClient();
@@ -789,5 +880,11 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
})();
</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>
+54 -33
View File
@@ -6,18 +6,16 @@
<title>Clientes - Chatc2</title>
<link rel="stylesheet" href="/css/main.css">
<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; }
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.container { flex: 1; padding: 24px; overflow-y: auto; }
</style>
<link rel="stylesheet" href="/css/dark-mode.css">
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?' Claro':'🌙 Escuro'});}
window.darkModeApply=function(a){var e=document.body;if(!e)return;e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});};
window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')==='true';};</script>
<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>
</head>
<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">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>
</nav>
<div class="sidebar-footer">
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button>
<a onclick="logout()"><span class="icon">🚪</span> Sair</a>
<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()" aria-label="Sair do sistema"><span class="icon">🚪</span> Sair</a>
</div>
</aside>
<div class="main">
<main class="main" id="main-content">
<div class="topbar">
<span class="topbar-title">👥 Clientes</span>
<div class="user-info">
@@ -47,12 +45,12 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
</div>
<div class="container">
<div class="search-bar">
<div class="search-bar" role="search">
<select id="empresaSelect">
<option value="">Todas as empresas</option>
</select>
<input type="text" id="searchInput" placeholder="Buscar por nome, matrícula ou CPF..." autofocus />
<button id="btnSearch">🔍 Buscar</button>
<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" aria-label="Buscar">🔍 Buscar</button>
<span class="total-info" id="totalInfo"></span>
</div>
@@ -249,28 +247,35 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
// === INICIAR ===
loadEmpresas().then(() => fetchClients(1, '', ''));
</script>
<script src="/js/dark-mode.js"></script>
</body>
</html>
<!-- Modal Nova Conversa -->
<div class="modal-overlay" id="modalNovaConv" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;z-index:1000">
<div style="background:#fff;border-radius:12px;padding:24px;width:90%;max-width:450px">
<h3 style="margin-bottom:16px">💬 Iniciar Conversa</h3>
<div class="modal-overlay" id="modalNovaConv">
<div class="modal" style="max-width:450px">
<h3>💬 Iniciar Conversa</h3>
<input type="hidden" id="convClienteId">
<input type="hidden" id="convEmpresaId">
<div class="form-group"><label>Número</label><input type="text" id="convNumero" readonly style="background:#f9fafb"></div>
<div class="form-group"><label>Cliente</label><input type="text" id="convNome" readonly style="background:#f9fafb"></div>
<div class="form-group"><label>Instância WhatsApp</label><select id="convInstancia" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:14px"></select></div>
<div class="form-group"><label>Mensagem inicial</label><textarea id="convMensagem" rows="3" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:14px;resize:vertical" placeholder="Digite a mensagem que será enviada..."></textarea></div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn" onclick="fecharModalConv()" style="padding:8px 16px;border:1px solid #d1d5db;border-radius:8px;background:#fff;cursor:pointer">Cancelar</button>
<button class="btn btn-primary" onclick="iniciarConversa()" style="padding:8px 16px;background:#667eea;color:#fff;border:none;border-radius:8px;cursor:pointer">💬 Enviar e Abrir Chat</button>
<div class="form-group"><label for="convNumero">Número</label><input type="text" id="convNumero" readonly></div>
<div class="form-group"><label for="convNome">Cliente</label><input type="text" id="convNome" readonly></div>
<div class="form-group"><label for="convInstancia">Instância WhatsApp</label><select id="convInstancia"></select></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 id="convFeedback" class="feedback-msg"></div>
<div class="modal-footer">
<button class="btn-secondary" onclick="fecharModalConv()">Cancelar</button>
<button class="btn-primary" onclick="iniciarConversa()">💬 Enviar e Abrir Chat</button>
</div>
</div>
</div>
<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 =====
var modalConvEl = document.getElementById('modalNovaConv');
@@ -280,8 +285,8 @@ window.abrirModalConversa = async function(empresaId, clienteId, nome, celular)
document.getElementById('convNumero').value = celular || '';
document.getElementById('convNome').value = nome || '';
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');
sel.innerHTML = '<option value="">Carregando...</option>';
try {
@@ -300,11 +305,11 @@ window.abrirModalConversa = async function(empresaId, clienteId, nome, celular)
sel.innerHTML = '<option value="">Erro ao carregar</option>';
}
modalConvEl.style.display = 'flex';
modalConvEl.classList.add('show');
};
window.fecharModalConv = function() {
modalConvEl.style.display = 'none';
modalConvEl.classList.remove('show');
};
window.iniciarConversa = async function() {
@@ -316,11 +321,10 @@ window.iniciarConversa = async function() {
var clienteId = document.getElementById('convClienteId').value;
if (!numero || !mensagem) {
alert('Informe o número e a mensagem');
mostrarConvFeedback('Informe o número e a mensagem', 'erro');
return;
}
// Adicionar código do país se necessário
var numCompleto = numero.startsWith('55') ? numero : '55' + numero;
try {
@@ -334,13 +338,30 @@ window.iniciarConversa = async function() {
fecharModalConv();
window.location.href = '/app/' + alias + '/company/' + empresaId + '/conversation/' + d.data.id;
} else {
alert('Erro: ' + d.error);
mostrarConvFeedback('Erro: ' + d.error, 'erro');
}
} catch(e) {
alert('Erro de conexão: ' + e.message);
mostrarConvFeedback('Erro de conexão: ' + e.message, 'erro');
}
};
// Fechar modal ao clicar fora
modalConvEl.addEventListener('click', function(e) { if (e.target === this) this.style.display = 'none'; });
function mostrarConvFeedback(msg, tipo) {
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>
+192 -80
View File
@@ -3,102 +3,187 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Avalie seu Atendimento</title>
<title>Avalie seu Atendimento - Chatc2</title>
<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; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
-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;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
color: var(--text-primary);
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.container {
background: #fff;
background: var(--surface);
border-radius: 20px;
padding: 40px;
max-width: 480px;
padding: 44px 40px 36px;
max-width: 440px;
width: 100%;
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 {
display: flex;
.logo {
width: 76px; height: 76px;
background: linear-gradient(135deg, var(--primary), var(--secondary));
border-radius: 20px;
display: inline-flex;
align-items: 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;
direction: rtl;
}
.stars input { display: none; }
.stars label {
font-size: 48px;
cursor: pointer;
font-size: 48px; cursor: pointer;
color: #d1d5db;
transition: color .2s, transform .15s;
user-select: none;
}
.stars label:hover,
.stars label:hover ~ label,
.stars input:checked ~ label {
color: #f59e0b;
transform: scale(1.1);
}
.stars input:checked + label {
color: #f59e0b;
}
textarea {
width: 100%;
padding: 14px 16px;
border: 2px solid #e5e7eb;
border-radius: 12px;
font-size: 14px;
font-family: inherit;
resize: vertical;
min-height: 80px;
outline: none;
transition: border-color .2s;
margin-bottom: 20px;
}
textarea:focus { border-color: #667eea; }
.btn {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: opacity .2s;
}
.btn:hover { opacity: .9; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.stars label:hover { transform: scale(1.15); }
.stars input:checked ~ label { color: var(--warning); }
.stars input:checked + label { color: var(--warning); }
.rating-text {
font-size: 14px;
color: #6b7280;
margin-bottom: 24px;
min-height: 20px;
font-size: 14px; color: var(--text-muted);
margin-bottom: 24px; min-height: 20px;
font-weight: 500;
}
/* 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 .icon { font-size: 64px; margin-bottom: 16px; }
.success h2 { color: #059669; margin-bottom: 8px; }
.success p { color: #6b7280; }
.success h2 { color: var(--success); margin-bottom: 8px; font-size: 20px; font-weight: 700; }
.success p { color: var(--text-muted); font-size: 14px; }
/* Error */
.erro {
color: #ef4444;
font-size: 14px;
margin-top: 12px;
display: none;
background: var(--danger-bg); color: var(--danger-text);
padding: 8px 14px; border-radius: 6px;
font-size: 13px; margin-top: 12px;
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>
</head>
@@ -135,7 +220,13 @@ textarea:focus { border-color: #667eea; }
</div>
<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() {
var params = new URLSearchParams(window.location.search);
@@ -144,27 +235,45 @@ function getParams() {
empresaId = params.get('empresa');
if (!alias || !conversaId || !empresaId) {
document.getElementById('erro').textContent = 'Link inválido. Entre em contato conosco.';
document.getElementById('erro').style.display = 'block';
document.querySelector('.stars').style.display = 'none';
document.querySelector('textarea').style.display = 'none';
var erro = document.getElementById('erro');
erro.textContent = 'Link inválido. Contate o suporte.';
erro.style.display = 'block';
document.getElementById('starContainer').style.display = 'none';
document.getElementById('comentario').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) {
input.addEventListener('change', function() {
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] || '';
});
});
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) {
document.getElementById('erro').textContent = 'Selecione uma avaliação de 1 a 5 estrelas.';
document.getElementById('erro').style.display = 'block';
var erro = document.getElementById('erro');
erro.textContent = 'Selecione uma avaliação de 1 a 5 estrelas.';
erro.style.display = 'block';
return;
}
@@ -186,8 +295,8 @@ async function enviar() {
});
var data = await resp.json();
if (data.success) {
document.getElementById('app').querySelector('.stars').style.display = 'none';
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('ratingText').style.display = 'none';
document.getElementById('success').style.display = 'block';
@@ -195,14 +304,17 @@ async function enviar() {
throw new Error(data.error || 'Erro ao enviar');
}
} catch(e) {
document.getElementById('erro').textContent = 'Erro ao enviar: ' + e.message;
document.getElementById('erro').style.display = 'block';
var erro = document.getElementById('erro');
erro.textContent = 'Erro ao enviar: ' + e.message;
erro.style.display = 'block';
btn.disabled = false;
btn.textContent = 'Enviar Avaliação';
}
}
};
getParams();
verificarAvaliacao();
})();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+58 -5
View File
@@ -110,7 +110,7 @@ body {
.sidebar-nav::-webkit-scrollbar { width: 3px; }
.sidebar-nav::-webkit-scrollbar-track { background: transparent; }
.sidebar-nav::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; }
.sidebar-nav::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; }
.sidebar-nav .nav-label {
font-size: 10px;
@@ -482,7 +482,7 @@ tr:last-child td { border-bottom: none; }
.modal-overlay.show { display: flex; }
.modal-box {
.modal-box, .modal {
background: var(--surface);
border-radius: var(--radius-lg);
padding: 28px;
@@ -497,14 +497,14 @@ tr:last-child td { border-bottom: none; }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.modal-box h3 {
.modal-box h3, .modal h3 {
margin-bottom: 18px;
font-size: 17px;
font-weight: 700;
color: var(--text-primary);
}
.modal-footer {
.modal-footer, .modal .modal-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
@@ -644,9 +644,20 @@ tr:last-child td { border-bottom: none; }
/* ===== SCROLLBAR CUSTOMIZADA ===== */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-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; }
/* ===== 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)
===================================================== */
@@ -730,3 +741,45 @@ tr:last-child td { border-bottom: none; }
.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; }
}
+35 -20
View File
@@ -6,28 +6,29 @@
<title>Dashboard - Chatc2</title>
<link rel="stylesheet" href="/css/main.css">
<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; }
.container { flex: 1; padding: 24px; overflow-y: auto; }
.section-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: #6b7280; margin: 4px 0 12px; }
.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 #9ca3af; }
.stat-card .num { font-size: 32px; font-weight: 800; color: #111827; line-height: 1; }
.stat-card .lbl { font-size: 13px; color: #6b7280; margin-top: 6px; font-weight: 600; }
.stat-card .desc { font-size: 11px; color: #9ca3af; margin-top: 4px; }
.stat-card { 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: #ef4444; }
.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: #9ca3af; }
.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 #f3f4f6; font-size:14px; }
.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:#6b7280; }
.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; }
@@ -35,18 +36,24 @@
.hm-cell { width:12px; height:12px; border-radius:2px; background:#ebedf0; }
.hm-cell.l1 { background:#9be9a8; } .hm-cell.l2 { background:#40c463; }
.hm-cell.l3 { background:#30a14e; } .hm-cell.l4 { background:#216e39; }
.hm-months { display:flex; gap:3px; font-size:10px; color:#9ca3af; margin-bottom:4px; height:12px; }
.hm-legend { display:flex; align-items:center; gap:4px; font-size:11px; color:#9ca3af; margin-top:8px; justify-content:flex-end; }
body.dark-mode .hm-cell { background:#161b22; }
.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>
<link rel="stylesheet" href="/css/dark-mode.css">
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?' Claro':'🌙 Escuro'});}
window.darkModeApply=function(a){var e=document.body;if(!e)return;e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});};
window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')==='true';};</script>
<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>
</head>
<body>
<!-- SIDEBAR -->
<aside class="sidebar">
<aside class="sidebar" role="navigation" aria-label="Navegação principal">
<div class="sidebar-brand">
<div class="logo">C2</div>
<div>
@@ -65,13 +72,13 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<a href="#" id="navRoutes" style="display:none"><span class="icon">📡</span> Rotas</a>
</nav>
<div class="sidebar-footer">
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button>
<a onclick="logout()"><span class="icon">🚪</span> Sair</a>
<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()" aria-label="Sair do sistema"><span class="icon">🚪</span> Sair</a>
</div>
</aside>
<!-- MAIN -->
<div class="main">
<main class="main" id="main-content">
<div class="topbar">
<span class="topbar-title">Dashboard</span>
<div class="user-info">
@@ -87,6 +94,7 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<div class="stat-card naoatendidas"><div class="num" id="cNaoAtendidas"></div><div class="lbl">Não Atendidas</div><div class="desc">Aguardando resposta do atendente</div></div>
<div class="stat-card naoatribuidas"><div class="num" id="cNaoAtribuidas"></div><div class="lbl">Não Atribuídas</div><div class="desc">Sem equipe e sem atendente</div></div>
<div class="stat-card pendentes"><div class="num" id="cPendentes"></div><div class="lbl">Pendentes</div><div class="desc">Somente com equipe</div></div>
<div 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>
<div class="section-title">Atendentes</div>
@@ -166,6 +174,7 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
document.getElementById('cNaoAtendidas').textContent = d.conversas.naoAtendidas;
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);
@@ -259,5 +268,11 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
}
</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>
+41 -38
View File
@@ -1,44 +1,47 @@
// Dark Mode Controller - Only handles initialization/persistence
// Dark Mode Controller — Unified for all Chatc2 pages
(function() {
if (typeof window.darkModeToggle !== 'function') {
// Ensure function exists (inline script in head should have defined it)
window.darkModeToggle = function() {
var el = document.body;
if (!el) return;
var isDark = localStorage.getItem('chatc2_dark_mode') === 'true';
var enable = !isDark;
el.classList.toggle('dark-mode', enable);
localStorage.setItem('chatc2_dark_mode', enable ? 'true' : 'false');
document.querySelectorAll('.dark-mode-toggle').forEach(function(btn) {
btn.innerHTML = enable ? '☀️ Claro' : '🌙 Escuro';
});
};
window.darkModeApply = function(enable) {
var el = document.body;
if (!el) return;
el.classList.toggle('dark-mode', enable);
localStorage.setItem('chatc2_dark_mode', enable ? 'true' : 'false');
document.querySelectorAll('.dark-mode-toggle').forEach(function(btn) {
btn.innerHTML = enable ? '☀️ Claro' : '🌙 Escuro';
});
};
window.darkModeIsDark = function() { return localStorage.getItem('chatc2_dark_mode') === 'true'; };
var osPrefersDark = window.matchMedia('(prefers-color-scheme: dark)');
var manualOverride = localStorage.getItem('chatc2_dark_mode_manual');
function getInitialTheme() {
if (manualOverride !== null) return manualOverride === 'true';
return osPrefersDark.matches;
}
// Apply on load
if (localStorage.getItem('chatc2_dark_mode') === 'true') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
document.body.classList.add('dark-mode');
document.querySelectorAll('.dark-mode-toggle').forEach(function(btn) {
btn.innerHTML = '☀️ Claro';
});
});
} else {
document.body.classList.add('dark-mode');
document.querySelectorAll('.dark-mode-toggle').forEach(function(btn) {
btn.innerHTML = '☀️ Claro';
});
function applyTheme(enable, isManual) {
if (document.body) {
document.body.classList.toggle('dark-mode', enable);
}
localStorage.setItem('chatc2_dark_mode', enable ? 'true' : 'false');
if (isManual) {
localStorage.setItem('chatc2_dark_mode_manual', enable ? 'true' : 'false');
}
document.querySelectorAll('.dark-mode-toggle').forEach(function(btn) {
btn.innerHTML = enable ? '☀️ Claro' : '🌙 Escuro';
});
}
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">
<style>
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%;
animation: gradientShift 12s ease infinite;
min-height: 100vh;
@@ -40,7 +40,7 @@
.login-header .logo {
width: 76px;
height: 76px;
background: linear-gradient(135deg, #667eea, #764ba2);
background: linear-gradient(135deg, var(--primary), #764ba2);
border-radius: 20px;
display: inline-flex;
align-items: center;
@@ -63,13 +63,13 @@
.login-header h1 {
font-size: 24px;
font-weight: 800;
color: #111827;
color: var(--text-primary);
margin-bottom: 6px;
letter-spacing: -0.5px;
}
.login-header p {
color: #6b7280;
color: var(--text-muted);
font-size: 14px;
}
@@ -81,31 +81,31 @@
display: block;
font-size: 13px;
font-weight: 600;
color: #374151;
color: var(--text-secondary);
margin-bottom: 7px;
}
.form-group input {
width: 100%;
padding: 12px 16px;
border: 2px solid #e5e7eb;
border: 2px solid var(--border);
border-radius: 11px;
font-size: 15px;
transition: all 0.15s;
outline: none;
background: #f9fafb;
color: #111827;
background: var(--surface-2);
color: var(--text-primary);
}
.form-group input:focus {
border-color: #667eea;
border-color: var(--primary);
background: #fff;
box-shadow: 0 0 0 4px rgba(102,126,234,0.12);
}
.form-group input.error {
border-color: #ef4444;
background: #fef2f2;
border-color: var(--danger);
background: var(--danger-bg);
}
.password-wrapper { position: relative; }
@@ -119,17 +119,17 @@
background: none;
border: none;
cursor: pointer;
color: #9ca3af;
color: var(--text-faint);
font-size: 18px;
padding: 4px;
transition: color 0.15s;
}
.toggle-password:hover { color: #6b7280; }
.toggle-password:hover { color: var(--text-muted); }
.btn-login {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #667eea, #764ba2);
background: linear-gradient(135deg, var(--primary), #764ba2);
color: #fff;
border: none;
border-radius: 11px;
@@ -145,8 +145,8 @@
.btn-login:disabled { opacity: 0.6; cursor: not-allowed; }
.error-message {
background: #fef2f2;
color: #dc2626;
background: var(--danger-bg);
color: var(--danger-text);
padding: 12px 16px;
border-radius: 10px;
font-size: 13px;
@@ -196,9 +196,7 @@
}
</style>
<link rel="stylesheet" href="/css/dark-mode.css">
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?' Claro':'🌙 Escuro'});}
window.darkModeApply=function(a){var e=document.body;if(!e)return;e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});};
window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')==='true';};</script>
<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>
</head>
<body>
<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>
</div>
<script src="/js/dark-mode.js"></script>
<!-- impeccable-live-start -->
<script src="http://localhost:8400/live.js"></script>
<!-- impeccable-live-end -->
</body>
</html>
+43 -39
View File
@@ -6,89 +6,87 @@
<title>Rotas - Chatc2 API</title>
<link rel="stylesheet" href="/css/main.css">
<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; }
.main { flex:1; display:flex; flex-direction:column; min-width:0; }
.container { flex:1; padding:24px; overflow-y:auto; }
/* Barra de busca/filtros */
.search-box { margin-bottom:18px; display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
.search-box input { flex:1; min-width:220px; padding:10px 16px; border:2px solid #e5e7eb; border-radius:8px; font-size:14px; outline:none; transition:border-color .15s; background:#fff; color:#1f2937; }
.search-box input:focus { border-color:#667eea; }
.search-box select { padding:10px 12px; border:2px solid #e5e7eb; border-radius:8px; font-size:13px; outline:none; background:#fff; cursor:pointer; color:#374151; }
.search-box select:focus { border-color:#667eea; }
.btn-ghost { padding:9px 14px; border:2px solid #e5e7eb; border-radius:8px; background:#fff; cursor:pointer; font-size:13px; color:#374151; }
.btn-ghost:hover { background:#f3f4f6; }
.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:var(--primary); }
.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:var(--primary); }
.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:var(--surface-3); }
/* 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; }
/* Categoria */
.cat-group { margin-bottom:26px; }
.cat-title { display:flex; align-items:center; gap:8px; font-size:15px; font-weight:700; color:#374151; margin:0 0 10px; }
.cat-title .count { font-size:11px; background:#e5e7eb; color:#6b7280; padding:2px 8px; border-radius:10px; font-weight:600; }
.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:var(--border); color:var(--text-muted); padding:2px 8px; border-radius:8px; font-weight:600; }
/* Card de rota (expansível) */
.route { background:#fff; border:1px solid #e5e7eb; border-radius:10px; margin-bottom:8px; overflow:hidden; }
.route[open] { box-shadow:0 1px 6px rgba(0,0,0,.06); border-color:#d1d5db; }
.route { 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:var(--border); }
.route-head { list-style:none; cursor:pointer; display:flex; align-items:center; gap:10px; padding:12px 14px; flex-wrap:wrap; }
.route-head::-webkit-details-marker { display:none; }
.route-head:hover { background:#fafafa; }
.route-head:hover { background:var(--surface-2); }
.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); }
.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.put { background:#d97706; }
.method.delete { background:#dc2626; }
.method.delete { background:var(--danger-text); }
.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-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-yes { background:#fef3c7; color:#92400e; }
.auth-no { background:#d1fae5; color:#065f46; }
.auth-yes { background:var(--warning-bg); color:var(--warning-text); }
.auth-no { background:var(--success-bg); color:var(--success-text); }
/* Detalhe expandido */
.route-detail { padding:14px 16px 16px; }
.block { margin-top:14px; }
.block:first-child { margin-top:0; }
.block-title { font-size:12px; font-weight:700; color:#6b7280; text-transform:uppercase; letter-spacing:.4px; margin-bottom:7px; display:flex; align-items:center; gap:8px; }
.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 th { text-align:left; font-size:10.5px; text-transform:uppercase; color:#9ca3af; padding:4px 10px; border-bottom:1px solid #eee; font-weight:600; }
.param-table td { padding:6px 10px; border-bottom:1px solid #f3f4f6; vertical-align:top; color:#374151; }
.param-table td.pname { font-family:'SF Mono',monospace; color:#1f2937; font-weight:600; white-space:nowrap; }
.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 var(--surface-3); vertical-align:top; color:var(--text-secondary); }
.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.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-curl { background:#111827; color:#a7f3d0; border-radius:8px; padding:12px 14px; font-family:'SF Mono',monospace; font-size:12px; line-height:1.5; overflow-x:auto; margin:0; white-space:pre; }
.req-note { font-size:12px; color:#6b7280; margin-top:7px; }
.req-note code { background:#f3f4f6; padding:1px 6px; border-radius:4px; color:#b91c1c; font-weight:600; }
.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:var(--text-muted); margin-top:7px; }
.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; }
.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-light { background:rgba(255,255,255,.12); color:#e2e8f0; }
.copy-light:hover { background:rgba(255,255,255,.24); }
.empty { text-align:center; padding:50px; color:#9ca3af; font-size:14px; }
.empty { text-align:center; padding:50px; color:var(--text-faint); font-size:14px; }
</style>
<link rel="stylesheet" href="/css/dark-mode.css">
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?' Claro':'🌙 Escuro'});}
window.darkModeApply=function(a){var e=document.body;if(!e)return;e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});};
window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')==='true';};</script>
<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>
</head>
<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>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>
</nav>
<div class="sidebar-footer">
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button>
<a onclick="logout()"><span class="icon">🚪</span> Sair</a>
<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()" aria-label="Sair do sistema"><span class="icon">🚪</span> Sair</a>
</div>
</aside>
<div class="main">
<main class="main" id="main-content">
<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>
</div>
<div class="container">
<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="filterMethod" onchange="filtrarRotas()">
<option value="">Todos os métodos</option>
@@ -332,5 +330,11 @@ carregarRotas();
})();
</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>
+342 -147
View File
@@ -6,36 +6,52 @@
<title>Configurações - Chatc2</title>
<link rel="stylesheet" href="/css/main.css">
<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; }
.main { flex:1; display:flex; flex-direction:column; min-width:0; }
.container { flex:1; padding:24px; overflow-y:auto; }
.tabs { display:flex; margin-bottom:24px; background:#fff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.08); border:1px solid #f3f4f6; }
.tabs button { flex:1; padding:13px 16px; border:none; background:#fff; font-size:14px; cursor:pointer; border-bottom:2px solid transparent; transition:all .15s; color:#6b7280; font-weight:500; }
.tabs button:hover { background:#f9fafb; color:#374151; }
.tabs button.active { border-bottom-color:#667eea; color:#667eea; font-weight:700; background:#fafbff; }
.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:var(--text-muted); font-weight:500; }
.tabs button:hover { background:var(--surface-2); color:var(--text-secondary); }
.tabs button.active { border-bottom-color:var(--primary); color:var(--primary); font-weight:700; background:#fafbff; }
.tab-content { display:none; }
.tab-content.active { display:block; }
.form-group { margin-bottom:16px; }
.form-group label { display:block; font-size:13px; font-weight:600; color:#374151; margin-bottom:5px; }
.form-group input, .form-group select, .form-group textarea { width:100%; padding:10px 14px; border:2px solid #e5e7eb; border-radius:8px; font-size:14px; outline:none; background:#f9fafb; color:#111827; transition:all .15s; font-family:inherit; }
.form-group input:focus, .form-group select:focus, .form-group textarea:focus { border-color:#667eea; background:#fff; box-shadow:0 0 0 3px rgba(102,126,234,0.1); }
.form-group 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 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: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 .toggle { display:flex; align-items:center; gap:8px; }
.form-group .toggle input { width:auto; }
.btn { display:inline-flex; align-items:center; gap:6px; padding:10px 20px; border:none; border-radius:8px; font-size:14px; font-weight:600; cursor:pointer; transition:all .15s; }
.btn-primary { background:#667eea; color:#fff; }
.btn-primary:hover { background:#5a67d8; }
.btn-danger { background:#ef4444; color:#fff; }
.btn-primary { background:var(--primary); color:#fff; }
.btn-primary:hover { background:var(--primary-dark); }
.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:disabled { opacity:0.5; cursor:not-allowed; background:var(--danger); }
.btn-sm { padding:6px 12px; font-size:12px; border-radius:6px; }
.badge { display:inline-block; padding:2px 9px; border-radius:10px; font-size:11px; font-weight:600; }
.badge-success { background:#d1fae5; color:#065f46; }
.badge-danger { background:#fef2f2; color:#991b1b; }
.badge-success { background:var(--success-bg); color:var(--success-text); }
.badge-danger { background:var(--danger-bg); color:var(--danger-text); }
.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.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) {
@@ -44,12 +60,10 @@ body { background:#f3f4f6; display:flex; min-height:100vh; }
}
</style>
<link rel="stylesheet" href="/css/dark-mode.css">
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?' Claro':'🌙 Escuro'});}
window.darkModeApply=function(a){var e=document.body;if(!e)return;e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});};
window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')==='true';};</script>
<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>
</head>
<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>
@@ -64,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>
</nav>
<div class="sidebar-footer">
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button>
<a onclick="logout()"><span class="icon">🚪</span> Sair</a></div>
<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()" aria-label="Sair do sistema"><span class="icon">🚪</span> Sair</a></div>
</aside>
<div class="main">
<div class="topbar"><span class="topbar-title">⚙️ Configurações</span></div>
<main class="main" id="main-content">
<span class="topbar-title">⚙️ Configurações</span></div>
<div class="container">
<div class="tabs">
<button class="active" onclick="ativarAba('equipe',this)">👥 Equipe</button>
@@ -84,17 +98,17 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3 style="margin:0">📋 Fluxo de Atendimento</h3>
</div>
<p style="color:#6b7280;font-size:13px;margin-bottom:16px">
<p style="color:var(--text-muted);font-size:13px;margin-bottom:16px">
Configure os submenus que aparecem quando o cliente seleciona uma equipe.
As opções iniciais são sempre as <strong>Equipes</strong> cadastradas,
com a opção de <strong>Segunda via de Boleto</strong> no final.
</p>
<div id="menusList"><p style="color:#9ca3af">Carregando...</p></div>
<div 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:#6b7280;font-size:13px;margin-bottom:16px">
<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>
@@ -103,7 +117,7 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<input type="text" id="novoMotivo" placeholder="Cadastrar novo motivo..." style="flex:1;padding:8px;border:2px solid #e5e7eb;border-radius:8px;font-size:13px" onkeydown="if(event.key==='Enter')adicionarMotivo()">
<button class="btn btn-primary btn-sm" onclick="adicionarMotivo()">+ Adicionar</button>
</div>
<div id="motivosList" style="margin-bottom:12px"><p style="color:#9ca3af;font-size:13px">Carregando...</p></div>
<div 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>
@@ -111,7 +125,7 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<div class="form-group"><div class="toggle"><input type="checkbox" id="flgResolVis"> <label for="flgResolVis">Visualizar Resolução na tela de conversa</label></div></div>
<div class="form-group"><div class="toggle"><input type="checkbox" id="flgResolObr"> <label for="flgResolObr">Obrigatório preencher (só finaliza com resolução)</label></div></div>
<button class="btn btn-primary" onclick="salvarResolucao()">💾 Salvar Fluxo de Resolução</button>
<div id="resolucaoFeedback" class="feedback-msg"></div><button class="btn btn-primary" onclick="salvarResolucao()">💾 Salvar Fluxo de Resolução</button>
</div>
</div>
@@ -122,11 +136,11 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<h3 style="margin:0">Equipes</h3>
<button class="btn btn-primary btn-sm" onclick="mostrarModalEquipe()">+ Nova Equipe</button>
</div>
<div id="equipesList"><p style="color:#9ca3af">Carregando...</p></div>
<div id="equipesList"><p style="color:var(--text-faint)">Carregando...</p></div>
</div>
<div class="card">
<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>
@@ -134,7 +148,7 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<div class="tab-content" id="tabEmpresa">
<div class="card">
<h3>Configurações da Empresa</h3>
<div id="configForm"><p style="color:#9ca3af">Carregando...</p></div>
<div id="configForm"><p style="color:var(--text-faint)">Carregando...</p></div>
</div>
</div>
@@ -145,7 +159,7 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<h3 style="margin:0">Etiquetas</h3>
<button class="btn btn-primary btn-sm" onclick="mostrarModalEtiqueta()">+ Nova Etiqueta</button>
</div>
<div id="etiquetasList"><p style="color:#9ca3af">Carregando...</p></div>
<div id="etiquetasList"><p style="color:var(--text-faint)">Carregando...</p></div>
</div>
</div>
@@ -156,61 +170,84 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<h3 style="margin:0">📱 Conexões WhatsApp</h3>
<button class="btn btn-primary btn-sm" onclick="mostrarModalConexao()">+ Nova Conexão</button>
</div>
<div id="conexoesList"><p style="color:#9ca3af">Carregando...</p></div>
<div id="conexoesList"><p style="color:var(--text-faint)">Carregando...</p></div>
</div>
</div>
</div>
</div>
<!-- Modal Equipe -->
<div class="modal-overlay" id="modalEquipe" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;z-index:1000">
<div style="background:#fff;border-radius:12px;padding:24px;width:90%;max-width:400px">
<div class="modal-overlay" id="modalEquipe">
<div class="modal-box" style="max-width:480px">
<h3 id="modalEquipeTitle">Nova Equipe</h3>
<input type="hidden" id="editEquipeId">
<div class="form-group"><label>Nome da Equipe</label><input type="text" id="equipeNome" placeholder="Ex: Atendimento"></div>
<div class="form-group"><label>Ordem</label><input type="number" id="equipeOrdem" value="0" min="0" style="width:80px"><span style="font-size:12px;color:#9ca3af;margin-left:8px">(menor = aparece primeiro)</span></div>
<div class="form-group"><label>Membros</label><div id="equipeMembros"></div></div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn" onclick="fecharModal('modalEquipe')" style="background:#f3f4f6">Cancelar</button>
<button class="btn btn-primary" onclick="salvarEquipe()">Salvar</button>
<div class="form-group">
<label for="equipeNome">Nome da Equipe</label>
<input type="text" id="equipeNome" placeholder="Ex: Atendimento">
</div>
<div class="form-group">
<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>
<!-- Modal Etiqueta -->
<div class="modal-overlay" id="modalEtiqueta" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;z-index:1000">
<div style="background:#fff;border-radius:12px;padding:24px;width:90%;max-width:400px">
<div class="modal-overlay" id="modalEtiqueta">
<div class="modal-box" style="max-width:400px">
<h3 id="modalEtiquetaTitle">Nova Etiqueta</h3>
<input type="hidden" id="editEtiquetaId">
<div class="form-group"><label>Nome</label><input type="text" id="etiquetaNome" placeholder="Ex: Cliente VIP"></div>
<div class="form-group"><label>Cor</label><input type="color" id="etiquetaCor" value="#667eea"></div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn" onclick="fecharModal('modalEtiqueta')" style="background:#f3f4f6">Cancelar</button>
<button class="btn btn-primary" onclick="salvarEtiqueta()">Salvar</button>
<div class="form-group"><label for="etiquetaNome">Nome</label><input type="text" id="etiquetaNome" placeholder="Ex: Cliente VIP"></div>
<div class="form-group"><label for="etiquetaCor">Cor</label><input type="color" id="etiquetaCor" value="#667eea"></div>
<div id="etiquetaFeedback" class="feedback-msg"></div>
<div class="modal-footer">
<button class="btn-secondary" onclick="fecharModal('modalEtiqueta')">Cancelar</button>
<button class="btn-primary" onclick="salvarEtiqueta()">Salvar</button>
</div>
</div>
</div>
<!-- Modal Menu -->
<div class="modal-overlay" id="modalMenu" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;z-index:1000">
<div style="background:#fff;border-radius:12px;padding:24px;width:90%;max-width:500px">
<div class="modal-overlay" id="modalMenu">
<div class="modal-box" style="max-width:520px">
<h3 id="modalMenuTitle">Novo Submenu</h3>
<input type="hidden" id="editMenuId">
<input type="hidden" id="editMenuEquipeId">
<input type="hidden" id="editMenuPaiId">
<div class="form-group" id="menuEquipeGroup"><label>Equipe</label><select id="menuEquipe"><option value="">Selecione...</option></select></div>
<div class="form-group"><label>Título</label><input type="text" id="menuTitulo" placeholder="Ex: Alteração Cadastral"></div>
<div class="form-group">
<label>Tipo</label>
<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 for="menuTitulo">Título</label><input type="text" id="menuTitulo" placeholder="Ex: Alteração Cadastral"></div>
<div class="form-group"><label for="menuTipo">Tipo</label>
<select id="menuTipo" onchange="mudarTipoMenu()">
<option value="M">📂 Submenu (mostra mais opções)</option>
<option value="T">💬 Texto (envia mensagem fixa)</option>
<option value="R">⚙️ Ação (executa alteração via API)</option>
</select>
</div>
<div class="form-group" id="menuTextoGroup" style="display:none"><label>Texto a ser enviado</label><textarea id="menuTexto" rows="3" placeholder="Digite a mensagem..."></textarea></div>
<div class="form-group" id="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">
<label>Ação (Rota)</label>
<label for="menuAcaoRota">Ação (Rota)</label>
<select id="menuAcaoRota">
<option value="">Selecione uma ação...</option>
<option value="listar_carnes">📄 Listar carnês / Boleto</option>
@@ -219,31 +256,41 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<option value="alterar_endereco">🏠 Alterar Endereço</option>
<option value="info_cliente">️ Mostrar dados do cliente</option>
</select>
<div style="margin-top:8px"><label>Pergunta ao cliente (se precisar de dados)</label>
<textarea id="menuAcaoPrompt" rows="2" placeholder="Ex: Informe seu novo e-mail:" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:13px;resize:vertical"></textarea></div>
<div 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:"></textarea></div>
</div>
<div class="form-group"><label>Ordem</label><input type="number" id="menuOrdem" value="0" min="0" style="width:80px"></div>
<div class="form-group"><label>Subordinado a (opcional)</label><select id="menuPai"><option value="">Nenhum (raiz da equipe)</option></select></div>
<div class="form-group"><label>Etiquetas (adicionar ao selecionar)</label><div id="menuEtiquetasContainer" style="display:flex;flex-wrap:wrap;gap:6px;padding:6px;border:1px solid #e5e7eb;border-radius:8px;min-height:36px"></div></div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px">
<button class="btn" onclick="fecharModal('modalMenu')" style="background:#f3f4f6">Cancelar</button>
<button class="btn btn-primary" onclick="salvarMenu()">Salvar</button>
<div 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 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 var(--border);border-radius:8px;min-height:36px"></div></div>
<div id="menuFeedback" class="feedback-msg"></div>
<div class="modal-footer">
<button class="btn-secondary" onclick="fecharModal('modalMenu')">Cancelar</button>
<button class="btn-primary" onclick="salvarMenu()">Salvar</button>
</div>
</div>
</div>
<!-- Modal Conexão -->
<div class="modal-overlay" id="modalConexao" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;z-index:1000">
<div style="background:#fff;border-radius:12px;padding:24px;width:90%;max-width:500px">
<div class="modal-overlay" id="modalConexao">
<div class="modal-box" style="max-width:520px">
<h3 id="modalConexaoTitle">Nova Conexão WhatsApp</h3>
<input type="hidden" id="editConexaoId">
<div class="form-group"><label>Nome da Instância</label><input type="text" id="conNome" placeholder="Ex: WhatsApp Comercial"></div>
<div class="form-group"><label>URL Evolution API</label><input type="text" id="conUrl" value="https://evoatende.c2sistemas.com.br" placeholder="https://evoatende.c2sistemas.com.br"></div>
<div class="form-group"><label>API Key</label><input type="text" id="conApiKey" placeholder="API Key"></div>
<div class="form-group"><label>Nome da Instância (Evolution)</label><input type="text" id="conInstance" placeholder="Ex: empresa1-wpp"></div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn" onclick="fecharModal('modalConexao')" style="background:#f3f4f6">Cancelar</button>
<button class="btn btn-primary" onclick="salvarConexao()" id="btnSalvarConexao">Conectar</button>
<div 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 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 for="conApiKey">API Key</label><input type="text" id="conApiKey" placeholder="API Key"></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 id="conexaoFeedback" class="feedback-msg"></div>
<div class="form-group" id="instanciasExistentesGroup" style="display:none">
<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>
@@ -251,6 +298,16 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
<script>
(function(){
'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 pathParts = window.location.pathname.split('/');
const alias = pathParts[2] || localStorage.getItem('chatc2_alias') || 'lajedo';
@@ -293,20 +350,56 @@ window.ativarAba = function(aba, btn) {
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 =====
async function carregarEquipes() {
var data = await api('/teams?empresaId=' + empresaId);
var div = document.getElementById('equipesList');
if (!data.success || !data.data || data.data.length === 0) {
div.innerHTML = '<p style="color:#9ca3af">Nenhuma equipe cadastrada</p>';
div.innerHTML = '<p style="color:var(--text-faint)">Nenhuma equipe cadastrada</p>';
return;
}
div.innerHTML = '<table><thead><tr><th>Ordem</th><th>Nome</th><th>Membros</th><th>Ações</th></tr></thead><tbody>' +
data.data.map(function(eq) {
var membros = (eq.membros || []).map(function(m) { return m.nome; }).join(', ') || '-';
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>' +
'<button class="btn btn-sm" onclick="editarEquipe(' + eq.id + ',' + (eq.ordem || 0) + ',\'' + eq.nome.replace(/'/g,"\\'") + '\')" style="background:#f3f4f6;margin-right:4px">✏️</button>' +
'<button class="btn btn-sm btn-danger" onclick="excluirEquipe(' + eq.id + ')">🗑️</button></td></tr>';
'<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 + ',\'' + eq.nome.replace(/'/g,"\\'") + '\')">🗑️</button></td></tr>';
}).join('') + '</tbody></table>';
}
@@ -314,24 +407,30 @@ window.mostrarModalEquipe = function() {
document.getElementById('editEquipeId').value = '';
document.getElementById('equipeOrdem').value = 0;
document.getElementById('equipeNome').value = '';
document.getElementById('equipeMensagem').value = '';
document.getElementById('equipeEtiqueta').value = '';
document.getElementById('modalEquipeTitle').textContent = 'Nova Equipe';
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('equipeOrdem').value = ordem;
document.getElementById('equipeNome').value = nome;
document.getElementById('equipeMensagem').value = mensagem || '';
document.getElementById('equipeEtiqueta').value = etiquetaId || '';
document.getElementById('modalEquipeTitle').textContent = 'Editar Equipe';
await carregarEtiquetasDropdown();
carregarUsuariosCheckbox(id);
document.getElementById('modalEquipe').style.display = 'flex';
document.getElementById('modalEquipe').classList.add('show');
};
async function carregarUsuariosCheckbox(equipeId) {
var div = document.getElementById('equipeMembros');
var data = await api('/company/users?empresaId=' + empresaId);
if (!data.success) { div.innerHTML = '<p style="color:#9ca3af">Erro ao carregar</p>'; return; }
if (!data.success) { div.innerHTML = '<p style="color:var(--text-faint)">Erro ao carregar</p>'; return; }
var membrosAtuais = [];
if (equipeId) {
@@ -346,27 +445,41 @@ async function carregarUsuariosCheckbox(equipeId) {
}).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() {
var id = document.getElementById('editEquipeId').value;
var nome = document.getElementById('equipeNome').value.trim();
var ordem = parseInt(document.getElementById('equipeOrdem').value) || 0;
var membros = Array.from(document.querySelectorAll('#equipeMembros input:checked')).map(function(cb) { return parseInt(cb.value); });
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) {
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(); }
} 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(); }
}
};
window.excluirEquipe = async function(id) {
if (!confirm('Excluir esta equipe?')) return;
var r = await api('/teams/' + id, { method: 'DELETE' });
if (r.success) carregarEquipes();
window.excluirEquipe = async function(id, nome) {
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' });
if (r.success) carregarEquipes();
});
};
// ===== USUÁRIOS =====
@@ -374,7 +487,7 @@ async function carregarUsuarios() {
var data = await api('/company/users?empresaId=' + empresaId);
var div = document.getElementById('usuariosList');
if (!data.success || !data.data || data.data.length === 0) {
div.innerHTML = '<p style="color:#9ca3af">Nenhum usuário encontrado</p>';
div.innerHTML = '<p style="color:var(--text-faint)">Nenhum usuário encontrado</p>';
return;
}
div.innerHTML = '<table><thead><tr><th>Nome</th><th>Login</th><th>Tipo Chat</th></tr></thead><tbody>' +
@@ -393,7 +506,7 @@ async function carregarMenus() {
var div = document.getElementById('menusList');
if (!eqData.success || !eqData.data || eqData.data.length === 0) {
div.innerHTML = '<p style="color:#9ca3af">Crie equipes primeiro para configurar os submenus.</p>';
div.innerHTML = '<p style="color:var(--text-faint)">Crie equipes primeiro para configurar os submenus.</p>';
return;
}
@@ -404,8 +517,8 @@ async function carregarMenus() {
var menusEq = menusAgrupados.find(function(g) { return g.equipeId === eq.id; });
var temMenus = menusEq && menusEq.menus && menusEq.menus.length > 0;
html += '<div style="margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;overflow:hidden">';
html += '<div style="background:#f9fafb;padding:10px 14px;font-weight:600;font-size:14px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between">';
html += '<div style="margin-bottom:16px;border:1px solid var(--border);border-radius:8px;overflow:hidden">';
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 += '<button class="btn btn-sm" onclick="mostrarModalMenu(' + eq.id + ')" style="background:#e0e7ff">+ Submenu</button>';
html += '</div>';
@@ -413,7 +526,7 @@ async function carregarMenus() {
if (temMenus) {
html += '<div style="padding:4px 0">' + montarArvoreHtml(menusEq.menus, 0) + '</div>';
} else {
html += '<p style="padding:12px 14px;color:#9ca3af;font-size:13px;margin:0">Nenhum submenu configurado.</p>';
html += '<p style="padding:12px 14px;color:var(--text-faint);font-size:13px;margin:0">Nenhum submenu configurado.</p>';
}
html += '</div>';
});
@@ -427,8 +540,8 @@ function montarArvoreHtml(menus, nivel) {
menus.forEach(function(m) {
var tipoIcon = m.tipo === 'M' ? '📂' : m.tipo === 'T' ? '💬' : m.tipo === 'R' ? '⚙️' : '❓';
html += '<div style="display:flex;align-items:center;padding:6px 10px;border-bottom:1px solid #f3f4f6">';
html += '<span style="flex:1;font-size:13px">' + tipoIcon + ' ' + m.titulo + ' <span style="color:#9ca3af;font-size:11px">(' + m.tipo + ')</span></span>';
html += '<button class="btn btn-sm" onclick="mostrarModalMenu(' + m.equipeId + ',' + m.id + ')" style="background:#f3f4f6;margin-right:4px;padding:4px 8px;font-size:11px">✏️</button>';
html += '<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: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="mostrarModalMenu(' + m.equipeId + ',null,' + m.id + ')" style="margin-left:4px;padding:4px 8px;font-size:11px"> Sub</button>';
html += '</div>';
@@ -545,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() {
@@ -575,12 +688,12 @@ async function buscarEtiquetasDoMenu(menuId) {
function renderEtiquetasCheckbox(data, selecionadas) {
var container = document.getElementById('menuEtiquetasContainer');
if (!data.success || !data.data || data.data.length === 0) {
container.innerHTML = '<span style="color:#9ca3af;font-size:12px">Nenhuma etiqueta cadastrada</span>';
container.innerHTML = '<span style="color:var(--text-faint);font-size:12px">Nenhuma etiqueta cadastrada</span>';
return;
}
container.innerHTML = data.data.map(function(e) {
var checked = selecionadas.includes(e.id) ? 'checked' : '';
return '<label style="display:flex;align-items:center;gap:4px;padding:4px 8px;background:#f3f4f6;border-radius:6px;font-size:12px;cursor:pointer">' +
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"> ' +
'<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:' + (e.cor || '#667eea') + '"></span> ' +
e.nome +
@@ -601,8 +714,8 @@ window.salvarMenu = async function() {
// Etiquetas selecionadas
var etiquetaIds = Array.from(document.querySelectorAll('#menuEtiquetasContainer input[type="checkbox"]:checked')).map(function(cb) { return parseInt(cb.value); }).filter(function(v) { return !isNaN(v); }).join(',');
if (!titulo) return alert('Informe o título');
if (!equipeId) return alert('Selecione a equipe');
if (!titulo) { mostrarFeedbackModal('menuFeedback', 'Informe o título', 'erro'); return; }
if (!equipeId) { mostrarFeedbackModal('menuFeedback', 'Selecione a equipe', 'erro'); return; }
var body = {
empresaId: empresaId,
@@ -622,18 +735,19 @@ window.salvarMenu = async function() {
var r = await api('/menus/' + editId, { method: 'PUT', body: JSON.stringify(body) });
console.log('[Menu] Resposta save:', JSON.stringify(r));
if (r.success) { fecharModal('modalMenu'); carregarMenus(); }
else alert('Erro: ' + (r.error || 'desconhecido'));
else mostrarFeedbackModal('menuFeedback', 'Erro: ' + (r.error || 'desconhecido'), 'erro');
} else {
var r = await api('/menus', { method: 'POST', body: JSON.stringify(body) });
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) {
if (!confirm('Excluir o submenu "' + titulo + '" e todos os seus sub-itens?')) return;
var r = await api('/menus/' + id, { method: 'DELETE' });
if (r.success) carregarMenus();
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' });
if (r.success) carregarMenus();
});
};
// ===== ETIQUETAS =====
@@ -641,14 +755,14 @@ async function carregarEtiquetas() {
var data = await api('/labels?empresaId=' + empresaId);
var div = document.getElementById('etiquetasList');
if (!data.success || !data.data || data.data.length === 0) {
div.innerHTML = '<p style="color:#9ca3af">Nenhuma etiqueta cadastrada</p>';
div.innerHTML = '<p style="color:var(--text-faint)">Nenhuma etiqueta cadastrada</p>';
return;
}
div.innerHTML = '<table><thead><tr><th>Nome</th><th>Cor</th><th>Ações</th></tr></thead><tbody>' +
data.data.map(function(l) {
return '<tr><td><strong>' + l.nome + '</strong></td><td><span class="tag" style="background:' + l.cor + '">' + l.cor + '</span></td><td>' +
'<button class="btn btn-sm" onclick="editarEtiqueta(' + l.id + ',\'' + l.nome.replace(/'/g,"\\'") + '\',\'' + l.cor + '\')" style="background:#f3f4f6;margin-right:4px">✏️</button>' +
'<button class="btn btn-sm btn-danger" onclick="excluirEtiqueta(' + l.id + ')">🗑️</button></td></tr>';
'<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 + ',\'' + l.nome.replace(/'/g,"\\'") + '\')">🗑️</button></td></tr>';
}).join('') + '</tbody></table>';
}
@@ -657,7 +771,7 @@ window.mostrarModalEtiqueta = function() {
document.getElementById('etiquetaNome').value = '';
document.getElementById('etiquetaCor').value = '#667eea';
document.getElementById('modalEtiquetaTitle').textContent = 'Nova Etiqueta';
document.getElementById('modalEtiqueta').style.display = 'flex';
document.getElementById('modalEtiqueta').classList.add('show');
};
window.editarEtiqueta = function(id, nome, cor) {
@@ -665,14 +779,14 @@ window.editarEtiqueta = function(id, nome, cor) {
document.getElementById('etiquetaNome').value = nome;
document.getElementById('etiquetaCor').value = cor;
document.getElementById('modalEtiquetaTitle').textContent = 'Editar Etiqueta';
document.getElementById('modalEtiqueta').style.display = 'flex';
document.getElementById('modalEtiqueta').classList.add('show');
};
window.salvarEtiqueta = async function() {
var id = document.getElementById('editEtiquetaId').value;
var nome = document.getElementById('etiquetaNome').value.trim();
var cor = document.getElementById('etiquetaCor').value;
if (!nome) return alert('Informe o nome');
if (!nome) { mostrarFeedbackModal('etiquetaFeedback', 'Informe o nome', 'erro'); return; }
if (id) {
var r = await api('/labels/' + id, { method: 'PUT', body: JSON.stringify({ nome: nome, cor: cor }) });
@@ -683,10 +797,11 @@ window.salvarEtiqueta = async function() {
}
};
window.excluirEtiqueta = async function(id) {
if (!confirm('Excluir esta etiqueta?')) return;
var r = await api('/labels/' + id, { method: 'DELETE' });
if (r.success) carregarEtiquetas();
window.excluirEtiqueta = async function(id, nome) {
mostrarConfirmacao('Excluir Etiqueta', 'A etiqueta será removida permanentemente.', nome, async function() {
var r = await api('/labels/' + id, { method: 'DELETE' });
if (r.success) carregarEtiquetas();
});
};
// ===== CONFIGURAÇÕES EMPRESA =====
@@ -704,14 +819,15 @@ async function carregarConfig() {
'<div class="form-group"><div class="toggle"><input type="checkbox" id="cfgNomeUser" ' + (cfg.enviarNomeUsuario === 'S' ? 'checked' : '') + '> <label for="cfgNomeUser">Mostrar nome do usuário nas mensagens ("Nome: Mensagem")</label></div></div>' +
'<div class="form-group"><div class="toggle"><input type="checkbox" id="cfgEnviarBoleto" ' + (cfg.enviarBoleto === 'S' ? 'checked' : '') + '> <label for="cfgEnviarBoleto">💳 Permitir enviar boleto na conversa (botão "Enviar boleto")</label></div></div>' +
'<div class="form-group"><div class="toggle"><input type="checkbox" id="cfgTriagem" ' + (cfg.triagemAtiva === 'S' ? 'checked' : '') + '> <label for="cfgTriagem">📋 Ativar fluxo de triagem (menu de opções)</label></div></div>' +
'<div class="form-group" id="triagemConfig" style="display:' + (cfg.triagemAtiva === 'S' ? 'block' : 'none') + ';padding:12px;background:#f9fafb;border-radius:8px;margin-bottom:12px">' +
'<div class="form-group" 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 após escolha</label><textarea id="cfgTriagemAfter" rows="2" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:13px;resize:vertical">' + (cfg.triagemMsgAfter || '') + '</textarea></div>' +
'<div class="form-group"><label>Número da opção "Segunda via de Boleto"</label><input type="text" id="cfgTriagemBoletoNum" value="' + (cfg.triagemBoletoNumero || '0') + '" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:14px"></div>' +
'</div>' +
'<div class="form-group"><div class="toggle"><input type="checkbox" id="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"><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
document.getElementById('cfgTriagem').addEventListener('change', function() {
@@ -747,10 +863,11 @@ window.salvarConfig = async function() {
triagemBoletoNumero: document.getElementById('cfgTriagemBoletoNum').value,
csatAtivo: document.getElementById('cfgCsat').checked ? 'S' : 'N',
csatMensagem: document.getElementById('cfgCsatMsg').value,
resolucaoSalvarMSC: document.getElementById('cfgResolucaoSalvarMSC').checked ? 'S' : 'N',
};
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
@@ -764,11 +881,11 @@ async function carregarResolucao() {
if (div && md.success) {
div.innerHTML = (md.data && md.data.length)
? md.data.map(function(m) {
return '<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 10px;border:1px solid #eee;border-radius:6px;margin-bottom:4px">' +
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 + ')">🗑️</button></div>';
'<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:#9ca3af;font-size:13px">Nenhum motivo cadastrado.</p>';
: '<p style="color:var(--text-faint);font-size:13px">Nenhum motivo cadastrado.</p>';
}
var cd = await api('/company/config?empresaId=' + empresaId);
if (cd.success) {
@@ -785,15 +902,24 @@ window.adicionarMotivo = async function() {
var d = (inp.value || '').trim();
if (!d) return;
var r = await api('/motivos', { method: 'POST', body: JSON.stringify({ descricao: d, empresaId: empresaId }) });
if (r.success) { inp.value = ''; carregarResolucao(); } else alert(r.error || 'Erro ao adicionar motivo');
if (r.success) { inp.value = ''; carregarResolucao(); } else mostrarFeedbackResolucao(r.error || 'Erro ao adicionar motivo', 'erro');
};
window.removerMotivo = async function(id) {
if (!confirm('Remover este motivo?')) return;
var r = await api('/motivos/' + id, { method: 'DELETE' });
if (r.success) carregarResolucao(); else alert(r.error || 'Erro ao remover');
window.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,
@@ -803,7 +929,7 @@ window.salvarResolucao = async function() {
resolucaoObrigatorio: document.getElementById('flgResolObr').checked ? 'S' : 'N',
};
var r = await api('/company/resolucao-config', { method: 'POST', body: JSON.stringify(body) });
if (r.success) alert('Fluxo de Resolução salvo!'); else alert(r.error || 'Erro ao salvar');
if (r.success) mostrarFeedbackModal('resolucaoFeedback', 'Fluxo de Resolução salvo!', 'sucesso'); else mostrarFeedbackModal('resolucaoFeedback', r.error || 'Erro ao salvar', 'erro');
};
// ===== CONEXÕES =====
@@ -811,7 +937,7 @@ async function carregarConexoes() {
var data = await api('/evolution/instances?empresaId=' + empresaId);
var div = document.getElementById('conexoesList');
if (!data.success || !data.data || data.data.length === 0) {
div.innerHTML = '<p style="color:#9ca3af">Nenhuma conexão cadastrada</p>';
div.innerHTML = '<p style="color:var(--text-faint)">Nenhuma conexão cadastrada</p>';
return;
}
div.innerHTML = '<table><thead><tr><th>Nome</th><th>Instância</th><th>URL</th><th>Status</th><th>Ações</th></tr></thead><tbody>' +
@@ -821,9 +947,9 @@ async function carregarConexoes() {
ins.status === 'D' ? '<span class="badge badge-danger">⛔ Desconectado</span>' : '<span class="badge badge-danger">❌ ' + ins.status + '</span>';
var nomeEsc = (ins.nome || '').replace(/'/g, "\\'");
return '<tr><td><strong>' + ins.nome + '</strong></td><td>' + ins.instanceName + '</td><td style="font-size:11px;max-width:150px;overflow:hidden;text-overflow:ellipsis">' + ins.url + '</td><td>' + status + '</td><td>' +
'<button class="btn btn-sm" onclick="editarConexao(' + ins.id + ')" style="background:#f3f4f6;margin-right:4px">✏️</button>' +
'<button class="btn btn-sm" onclick="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="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>';
}
@@ -835,7 +961,46 @@ window.mostrarModalConexao = function() {
document.getElementById('conInstance').value = '';
document.getElementById('modalConexaoTitle').textContent = 'Nova Conexão WhatsApp';
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) {
@@ -847,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">' +
'<h2 style="margin-bottom:20px">Escaneie o QR Code</h2>' +
'<img src="' + imgSrc + '" style="max-width:300px">' +
'<p style="margin-top:20px;color:#6b7280">Abra o WhatsApp no celular e escaneie</p></body></html>');
'<p style="margin-top:20px;color:var(--text-muted)">Abra o WhatsApp no celular e escaneie</p></body></html>');
} else {
alert('Erro ao gerar QR Code: ' + JSON.stringify(r.data));
mostrarFeedbackModal('conexaoFeedback', 'Erro ao gerar QR Code', 'erro');
}
};
@@ -857,9 +1022,9 @@ window.gerarQRCode = async function(id) {
window.editarConexao = async function(id) {
// Busca dados atuais da API
var data = await api('/evolution/instances?empresaId=' + empresaId);
if (!data.success) return alert('Erro ao carregar dados');
if (!data.success) { mostrarFeedbackModal('conexaoFeedback', 'Erro ao carregar dados', 'erro'); return; }
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('conNome').value = ins.nome;
@@ -868,18 +1033,16 @@ window.editarConexao = async function(id) {
document.getElementById('conInstance').value = ins.instanceName;
document.getElementById('modalConexaoTitle').textContent = 'Editar Conexão WhatsApp';
document.getElementById('btnSalvarConexao').textContent = 'Salvar';
document.getElementById('modalConexao').style.display = 'flex';
document.getElementById('modalConexao').classList.add('show');
};
// ===== EXCLUIR CONEXÃO =====
window.excluirConexao = async function(id, nome) {
if (!confirm('Excluir a conexão "' + nome + '"?')) return;
var r = await api('/evolution/instances/' + id, { method: 'DELETE' });
if (r.success) {
carregarConexoes();
} else {
alert('Erro: ' + r.error);
}
mostrarConfirmacao('Excluir Conexao', 'A conexao WhatsApp "' + nome + '" sera removida.', nome, async function() {
var r = await api('/evolution/instances/' + id, { method: 'DELETE' });
if (r.success) carregarConexoes();
else mostrarFeedbackModal('conexaoFeedback', 'Erro: ' + r.error, 'erro');
});
};
window.salvarConexao = async function() {
@@ -891,8 +1054,9 @@ window.salvarConexao = async function() {
INS_INSTANCE_NAME: document.getElementById('conInstance').value.trim(),
};
if (!data.INS_NOME || !data.INS_URL || !data.INS_API_KEY || !data.INS_INSTANCE_NAME)
return alert('Preencha todos os campos');
if (!data.INS_NOME || !data.INS_URL || !data.INS_API_KEY || !data.INS_INSTANCE_NAME) {
mostrarFeedbackModal('conexaoFeedback', 'Preencha todos os campos', 'erro'); return;
}
var r;
if (editId) {
@@ -912,16 +1076,24 @@ window.salvarConexao = async function() {
carregarConexoes();
if (!editId && r.data && r.data.id) gerarQRCode(r.data.id);
} else {
alert('Erro: ' + r.error);
mostrarFeedbackModal('conexaoFeedback', 'Erro: ' + r.error, 'erro');
}
};
// ===== 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
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 =====
@@ -935,6 +1107,29 @@ carregarResolucao();
})();
</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>
<!-- 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>
-45
View File
@@ -1,45 +0,0 @@
/**
* Estrutura do "Fluxo de Resolução" (motivos + resolução do atendimento).
* Cria de forma idempotente no Firebird:
* - Tabela CHATC2_MOTIVOS_ATENDIMENTO
* - Flags em CHATC2_CONFIGURACOES_EMPRESA (CFE_MOTIVO_*, CFE_RESOLUCAO_*)
* - Colunas CON_MOTIVO_ID / CON_RESOLUCAO em CHATC2_CONVERSAS
*
* Usa DDL no subconjunto que funciona nos dois bancos (INTEGER, VARCHAR, CHAR,
* ALTER TABLE ... ADD). A tabela nova é criada com nome MAIÚSCULO entre aspas
* para manter a convenção do schema migrado.
*/
const db = require('./database');
const prontos = {};
async function tentar(alias, sql) {
// DDL idempotente: ignora erros de "já existe" (e similares entre dialetos)
try { await db.execute(alias, sql); } catch (e) { /* noop */ }
}
async function garantirEstrutura(alias) {
if (prontos[alias]) return;
await tentar(alias, `CREATE TABLE "CHATC2_MOTIVOS_ATENDIMENTO" (
MOT_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
MOT_EMPRESA_ID INTEGER,
MOT_DESCRICAO VARCHAR(150),
MOT_SITUACAO CHAR(1) DEFAULT 'A'
)`);
await tentar(alias, `ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_MOTIVO_VISUALIZAR CHAR(1) DEFAULT 'N'`);
await tentar(alias, `ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_MOTIVO_OBRIGATORIO CHAR(1) DEFAULT 'N'`);
await tentar(alias, `ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_RESOLUCAO_VISUALIZAR CHAR(1) DEFAULT 'N'`);
await tentar(alias, `ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_RESOLUCAO_OBRIGATORIO CHAR(1) DEFAULT 'N'`);
// Envio de boleto na conversa (checkbox em Configurações > Empresa)
await tentar(alias, `ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_ENVIAR_BOLETO CHAR(1) DEFAULT 'N'`);
await tentar(alias, `ALTER TABLE CHATC2_CONVERSAS ADD CON_MOTIVO_ID INTEGER`);
await tentar(alias, `ALTER TABLE CHATC2_CONVERSAS ADD CON_RESOLUCAO VARCHAR(4000)`);
prontos[alias] = true;
}
module.exports = { garantirEstrutura };
+4
View File
@@ -19,6 +19,10 @@ router.post('/:alias/conversations/:id/labels', authenticateToken, ChatControlle
router.post('/:alias/conversations/:id/link-client', authenticateToken, ChatController.linkClient);
router.get('/:alias/conversations/:id/boletos', authenticateToken, ChatController.getBoletos);
router.post('/:alias/conversations/:id/send-boleto', authenticateToken, ChatController.sendBoleto);
router.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);
module.exports = router;
+1
View File
@@ -39,6 +39,7 @@ router.post('/:alias/evolution/connect', EvolutionController.connect);
router.put('/:alias/evolution/instances/:id', EvolutionController.updateInstance);
router.delete('/:alias/evolution/instances/:id', EvolutionController.deleteInstance);
router.post('/:alias/evolution/qrcode/:id', EvolutionController.generateQRCode);
router.post('/:alias/evolution/fetch-instances', EvolutionController.fetchInstances);
router.post('/:alias/evolution/refresh-photo/:conversaId', EvolutionController.refreshPhoto);
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)
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;
+18
View File
@@ -10,6 +10,8 @@ const databaseRoutes = require('./databaseRoutes');
const dashboardRoutes = require('./dashboardRoutes');
const RoutesController = require('../controllers/routesController');
const EvolutionController = require('../controllers/evolutionController');
const authenticateToken = require('../middlewares/auth');
const metrics = require('../services/metrics');
// Webhook Evolution (sem auth - DEVE vir antes de qualquer middleware de auth)
// Suporta URL com alias (ex: /api/lajedo/webhook/evolution) ou sem alias
@@ -24,8 +26,24 @@ router.get('/api/:alias/webhook/ping', EvolutionController.webhookPing);
router.get('/api/:alias/media/:mediaId', require('../controllers/chatController').getMedia);
// 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);
// 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)
router.get('/api/routes', RoutesController.list);
+10
View File
@@ -24,6 +24,16 @@ async function start() {
console.log('\n⚠️ Alguns bancos não puderam ser conectados. Verifique as configurações.');
}
// Executa migrações pendentes automaticamente (idempotente, seguro)
console.log('');
const migrationRunner = require('../scripts/migrationRunner');
for (const alias of listAliases()) {
try { await migrationRunner.executarMigracoes(alias); } catch (e) {
console.log(` ⚠️ Migrações para "${alias}" ignoradas: ${e.message.substring(0, 80)}`);
}
}
console.log('');
const server = app.listen(PORT, () => {
console.log(`\n🚀 API Chatc2 rodando em http://localhost:${PORT}`);
console.log(`📋 Aliases disponíveis: ${listAliases().join(', ')}`);
+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 };