diff --git a/gti-maquina-comercial/.claude/launch.json b/gti-maquina-comercial/.claude/launch.json new file mode 100644 index 0000000..3c4400c --- /dev/null +++ b/gti-maquina-comercial/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "gti-servidor", + "runtimeExecutable": "node", + "runtimeArgs": ["scripts/servidor.mjs"], + "port": 3900 + } + ] +} diff --git a/gti-maquina-comercial/.env.example b/gti-maquina-comercial/.env.example new file mode 100644 index 0000000..580b1d1 --- /dev/null +++ b/gti-maquina-comercial/.env.example @@ -0,0 +1,38 @@ +# GTI Maquina Comercial - configuracao local (copie para .env se precisar mudar algo) + +# Porta do aplicativo local (padrao 3900) +#GTI_PORT=3900 + +# Caminho do banco SQLite (padrao data/gti.db) +#GTI_DB_PATH=data/gti.db + +# Pasta com os CSVs da base CNPJ extraida (padrao prospeccao/cnpj) +#CNPJ_BASE_DIR=prospeccao/cnpj + +# Necessaria APENAS para a fonte Google Places (paga) +#GOOGLE_MAPS_API_KEY= + +# Senha pedida para confirmar a limpeza total da base (Configuracoes > Zona de risco) +#GTI_RESET_PASSWORD=troque-esta-senha + +# Credenciais da API externa do Z-PRO (Admin > API no painel do Z-PRO), usadas +# pela automacao de envio WhatsApp. Normalmente configuradas direto na tela +# (Configuracoes > Credenciais Z-PRO) — essas variaveis aqui sao so um +# fallback (util em deploy via Docker/Proxmox) e so valem se os campos da +# tela estiverem vazios. Sem credenciais em nenhum dos dois lugares a +# automacao fica sem efeito mesmo com outreach.ativo=true. +#ZPRO_BASE_URL=seu-dominio-zpro.com.br +#ZPRO_API_ID= +#ZPRO_BEARER_TOKEN= +#ZPRO_WHATSAPP_ID= + +# Endpoints das fontes gratuitas (padroes ja funcionam; mude so se precisar) +#OVERPASS_API_URL=https://overpass-api.de/api/interpreter,https://overpass.kumi.systems/api/interpreter +#NOMINATIM_API_URL=https://nominatim.openstreetmap.org/search +#WIKIDATA_API_URL=https://query.wikidata.org/sparql +#BRASILAPI_URL=https://brasilapi.com.br/api + +# Ritmo das consultas ao OpenStreetMap (ms) +#OVERPASS_REQUEST_DELAY_MS=5000 +#OVERPASS_TIMEOUT_MS=15000 +#OVERPASS_TEXT_SEARCH=false diff --git a/gti-maquina-comercial/.gitignore b/gti-maquina-comercial/.gitignore new file mode 100644 index 0000000..587d138 --- /dev/null +++ b/gti-maquina-comercial/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +.env +.env.local +data/ +prospeccao/cnpj/rfb-raw/ +prospeccao/cnpj/*.csv +prospeccao/cnpj/*.ndjson +*.log +GTI-Comercial.exe +launcher/publish/ +.worktrees/ diff --git a/gti-maquina-comercial/INICIAR-GTI.bat b/gti-maquina-comercial/INICIAR-GTI.bat new file mode 100644 index 0000000..e7a9c18 --- /dev/null +++ b/gti-maquina-comercial/INICIAR-GTI.bat @@ -0,0 +1,10 @@ +@echo off +setlocal +cd /d "%~dp0" +if exist "GTI-Comercial.exe" ( + start "" "GTI-Comercial.exe" +) else ( + echo GTI-Comercial.exe nao encontrado. Gerando agora... + powershell -NoProfile -ExecutionPolicy Bypass -File "scripts\build-launcher.ps1" + if exist "GTI-Comercial.exe" start "" "GTI-Comercial.exe" +) diff --git a/gti-maquina-comercial/README.md b/gti-maquina-comercial/README.md new file mode 100644 index 0000000..974fb54 --- /dev/null +++ b/gti-maquina-comercial/README.md @@ -0,0 +1,192 @@ +# GTI Maquina Comercial + +Aplicativo local do Grupo GTI para capturar, qualificar, tratar e acionar leads B2B via WhatsApp. O sistema roda no computador do usuario, usa SQLite como banco local e integra com Z-PRO/GChat para envio manual ou automatico. + +URL local padrao: `http://127.0.0.1:3900` + +Login inicial: `admin` / `admin` + +## Uso Diario + +1. Abra `GTI-Comercial.exe` ou `INICIAR-GTI.bat`. +2. Acesse `http://127.0.0.1:3900` e faca login. +3. Use a aba `Capturar Leads` para escolher UF, cidades, nichos e fontes. +4. Use a aba `Tratar Leads` para filtrar, priorizar, abrir detalhes e exportar leads. +5. Use o botao de WhatsApp na primeira coluna da tabela para iniciar uma mensagem para um lead especifico. +6. Use `Configuracoes` para ajustar Z-PRO, templates WABA, limites de envio, usuarios e exportacoes. + +## Principais Recursos + +- Captura de leads B2B por UF, cidade e nicho. +- Base CNPJ da Receita Federal como fonte principal. +- Fontes complementares: OpenStreetMap/Overpass, Nominatim, Wikidata, BrasilAPI e Google Places. +- Deduplicacao por CNPJ, telefone/WhatsApp ou empresa+cidade. +- Scores de qualidade e potencial comercial. +- Dashboard com totais, rankings, funil e maior oportunidade. +- Tabela de tratamento com filtros, paginação e detalhe completo do lead. +- Acao rapida de WhatsApp por lead, fixa na primeira coluna da tabela. +- Envio manual por Z-PRO para numero especifico. +- Envio oficial por template WABA aprovado. +- Sincronizacao de templates WABA do GChat. +- Pre-visualizacao de templates com variaveis resolvidas por lead. +- Automacao de envio por produto recomendado, com limite diario. +- Exportacoes para Excel/CSV, oportunidades Z-PRO e disparo em massa. +- Login local com usuarios `admin` e `comercial`. + +## Fluxo Comercial + +1. Capture ou importe a base de leads. +2. Filtre por cidade, nicho, qualidade, WhatsApp, prioridade ou status de exportacao. +3. Revise o lead na tabela ou no modal de detalhes. +4. Clique no botao de WhatsApp do lead para abrir o envio manual com o numero preenchido. +5. Escolha texto livre ou template oficial. +6. Se usar template, selecione o modelo salvo e confira a pre-visualizacao. +7. Envie pelo Z-PRO e acompanhe o log de envios. + +Ao abrir o envio manual a partir de um lead, o app busca os dados completos desse cliente. Variaveis como `{{cnpj}}`, `{{empresa}}`, `{{businessName}}`, `{{produto}}`, `{{cidade}}`, `{{uf}}`, `{{segmento}}`, `{{whatsapp}}` e `{{email}}` sao preenchidas com os dados reais do lead na pre-visualizacao e no envio. + +## WhatsApp, Z-PRO e GChat + +O app suporta dois tipos de envio: + +| Canal | Uso | Observacao | +|---|---|---| +| Nao oficial/texto livre | Mensagem manual simples | Usa endpoint externo do Z-PRO para enviar texto | +| Oficial/template WABA | Mensagem com template aprovado | Usa template Meta/WABA via Z-PRO | + +Configuracoes importantes ficam em `Configuracoes`: + +- Base URL Z-PRO. +- API ID Z-PRO. +- Token/Bearer token. +- Rota do template (`template`, `templateBody` ou equivalente configurado). +- URL de templates do GChat. +- Token GChat opcional para sincronizar modelos. +- Limite diario de envios. +- Templates automaticos por produto. + +O seletor de templates WABA permite carregar modelos sincronizados do GChat. O sistema identifica variaveis posicionais como `{{1}}`, `{{2}}`, `{{3}}` no corpo do template e mostra os campos necessarios. + +## Variaveis Suportadas + +As mensagens e templates podem usar variaveis internas da Maquina Comercial/GChat: + +| Variavel | Valor esperado | +|---|---| +| `{{name}}` | Nome do contato quando existir; fallback para empresa | +| `{{empresa}}` | Nome da empresa | +| `{{businessName}}` | Nome da empresa no padrao GChat | +| `{{firstName}}` | Primeiro nome | +| `{{lastName}}` | Sobrenome, quando existir | +| `{{produto}}` | Produto recomendado/interesse | +| `{{protocol}}` | Assunto/protocolo; fallback para produto | +| `{{cidade}}` | Cidade | +| `{{uf}}` | UF | +| `{{segmento}}` | Nicho/segmento | +| `{{cnpj}}` | CNPJ | +| `{{email}}` | E-mail | +| `{{phoneNumber}}` | WhatsApp/telefone | +| `{{whatsapp}}` | WhatsApp/telefone | +| `{{kanban}}` | Produto/interesse | +| `{{user}}` | Remetente | +| `{{userEmail}}` | E-mail do remetente | +| `{{greeting}}` | Saudacao | + +Tambem existem placeholders legados: `{empresa}`, `{cidade}`, `{produto}` e `{segmento}`. + +## Exportacoes + +| Tipo | Saida | Para que serve | +|---|---|---| +| Planilha | CSV + XLS | Analise comercial e tratamento externo | +| Z-PRO Oportunidades | JSONL | Payloads para criacao de oportunidades | +| Z-PRO Disparo em massa | CSV | `dataInput` para disparo em massa | + +Leads exportados ficam marcados. Use o filtro de nao exportados para evitar retrabalho. + +## Score e Priorizacao + +O app calcula scores para ajudar o time comercial a decidir quem abordar primeiro. + +Regras principais: + +- WhatsApp/telefone valido aumenta a prioridade. +- CNPJ valido aumenta a confiabilidade. +- Empresa valida, cidade, nicho, CNAE, website e e-mail melhoram o score. +- Nichos com maior aderencia comercial recebem mais peso. +- Leads sem WhatsApp podem continuar cadastrados, mas aparecem com prioridade menor. +- Duplicidade segue a ordem CNPJ, telefone, empresa+cidade. + +## Fontes de Captura + +- Base CNPJ da Receita Federal: fonte principal para empresas ativas por UF. +- OpenStreetMap/Overpass: fonte aberta complementar. +- Nominatim: geocodificacao e apoio geografico. +- Wikidata: fonte aberta de baixo volume. +- BrasilAPI: enriquecimento de dados cadastrais. +- Google Places: fonte opcional paga, exige `GOOGLE_MAPS_API_KEY` no `.env`. + +## Comandos + +```bash +npm start +npm test +npm run capture +npm run extract +npm run recalc-scores +npm run outreach +npm run build:launcher +``` + +Exemplos: + +```bash +node scripts/servidor.mjs +node scripts/capturar-leads.mjs --sources=cnpj-base --uf=BA --cities="Ilheus" +node scripts/extrair-base-cnpj.mjs --uf=SP +node scripts/enviar-outreach.mjs +``` + +## Estrutura + +```text +GTI-Comercial.exe Launcher local do app +INICIAR-GTI.bat Inicializacao alternativa no Windows +scripts/ Entradas de linha de comando +src/ Codigo do servidor e regras de negocio +src/server/ HTTP, rotas, sessoes e autenticacao +src/capture/ Pipeline de captura +src/sources/ Fontes de dados +src/treatment/ Consultas e dashboard de tratamento +src/export/ Exportacoes Excel/CSV/Z-PRO +src/outreach/ Z-PRO, templates WABA e automacao WhatsApp +public/ Interface HTML/CSS/JS +tests/ Testes automatizados +data/gti.db Banco SQLite local, nao versionado +data/exports/ Arquivos exportados, nao versionado +prospeccao/cnpj/ Base CNPJ e dados brutos da Receita +docs/ Documentacao complementar +.env Segredos locais, nao versionado +``` + +## Requisitos + +- Windows. +- Node.js 24 ou superior. +- Para extrair novas UFs, `unzip`, Git Bash ou `tar` nativo do Windows. + +## Seguranca e Dados + +- O banco principal fica em `data/gti.db`. +- Segredos locais devem ficar no `.env` ou nas configuracoes locais do app. +- Tokens Z-PRO/GChat nao devem ser commitados. +- A zona de risco em Configuracoes apaga leads, capturas e exportacoes; a acao e irreversivel. +- Nao ha backup automatico do banco local. + +## Problemas Comuns + +- Se a tela nao abrir, reinicie por `GTI-Comercial.exe` ou `INICIAR-GTI.bat`. +- Se preferir depurar, rode `node scripts/servidor.mjs` no terminal. +- Log do servidor: `data/server.log`. +- Se a sincronizacao de templates GChat falhar com erro de token, gere/salve um token GChat valido novamente. +- Se um template WABA falhar no Z-PRO, teste outro template aprovado e confira rota, idioma e variaveis obrigatorias. diff --git a/gti-maquina-comercial/docs/01-visao-geral.md b/gti-maquina-comercial/docs/01-visao-geral.md new file mode 100644 index 0000000..f957433 --- /dev/null +++ b/gti-maquina-comercial/docs/01-visao-geral.md @@ -0,0 +1,62 @@ +# Visão Geral + +O Grupo GTI opera uma **máquina comercial local**: um aplicativo (GTI-Comercial.exe) +que captura leads B2B das fontes públicas — principalmente a base oficial de CNPJs +da Receita Federal — e ajuda a tratá-los e exportá-los para as campanhas de +WhatsApp no Z-PRO. + +## Soluções vendidas + +- **ERP e sistemas de gestão** para comércio, serviços, restaurantes, mercados e distribuidoras. +- **Gestão de TI** para empresas com suporte, infraestrutura, servidores, redes, backup e segurança. +- **Autoatendimento para WhatsApp** com triagem, atendimento automático, vendas e agendamento. + +Cada nicho do catálogo (57 nichos) já vem associado ao interesse provável e às dores +típicas, que alimentam a personalização das mensagens. + +## Arquitetura (simples de propósito) + +``` +GTI-Comercial.exe → servidor Node local (porta 3900) → navegador + │ + ├── Captura: base CNPJ por UF + OpenStreetMap + Wikidata (+ Google opcional) + ├── Banco SQLite local (data/gti.db): leads, score, dedup, histórico + └── Tratamento: dashboard, filtros, relatórios e exportações Z-PRO +``` + +Sem n8n, sem serviços externos obrigatórios: tudo roda no computador da GTI hoje +(e vai rodar num container Docker no Proxmox da empresa no futuro, sem mudança de +código). O primeiro contato via WhatsApp pode ser automático (ver +[08-automacao-envio.md](08-automacao-envio.md)); campanhas maiores e o +acompanhamento seguem por fora, no Z-PRO, a partir dos arquivos gerados pelo +módulo de tratamento. + +## Premissas + +- Foco inicial na Bahia (Santo Antônio de Jesus e região + capitais), mas o app + funciona para **qualquer UF/cidade do Brasil** sem mudança de interface. +- A equipe não tem braço para operação manual intensa: o app faz a triagem + (score de qualidade dos dados + score comercial por nicho, combinados numa + prioridade P1-P4) e evita retrabalho (leads exportados ficam marcados). +- O WhatsApp é o canal central de conversão — leads com telefone válido têm prioridade. + +## Resultado esperado + +- Base grande e organizada de leads com WhatsApp (97%+ de cobertura via base CNPJ). +- Relatórios por nicho/cidade prontos para campanhas segmentadas. +- Histórico de tudo que já foi exportado/enviado, sem duplicar contato. +- Dashboard de gestão comercial na tela de Tratamento: percentual da base + pronta para abordagem imediata (Potencial Comercial), cobertura da base + já trabalhada, distribuição visual por prioridade, produtos recomendados + por cidade e meta diária de exportações da equipe. +- Interface explica o "porquê" de cada score (composição detalhada no modal + do lead), destaca a cidade+produto de maior oportunidade, mostra a + evolução diária de exportações e oferece atalhos de um clique para gerar + listas de prospecção (só P1, só um produto, só não exportados etc.). +- Acesso protegido por login (usuário/senha): o primeiro acesso vem com + `admin`/`admin`, que pode cadastrar novos usuários (comercial ou admin) + pela tela de Usuários. +- Automação de primeiro contato via WhatsApp (opcional, desligada por padrão): + manda uma mensagem com o template do produto recomendado pra cada lead ainda + não exportado, registra o que foi mandado e aplica tags no contato do Z-PRO — + ver [08-automacao-envio.md](08-automacao-envio.md). diff --git a/gti-maquina-comercial/docs/03-playbook-comercial.md b/gti-maquina-comercial/docs/03-playbook-comercial.md new file mode 100644 index 0000000..28e39b7 --- /dev/null +++ b/gti-maquina-comercial/docs/03-playbook-comercial.md @@ -0,0 +1,83 @@ +# Playbook Comercial + +## Oferta De Entrada + +Nome: Diagnostico GTI de Gestao, TI e Atendimento. + +Promessa: em 15 minutos, identificar gargalos em sistema, atendimento, infraestrutura ou processos manuais da empresa. + +Essa oferta reduz resistencia porque nao comeca vendendo ERP, contrato de TI ou automacao diretamente. + +## ICP Principal + +Priorizar empresas de Santo Antonio de Jesus e regiao com qualquer uma destas caracteristicas: + +- Controle por planilha ou papel. +- Sistema atual ruim ou insatisfatorio. +- Alto volume de WhatsApp. +- Muitos computadores ou usuarios. +- Problemas frequentes com suporte, rede, backup ou servidor. +- Dificuldade com estoque, financeiro, fiscal ou vendas. + +## Segmentos Prioritarios + +- Mercados e supermercados. +- Farmacias e drogarias. +- Restaurantes e delivery. +- Distribuidoras. +- Autopecas. +- Material de construcao. +- Clinicas. +- Oficinas. +- Escolas. +- Lojas de varejo. +- Empresas de servicos. + +## Metodo De Atendimento + +Usar SPIN de forma simples: + +- Situacao: entender como a empresa funciona hoje. +- Problema: descobrir gargalos. +- Implicacao: mostrar o impacto de continuar assim. +- Necessidade: conectar a dor a uma solucao GTI. + +## Perguntas Do Diagnostico + +- Como voces controlam vendas, estoque e financeiro hoje? +- Qual processo mais toma tempo ou gera retrabalho? +- Hoje voces usam sistema, planilha ou controle manual? +- Quando da problema de TI, quem resolve? +- Existe rotina de backup? +- Quantas mensagens chegam por dia no WhatsApp? +- Qual o impacto se esse problema continuar por mais 6 meses? +- Se a GTI resolver isso, qual resultado seria mais importante? + +## Roteiro Para Lead Quente + +```text +Ola, [nome]. Vi aqui seu contato sobre [servico]. +Pelo que voce informou, parece que existe uma oportunidade clara de melhorar [dor]. +Quero fazer um diagnostico rapido de 15 minutos para entender sua operacao e te dizer o melhor caminho. +Pode ser hoje pela tarde ou amanha pela manha? +``` + +## Follow-up Manual Em Proposta + +Dia 1: + +```text +Oi, [nome]. Conseguiu avaliar a proposta da GTI? Posso te ajudar com alguma duvida tecnica ou comercial? +``` + +Dia 3: + +```text +[nome], queria confirmar se esse projeto ainda faz sentido para agora ou se prefere que eu reprograme o contato para outro momento. +``` + +Dia 7: + +```text +Vou deixar seu atendimento em aberto para retomarmos quando fizer sentido. Se quiser avancar, me responda por aqui. +``` diff --git a/gti-maquina-comercial/docs/04-lgpd-whatsapp.md b/gti-maquina-comercial/docs/04-lgpd-whatsapp.md new file mode 100644 index 0000000..a77efd7 --- /dev/null +++ b/gti-maquina-comercial/docs/04-lgpd-whatsapp.md @@ -0,0 +1,48 @@ +# LGPD E Regras De WhatsApp + +Este projeto deve ser operado com cuidado para evitar bloqueio de WhatsApp e problemas de privacidade. + +## Regras Basicas + +- Guardar origem do lead. +- Registrar consentimento quando existir. +- Evitar disparo massivo sem opt-in. +- Permitir descadastro ou parada de contato. +- Nao comprar listas frias para disparo automatico. +- Usar abordagem consultiva, nao agressiva. +- Manter historico de data, origem e mensagem enviada. + +## Mensagem De Descadastro + +Toda sequencia pode aceitar palavras como: + +```text +PARAR +CANCELAR +SAIR +NAO QUERO +``` + +Quando uma dessas respostas chegar, o lead deve ser marcado como `Opt-out` no CRM e sair das automacoes. + +## Prospecao Ativa Segura + +Para listas frias locais, a abordagem deve ser manual ou semi-automatizada, com baixo volume e personalizacao. + +Exemplo: + +```text +Ola, tudo bem? Aqui e do Grupo GTI, de Santo Antonio de Jesus. +Vi que sua empresa atua em [segmento] e queria entender se hoje voces ja usam algum sistema para vendas, estoque ou atendimento. +Se nao fizer sentido, pode me avisar que nao envio novas mensagens. +``` + +## Dados Minimos No CRM + +- Nome. +- Empresa. +- WhatsApp. +- Origem. +- Data do primeiro contato. +- Consentimento. +- Status de opt-out. diff --git a/gti-maquina-comercial/docs/07-prospeccao-automatica.md b/gti-maquina-comercial/docs/07-prospeccao-automatica.md new file mode 100644 index 0000000..1e759ea --- /dev/null +++ b/gti-maquina-comercial/docs/07-prospeccao-automatica.md @@ -0,0 +1,75 @@ +# Captura de Leads + +A captura procura empresas por **UF + cidades + nichos** nas fontes selecionadas, +pontua cada lead (score/classificação) e grava tudo no banco local com +deduplicação automática — um lead capturado uma vez nunca é duplicado, em nenhuma +execução futura. + +O jeito normal de usar é pela **aba "Capturar Leads" do aplicativo** +(GTI-Comercial.exe). Este documento cobre os detalhes e o uso por linha de comando. + +## Fontes + +| Fonte | Custo | Observações | +|---|---|---| +| `cnpj-base` | grátis | **Principal.** Base oficial da Receita Federal extraída por UF (~97% com telefone). | +| `brasilapi` | grátis | Mesma base CNPJ, enriquecida online via BrasilAPI (mais lenta). | +| `overpass` | grátis | OpenStreetMap por área da cidade (bounding box automática via geocode). | +| `nominatim` | grátis | Busca textual no OpenStreetMap. | +| `wikidata` | grátis | Empresas registradas no Wikidata (volume baixo). | +| `google-places` | paga | Exige `GOOGLE_MAPS_API_KEY` no `.env`. | +| `mock` | — | 3 leads fictícios para testar o fluxo. | + +## Base CNPJ por estado + +Cada UF precisa de uma extração única (~40–60 min) que gera +`prospeccao/cnpj/estabelecimentos-.csv` a partir dos zips brutos da Receita +(`prospeccao/cnpj/rfb-raw/`, Brasil inteiro, já baixados). A interface oferece o +botão **"Extrair base agora"** quando a UF escolhida ainda não tem base; na linha +de comando: + +```bash +node scripts/extrair-base-cnpj.mjs --uf=SP +``` + +A extração cobre **todos os municípios da UF** — depois dela, qualquer cidade do +estado pode ser capturada na hora, sem re-extração. Um checkpoint em disco permite +retomar de onde parou se o processo for interrompido. + +## Deduplicação e pontuação + +- Chave de duplicidade: **CNPJ válido → WhatsApp válido → empresa+cidade**. +- **Score de Qualidade** (0-100, "os dados são confiáveis?"): +30 WhatsApp válido, + +20 CNPJ válido, +20 empresa válida, +5 cidade, +10 nicho, +10 CNAE, +5 website. +- **Score Comercial** (0-100, "qual a chance de comprar?"): aderência do nicho + (alta 40 / média 30 / baixa 20, configurada por nicho em `prospeccao/config.json`) + + website 15 + e-mail 10 + WhatsApp válido 15 + CNPJ válido 20. +- **Score Geral** = 60% comercial + 40% qualidade — é o valor usado para ordenar + a tela de Tratamento. +- **Classificação**: 90-100 Excelente, 75-89 Bom, 55-74 Médio, 35-54 Baixo, + 0-34 Muito Baixo. A **Prioridade comercial** (P1-P4, mostrada na tela) deriva + direto dela: Excelente/Bom ⇒ P1 (atacar já), Médio ⇒ P2 (na semana), Baixo ⇒ P3, + Muito Baixo ⇒ P4 (só em campanhas específicas). +- Cada nicho tem produtos GTI recomendados automaticamente (ERP, G-Delivery, + G-Chat, Sistema de Ponto) que aparecem no card do lead. Se o catálogo de + aderência/produtos do `config.json` mudar, rode `npm run recalc-scores` para + recalcular todos os leads já capturados. + +## Linha de comando (uso avançado) + +```bash +node scripts/capturar-leads.mjs --sources=mock +node scripts/capturar-leads.mjs --sources=cnpj-base --uf=BA --cities="Ilheus,Itabuna" +node scripts/capturar-leads.mjs --sources=cnpj-base,overpass --uf=BA --cities=all --segments=all +node scripts/capturar-leads.mjs --help +``` + +Parâmetros úteis: `--segments=a,b,c` (nichos pelo nome), `--segment-limit=N`, +`--city-limit=N`, `--max=N` (resultados por consulta nas fontes de API), +`--query-limit=N` (teto total de consultas). + +## Ritmo e boas práticas (fontes OpenStreetMap) + +Os servidores públicos do Overpass/Nominatim têm limites de uso. O app já aplica +espera entre consultas (`OVERPASS_REQUEST_DELAY_MS`), failover entre endpoints e +cooldown após falhas — falha de uma cidade não derruba a captura, só pula. diff --git a/gti-maquina-comercial/docs/08-automacao-envio.md b/gti-maquina-comercial/docs/08-automacao-envio.md new file mode 100644 index 0000000..bfbe958 --- /dev/null +++ b/gti-maquina-comercial/docs/08-automacao-envio.md @@ -0,0 +1,133 @@ +# Automação de envio WhatsApp (Z-PRO) + +Manda automaticamente a primeira mensagem de WhatsApp pro produto/serviço +recomendado de cada lead, usando um template por produto, e registra tudo +(quem recebeu, qual produto, quando) pra nunca mandar a mesma coisa duas +vezes pro mesmo lead. + +## Como funciona + +- Roda **sozinha, dentro do próprio servidor**, a cada 20 minutos, enquanto + o app estiver ligado — não depende de Agendador de Tarefas do Windows nem + de cron. Isso é de propósito: o projeto hoje roda local, mas vai ser + migrado pra um container Docker no Proxmox da empresa, e um agendador do + sistema operacional teria que ser reconfigurado nesse dia. O timer interno + funciona idêntico nos dois ambientes. +- Só considera leads **ainda não exportados** (`exported_at` vazio). Assim + que um lead vira oportunidade manual no Z-PRO (exportação normal da tela + de Tratamento), a automação para de mandar mensagem pra ele — quem assumiu + o lead foi um vendedor, não o robô. +- Cada lead tem uma lista de produtos recomendados por nicho (ex.: ERP 5⭐, + G-Delivery 3⭐). Hoje a automação só tenta o **produto principal** (o de + maior estrela), uma única vez por lead. Existe uma segunda etapa pronta + (mas desligada) pra tentar o próximo produto da lista se não houver + resposta depois de alguns dias — ver `outreach.tentarProximoProduto` + abaixo. +- Manda uma mensagem por número, com controle nosso de quem já recebeu o + quê — não pelo `bulkSendMessageWithVariable` usado na exportação manual + em massa, que depende de um CSV com substituição posicional de variável. + Cada produto tem um **canal** próprio (ver seção abaixo). +- Depois de mandar, aplica duas tags no contato do Z-PRO: + `GTI P1`/`GTI P2`/`GTI P3`/`GTI P4` (prioridade do lead) e + `GTI Oferta: ` (o que foi ofertado) — pra quem for atender depois + já ver esse contexto direto na conversa. + +## Canal oficial x não-oficial + +Cada produto/template tem um seletor de canal em Configurações: + +- **Não-oficial (texto livre)** — usa `bulkIndividual`, funciona numa + conexão QR Code/Baileys do Z-PRO. Manda o texto do template com + `{empresa}`/`{cidade}`/`{segmento}` substituídos. É o padrão, e o risco + aqui é o de sempre com WhatsApp não-oficial: possibilidade de bloqueio do + número por comportamento automatizado (por isso o limite diário e o + intervalo aleatório entre envios). +- **Oficial (Meta, template aprovado)** — usa a API oficial do WhatsApp + (Cloud API/WABA) via Z-PRO. Aqui **não dá pra mandar texto livre**: o + WhatsApp só permite iniciar uma conversa com um **template pré-aprovado** + pelo Meta Business Manager (fora da janela de 24h de uma conversa já + iniciada pelo cliente). Por isso o campo vira "Nome do template aprovado" + (tem que ser **exatamente** o nome cadastrado no Meta) + "Idioma" (ex.: + `pt_BR`). + + **A aprovação do template em si é feita fora deste app** — no Meta + Business Manager (ou no painel do Z-PRO, se ele tiver essa tela) — o + Z-PRO só expõe o *envio* usando um template que já existe, não a criação. + Cadastre e espere aprovar lá antes de configurar o nome aqui. + + Limitação atual: o envio oficial aqui é só nome+idioma, sem variáveis + dentro do corpo do template aprovado (a API do Z-PRO tem endpoints + `templateBody`/`templateMarketingBody` que parecem aceitar variáveis, mas + o formato exato não está documentado na collection — se for necessário, + precisa testar contra a instância real antes de automatizar). + +## Configurar pela primeira vez + +1. **Credenciais do Z-PRO**: no painel do Z-PRO, vá em Admin > API e gere um + token (Base URL, API ID, Token e WhatsApp ID). Na tela, abra + **⚙️ Configurações > Credenciais Z-PRO (WhatsApp)**, preencha os 4 campos + e clique em **Testar conexão** (salva e faz uma chamada real e barata pra + confirmar que as credenciais estão certas, sem mandar nenhuma mensagem). + Sem essas 4 credenciais a automação não manda nada, mesmo ligada. + + Alternativa pra quem prefere não guardar o token no banco local (ex.: em + produção no Docker/Proxmox, gerenciando segredo por variável de + ambiente): deixe os 4 campos da tela em branco e defina + `ZPRO_BASE_URL`/`ZPRO_API_ID`/`ZPRO_BEARER_TOKEN`/`ZPRO_WHATSAPP_ID` no + `.env` — a tela vazia cai automaticamente pro `.env`. Se a tela tiver + algum valor preenchido, ele tem prioridade sobre o `.env`. +2. Na mesma tela, em **Automação de envio (WhatsApp)**: revise o texto de + cada template (um por produto — `{empresa}`, `{cidade}` e `{segmento}` + são substituídos automaticamente), ajuste o **limite por dia** se + quiser, e marque **Ativada**. Salve. + +Antes de ativar, vale mandar um teste manual (próxima seção) pro seu +próprio WhatsApp. + +## Configurações (`settings`, chave → o que faz) + +| Chave | Padrão | O que faz | +|---|---|---| +| `outreach.ativo` | `false` | Interruptor geral. Começa desligado de propósito. | +| `outreach.mensagensPorDia` | `10` | Máximo de envios automáticos por dia (soma de todos os produtos). | +| `outreach.diasEntreProdutos` | `5` | Dias de espera antes de tentar o próximo produto (só usado se a cascata estiver ligada). | +| `outreach.tentarProximoProduto` | `false` | Liga a cascata: quando `false`, só tenta o produto principal uma vez. | +| `outreach.horaInicio` / `outreach.horaFim` | `08:00` / `18:00` | Só manda mensagem dentro desse horário. | + +Tudo isso é editável pela tela de Configurações (os dois primeiros) ou +diretamente via `PUT /api/settings` (todos). + +## Disparo manual (fora do ciclo automático) + +Pra testar ou forçar um lote sem esperar o próximo ciclo de 20 min: + +```bash +npm run outreach +``` + +Respeita as mesmas regras do ciclo automático (`outreach.ativo`, horário +comercial, limite diário) — não manda nada que a tela não mostraria. + +## Enviar pra um número específico + +Em **Configurações > Enviar mensagem manual**: digite o número (com DDI+DDD, +ex.: `5511999999999`), escolha o canal (texto livre ou template oficial já +aprovado) e clique em Enviar. Não depende de lead cadastrado nem de produto. + +No canal **Não-oficial (texto livre)**, o app usa `createTicket` para criar uma +conversa visível no painel do Z-PRO. O ticket usa o canal informado em +`WhatsApp ID` e o usuário de `Responsável` nas configurações. + +No canal **Oficial**, o app usa o endpoint de template aprovado (`template`) e +também espera um `ticketId` na resposta. + +Toda tentativa fica registrada no mesmo histórico dos envios automáticos +(marcado como origem "manual") e **conta pro limite diário**, já que o risco de +bloqueio é do número de WhatsApp usado, não de quem disparou a mensagem. + +## Onde fica o histórico + +Toda tentativa de envio (sucesso ou erro) vira uma linha na tabela +`outreach_sends` (lead, produto, mensagem exata mandada, status, quando). +Um resumo do dia (enviados hoje, por produto, últimos 20 envios) fica +disponível em `GET /api/outreach/status`. diff --git a/gti-maquina-comercial/docs/referencia/Z-PRO.postman_collection.json b/gti-maquina-comercial/docs/referencia/Z-PRO.postman_collection.json new file mode 100644 index 0000000..96e50b1 --- /dev/null +++ b/gti-maquina-comercial/docs/referencia/Z-PRO.postman_collection.json @@ -0,0 +1,9244 @@ +{ + "info": { + "_postman_id": "8c70ee78-d85c-478f-be3e-d702283f72da", + "name": "Z-PRO v4.x.x.x", + "description": "Postman Z-PRO v4.x.x.x", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "56519634", + "_collection_link": "https://go.postman.co/collection/25151510-8c70ee78-d85c-478f-be3e-d702283f72da?source=collection_link" + }, + "item": [ + { + "name": "📅 Agendamentos e Lembretes", + "item": [ + { + "name": "AppointmentCreate", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Reunião de Vendas\",\n \"description\": \"Reunião sobre proposta comercial\",\n \"contactId\": \"{{contactId}}\",\n \"contactName\": \"João Silva\",\n \"contactPhone\": \"5511999999999\",\n \"whatsappId\": \"{{whatsappId}}\",\n \"startAt\": \"2026-04-15T14:00:00.000Z\",\n \"endAt\": \"2026-04-15T15:00:00.000Z\",\n \"status\": \"pending\",\n \"notes\": \"Levar proposta impressa\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/appointment/create", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "appointment", + "create" + ] + } + }, + "response": [] + }, + { + "name": "AppointmentDelete", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/appointment/delete/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "appointment", + "delete", + "{{id}}" + ] + } + }, + "response": [] + }, + { + "name": "AppointmentList", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/appointment/list?page=1&limit=20&status=pending", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "appointment", + "list" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "limit", + "value": "20" + }, + { + "key": "status", + "value": "pending", + "description": "pending | confirmed | cancelled | completed" + }, + { + "key": "startFrom", + "value": "2026-04-01T00:00:00.000Z", + "description": "Filtrar agendamentos a partir desta data (ISO 8601)", + "disabled": true + }, + { + "key": "startTo", + "value": "2026-04-30T23:59:59.000Z", + "description": "Filtrar agendamentos até esta data (ISO 8601)", + "disabled": true + }, + { + "key": "search", + "value": "", + "description": "Busca por título ou nome do contato", + "disabled": true + } + ] + } + }, + "response": [] + }, + { + "name": "AppointmentShow", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/appointment/show/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "appointment", + "show", + "{{id}}" + ] + } + }, + "response": [] + }, + { + "name": "AppointmentUpdate", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Reunião de Vendas (atualizada)\",\n \"description\": \"Descrição atualizada\",\n \"contactId\": \"{{contactId}}\",\n \"contactName\": \"João Silva\",\n \"contactPhone\": \"5511999999999\",\n \"whatsappId\": \"{{whatsappId}}\",\n \"startAt\": \"2026-04-15T15:00:00.000Z\",\n \"endAt\": \"2026-04-15T16:00:00.000Z\",\n \"status\": \"confirmed\",\n \"notes\": \"Cliente confirmou presença\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/appointment/update/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "appointment", + "update", + "{{id}}" + ] + } + }, + "response": [] + }, + { + "name": "ScheduleReminderCreate", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Lembrete 24h antes\",\n \"description\": \"Envia mensagem ao contato 24h antes do agendamento\",\n \"hoursBeforeEvent\": 24,\n \"messageType\": \"message\",\n \"messageContent\": \"Olá {{nome}}, lembrete da sua reunião amanhã!\",\n \"whatsappId\": \"{{whatsappId}}\",\n \"active\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/scheduleReminder/create", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "scheduleReminder", + "create" + ] + } + }, + "response": [] + }, + { + "name": "ScheduleReminderDelete", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/scheduleReminder/delete/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "scheduleReminder", + "delete", + "{{id}}" + ] + } + }, + "response": [] + }, + { + "name": "ScheduleReminderList", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/scheduleReminder/list", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "scheduleReminder", + "list" + ] + } + }, + "response": [] + }, + { + "name": "ScheduleReminderToggle", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/scheduleReminder/toggle/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "scheduleReminder", + "toggle", + "{{id}}" + ] + } + }, + "response": [] + }, + { + "name": "ScheduleReminderUpdate", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Lembrete 48h antes\",\n \"description\": \"Envia mensagem 48h antes do evento\",\n \"hoursBeforeEvent\": 48,\n \"messageType\": \"message\",\n \"messageContent\": \"Olá {{nome}}, sua reunião é em 2 dias!\",\n \"whatsappId\": \"{{whatsappId}}\",\n \"active\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/scheduleReminder/update/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "scheduleReminder", + "update", + "{{id}}" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "📣 Campanhas", + "item": [ + { + "name": "CampaignCancel", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/campaign/cancel/{{campaignId}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "campaign", + "cancel", + "{{campaignId}}" + ] + } + }, + "response": [] + }, + { + "name": "CampaignContactsAdd", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "[\n {\n \"name\": \"Contato 1\",\n \"number\": \"5511999990001\"\n },\n {\n \"name\": \"Contato 2\",\n \"number\": \"5511999990002\"\n }\n]", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/campaign/contacts/add/{{campaignId}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "campaign", + "contacts", + "add", + "{{campaignId}}" + ] + } + }, + "response": [] + }, + { + "name": "CampaignContactsList", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/campaign/contacts/{{campaignId}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "campaign", + "contacts", + "{{campaignId}}" + ] + } + }, + "response": [] + }, + { + "name": "CampaignContactsRemove", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/campaign/contacts/remove/{{campaignId}}/{{contactId}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "campaign", + "contacts", + "remove", + "{{campaignId}}", + "{{contactId}}" + ] + } + }, + "response": [] + }, + { + "name": "CampaignContactsRemoveAll", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/campaign/contacts/removeAll/{{campaignId}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "campaign", + "contacts", + "removeAll", + "{{campaignId}}" + ] + } + }, + "response": [] + }, + { + "name": "CampaignCreate", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Campanha Teste\",\n \"start\": \"2026-04-10T10:00:00.000Z\",\n \"message1\": \"Olá {nome}!\",\n \"message2\": \"Como vai?\",\n \"message3\": \"Até logo!\",\n \"sessionId\": \"{{sessionId}}\",\n \"delay\": \"5\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/campaign/create", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "campaign", + "create" + ] + } + }, + "response": [] + }, + { + "name": "CampaignDelete", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/campaign/delete/{{campaignId}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "campaign", + "delete", + "{{campaignId}}" + ] + } + }, + "response": [] + }, + { + "name": "CampaignDuplicate", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/campaign/duplicate/{{campaignId}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "campaign", + "duplicate", + "{{campaignId}}" + ] + } + }, + "response": [] + }, + { + "name": "CampaignList", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/campaign/list?page=1&limit=10", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "campaign", + "list" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "limit", + "value": "10" + } + ] + } + }, + "response": [] + }, + { + "name": "CampaignPause", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/campaign/pause/{{campaignId}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "campaign", + "pause", + "{{campaignId}}" + ] + } + }, + "response": [] + }, + { + "name": "CampaignReport", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/campaign/report/{{campaignId}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "campaign", + "report", + "{{campaignId}}" + ] + } + }, + "response": [] + }, + { + "name": "CampaignResume", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/campaign/resume/{{campaignId}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "campaign", + "resume", + "{{campaignId}}" + ] + } + }, + "response": [] + }, + { + "name": "CampaignSkip", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/campaign/skip/{{campaignId}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "campaign", + "skip", + "{{campaignId}}" + ] + } + }, + "response": [] + }, + { + "name": "CampaignStart", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/campaign/start/{{campaignId}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "campaign", + "start", + "{{campaignId}}" + ] + } + }, + "response": [] + }, + { + "name": "CampaignUpdate", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Campanha Atualizada\",\n \"start\": \"2026-04-15T10:00:00.000Z\",\n \"message1\": \"Olá!\",\n \"message2\": \"Tudo bem?\",\n \"message3\": \"Até logo!\",\n \"sessionId\": \"{{sessionId}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/campaign/update/{{campaignId}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "campaign", + "update", + "{{campaignId}}" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "🔌 Canais e Sessões", + "item": [ + { + "name": "CreateSession", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Nome da instância do WhatsApp\n \"name\": \"My WhatsApp Instance\",\n \n // Status inicial da sessão (DISCONNECTED, CONNECTED)\n \"status\": \"DISCONNECTED\",\n \n // Tipo da sessão\n \"type\": \"baileys\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/createtSession", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "createtSession" + ] + } + }, + "response": [] + }, + { + "name": "DeleteSession", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // ID da sessão do WhatsApp que será deletada\n \"whatsappId\": 1\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/deleteSession", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "deleteSession" + ] + } + }, + "response": [] + }, + { + "name": "RequestNewQrCode", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // ID da sessão do WhatsApp que terá um novo QR Code gerado\n \"whatsappId\": 1\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/requestNewQrCodeSession", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "requestNewQrCodeSession" + ] + } + }, + "response": [] + }, + { + "name": "ShowChannelInformation", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Número do WhatsApp do canal (formato: 5511999999999)\n \"number\": \"5515998566622\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/showChannel", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "showChannel" + ] + } + }, + "response": [] + }, + { + "name": "ShowChannelInformationById", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // ID do canal que será consultado\n \"id\": 43\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/showChannelById", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "showChannelById" + ] + } + }, + "response": [] + }, + { + "name": "ShowQrCode", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // ID da sessão do WhatsApp que terá o QR Code gerado\n \"whatsappId\": 1\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/qrCodeSession", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "qrCodeSession" + ] + } + }, + "response": [] + }, + { + "name": "StartSession", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // ID da sessão do WhatsApp que será iniciada\n \"whatsappId\": 1\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/startSession", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "startSession" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "👤 Contatos", + "item": [ + { + "name": "BlockContact", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // ID do contato que será bloqueado/desbloqueado\n \"contactId\": 1,\n \n // true para bloquear, false para desbloquear\n \"blocked\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/blockContact", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "blockContact" + ] + } + }, + "response": [] + }, + { + "name": "CreateContact", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Nome completo do contato\n \"name\": \"Nome\", \n \n // Número do WhatsApp (formato: 5511999999999)\n \"number\": \"5515998566622\", \n \n // Email do contato\n \"email\": \"contato@email.com\",\n \n // CPF do contato (formato: 000.000.000-00)\n \"cpf\": \"012.345.678.91\",\n \n // Primeiro nome do contato\n \"firstName\": \"Nome Principal\",\n \n // Sobrenome do contato\n \"lastName\": \"Sobrenome\",\n \n // Nome da empresa do contato\n \"businessName\": \"Empresa\",\n \n // Data de nascimento (formato: DD/MM/AAAA)\n \"birthdayDate\": \"01/01/1990\",\n \n // Chave única do sistema para identificação (valor gerado do seu lado para controle do envio e não pode se repetir)\n \"externalKey\": \"ID_UNICA_DO_SISTEMA_CLIENTE_PARA_EXECUTAR_UMA_ACAO_COM_WEBHOOK\",\n // Validar numero (opcional, padrao true). Se false, usa o numero exatamente como enviado, sem normalizacao do 9o digito BR — recomendado p/ WABA evitar ticket duplicado\n \"validateNumber\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/createContact", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "createContact" + ] + } + }, + "response": [] + }, + { + "name": "GetContactExtraInfo", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/getContactExtraInfo?contactId=1", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "getContactExtraInfo" + ], + "query": [ + { + "key": "contactId", + "value": "1", + "description": "ID do contato" + } + ] + } + }, + "response": [] + }, + { + "name": "SearchContacts", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Parâmetro de busca textual (opcional) - busca em nome, número, CPF, etc\n \"searchParam\": \"\",\n \n // Número da página (padrão: 1)\n \"page\": 1,\n \n // Limite de resultados por página (padrão: 40)\n \"limit\": 40,\n \n // ID de uma tag para filtrar (opcional)\n \"tagId\": 1,\n \n // OU array de IDs de tags para filtrar por múltiplas tags (opcional)\n // \"tagIds\": [1, 2, 3],\n \n // ID da wallet para filtrar (opcional)\n // \"walletId\": 1,\n \n // Filtrar por contatos bloqueados (opcional: true/false)\n // \"blocked\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/contacts/search", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "contacts", + "search" + ] + } + }, + "response": [] + }, + { + "name": "ShowContact", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Número do WhatsApp do contato (formato: 5511999999999)\n \"number\": \"5515998566622\",\n // Validar numero (opcional, padrao true). Se false, usa o numero exatamente como enviado, sem normalizacao do 9o digito BR — recomendado p/ WABA evitar ticket duplicado\n \"validateNumber\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/showcontact", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "showcontact" + ] + } + }, + "response": [] + }, + { + "name": "UpdateContact", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Nome completo do contato\n \"name\": \"Nome\", \n \n // Número do WhatsApp (formato: 5511999999999)\n \"number\": \"5515998566622\", \n \n // Email do contato\n \"email\": \"contato@email.com\",\n \n // CPF do contato (formato: 000.000.000-00)\n \"cpf\": \"012.345.678.91\",\n \n // Primeiro nome do contato\n \"firstName\": \"Nome Principal\",\n \n // Sobrenome do contato\n \"lastName\": \"Sobrenome\",\n \n // Nome da empresa do contato\n \"businessName\": \"Empresa\",\n \n // Data de nascimento (formato: DD/MM/AAAA)\n \"birthdayDate\": \"01/01/1990\",\n \n // ID do kanban do contato\n \"kanban\": 2,\n \n // Chave única do sistema para identificação (valor gerado do seu lado para controle do envio e não pode se repetir)\n \"externalKey\": \"ID_UNICA_DO_SISTEMA_CLIENTE_PARA_EXECUTAR_UMA_ACAO_COM_WEBHOOK\",\n // Validar numero (opcional, padrao true). Se false, usa o numero exatamente como enviado, sem normalizacao do 9o digito BR — recomendado p/ WABA evitar ticket duplicado\n \"validateNumber\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/updateContact", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "updateContact" + ] + } + }, + "response": [] + }, + { + "name": "UpdateContactExtraInfo", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // ID do contato que terá as informações adicionais atualizadas\n \"contactId\": 1,\n \n // Array de campos personalizados (extraInfo)\n \"extraInfo\": [\n {\n \"name\": \"Campo Personalizado 1\",\n \"value\": \"Valor do campo 1\"\n },\n {\n \"name\": \"Campo Personalizado 2\",\n \"value\": \"Valor do campo 2\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/updateContactExtraInfo", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "updateContactExtraInfo" + ] + } + }, + "response": [] + }, + { + "name": "UpdateContactKanban", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // ID do contato que terá o kanban (carteira) alterado\n \"contactId\": 1,\n \n // ID do kanban (carteira) que será atribuído ao contato\n \"kanban\": 2\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/updateContactKanban", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "updateContactKanban" + ] + } + }, + "response": [] + }, + { + "name": "UpdateContactWallet", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // ID do contato que será carteirizado\n \"contactId\": 1,\n \n // ID da wallet que será atribuída ao contato (pode usar walletId ou walletIds)\n \"walletId\": 2,\n \n // OU array de IDs de wallets para atribuir múltiplas wallets de uma vez\n // \"walletIds\": [1, 2, 3]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/updateContactWallet", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "updateContactWallet" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "🏗️ CRM - Pipeline", + "item": [ + { + "name": "PipelineCreate", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Funil de Vendas\",\n \"description\": \"Pipeline principal\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/pipeline/create", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "pipeline", + "create" + ] + } + }, + "response": [] + }, + { + "name": "PipelineDelete", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/pipeline/delete/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "pipeline", + "delete", + "{{id}}" + ] + } + }, + "response": [] + }, + { + "name": "PipelineList", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/pipeline/list?page=1&limit=20", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "pipeline", + "list" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "limit", + "value": "20" + } + ] + } + }, + "response": [] + }, + { + "name": "PipelineShow", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/pipeline/show/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "pipeline", + "show", + "{{id}}" + ] + } + }, + "response": [] + }, + { + "name": "PipelineUpdate", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Funil Atualizado\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/pipeline/update/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "pipeline", + "update", + "{{id}}" + ] + } + }, + "response": [] + }, + { + "name": "StageCreate", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Prospecção\",\n \"pipelineId\": \"{{pipelineId}}\",\n \"order\": 1,\n \"color\": \"#00a300\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/stage/create", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "stage", + "create" + ] + } + }, + "response": [] + }, + { + "name": "StageDelete", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/stage/delete/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "stage", + "delete", + "{{id}}" + ] + } + }, + "response": [] + }, + { + "name": "StageList", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/stage/list?page=1&limit=20&pipelineId={{pipelineId}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "stage", + "list" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "limit", + "value": "20" + }, + { + "key": "pipelineId", + "value": "{{pipelineId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "StageShow", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/stage/show/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "stage", + "show", + "{{id}}" + ] + } + }, + "response": [] + }, + { + "name": "StageUpdate", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Qualificação\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/stage/update/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "stage", + "update", + "{{id}}" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "📊 Dashboard", + "item": [ + { + "name": "DashTicketsAndTimes", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/dash/ticketsAndTimes?startDate=2026-03-01&endDate=2026-03-31", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "dash", + "ticketsAndTimes" + ], + "query": [ + { + "key": "startDate", + "value": "2026-03-01" + }, + { + "key": "endDate", + "value": "2026-03-31" + } + ] + } + }, + "response": [] + }, + { + "name": "DashTicketsChannels", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/dash/ticketsChannels?startDate=2026-03-01&endDate=2026-03-31", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "dash", + "ticketsChannels" + ], + "query": [ + { + "key": "startDate", + "value": "2026-03-01" + }, + { + "key": "endDate", + "value": "2026-03-31" + } + ] + } + }, + "response": [] + }, + { + "name": "DashTicketsEvolution", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/dash/ticketsEvolution?startDate=2026-03-01&endDate=2026-03-31", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "dash", + "ticketsEvolution" + ], + "query": [ + { + "key": "startDate", + "value": "2026-03-01" + }, + { + "key": "endDate", + "value": "2026-03-31" + } + ] + } + }, + "response": [] + }, + { + "name": "DashTicketsEvolutionByValue", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/dash/ticketsEvolutionByValue?startDate=2026-03-01&endDate=2026-03-31", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "dash", + "ticketsEvolutionByValue" + ], + "query": [ + { + "key": "startDate", + "value": "2026-03-01" + }, + { + "key": "endDate", + "value": "2026-03-31" + } + ] + } + }, + "response": [] + }, + { + "name": "DashTicketsEvolutionChannelsName", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/dash/ticketsEvolutionChannelsName?startDate=2026-03-01&endDate=2026-03-31", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "dash", + "ticketsEvolutionChannelsName" + ], + "query": [ + { + "key": "startDate", + "value": "2026-03-01" + }, + { + "key": "endDate", + "value": "2026-03-31" + } + ] + } + }, + "response": [] + }, + { + "name": "DashTicketsPerUser", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/dash/ticketsPerUser?startDate=2026-03-01&endDate=2026-03-31", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "dash", + "ticketsPerUser" + ], + "query": [ + { + "key": "startDate", + "value": "2026-03-01" + }, + { + "key": "endDate", + "value": "2026-03-31" + } + ] + } + }, + "response": [] + }, + { + "name": "DashTicketsQueue", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/dash/ticketsQueue?startDate=2026-03-01&endDate=2026-03-31", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "dash", + "ticketsQueue" + ], + "query": [ + { + "key": "startDate", + "value": "2026-03-01" + }, + { + "key": "endDate", + "value": "2026-03-31" + } + ] + } + }, + "response": [] + }, + { + "name": "DashTicketsReasons", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/dash/ticketsReasons?startDate=2026-03-01&endDate=2026-03-31", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "dash", + "ticketsReasons" + ], + "query": [ + { + "key": "startDate", + "value": "2026-03-01" + }, + { + "key": "endDate", + "value": "2026-03-31" + } + ] + } + }, + "response": [] + }, + { + "name": "DashTicketsStatus", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/dash/ticketsStatus?startDate=2026-03-01&endDate=2026-03-31", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "dash", + "ticketsStatus" + ], + "query": [ + { + "key": "startDate", + "value": "2026-03-01" + }, + { + "key": "endDate", + "value": "2026-03-31" + } + ] + } + }, + "response": [] + }, + { + "name": "DashTicketsUser", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/dash/ticketsUser?startDate=2026-03-01&endDate=2026-03-31", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "dash", + "ticketsUser" + ], + "query": [ + { + "key": "startDate", + "value": "2026-03-01" + }, + { + "key": "endDate", + "value": "2026-03-31" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "📤 Envio em Lote", + "item": [ + { + "name": "BulkDispatchIncrementProgress", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"success\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/bulkDispatch/incrementProgress/{{dispatchId}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "bulkDispatch", + "incrementProgress", + "{{dispatchId}}" + ] + } + }, + "response": [] + }, + { + "name": "BulkDispatchList", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/bulkDispatch/list?page=1&limit=20", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "bulkDispatch", + "list" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "limit", + "value": "20" + } + ] + } + }, + "response": [] + }, + { + "name": "BulkDispatchShow", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/bulkDispatch/show/{{dispatchId}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "bulkDispatch", + "show", + "{{dispatchId}}" + ] + } + }, + "response": [] + }, + { + "name": "BulkDispatchUpdate", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"status\": \"cancelled\",\n \"cancellationReason\": \"Cancelado pelo usuário\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/bulkDispatch/update/{{dispatchId}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "bulkDispatch", + "update", + "{{dispatchId}}" + ] + } + }, + "response": [] + }, + { + "name": "BulkFastMessage", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"whatsappId\": \"{{whatsappId}}\",\n \"whatsappType\": \"baileys\",\n \"arrayNumbers\": [\n \"5511999990001\",\n \"5511999990002\"\n ],\n \"message\": \"Olá, mensagem rápida!\",\n \"min\": 3,\n \"max\": 8\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/bulkFastMessage", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "bulkFastMessage" + ] + } + }, + "response": [] + }, + { + "name": "BulkIndividual", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"whatsappId\": \"{{whatsappId}}\",\n \"number\": \"5511999990001\",\n \"message\": \"Olá, mensagem individual!\",\n // Chave única do sistema para identificação (valor gerado do seu lado para controle do envio e não pode se repetir)\n \"externalKey\": \"ID_UNICA_DO_SISTEMA_CLIENTE_PARA_EXECUTAR_UMA_ACAO_COM_WEBHOOK\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/bulkIndividual", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "bulkIndividual" + ] + } + }, + "response": [] + }, + { + "name": "BulkSendMessage", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"whatsappId\": \"{{whatsappId}}\",\n \"arrayNumbers\": [\n \"5511999990001\",\n \"5511999990002\"\n ],\n \"message\": \"Olá, tudo bem?\",\n \"min\": 3,\n \"max\": 8\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/bulkSendMessage", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "bulkSendMessage" + ] + } + }, + "response": [] + }, + { + "name": "BulkSendMessageWithVariable", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"whatsappId\": \"{{whatsappId}}\",\n \"message\": \"Olá {nome}, seu código é {codigo}\",\n \"dataInput\": \"5511999990001,João,ABC123\\n5511999990002,Maria,DEF456\",\n \"min\": 3,\n \"max\": 8\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/bulkSendMessageWithVariable", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "bulkSendMessageWithVariable" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "📂 Galeria", + "item": [ + { + "name": "GalleryDelete", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/gallery/delete/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "gallery", + "delete", + "{{id}}" + ] + } + }, + "response": [] + }, + { + "name": "GalleryList", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/gallery/list?pageNumber=1&fileType=image", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "gallery", + "list" + ], + "query": [ + { + "key": "pageNumber", + "value": "1" + }, + { + "key": "fileType", + "value": "image" + } + ] + } + }, + "response": [] + }, + { + "name": "GalleryUpload", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"description\": \"Arquivo de marketing\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/gallery/upload", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "gallery", + "upload" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "👥 Grupos WhatsApp", + "item": [ + { + "name": "GroupAddParticipant", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"whatsappId\": \"{{whatsappId}}\",\n \"groupIds\": [\n \"{{groupId}}\"\n ],\n \"participants\": [\n \"5511999990001\"\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/group/addParticipant", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "group", + "addParticipant" + ] + } + }, + "response": [] + }, + { + "name": "GroupChangeDescription", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"whatsappId\": \"{{whatsappId}}\",\n \"groupIds\": [\n \"{{groupId}}\"\n ],\n \"description\": \"Nova descrição do grupo\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/group/changeDescription", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "group", + "changeDescription" + ] + } + }, + "response": [] + }, + { + "name": "GroupChangePicture", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"whatsappId\": \"{{whatsappId}}\",\n \"groupIds\": [\n \"{{groupId}}\"\n ],\n \"picture\": \"https://exemplo.com/foto.jpg\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/group/changePicture", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "group", + "changePicture" + ] + } + }, + "response": [] + }, + { + "name": "GroupChangeTitle", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"whatsappId\": \"{{whatsappId}}\",\n \"groupIds\": [\n \"{{groupId}}\"\n ],\n \"title\": \"Novo Título do Grupo\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/group/changeTitle", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "group", + "changeTitle" + ] + } + }, + "response": [] + }, + { + "name": "GroupCreate", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"whatsappId\": \"{{whatsappId}}\",\n \"titles\": [\n \"Meu Grupo 1\"\n ],\n \"number\": \"5511999990001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/group/create", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "group", + "create" + ] + } + }, + "response": [] + }, + { + "name": "GroupDemote", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"whatsappId\": \"{{whatsappId}}\",\n \"groupIds\": [\n \"{{groupId}}\"\n ],\n \"participants\": [\n \"5511999990001\"\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/group/demote", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "group", + "demote" + ] + } + }, + "response": [] + }, + { + "name": "GroupGetInviteLink", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"whatsappId\": \"{{whatsappId}}\",\n \"groupId\": \"{{groupId}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/group/getInviteLink", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "group", + "getInviteLink" + ] + } + }, + "response": [] + }, + { + "name": "GroupList", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"whatsappId\": \"{{whatsappId}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/group/list", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "group", + "list" + ] + } + }, + "response": [] + }, + { + "name": "GroupListParticipants", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"whatsappId\": \"{{whatsappId}}\",\n \"groupIds\": [\n \"{{groupId}}\"\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/group/listParticipants", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "group", + "listParticipants" + ] + } + }, + "response": [] + }, + { + "name": "GroupPromote", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"whatsappId\": \"{{whatsappId}}\",\n \"groupIds\": [\n \"{{groupId}}\"\n ],\n \"participants\": [\n \"5511999990001\"\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/group/promote", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "group", + "promote" + ] + } + }, + "response": [] + }, + { + "name": "GroupRemoveParticipant", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"whatsappId\": \"{{whatsappId}}\",\n \"groupIds\": [\n \"{{groupId}}\"\n ],\n \"participants\": [\n \"5511999990001\"\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/group/removeParticipant", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "group", + "removeParticipant" + ] + } + }, + "response": [] + }, + { + "name": "GroupSetAdminsOnly", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"whatsappId\": \"{{whatsappId}}\",\n \"groupIds\": [\n \"{{groupId}}\"\n ],\n \"adminsOnly\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/group/setAdminsOnly", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "group", + "setAdminsOnly" + ] + } + }, + "response": [] + }, + { + "name": "GroupShowById", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"whatsappId\": \"{{whatsappId}}\",\n \"groupId\": \"{{groupId}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/group/showById", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "group", + "showById" + ] + } + }, + "response": [] + }, + { + "name": "ListGroupsInfo", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n //lista todos os grupos\n \"listGroups\": true, \n \n //lista todos os grupos e adiciona o array de participantes\n \"listParticipants\": true \n\n // opicional (lista dados de um grupo específico ex: idDoGrupo@g.us)\n // \"groupId\": \"46211553263266@g.us\" \n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/listGroupInfo", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "listGroupInfo" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "🟢 Interativo Baileys", + "item": [ + { + "name": "BaileysQuickReply", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": 1262,\n \"body\": {\n \"text\": \"Escolha uma opcao:\"\n },\n \"footer\": {\n \"text\": \"Rodape opcional\"\n },\n \"buttons\": [\n {\n \"display_text\": \"Opcao 1\",\n \"id\": \"op1\"\n },\n {\n \"display_text\": \"Opcao 2\",\n \"id\": \"op2\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/sendInteractive/baileys/quickReply", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "sendInteractive", + "baileys", + "quickReply" + ] + } + }, + "response": [] + }, + { + "name": "BaileysSingleSelect", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": 1262,\n \"body\": {\n \"text\": \"Escolha uma opcao:\"\n },\n \"footer\": {\n \"text\": \"Rodape\"\n },\n \"list\": {\n \"title\": \"Ver opcoes\",\n \"sections\": [\n {\n \"title\": \"Secao 1\",\n \"rows\": [\n {\n \"id\": \"1\",\n \"title\": \"Item 1\",\n \"description\": \"Descricao\"\n },\n {\n \"id\": \"2\",\n \"title\": \"Item 2\"\n }\n ]\n }\n ]\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/sendInteractive/baileys/singleSelect", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "sendInteractive", + "baileys", + "singleSelect" + ] + } + }, + "response": [] + }, + { + "name": "BaileysPixButton", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": 1262,\n \"pixType\": \"EVP\",\n \"pixKey\": \"chave-aleatoria-uuid\",\n \"pixName\": \"Nome Beneficiario\",\n \"bodyText\": \"Pague com PIX:\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/sendInteractive/baileys/pixButton", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "sendInteractive", + "baileys", + "pixButton" + ] + } + }, + "response": [] + }, + { + "name": "BaileysCtaCopy", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": 1262,\n \"body\": {\n \"text\": \"Copie seu codigo:\"\n },\n \"footer\": {\n \"text\": \"Valido por 24h\"\n },\n \"displayText\": \"Copiar codigo\",\n \"copyCode\": \"BR123456789\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/sendInteractive/baileys/ctaCopy", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "sendInteractive", + "baileys", + "ctaCopy" + ] + } + }, + "response": [] + }, + { + "name": "BaileysCtaUrl", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": 1262,\n \"body\": {\n \"text\": \"Acesse nosso site:\"\n },\n \"footer\": {\n \"text\": \"Clique para visitar\"\n },\n \"displayText\": \"Visitar site\",\n \"url\": \"https://exemplo.com\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/sendInteractive/baileys/ctaUrl", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "sendInteractive", + "baileys", + "ctaUrl" + ] + } + }, + "response": [] + }, + { + "name": "BaileysCtaCall", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": 1262,\n \"body\": {\n \"text\": \"Precisa de ajuda?\"\n },\n \"footer\": {\n \"text\": \"Ligue para nos\"\n },\n \"displayText\": \"Ligar agora\",\n \"phoneNumber\": \"+5511999999999\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/sendInteractive/baileys/ctaCall", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "sendInteractive", + "baileys", + "ctaCall" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "📷 Interativo Instagram", + "item": [ + { + "name": "Quick Reply", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{API_TOKEN}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": 123,\n \"message\": \"Escolha uma opção:\",\n \"quickReplies\": [\n {\n \"content_type\": \"text\",\n \"title\": \"Sim\",\n \"payload\": \"YES\"\n },\n {\n \"content_type\": \"text\",\n \"title\": \"Não\",\n \"payload\": \"NO\"\n },\n {\n \"content_type\": \"user_phone_number\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/v2/api/external/{{apiId}}/sendInteractive/instagram/quickReply", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "v2", + "api", + "external", + "{{apiId}}", + "sendInteractive", + "instagram", + "quickReply" + ] + } + }, + "response": [] + }, + { + "name": "Button Template", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{API_TOKEN}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": 123,\n \"message\": \"Clique em uma opção:\",\n \"buttons\": [\n {\n \"type\": \"postback\",\n \"title\": \"Comprar\",\n \"payload\": \"BUY\"\n },\n {\n \"type\": \"postback\",\n \"title\": \"Falar com atendente\",\n \"payload\": \"HUMAN\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/v2/api/external/{{apiId}}/sendInteractive/instagram/buttonTemplate", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "v2", + "api", + "external", + "{{apiId}}", + "sendInteractive", + "instagram", + "buttonTemplate" + ] + } + }, + "response": [] + }, + { + "name": "Generic Template (cards)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{API_TOKEN}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": 123,\n \"elements\": [\n {\n \"title\": \"Produto 1\",\n \"subtitle\": \"Descrição\",\n \"image_url\": \"https://...\",\n \"buttons\": [\n {\n \"type\": \"postback\",\n \"title\": \"Ver\",\n \"payload\": \"PROD_1\"\n }\n ]\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/v2/api/external/{{apiId}}/sendInteractive/instagram/genericTemplate", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "v2", + "api", + "external", + "{{apiId}}", + "sendInteractive", + "instagram", + "genericTemplate" + ] + } + }, + "response": [] + }, + { + "name": "Ice Breakers (get/set/delete)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{API_TOKEN}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"action\": \"set\",\n \"iceBreakers\": [\n {\n \"question\": \"Qual o horário?\",\n \"payload\": \"HOURS\"\n },\n {\n \"question\": \"Vocês fazem entrega?\",\n \"payload\": \"DELIVERY\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/v2/api/external/{{apiId}}/instagram/iceBreakers", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "v2", + "api", + "external", + "{{apiId}}", + "instagram", + "iceBreakers" + ] + } + }, + "response": [] + }, + { + "name": "Persistent Menu (get/set/delete)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{API_TOKEN}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"action\": \"set\",\n \"composerInputDisabled\": false,\n \"menuItems\": [\n {\n \"type\": \"postback\",\n \"title\": \"Menu\",\n \"payload\": \"MAIN_MENU\"\n },\n {\n \"type\": \"web_url\",\n \"title\": \"Site\",\n \"url\": \"https://...\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/v2/api/external/{{apiId}}/instagram/persistentMenu", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "v2", + "api", + "external", + "{{apiId}}", + "instagram", + "persistentMenu" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "💬 Interativo Messenger", + "item": [ + { + "name": "Quick Reply", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{API_TOKEN}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": 123,\n \"message\": \"Escolha:\",\n \"quickReplies\": [\n {\n \"content_type\": \"text\",\n \"title\": \"Sim\",\n \"payload\": \"YES\"\n },\n {\n \"content_type\": \"text\",\n \"title\": \"Não\",\n \"payload\": \"NO\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/v2/api/external/{{apiId}}/sendInteractive/messenger/quickReply", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "v2", + "api", + "external", + "{{apiId}}", + "sendInteractive", + "messenger", + "quickReply" + ] + } + }, + "response": [] + }, + { + "name": "Button Template", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{API_TOKEN}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": 123,\n \"message\": \"Opções:\",\n \"buttons\": [\n {\n \"type\": \"postback\",\n \"title\": \"Opção 1\",\n \"payload\": \"OPT_1\"\n },\n {\n \"type\": \"web_url\",\n \"title\": \"Visitar\",\n \"url\": \"https://...\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/v2/api/external/{{apiId}}/sendInteractive/messenger/buttonTemplate", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "v2", + "api", + "external", + "{{apiId}}", + "sendInteractive", + "messenger", + "buttonTemplate" + ] + } + }, + "response": [] + }, + { + "name": "Generic Template (cards)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{API_TOKEN}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": 123,\n \"elements\": [\n {\n \"title\": \"Card 1\",\n \"subtitle\": \"Subtítulo\",\n \"image_url\": \"https://...\",\n \"buttons\": [\n {\n \"type\": \"postback\",\n \"title\": \"Ação\",\n \"payload\": \"ACT\"\n }\n ]\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/v2/api/external/{{apiId}}/sendInteractive/messenger/genericTemplate", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "v2", + "api", + "external", + "{{apiId}}", + "sendInteractive", + "messenger", + "genericTemplate" + ] + } + }, + "response": [] + }, + { + "name": "Media Template (image/video + botões)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{API_TOKEN}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": 123,\n \"mediaType\": \"image\",\n \"mediaUrl\": \"https://exemplo.com/imagem.jpg\",\n \"buttons\": [\n {\n \"type\": \"postback\",\n \"title\": \"Comprar\",\n \"payload\": \"BUY\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/v2/api/external/{{apiId}}/sendInteractive/messenger/mediaTemplate", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "v2", + "api", + "external", + "{{apiId}}", + "sendInteractive", + "messenger", + "mediaTemplate" + ] + } + }, + "response": [] + }, + { + "name": "Receipt Template (recibo)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{API_TOKEN}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": 123,\n \"receipt\": {\n \"recipient_name\": \"João Silva\",\n \"order_number\": \"ORD-001\",\n \"currency\": \"BRL\",\n \"payment_method\": \"PIX\",\n \"summary\": {\n \"total_cost\": 150.0\n },\n \"elements\": [\n {\n \"title\": \"Produto A\",\n \"price\": 100.0,\n \"quantity\": 1\n },\n {\n \"title\": \"Produto B\",\n \"price\": 50.0,\n \"quantity\": 1\n }\n ]\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/v2/api/external/{{apiId}}/sendInteractive/messenger/receiptTemplate", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "v2", + "api", + "external", + "{{apiId}}", + "sendInteractive", + "messenger", + "receiptTemplate" + ] + } + }, + "response": [] + }, + { + "name": "Message Tag (Marketing/Utility)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{API_TOKEN}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": 123,\n \"message\": \"Seu pedido foi aprovado.\",\n \"tag\": \"POST_PURCHASE_UPDATE\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/v2/api/external/{{apiId}}/sendInteractive/messenger/messageTag", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "v2", + "api", + "external", + "{{apiId}}", + "sendInteractive", + "messenger", + "messageTag" + ] + } + }, + "response": [] + }, + { + "name": "Customer Feedback Template (NPS/CSAT)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{API_TOKEN}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": 123,\n \"title\": \"Avalie sua experiência\",\n \"subtitle\": \"Opcional\",\n \"business_privacy_url\": \"https://exemplo.com/privacidade\",\n \"expires_in_days\": 7,\n \"feedback_screens\": [\n {\n \"questions\": [\n {\n \"id\": \"CSAT\",\n \"type\": \"csat\",\n \"title\": \"Como foi nosso atendimento?\"\n }\n ]\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/v2/api/external/{{apiId}}/sendInteractive/messenger/customerFeedback", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "v2", + "api", + "external", + "{{apiId}}", + "sendInteractive", + "messenger", + "customerFeedback" + ] + } + }, + "response": [] + }, + { + "name": "Greeting Text (get/set/delete)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{API_TOKEN}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"action\": \"set\",\n \"greetings\": [\n {\n \"locale\": \"default\",\n \"text\": \"Olá! Como podemos ajudar?\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/v2/api/external/{{apiId}}/messenger/greeting", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "v2", + "api", + "external", + "{{apiId}}", + "messenger", + "greeting" + ] + } + }, + "response": [] + }, + { + "name": "Personas (list/create/delete)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{API_TOKEN}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"action\": \"create\",\n \"name\": \"Atendente Ana\",\n \"profilePictureUrl\": \"https://exemplo.com/ana.jpg\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/v2/api/external/{{apiId}}/messenger/personas", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "v2", + "api", + "external", + "{{apiId}}", + "messenger", + "personas" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "🟩 Interativo UazAPI", + "item": [ + { + "name": "UazapiButton", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": 1262,\n \"text\": \"Escolha uma opcao:\",\n \"choices\": [\n \"Opcao 1\",\n \"Opcao 2\",\n \"Opcao 3\"\n ],\n \"footerText\": \"Rodape opcional\",\n \"imageButton\": \"https://exemplo.com/img.png\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/sendInteractive/uazapi/button", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "sendInteractive", + "uazapi", + "button" + ] + } + }, + "response": [] + }, + { + "name": "UazapiList", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": 1262,\n \"text\": \"Escolha uma opcao:\",\n \"choices\": [\n \"Item 1\",\n \"Item 2\",\n \"Item 3\",\n \"Item 4\"\n ],\n \"listButton\": \"Ver opcoes\",\n \"footerText\": \"Selecione uma opcao\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/sendInteractive/uazapi/list", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "sendInteractive", + "uazapi", + "list" + ] + } + }, + "response": [] + }, + { + "name": "UazapiPoll", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": 1262,\n \"text\": \"Qual sua preferencia?\",\n \"choices\": [\n \"Opcao A\",\n \"Opcao B\",\n \"Opcao C\"\n ],\n \"selectableCount\": 1\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/sendInteractive/uazapi/poll", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "sendInteractive", + "uazapi", + "poll" + ] + } + }, + "response": [] + }, + { + "name": "UazapiCarousel", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": 1262,\n \"text\": \"Confira nossas opcoes:\",\n \"carousel\": [\n {\n \"text\": \"Produto A\",\n \"image\": \"https://exemplo.com/a.png\",\n \"buttons\": [\n {\n \"text\": \"Ver detalhes\",\n \"type\": \"REPLY\"\n }\n ]\n },\n {\n \"text\": \"Produto B\",\n \"image\": \"https://exemplo.com/b.png\",\n \"buttons\": [\n {\n \"text\": \"Comprar\",\n \"type\": \"URL\"\n }\n ]\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/sendInteractive/uazapi/carousel", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "sendInteractive", + "uazapi", + "carousel" + ] + } + }, + "response": [] + }, + { + "name": "UazapiPixButton", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": 1262,\n \"pixType\": \"EVP\",\n \"pixKey\": \"chave-aleatoria-uuid\",\n \"pixName\": \"Nome Beneficiario\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/sendInteractive/uazapi/pixButton", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "sendInteractive", + "uazapi", + "pixButton" + ] + } + }, + "response": [] + }, + { + "name": "UazapiLocationButton", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": 1262,\n \"text\": \"Por favor, compartilhe sua localizacao:\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/sendInteractive/uazapi/locationButton", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "sendInteractive", + "uazapi", + "locationButton" + ] + } + }, + "response": [] + }, + { + "name": "UazapiRequestPayment", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": 1262,\n \"amount\": 150.0,\n \"title\": \"Pedido #001\",\n \"text\": \"Sua cobranca esta pronta:\",\n \"footer\": \"Vencimento em 3 dias\",\n \"itemName\": \"Produto X\",\n \"invoiceNumber\": \"NF-001\",\n \"pixType\": \"EVP\",\n \"pixKey\": \"chave-aleatoria-uuid\",\n \"pixName\": \"Nome Beneficiario\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/sendInteractive/uazapi/requestPayment", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "sendInteractive", + "uazapi", + "requestPayment" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "🔘 Interativo Waba", + "item": [ + { + "name": "SendButtonWABA", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Número do WhatsApp do destinatário (formato: 5511999999999)\n \"number\": \"5515998566622\",\n \n // Mensagem que acompanhará os botões\n \"message\": \"Escolha uma opção:\",\n \n // Texto do primeiro botão\n \"button1\": \"Opção 1\",\n \n // Texto do segundo botão\n \"button2\": \"Opção 2\",\n \n // Texto do terceiro botão (opcional)\n \"button3\": \"Opção 3\",\n \n // ID do ticket \n \"ticketId\": 1262\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/sendButtonWABA", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "sendButtonWABA" + ] + } + }, + "response": [] + }, + { + "name": "SendListWABA", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Número do WhatsApp do destinatário (formato: 5511999999999)\n \"number\": \"5515998566622\",\n \n // Cabeçalho da lista\n \"header\": \"Menu Principal\",\n \n // Corpo da lista\n \"body\": \"Escolha uma opção da lista:\",\n \n // Rodapé da lista\n \"footer\": \"Selecione uma opção\",\n \n // Texto do botão\n \"button_text\": \"Ver opções\",\n \n // Seções da lista (array de objetos)\n \"sections\": [\n {\n \"title\": \"Seção 1\",\n \"rows\": [\n {\n \"id\": \"1\",\n \"title\": \"Opção 1\",\n \"description\": \"Descrição da opção 1\"\n },\n {\n \"id\": \"2\",\n \"title\": \"Opção 2\",\n \"description\": \"Descrição da opção 2\"\n }\n ]\n }\n ],\n \n // ID do ticket\n \"ticketId\": 1262\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/sendListWABA", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "sendListWABA" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "🏷️ Kanban / Tags / Motivos / Filas", + "item": [ + { + "name": "CreateKanban", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Coluna 1\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/createKanban", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "createKanban" + ] + } + }, + "response": [] + }, + { + "name": "CreateQueueData", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"queue\": \"Suporte\",\n \"isActive\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/createQueueData", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "createQueueData" + ] + } + }, + "response": [] + }, + { + "name": "CreateReason", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Resolvido\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/createReason", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "createReason" + ] + } + }, + "response": [] + }, + { + "name": "CreateTag", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"tag\": \"Urgente\",\n \"color\": \"#FF0000\",\n \"isActive\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/createTag", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "createTag" + ] + } + }, + "response": [] + }, + { + "name": "DeleteKanban", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/deleteKanban/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "deleteKanban", + "{{id}}" + ] + } + }, + "response": [] + }, + { + "name": "DeleteQueueData", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/deleteQueueData/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "deleteQueueData", + "{{id}}" + ] + } + }, + "response": [] + }, + { + "name": "DeleteReason", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/deleteReason/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "deleteReason", + "{{id}}" + ] + } + }, + "response": [] + }, + { + "name": "DeleteTag", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/deleteTag/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "deleteTag", + "{{id}}" + ] + } + }, + "response": [] + }, + { + "name": "ListKanban", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/listKanban", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "listKanban" + ] + } + }, + "response": [] + }, + { + "name": "ListReasons", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/listReasons", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "listReasons" + ] + } + }, + "response": [] + }, + { + "name": "UpdateKanban", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Coluna Atualizada\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/updateKanban/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "updateKanban", + "{{id}}" + ] + } + }, + "response": [] + }, + { + "name": "UpdateQueueData", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"queue\": \"Suporte v2\",\n \"isActive\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/updateQueueData/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "updateQueueData", + "{{id}}" + ] + } + }, + "response": [] + }, + { + "name": "UpdateReason", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Resolvido com sucesso\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/updateReason/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "updateReason", + "{{id}}" + ] + } + }, + "response": [] + }, + { + "name": "UpdateTagData", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"tag\": \"Urgente v2\",\n \"color\": \"#CC0000\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/updateTagData/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "updateTagData", + "{{id}}" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "📊 Listagens", + "item": [ + { + "name": "GetAllSessionApis", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/getAllSessionApis", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "getAllSessionApis" + ] + } + }, + "response": [] + }, + { + "name": "ListChannels", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/listChannels", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "listChannels" + ] + } + }, + "response": [] + }, + { + "name": "ListContacts", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/listContacts?pageNumber=1", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "listContacts" + ], + "query": [ + { + "key": "pageNumber", + "value": "1", + "description": "Número da página" + }, + { + "key": "searchParam", + "value": "", + "description": "Parâmetro de busca (opcional)", + "disabled": true + }, + { + "key": "walletId", + "value": "", + "description": "ID da wallet (opcional)", + "disabled": true + }, + { + "key": "tagId", + "value": "", + "description": "ID da tag (opcional)", + "disabled": true + } + ] + } + }, + "response": [] + }, + { + "name": "ListOpportunities", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/listOpportunities?page=1&limit=40", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "listOpportunities" + ], + "query": [ + { + "key": "page", + "value": "1", + "description": "Número da página" + }, + { + "key": "limit", + "value": "40", + "description": "Limite de resultados por página" + }, + { + "key": "status", + "value": "", + "description": "Status da oportunidade (opcional)", + "disabled": true + }, + { + "key": "pipelineId", + "value": "", + "description": "ID do pipeline (opcional)", + "disabled": true + } + ] + } + }, + "response": [] + }, + { + "name": "ListQueues", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/listQueues", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "listQueues" + ] + } + }, + "response": [] + }, + { + "name": "ListSessions", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/listSessions", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "listSessions" + ] + } + }, + "response": [] + }, + { + "name": "ListTags", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/listTags?isActive=true", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "listTags" + ], + "query": [ + { + "key": "isActive", + "value": "true", + "description": "Filtrar por tags ativas (true/false)" + } + ] + } + }, + "response": [] + }, + { + "name": "ListTickets", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/listTickets?pageNumber=1&status=open", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "listTickets" + ], + "query": [ + { + "key": "pageNumber", + "value": "1", + "description": "Número da página" + }, + { + "key": "status", + "value": "open", + "description": "Status do ticket (open, pending, closed)" + }, + { + "key": "searchParam", + "value": "", + "description": "Parâmetro de busca (opcional)", + "disabled": true + }, + { + "key": "queuesIds", + "value": "", + "description": "IDs das filas (opcional)", + "disabled": true + }, + { + "key": "whatsappIds", + "value": "", + "description": "IDs dos canais (opcional)", + "disabled": true + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "🔍 Listagens Utilitárias", + "item": [ + { + "name": "ListAutoReplies", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/listAutoReplies", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "listAutoReplies" + ] + } + }, + "response": [] + }, + { + "name": "ListChatFlows", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/listChatFlows", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "listChatFlows" + ] + } + }, + "response": [] + }, + { + "name": "ListFastReplies", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/listFastReplies", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "listFastReplies" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "📞 Logs de Chamadas", + "item": [ + { + "name": "CallLogList", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/callLog/list?page=1&limit=20", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "callLog", + "list" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "limit", + "value": "20" + } + ] + } + }, + "response": [] + }, + { + "name": "CallLogShow", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/callLog/show/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "callLog", + "show", + "{{id}}" + ] + } + }, + "response": [] + }, + { + "name": "WavoipCallList", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/wavoip/calls?page=1&limit=20", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "wavoip", + "calls" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "limit", + "value": "20" + } + ] + } + }, + "response": [] + }, + { + "name": "WavoipCallShow", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/wavoip/calls/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "wavoip", + "calls", + "{{id}}" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "📨 Mensagens", + "item": [ + { + "name": "GetMessageByMessageId", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/getMessageByMessageId?messageId=wamid.xxxxx", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "getMessageByMessageId" + ], + "query": [ + { + "key": "messageId", + "value": "wamid.xxxxx", + "description": "ID da mensagem retornado pela Meta (ex: wamid.xxxxx)" + } + ] + } + }, + "response": [] + }, + { + "name": "SendMessageAPIFile", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "media", + "description": "Arquivo a ser enviado (imagem, documento, etc)", + "type": "file", + "src": "/C:/Users/Pedro/Downloads/cps_vanderlei.docx" + }, + { + "key": "body", + "value": "Mensagem de teste 2", + "description": "Texto da mensagem que acompanhará o arquivo", + "type": "text" + }, + { + "key": "number", + "value": "5515998566622", + "description": "Número do WhatsApp do destinatário (formato: 5511999999999)", + "type": "text" + }, + { + "key": "externalKey", + "value": "ID_UNICA_DO_SISTEMA_CLIENTE_PARA_EXECUTAR_UMA_ACAO_COM_WEBHOOK", + "description": "Chave única do sistema para identificação (valor gerado do seu lado para controle do envio e não pode se repetir)", + "type": "text" + }, + { + "key": "isClosed", + "value": "false", + "description": "Define se o ticket será fechado após o envio", + "type": "text" + }, + { + "key": "validateNumber", + "value": "true", + "description": "Validar numero (opcional, padrao true). Se false, usa o numero exatamente como enviado, sem normalizacao do 9o digito BR (recomendado p/ WABA).", + "type": "text" + } + ] + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}" + ] + } + }, + "response": [] + }, + { + "name": "SendMessageAPIFileURL", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n // URL do arquivo que será enviado (imagem, documento, etc)\r\n \"mediaUrl\": \"https://comunidadezdg.com.br/wp-content/uploads/2022/07/icon100.png\",\r\n \r\n // Texto da mensagem que acompanhará o arquivo\r\n \"body\": \"A mensagem desejada\",\r\n \r\n // Número do WhatsApp do destinatário (formato: 5511999999999)\r\n \"number\": \"5515998566622\",\r\n \r\n // Chave única do sistema para identificação (valor gerado do seu lado para controle do envio e não pode se repetir)\r\n \"externalKey\": \"ID_UNICA_DO_SISTEMA_CLIENTE_PARA_EXECUTAR_UMA_ACAO_COM_WEBHOOK\",\r\n \r\n // Define se o ticket será fechado após o envio\r\n \"isClosed\": false,\r\n // Validar numero (opcional, padrao true). Se false, usa o numero exatamente como enviado, sem normalizacao do 9o digito BR — recomendado p/ WABA evitar ticket duplicado\r\n \"validateNumber\": true\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/url", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "url" + ] + } + }, + "response": [] + }, + { + "name": "SendMessageAPIText", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Texto da mensagem que será enviada\n \"body\": \"A mensagem desejada\",\n \n // Número do WhatsApp do destinatário (formato: 5511999999999)\n \"number\": \"5515998566622\",\n \n // Chave única do sistema para identificação (valor gerado do seu lado para controle do envio e não pode se repetir)\n \"externalKey\": \"ID_UNICA_DO_SISTEMA_CLIENTE_PARA_EXECUTAR_UMA_ACAO_COM_WEBHOOK\",\n \n // Define se o ticket será fechado após o envio\n \"isClosed\": false,\n // Validar numero (opcional, padrao true). Se false, usa o numero exatamente como enviado, sem normalizacao do 9o digito BR — recomendado p/ WABA evitar ticket duplicado\n \"validateNumber\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}" + ] + } + }, + "response": [] + }, + { + "name": "SendMessageAPITextBase64", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Texto da mensagem que acompanhará o arquivo\n \"body\": \"A mensagem desejada\",\n \n // Número do WhatsApp do destinatário (formato: 5511999999999)\n \"number\": \"5515998566622\",\n \n // Arquivo codificado em base64\n \"base64Data\": \"iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==\",\n \n // Tipo MIME do arquivo (ex: image/png, application/pdf)\n \"mimeType\": \"image/png\",\n \n // Nome do arquivo que será exibido\n \"fileName\": \"exemplo_imagem\",\n \n // Define se o ticket será fechado após o envio\n \"isClosed\": false,\n // Validar numero (opcional, padrao true). Se false, usa o numero exatamente como enviado, sem normalizacao do 9o digito BR — recomendado p/ WABA evitar ticket duplicado\n \"validateNumber\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/base64", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "base64" + ] + } + }, + "response": [] + }, + { + "name": "SendMessageAPIVoice", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // URL do arquivo de áudio que será enviado\n \"audio\": \"https://comunidadezdg.com.br/wp-content/uploads/2022/12/audio.ogg\",\n \n // Número do WhatsApp do destinatário (formato: 5511999999999)\n \"number\": \"5515998566622\",\n \n // Chave única do sistema para identificação (valor gerado do seu lado para controle do envio e não pode se repetir)\n \"externalKey\": \"ID_UNICA_DO_SISTEMA_CLIENTE_PARA_EXECUTAR_UMA_ACAO_COM_WEBHOOK\",\n \n // Define se o ticket será fechado após o envio\n \"isClosed\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/voice", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "voice" + ] + } + }, + "response": [] + }, + { + "name": "SendMessageParams", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/params/?body=A%20mensagem%20desejadaa&number=5515998566622&externalKey=ID_UNICA_DO_SISTEMA_CLIENTE_PARA_EXECUTAR_UMA_ACAO_COM_WEBHOOK&bearertoken={{BearerToken}}&isClosed=false&validateNumber=true", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "params", + "" + ], + "query": [ + { + "key": "body", + "value": "A%20mensagem%20desejadaa", + "description": "Texto da mensagem que será enviada" + }, + { + "key": "number", + "value": "5515998566622", + "description": "Número do WhatsApp do destinatário (formato: 5511999999999)" + }, + { + "key": "externalKey", + "value": "ID_UNICA_DO_SISTEMA_CLIENTE_PARA_EXECUTAR_UMA_ACAO_COM_WEBHOOK", + "description": "Chave única do sistema para identificação (valor gerado do seu lado para controle do envio e não pode se repetir)" + }, + { + "key": "bearertoken", + "value": "{{BearerToken}}", + "description": "Token de autenticação" + }, + { + "key": "isClosed", + "value": "false", + "description": "Define se o ticket será fechado após o envio" + }, + { + "key": "validateNumber", + "value": "true", + "description": "Validar numero (opcional, padrao true). Se false, usa o numero exatamente como enviado, sem normalizacao do 9o digito BR (recomendado p/ WABA)." + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "📨 Mensagens Avançadas", + "item": [ + { + "name": "SearchMessages", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/searchMessages?ticketId={{ticketId}}&searchParam=olá&page=1&limit=20", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "searchMessages" + ], + "query": [ + { + "key": "ticketId", + "value": "{{ticketId}}" + }, + { + "key": "searchParam", + "value": "olá" + }, + { + "key": "page", + "value": "1" + }, + { + "key": "limit", + "value": "20" + } + ] + } + }, + "response": [] + }, + { + "name": "SendLocation", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"number\": \"5511999990001\",\n \"latitude\": -23.5505,\n \"longitude\": -46.6333,\n \"name\": \"São Paulo\",\n \"address\": \"Av. Paulista, 1000\",\n \"ticketId\": null,\n // Chave única do sistema para identificação (valor gerado do seu lado para controle do envio e não pode se repetir)\n \"externalKey\": \"ID_UNICA_DO_SISTEMA_CLIENTE_PARA_EXECUTAR_UMA_ACAO_COM_WEBHOOK\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/sendLocation", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "sendLocation" + ] + } + }, + "response": [] + }, + { + "name": "SendVcard", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"number\": \"5511999990001\",\n \"contact\": [\n {\n \"fullName\": \"João Silva\",\n \"wuid\": \"5511999990002@s.whatsapp.net\",\n \"phoneNumber\": \"5511999990002\"\n }\n ],\n \"ticketId\": null,\n // Chave única do sistema para identificação (valor gerado do seu lado para controle do envio e não pode se repetir)\n \"externalKey\": \"ID_UNICA_DO_SISTEMA_CLIENTE_PARA_EXECUTAR_UMA_ACAO_COM_WEBHOOK\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/sendVcard", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "sendVcard" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "👥 Mensagens em Grupo", + "item": [ + { + "name": "SendGroupMessageAPIFile", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "media", + "description": "Arquivo(s) a ser enviado (imagem, documento, etc)", + "type": "file", + "src": [ + "/C:/Users/Pedro/Downloads/cps_vanderlei.docx", + "/C:/Users/pedro/OneDrive/Desktop/izing-passaporte/frontend/public/bg-wchats.png" + ] + }, + { + "key": "body", + "value": "Mensagem de teste 2", + "description": "Texto da mensagem que acompanhará o arquivo", + "type": "text" + }, + { + "key": "number", + "value": "12356818915189153", + "description": "ID do grupo do WhatsApp", + "type": "text" + }, + { + "key": "externalKey", + "value": "ID_UNICA_DO_SISTEMA_CLIENTE_PARA_EXECUTAR_UMA_ACAO_COM_WEBHOOK", + "description": "Chave única do sistema para identificação (valor gerado do seu lado para controle do envio e não pode se repetir)", + "type": "text" + }, + { + "key": "isClosed", + "value": "false", + "description": "Define se o ticket será fechado após o envio", + "type": "text" + } + ] + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/group", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "group" + ] + } + }, + "response": [] + }, + { + "name": "SendGroupMessageAPIText", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Texto da mensagem que será enviada\n \"body\": \"A mensagem desejada\",\n \n // ID do grupo do WhatsApp\n \"number\": \"12356818915189153\",\n \n // Chave única do sistema para identificação (valor gerado do seu lado para controle do envio e não pode se repetir)\n \"externalKey\": \"ID_UNICA_DO_SISTEMA_CLIENTE_PARA_EXECUTAR_UMA_ACAO_COM_WEBHOOK\",\n \n // Define se o ticket será fechado após o envio\n \"isClosed\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/group", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "group" + ] + } + }, + "response": [] + }, + { + "name": "SendMessageAPIFileURLGroup", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n // URL do arquivo que será enviado (imagem, documento, etc)\r\n \"mediaUrl\": \"https://comunidadezdg.com.br/wp-content/uploads/2022/07/icon100.png\",\r\n \r\n // Texto da mensagem que acompanhará o arquivo\r\n \"body\": \"A mensagem desejada\",\r\n \r\n // ID do grupo do WhatsApp\r\n \"number\": \"12356818915189153\",\r\n \r\n // Chave única do sistema para identificação (valor gerado do seu lado para controle do envio e não pode se repetir)\r\n \"externalKey\": \"ID_UNICA_DO_SISTEMA_CLIENTE_PARA_EXECUTAR_UMA_ACAO_COM_WEBHOOK\",\r\n \r\n // Define se o ticket será fechado após o envio\r\n \"isClosed\": false\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/groupMediaUrl", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "groupMediaUrl" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "💼 Oportunidades", + "item": [ + { + "name": "CreateOpportunity", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Campos do contato\n \"number\": \"5535912345678\", // número do WhatsApp\n \"contactName\": \"Nome do Contato\", // nome do contato\n \"email\": \"contato@email.com\", // email do contato\n\n // Campos da oportunidade\n \"name\": \"Nome da Oportunidade 2\", // nome da oportunidade\n \"value\": 10000.00, // valor da oportunidade\n \"status\": \"open\", // pode ser: \"open\", \"win\", \"lose\"\n \"pipelineId\": 16, // ID do pipeline\n \"stageId\": 7, // ID do estágio\n \"responsibleId\": 1, // ID do responsável\n \"closingForecast\": \"2024-12-31\", // data esperada de fechamento\n \"description\": \"Descrição da oportunidade\", // descrição da oportunidade\n // Validar numero (opcional, padrao true). Se false, usa o numero exatamente como enviado, sem normalizacao do 9o digito BR — recomendado p/ WABA evitar ticket duplicado\n \"validateNumber\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/createOpportunity", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "createOpportunity" + ] + } + }, + "response": [] + }, + { + "name": "DeleteOpportunity", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Campos da oportunidade\n \"opportunityId\": 19 // id da oportunidade\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/deleteOpportunity", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "deleteOpportunity" + ] + } + }, + "response": [] + }, + { + "name": "UpdateOpportunity", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Campos da oportunidade\n \"opportunityId\": 30, // id da oportunidade\n \"name\": \"Nome da Oportunidade 2 API\", // nome da oportunidade\n \"value\": 500.00, // valor da oportunidade\n \"status\": \"open\", // pode ser: \"open\", \"win\", \"lose\"\n \"pipelineId\": 16, // ID do pipeline\n \"stageId\": 7, // ID do estágio\n \"responsibleId\": 1, // ID do responsável\n \"closingForecast\": \"2024-12-31\", // data esperada de fechamento\n \"description\": \"Descrição da oportunidade\" // descrição da oportunidade\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/updateOpportunity", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "updateOpportunity" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "📋 Templates WABA", + "item": [ + { + "name": "SendTemplateWaba", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n // Número do WhatsApp do destinatário (formato: 5511999999999)\r\n \"number\": \"5515998566622\",\r\n \r\n // Define se o ticket será fechado após o envio\r\n \"isClosed\": false,\r\n \r\n // Dados do template do WhatsApp Business API\r\n \"templateData\": {\r\n // Produto de mensageria (sempre whatsapp)\r\n \"messaging_product\": \"whatsapp\",\r\n \r\n // Número do destinatário (mesmo do campo number)\r\n \"to\": \"5515998566622\",\r\n \r\n // Tipo da mensagem (sempre template)\r\n \"type\": \"template\",\r\n \r\n // Configurações do template\r\n \"template\": {\r\n // Nome do template aprovado\r\n \"name\": \"hello_world\",\r\n \r\n // Configurações de idioma\r\n \"language\": {\r\n // Código do idioma (ex: en_US, pt_BR)\r\n \"code\": \"en_US\"\r\n }\r\n }\r\n },\r\n // Validar numero (opcional, padrao true). Se false, usa o numero exatamente como enviado, sem normalizacao do 9o digito BR — recomendado p/ WABA evitar ticket duplicado\r\n \"validateNumber\": true\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/template", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "template" + ] + }, + "description": "A resposta retorna o ticketId do atendimento criado ou reutilizado. Exemplo: { \"success\": true, \"data\": { \"message\": \"Message sent successfully\", \"ticketId\": 123 } }" + }, + "response": [] + }, + { + "name": "SendTemplateWabaBody", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n // Número do WhatsApp do destinatário (formato: 5511999999999)\r\n \"number\": \"5515998566622\",\r\n \r\n // Define se o ticket será fechado após o envio\r\n \"isClosed\": false,\r\n \r\n // Dados do template do WhatsApp Business API\r\n \"templateData\": {\r\n // Produto de mensageria (sempre whatsapp)\r\n \"messaging_product\": \"whatsapp\",\r\n \r\n // Número do destinatário (mesmo do campo number)\r\n \"to\": \"5515998566622\",\r\n \r\n // Tipo da mensagem (sempre template)\r\n \"type\": \"template\",\r\n \r\n // Configurações do template\r\n \"template\": {\r\n // Nome do template aprovado\r\n \"name\": \"hello_world\",\r\n \r\n // Configurações de idioma\r\n \"language\": {\r\n // Código do idioma (ex: en_US, pt_BR)\r\n \"code\": \"en_US\"\r\n }\r\n }\r\n },\r\n // Validar numero (opcional, padrao true). Se false, usa o numero exatamente como enviado, sem normalizacao do 9o digito BR — recomendado p/ WABA evitar ticket duplicado\r\n \"validateNumber\": true\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/templateBody", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "templateBody" + ] + }, + "description": "A resposta retorna o ticketId do atendimento criado ou reutilizado. Exemplo: { \"success\": true, \"data\": { \"message\": \"Message sent successfully\", \"ticketId\": 123 } }" + }, + "response": [] + }, + { + "name": "SendTemplateWabaMarketing", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n // Número do WhatsApp do destinatário (formato: 5511999999999)\r\n \"number\": \"5515998566622\",\r\n \r\n // Define se o ticket será fechado após o envio\r\n \"isClosed\": false,\r\n \r\n // Dados do template do WhatsApp Business API\r\n \"templateData\": {\r\n // Produto de mensageria (sempre whatsapp)\r\n \"messaging_product\": \"whatsapp\",\r\n \r\n // Número do destinatário (mesmo do campo number)\r\n \"to\": \"5515998566622\",\r\n \r\n // Tipo da mensagem (sempre template)\r\n \"type\": \"template\",\r\n \r\n // Configurações do template\r\n \"template\": {\r\n // Nome do template aprovado\r\n \"name\": \"hello_world\",\r\n \r\n // Configurações de idioma\r\n \"language\": {\r\n // Código do idioma (ex: en_US, pt_BR)\r\n \"code\": \"en_US\"\r\n }\r\n }\r\n },\r\n // Validar numero (opcional, padrao true). Se false, usa o numero exatamente como enviado, sem normalizacao do 9o digito BR — recomendado p/ WABA evitar ticket duplicado\r\n \"validateNumber\": true\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/templateMarketingBody", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "templateMarketingBody" + ] + }, + "description": "A resposta retorna o ticketId do atendimento criado ou reutilizado. Exemplo: { \"success\": true, \"data\": { \"message\": \"Message sent successfully\", \"ticketId\": 123 } }" + }, + "response": [] + } + ] + }, + { + "name": "🏢 Tenant API", + "item": [ + { + "name": "CreateApi", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{SuperAdminToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Nome da API que será criada\n \"name\": \"API 1\",\n \n // ID da sessão do WhatsApp associada\n \"sessionId\": 1,\n \n // URL para receber atualizações de status do serviço (opcional)\n \"urlServiceStatus\": null,\n \n // URL para receber atualizações de status das mensagens (opcional)\n \"urlMessageStatus\": null,\n \n // ID do usuário que está criando a API\n \"userId\": 1,\n \n // Token de autenticação para a API\n \"authToken\": \"123456\",\n \n // ID do tenant que terá a API\n \"tenant\": 1\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/tenantCreateApi", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "tenantCreateApi" + ] + } + }, + "response": [] + }, + { + "name": "CreateSessionTenant", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{SuperAdminToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Tenant\n \"tenant\": 1,\n\n // Nome da instância do WhatsApp\n \"name\": \"My WhatsApp Instance\",\n \n // Status inicial da sessão (DISCONNECTED, CONNECTED)\n \"status\": \"DISCONNECTED\",\n \n // Tipo da sessão (whatsapp, baileys, meow, evo, uazapi, zapi)\n \"type\": \"baileys\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/tenantApiCreateSession", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "tenantApiCreateSession" + ] + } + }, + "response": [] + }, + { + "name": "DeleteApi", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{SuperAdminToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // ID da sessão do WhatsApp associada à API\n \"sessionId\": 43,\n \n // ID do usuário que está deletando a API\n \"userId\": 1,\n \n // ID do tenant que possui a API\n \"tenant\": 1,\n \n // ID da API que será deletada\n \"apiId\": \"5ec32c80-4549-4256-8ed1-ed57b86396c3\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/tenantDeleteApi", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "tenantDeleteApi" + ] + } + }, + "response": [] + }, + { + "name": "ListTenants", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{SuperAdminToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "https://{{BaseUrl}}/tenantApiListTenants", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "tenantApiListTenants" + ] + } + }, + "response": [] + }, + { + "name": "ShowTenant", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{SuperAdminToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // ID do tenant que será consultado\n \"id\": 1\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/tenantApiShowTenant", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "tenantApiShowTenant" + ] + } + }, + "response": [] + }, + { + "name": "StoreTenant", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{SuperAdminToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Status do tenant (active, inactive)\n \"status\": \"active\",\n \n // Nome da empresa/tenant\n \"name\": \"Empresa Exemplo\",\n \n // Número máximo de usuários permitidos\n \"maxUsers\": 3,\n \n // Número máximo de conexões WhatsApp permitidas\n \"maxConnections\": 3,\n \n // Confirmação de aceite dos termos\n \"acceptTerms\": true,\n \n // Email do administrador do tenant\n \"email\": \"user@example.com\",\n \n // Senha do administrador (mínimo 8 caracteres, incluindo números e caracteres especiais)\n \"password\": \"securePassword123!\",\n \n // Nome completo do administrador\n \"userName\": \"Pedro Bastos\",\n \n // Perfil do administrador (admin, user)\n \"profile\": \"admin\",\n\n // Vinculo de gateway de pagamento (OPCIONAL). Define qual gateway o webhook\n // usa para atualizar o status de pagamento do tenant automaticamente.\n // Valores: asaas, stripe, pagarme, mercadopago.\n \"paymentGateway\": \"stripe\",\n\n // Credenciais do gateway informado acima (envie apenas o par do gateway escolhido).\n // Exemplo Stripe: Customer ID + Secret Key.\n \"stripeCustomerId\": \"cus_XXXXXXXXXXXXXX\",\n \"stripeToken\": \"sk_live_XXXXXXXXXXXXXXXX\"\n\n // Outros gateways seguem o mesmo padrao CustomerId + Token:\n // \"pagarmeCustomerId\": \"cus_xxx\", \"pagarmeToken\": \"sk_xxx\"\n // \"mercadopagoCustomerId\": \"123456\", \"mercadopagoToken\": \"APP_USR-xxx\"\n // \"asaasCustomerId\": \"cus_xxx\", \"asaasToken\": \"xxx\", \"asaas\": \"enabled\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/tenantApiStoreTenant", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "tenantApiStoreTenant" + ] + } + }, + "response": [] + }, + { + "name": "UpdateTenant", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{SuperAdminToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Identidade do tenant (CPF/CNPJ) - identificador usado para reconhecer qual tenant será atualizado\n \"identity\": \"07122989674\",\n\n // Status do tenant (active, inactive)\n \"status\": \"active\",\n\n // Número máximo de usuários permitidos\n \"maxUsers\": 100,\n\n // Número máximo de conexões permitidas\n \"maxConnections\": 10,\n\n // Gateway de pagamento (none, asaas, mercadopago, stripe, pagarme)\n \"paymentGateway\": \"asaas\",\n\n // Credenciais do gateway (OPCIONAL) — envie o par correspondente ao paymentGateway.\n // Ao trocar para um gateway nao-Asaas, o modo Asaas legado e desligado automaticamente.\n // Stripe: \"stripeCustomerId\": \"cus_xxx\", \"stripeToken\": \"sk_live_xxx\"\n // Pagar.me: \"pagarmeCustomerId\": \"cus_xxx\", \"pagarmeToken\": \"sk_xxx\"\n // Mercado Pago: \"mercadopagoCustomerId\": \"123456\", \"mercadopagoToken\": \"APP_USR-xxx\"\n\n // Chat de suporte habilitado (enabled, disabled)\n \"supportChatEnabled\": \"enabled\",\n\n // Menus visíveis — array de strings com os menus habilitados\n // Opções: Groups, MassDispatch, Kanban, Tasks, Api, ChatBot, Reports, Campaigns, PrivateChat, Teams, AllowedChannels\n \"menuVisibility\": [\"Groups\", \"MassDispatch\", \"Kanban\", \"Tasks\", \"Api\", \"ChatBot\", \"Reports\", \"Campaigns\", \"PrivateChat\", \"Teams\", \"AllowedChannels\"],\n\n // Canais permitidos — array de strings com os canais habilitados\n // Opções: waba, baileys, whatsapp, meow, evo, zapi, uazapi, telegram, hub, webchat, webmail, wabaoauth, instagramoauth, facebookoauth\n \"allowedChannels\": [\"waba\", \"baileys\", \"whatsapp\", \"meow\", \"evo\", \"zapi\", \"uazapi\", \"telegram\", \"hub\", \"webchat\", \"webmail\", \"wabaoauth\", \"instagramoauth\", \"facebookoauth\"],\n\n // Limite máximo de conexões por tipo de canal. Use 0 para ilimitado.\n \"channelConnectionLimits\": {\n \"waba\": 0,\n \"baileys\": 0,\n \"whatsapp\": 0,\n \"meow\": 0,\n \"evo\": 0,\n \"zapi\": 0,\n \"uazapi\": 0,\n \"telegram\": 0,\n \"hub\": 0,\n \"webchat\": 0,\n \"webmail\": 0,\n \"wabaoauth\": 0,\n \"instagramoauth\": 0,\n \"facebookoauth\": 0\n },\n\n // OAuth (Meta Embedded Signup)\n // OAuth habilitado (true/false)\n \"oauthEnabled\": false,\n\n // URL do Proxy OAuth\n \"oauthProxyUrl\": \"https://proxy.exemplo.com/oauth\",\n\n // URL do Proxy de Webhook Instagram\n \"instagramWebhookProxyUrl\": \"https://proxy.exemplo.com/instagram/webhook\",\n\n // Segredo do Proxy Instagram\n \"instagramWebhookProxySecret\": \"secret_instagram\",\n\n // URL do Proxy de Webhook Messenger\n \"messengerWebhookProxyUrl\": \"https://proxy.exemplo.com/messenger/webhook\",\n\n // Segredo do Proxy Messenger\n \"messengerWebhookProxySecret\": \"secret_messenger\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/tenantApiUpdateTenant", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "tenantApiUpdateTenant" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "🎫 Tickets", + "item": [ + { + "name": "AddTag", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // ID do ticket que receberá a tag\n \"ticketId\": 4,\n \n // ID da tag que será adicionada (pode usar tagId ou tagIds)\n \"tagId\": 1,\n \n // OU array de IDs de tags para adicionar múltiplas tags de uma vez\n // \"tagIds\": [1, 2, 3]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/addTag", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "addTag" + ] + } + }, + "response": [] + }, + { + "name": "AddTagContact", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Atribui tag(s) ao CONTATO sem precisar de ticket.\n // Informe contactId OU number para localizar o contato.\n \"contactId\": 123,\n \n // OU localize pelo numero (aceita variantes BR)\n // \"number\": \"5511999999999\",\n \n // ID da tag a adicionar (use tagId ou tagIds). Mantem as tags existentes.\n \"tagId\": 1,\n // Validar numero (opcional, padrao true). Se false, usa o numero exatamente como enviado, sem normalizacao do 9o digito BR — recomendado p/ WABA evitar ticket duplicado\n \"validateNumber\": true\n \n // OU array de IDs de tags\n // \"tagIds\": [1, 2, 3]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/addTagContact", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "addTagContact" + ] + } + }, + "response": [] + }, + { + "name": "RemoveTagContact", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Remove tag(s) do CONTATO sem precisar de ticket.\n // Informe contactId OU number para localizar o contato.\n \"number\": \"5511999999999\",\n \n // OU localize pelo ID do contato\n // \"contactId\": 123,\n \n // ID(s) da tag a remover (use tagId ou tagIds)\n \"tagIds\": [1, 2],\n // Validar numero (opcional, padrao true). Se false, usa o numero exatamente como enviado, sem normalizacao do 9o digito BR — recomendado p/ WABA evitar ticket duplicado\n \"validateNumber\": true\n \n // OU uma unica tag\n // \"tagId\": 1\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/removeTagContact", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "removeTagContact" + ] + } + }, + "response": [] + }, + { + "name": "CreateNotes", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Texto da nota que será adicionada ao ticket\n \"notes\": \"Mensagem da nota\", \n \n // ID do ticket que receberá a nota\n \"ticketId\": 1262, \n \n // ID do usuário que está criando a nota\n \"userId\": 1,\n \n // Chave única do sistema para identificação (valor gerado do seu lado para controle do envio e não pode se repetir)\n \"idFront\": \"ID_UNICA_DO_SISTEMA_CLIENTE_PARA_EXECUTAR_UMA_ACAO_COM_WEBHOOK\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/createNotes", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "createNotes" + ] + } + }, + "response": [] + }, + { + "name": "CreateTicket", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Texto da mensagem inicial do ticket\n \"body\": \"A mensagem desejada\",\n \n // Para canais WhatsApp/outros: número do contato (formato: 5511999999999)\n \"number\": \"5535988754197\",\n \n // Para canal WEBMAIL: email do contato (obrigatório quando channelId for de um canal webmail)\n // \"email\": \"contato@exemplo.com\",\n \n // ID do canal (opcional). Para webmail: envie o id do canal webmail (não vem do APIConfig)\n // \"channelId\": 17841443941506797,\n \n // Chave única do sistema para identificação (valor gerado do seu lado para controle do envio e não pode se repetir)\n \"externalKey\": \"ID_UNICA_DO_SISTEMA_CLIENTE_PARA_EXECUTAR_UMA_ACAO_COM_WEBHOOK\",\n \n // ID do usuário que está criando o ticket\n \"userId\": 3,\n \n // Status inicial do ticket (pending ou open)\n \"status\": \"pending\",\n // Validar numero (opcional, padrao true). Se false, usa o numero exatamente como enviado, sem normalizacao do 9o digito BR — recomendado p/ WABA evitar ticket duplicado\n \"validateNumber\": true\n \n // ID do kanban (opcional)\n // \"kanbanId\": 1,\n \n // ID do fluxo de chat (opcional)\n // \"chatFlowId\": 16,\n \n // ID da fila (opcional)\n // \"queueId\": 1,\n \n // ID da demanda (opcional)\n // \"reasonId\": 1,\n \n // Valor do ticket (opcional)\n // \"value\": 10.25,\n \n // Para webmail: nome do contato (opcional, usado na criação do contato)\n // \"name\": \"Nome do Contato\",\n // \"firstName\": \"Nome\",\n // \"lastName\": \"Sobrenome\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/createTicket", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "createTicket" + ] + } + }, + "response": [] + }, + { + "name": "CreateTicketFile", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "media", + "description": "Arquivo a ser enviado (imagem, documento, etc)", + "type": "file", + "src": "/C:/Users/Pedro/Downloads/cps_vanderlei.docx" + }, + { + "key": "body", + "value": "Mensagem de teste 2", + "description": "Texto da mensagem inicial do ticket", + "type": "text" + }, + { + "key": "number", + "value": "5515998566622", + "description": "Número do WhatsApp do contato (formato: 5511999999999)", + "type": "text" + }, + { + "key": "externalKey", + "value": "ID_UNICA_DO_SISTEMA_CLIENTE_PARA_EXECUTAR_UMA_ACAO_COM_WEBHOOK", + "description": "Chave única do sistema para identificação (valor gerado do seu lado para controle do envio e não pode se repetir)", + "type": "text" + }, + { + "key": "userId", + "value": "3", + "description": "ID do usuário que está criando o ticket", + "type": "text" + }, + { + "key": "status", + "value": "pending", + "description": "Status inicial do ticket (pending, open, closed)", + "type": "text" + }, + { + "key": "kanbanId", + "value": "1", + "description": "ID do kanban (opcional)", + "type": "text" + }, + { + "key": "chatFlowId", + "value": "10", + "description": "ID do fluxo de chat (opcional)", + "type": "text" + }, + { + "key": "queueId", + "value": "1", + "description": "ID da fila (opcional)", + "type": "text" + }, + { + "key": "channelId", + "value": "", + "description": "ID do canal (opcional). Para webmail: envie o id do canal webmail", + "type": "text" + }, + { + "key": "email", + "value": "", + "description": "Para canal webmail: email do contato (obrigatório quando usar channelId de webmail)", + "type": "text" + }, + { + "key": "name", + "value": "", + "description": "Para webmail: nome do contato (opcional)", + "type": "text" + }, + { + "key": "validateNumber", + "value": "true", + "description": "Validar numero (opcional, padrao true). Se false, usa o numero exatamente como enviado, sem normalizacao do 9o digito BR (recomendado p/ WABA).", + "type": "text" + } + ] + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/createTicket", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "createTicket" + ] + } + }, + "response": [] + }, + { + "name": "CreateTicketWebmail", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Texto da mensagem inicial do ticket\n \"body\": \"Mensagem inicial do ticket por email\",\n \n // Email do contato (obrigatório para webmail)\n \"email\": \"contato@exemplo.com\",\n \n // ID do canal webmail (opcional; se não enviar, usa canal da APIConfig)\n \"channelId\": 17841443941506797,\n \n // Chave única do sistema para identificação (valor gerado do seu lado para controle do envio e não pode se repetir)\n \"externalKey\": \"ID_UNICA_DO_SISTEMA_CLIENTE_PARA_EXECUTAR_UMA_ACAO_COM_WEBHOOK\",\n \"userId\": 3,\n \"status\": \"pending\",\n \n // Opcionais: nome do contato (usado ao criar contato se não existir)\n \"name\": \"Nome do Contato\",\n \"firstName\": \"Nome\",\n \"lastName\": \"Sobrenome\",\n // Validar numero (opcional, padrao true). Se false, usa o numero exatamente como enviado, sem normalizacao do 9o digito BR — recomendado p/ WABA evitar ticket duplicado\n \"validateNumber\": true\n \n // \"kanbanId\": 1,\n // \"chatFlowId\": 16,\n // \"queueId\": 1,\n // \"reasonId\": 1,\n // \"value\": 10.25\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/createTicket", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "createTicket" + ] + } + }, + "response": [] + }, + { + "name": "ListNotes", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/listNotes?ticketId=1262", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "listNotes" + ], + "query": [ + { + "key": "ticketId", + "value": "1262", + "description": "ID do ticket" + } + ] + } + }, + "response": [] + }, + { + "name": "RemoveTag", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // ID do ticket que terá a tag removida\n \"ticketId\": 4,\n \n // ID da tag que será removida (pode usar tagId ou tagIds)\n \"tagId\": 1,\n \n // OU array de IDs de tags para remover múltiplas tags de uma vez\n // \"tagIds\": [1, 2, 3]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/removeTag", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "removeTag" + ] + } + }, + "response": [] + }, + { + "name": "SendPresence", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // ID do ticket associado\n \"ticketId\": 1262,\n \n // Estado de presence: \"typing\" (digitando), \"paused\" (pausado) ou \"recording\" (gravando)\n \"state\": \"typing\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/sendPresence", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "sendPresence" + ] + } + }, + "response": [] + }, + { + "name": "SetQueue", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // ID do ticket que terá a fila alterada\n \"ticketId\": 4,\n \n // ID da fila que será atribuída ao ticket\n \"queueId\": 1\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/updatequeue", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "updatequeue" + ] + } + }, + "response": [] + }, + { + "name": "SetTag", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // ID do ticket que terá a tag alterada\n \"ticketId\": 4,\n \n // ID da tag que será atribuída ao ticket\n \"tag\": 1\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/updatetag", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "updatetag" + ] + } + }, + "response": [] + }, + { + "name": "SetTicketInfo", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // ID do ticket que será atualizado\n \"ticketId\": 1262,\n \n // ID do usuário que está fazendo a atualização\n \"userId\": 1,\n \n // Status do ticket (pending, open, closed)\n \"status\": \"pending\",\n \n // ID da fila do ticket (null para remover)\n \"queueId\": null,\n \n // Status de integração com Typebot\n \"typebotStatus\": false,\n \n // Status de integração com ChatGPT\n \"chatgptStatus\": false,\n \n // Status de integração com Dialogflow\n \"dialogflowStatus\": false,\n \n // Status de integração com Dify\n \"difyStatus\": false,\n \n // Status de integração com N8N\n \"n8nStatus\": false,\n \n // ID do fluxo de chat (null para remover)\n \"chatFlowId\": null\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/updateticketinfo", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "updateticketinfo" + ] + } + }, + "response": [] + }, + { + "name": "ShowAllMessages", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // ID do ticket que terá as mensagens listadas\n \"ticket\": \"123\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/showAllMessages", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "showAllMessages" + ] + } + }, + "response": [] + }, + { + "name": "ShowAllTicketInformation", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Número do WhatsApp do contato (formato: 5511999999999)\n \"number\": \"5515998566622\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/showallticket", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "showallticket" + ] + } + }, + "response": [] + }, + { + "name": "ShowTicketInformation", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Número do WhatsApp do contato (formato: 5511999999999)\n \"number\": \"5515998566622\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/showticket", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "showticket" + ] + } + }, + "response": [] + }, + { + "name": "ShowTicketInformationChatBot", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Número do WhatsApp do contato (formato: 5511999999999)\n \"number\": \"5515998566622\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/showticketchatbot", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "showticketchatbot" + ] + } + }, + "response": [] + }, + { + "name": "UpdateNote", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // ID da nota que será atualizada\n \"noteId\": 1,\n \n // Novo conteúdo da nota\n \"notes\": \"Conteúdo atualizado da nota\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/updateNote", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "updateNote" + ] + } + }, + "response": [] + }, + { + "name": "UpdateTicketChannel", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // ID do ticket que terá o canal alterado\n \"ticketId\": 1262,\n \n // ID do novo canal (WhatsApp)\n \"whatsappId\": 1,\n \n // Tipo do canal (whatsapp, baileys, waba, etc)\n \"channel\": \"whatsapp\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/updateTicketChannel", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "updateTicketChannel" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "🎫 Tickets - Extras", + "item": [ + { + "name": "EndTicketPause", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/ticket/pause/end/{{ticketId}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "ticket", + "pause", + "end", + "{{ticketId}}" + ] + } + }, + "response": [] + }, + { + "name": "ListTicketEvaluations", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/listTicketEvaluations?page=1&limit=20", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "listTicketEvaluations" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "limit", + "value": "20" + } + ] + } + }, + "response": [] + }, + { + "name": "ListTicketPauseLogs", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/ticket/pause/logs/{{ticketId}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "ticket", + "pause", + "logs", + "{{ticketId}}" + ] + } + }, + "response": [] + }, + { + "name": "ShowTicketById", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": \"{{ticketId}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/showTicketById", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "showTicketById" + ] + } + }, + "response": [] + }, + { + "name": "StartTicketPause", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"pauseReason\": \"Aguardando cliente\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/ticket/pause/start/{{ticketId}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "ticket", + "pause", + "start", + "{{ticketId}}" + ] + } + }, + "response": [] + }, + { + "name": "TicketShareCreate", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"ticketId\": \"{{ticketId}}\",\n \"inviteUrl\": \"https://app.example.com/shared\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/ticket/share", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "ticket", + "share" + ] + } + }, + "response": [] + }, + { + "name": "TicketShareShow", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/ticket/share/{{ticketId}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "ticket", + "share", + "{{ticketId}}" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "✅ To-Do List", + "item": [ + { + "name": "TodoCreate", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Ligar para cliente\",\n \"description\": \"Confirmar proposta\",\n \"owner\": \"João\",\n \"ownerId\": \"{{userId}}\",\n \"status\": \"pending\",\n \"priority\": \"high\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/todo/create", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "todo", + "create" + ] + } + }, + "response": [] + }, + { + "name": "TodoDelete", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/todo/delete/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "todo", + "delete", + "{{id}}" + ] + } + }, + "response": [] + }, + { + "name": "TodoList", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/todo/list", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "todo", + "list" + ] + } + }, + "response": [] + }, + { + "name": "TodoLogs", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/todo/logs/{{userId}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "todo", + "logs", + "{{userId}}" + ] + } + }, + "response": [] + }, + { + "name": "TodoUpdate", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"status\": \"finished\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/todo/update/{{id}}", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "todo", + "update", + "{{id}}" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "👥 Usuários", + "item": [ + { + "name": "CreateUser", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // Email do usuário\n \"email\": \"usuario@example.com\",\n \n // Senha do usuário (mínimo 8 caracteres)\n \"password\": \"senha123\",\n \n // Nome completo do usuário\n \"name\": \"Nome do Usuário\",\n \n // Perfil do usuário (admin, user)\n \"profile\": \"user\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/createUser", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "createUser" + ] + } + }, + "response": [] + }, + { + "name": "GetUserStatus", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/getUserStatus?userId=1", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "getUserStatus" + ], + "query": [ + { + "key": "userId", + "value": "1", + "description": "ID do usuário" + } + ] + } + }, + "response": [] + }, + { + "name": "ListUsers", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/listUsers?pageNumber=1", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "listUsers" + ], + "query": [ + { + "key": "pageNumber", + "value": "1", + "description": "Número da página" + }, + { + "key": "searchParam", + "value": "", + "description": "Parâmetro de busca (opcional)", + "disabled": true + } + ] + } + }, + "response": [] + }, + { + "name": "UpdateUser", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{BearerToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n // ID do usuário que será atualizado\n \"userId\": 1,\n \n // Nome completo do usuário (opcional)\n \"name\": \"Nome Atualizado\",\n \n // Email do usuário (opcional)\n \"email\": \"novoemail@example.com\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{BaseUrl}}/v2/api/external/{{ApiID}}/updateUser", + "protocol": "https", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "v2", + "api", + "external", + "{{ApiID}}", + "updateUser" + ] + } + }, + "response": [] + } + ] + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{vault:authorization-secret}}", + "type": "string" + } + ] + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "BaseUrl", + "value": "backend_url_here" + }, + { + "key": "BearerToken", + "value": "your_token_here" + }, + { + "key": "SuperAdminToken", + "value": "your_superadmin_token_here" + }, + { + "key": "ApiID", + "value": "your_api_id_here" + } + ] +} \ No newline at end of file diff --git a/gti-maquina-comercial/docs/superpowers/plans/2026-08-01-campaign-outreach-port.md b/gti-maquina-comercial/docs/superpowers/plans/2026-08-01-campaign-outreach-port.md new file mode 100644 index 0000000..97e948d --- /dev/null +++ b/gti-maquina-comercial/docs/superpowers/plans/2026-08-01-campaign-outreach-port.md @@ -0,0 +1,2278 @@ +# Campanhas de Outreach (GTI Comercial) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Portar a metodologia de campanhas de outreach da Máquina Comercial Pessoal (fila com preview/aprovação, variantes A/B/C, escalonamento semanal de limite, pausa automática por saúde, classificação de estágio, agendamento) para a GTI Comercial, adaptada ao envio via Z-PRO (canal não-oficial, texto livre) e a um catálogo dinâmico (operador escolhe produto/segmentos/mensagens pela tela, em vez de um catálogo fixo em código). + +**Architecture:** Novo diretório `src/outreach/campaign-*.mjs`, paralelo aos módulos existentes `run.mjs`/`scheduler.mjs` (nenhum dos dois é modificado). Uma migração de schema nova (v8) adiciona as tabelas de campanha. Uma rota nova (`src/server/routes/campaigns.mjs`) expõe tudo via HTTP. Uma aba nova no front-end (`public/js/campanhas.js`) consome essas rotas. O envio em si usa `zproClient.sendText` (síncrono: ou resolve `enviado` ou lança erro, sem estado intermediário de "reservado" — diferente da Pessoal, que precisa desse estado por causa da entrega assíncrona incerta do Baileys; aqui não é necessário). + +**Tech Stack:** Node.js (`node:sqlite` via `DatabaseSync`), `node:test`/`node:assert`, HTML/CSS/JS vanilla (sem framework), mesmo padrão de módulos IIFE do front-end existente (`Tratamento`). + +## Global Constraints + +- Não modificar `src/outreach/run.mjs`, `src/outreach/scheduler.mjs`, `src/server/routes/outreach.mjs`, `public/js/app.js` (além do ponto de wiring da nova aba), nem qualquer arquivo com trabalho não commitado alheio a esta feature — o repositório tem WIP substancial em andamento que não deve ser tocado ou conflitado. +- Vocabulário de status segue o idioma português já usado em todo o resto do schema da GTI Comercial (`enviado`/`erro` em `outreach_sends`, não `aceito`/`pendente`/`desconhecido` como na Pessoal). Campanha: `rascunho|rodando|pausado|concluido`. Fila: `preview|approved|cancelled|sent|error`. +- Variantes A/B/C só existem no canal não-oficial (`zproClient.sendText`) — o canal oficial (WABA) não aceita texto livre, então não há como variar template. +- `outreach_sends` recebe `origem='automatico'` para envios de campanha (mesma convenção do `run.mjs`, não um valor `'campanha'` novo) — a atribuição de um envio a uma campanha é feita pelo JOIN via `outreach_campaign_queue.outreach_send_id`, não por um valor de `origem` dedicado. +- Prevenção de contato duplicado com o `runOutreachBatch` existente: um lead com `outreach_sends.status='enviado'` para o mesmo produto (seja por `lead_id` ou por `numero`, cobrindo tanto envio automático quanto manual) nunca entra na fila de campanha para aquele produto. +- Cada task abaixo tem teste próprio (`node --test tests/.test.mjs`) e termina com commit. + +--- + +### Task 1: Migração de schema (v8) — tabelas de campanha + +**Files:** +- Modify: `src/db.mjs` (array `MIGRATIONS`, adicionar 8º elemento) +- Test: `tests/db.test.mjs` (adicionar um teste ao arquivo existente) + +**Interfaces:** +- Produces: tabelas `outreach_campaigns`, `outreach_campaign_variants`, `outreach_campaign_queue`, `outreach_lead_states`, `outreach_stage_events`, `outreach_appointments`, `outreach_opt_out`, todas acessíveis via `db.prepare(...)` após `openDb()`/`migrate()`. + +- [ ] **Step 1: Escrever o teste que falha** + +Adicionar ao final de `tests/db.test.mjs`: + +```js +test('migracao v8 cria as tabelas de campanha de outreach', () => { + const db = openDb(':memory:'); + const tabelas = db.prepare( + `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'outreach_%'` + ).all().map((r) => r.name); + for (const nome of [ + 'outreach_campaigns', 'outreach_campaign_variants', 'outreach_campaign_queue', + 'outreach_lead_states', 'outreach_stage_events', 'outreach_appointments', 'outreach_opt_out' + ]) { + assert.ok(tabelas.includes(nome), `tabela ${nome} deveria existir`); + } + db.prepare(`INSERT INTO outreach_campaigns (nome, produto, segmentos_json) VALUES ('Teste', 'ERP', '["oficinas"]')`).run(); + const campanha = db.prepare('SELECT * FROM outreach_campaigns').get(); + assert.equal(campanha.status, 'rascunho'); + assert.equal(campanha.limite_diario_atual, 10); + db.close(); +}); +``` + +- [ ] **Step 2: Rodar e confirmar que falha** + +Run: `node --test tests/db.test.mjs` +Expected: FAIL — `no such table: outreach_campaigns` + +- [ ] **Step 3: Adicionar a migração v8** + +Em `src/db.mjs`, adicionar como último elemento do array `MIGRATIONS` (depois do comentário `// v7 — ...` e sua string, antes do `];` que fecha o array): + +```js + // v8 — campanhas de outreach com catalogo dinamico (a Comercial tem 57 + // segmentos e 4 produtos, catalogo fixo em codigo nao cabe nessa escala: + // o operador cria a campanha pela tela). Roda ao lado do runOutreachBatch + // existente (run.mjs) sem substitui-lo. outreach_opt_out e novo aqui (nao + // existia antes): uma fila que recontata o mesmo lead ao longo de semanas + // precisa checar quem ja pediu pra parar. + ` + CREATE TABLE outreach_campaigns ( + id INTEGER PRIMARY KEY, + nome TEXT NOT NULL, + produto TEXT NOT NULL, + segmentos_json TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'rascunho', + pause_reason TEXT, + limite_diario_atual INTEGER NOT NULL DEFAULT 10, + limite_diario_teto INTEGER NOT NULL DEFAULT 80, + semana_iniciada_em TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE outreach_campaign_variants ( + id INTEGER PRIMARY KEY, + campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id), + code TEXT NOT NULL, + mensagem TEXT NOT NULL, + ativo INTEGER NOT NULL DEFAULT 1 + ); + CREATE INDEX idx_campaign_variants_campaign ON outreach_campaign_variants(campaign_id); + + CREATE TABLE outreach_campaign_queue ( + id INTEGER PRIMARY KEY, + campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id), + lead_id INTEGER NOT NULL REFERENCES leads(id), + variant_id INTEGER NOT NULL REFERENCES outreach_campaign_variants(id), + segmento TEXT NOT NULL DEFAULT '', + numero TEXT NOT NULL, + rendered_message TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'preview', + cancel_reason TEXT, + outreach_send_id INTEGER REFERENCES outreach_sends(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX idx_campaign_queue_campaign_status ON outreach_campaign_queue(campaign_id, status); + CREATE INDEX idx_campaign_queue_lead ON outreach_campaign_queue(lead_id); + + CREATE TABLE outreach_lead_states ( + lead_id INTEGER PRIMARY KEY REFERENCES leads(id), + stage TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE outreach_stage_events ( + id INTEGER PRIMARY KEY, + lead_id INTEGER NOT NULL REFERENCES leads(id), + campaign_id INTEGER REFERENCES outreach_campaigns(id), + stage TEXT NOT NULL, + note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX idx_stage_events_lead ON outreach_stage_events(lead_id); + + CREATE TABLE outreach_appointments ( + id INTEGER PRIMARY KEY, + lead_id INTEGER NOT NULL REFERENCES leads(id), + kind TEXT NOT NULL, + scheduled_at TEXT, + note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX idx_appointments_lead ON outreach_appointments(lead_id); + + CREATE TABLE outreach_opt_out ( + numero TEXT PRIMARY KEY, + motivo TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + ` +``` + +- [ ] **Step 4: Rodar e confirmar que passa** + +Run: `node --test tests/db.test.mjs` +Expected: PASS + +- [ ] **Step 5: Rodar a suite inteira (garantir que a migracao nao quebrou nada)** + +Run: `node --test tests/*.test.mjs` +Expected: todos os testes existentes continuam passando + +- [ ] **Step 6: Commit** + +```bash +git add src/db.mjs tests/db.test.mjs +git commit -m "Add campaign outreach schema (migration v8)" +``` + +--- + +### Task 2: `campaign-store.mjs` — CRUD de campanha, variantes, opt-out, escalonamento + +**Files:** +- Create: `src/outreach/campaign-store.mjs` +- Test: `tests/campaign-store.test.mjs` + +**Interfaces:** +- Consumes: `transaction(db, fn)` de `../db.mjs` (já existe). +- Produces: `createCampaign(db, {nome, produto, segmentos, variantes, limiteDiarioInicial, limiteDiarioTeto}) -> campanha`, `getCampaign(db, id) -> campanha|null` (com `.segmentos` array e `.variants` array), `listCampaigns(db) -> campanha[]`, `listVariants(db, campaignId) -> variant[]`, `setCampaignStatus(db, campaignId, status, pauseReason) -> campanha`, `resumeCampaign(db, campaignId) -> campanha`, `escalateWeeklyLimits(db, now) -> number`, `isOptedOut(db, numero) -> boolean`, `addOptOut(db, numero, motivo) -> void`, `removeOptOut(db, numero) -> void`. Essas assinaturas são usadas por `campaign-queue.mjs`, `campaign-worker.mjs` e `routes/campaigns.mjs` nas próximas tasks. + +- [ ] **Step 1: Escrever os testes que falham** + +Criar `tests/campaign-store.test.mjs`: + +```js +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { openDb } from '../src/db.mjs'; +import { + createCampaign, getCampaign, listCampaigns, setCampaignStatus, resumeCampaign, + escalateWeeklyLimits, isOptedOut, addOptOut, removeOptOut +} from '../src/outreach/campaign-store.mjs'; + +function campanhaBase(overrides = {}) { + return { + nome: 'ERP oficinas', + produto: 'ERP', + segmentos: ['oficinas_mecanicas', 'autopecas'], + variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }, { code: 'B', mensagem: 'Oi {empresa}' }], + ...overrides + }; +} + +test('createCampaign grava campanha, segmentos e variantes', () => { + const db = openDb(':memory:'); + const campanha = createCampaign(db, campanhaBase()); + assert.equal(campanha.status, 'rascunho'); + assert.equal(campanha.produto, 'ERP'); + assert.deepEqual(campanha.segmentos, ['oficinas_mecanicas', 'autopecas']); + assert.equal(campanha.variants.length, 2); + assert.equal(campanha.variants[0].code, 'A'); + db.close(); +}); + +test('listCampaigns retorna mais recente primeiro', () => { + const db = openDb(':memory:'); + createCampaign(db, campanhaBase({ nome: 'Primeira' })); + createCampaign(db, campanhaBase({ nome: 'Segunda' })); + const campanhas = listCampaigns(db); + assert.equal(campanhas.length, 2); + assert.equal(campanhas[0].nome, 'Segunda'); + db.close(); +}); + +test('setCampaignStatus pausa e resumeCampaign retoma limpando o motivo', () => { + const db = openDb(':memory:'); + const campanha = createCampaign(db, campanhaBase()); + setCampaignStatus(db, campanha.id, 'rodando'); + const pausada = setCampaignStatus(db, campanha.id, 'pausado', 'high_failure_rate'); + assert.equal(pausada.status, 'pausado'); + assert.equal(pausada.pause_reason, 'high_failure_rate'); + const retomada = resumeCampaign(db, campanha.id); + assert.equal(retomada.status, 'rodando'); + assert.equal(retomada.pause_reason, null); + db.close(); +}); + +test('resumeCampaign nao faz nada se a campanha nao estiver pausada', () => { + const db = openDb(':memory:'); + const campanha = createCampaign(db, campanhaBase()); + const resultado = resumeCampaign(db, campanha.id); + assert.equal(resultado.status, 'rascunho'); + db.close(); +}); + +test('escalateWeeklyLimits dobra o limite apos 7 dias rodando, respeitando o teto', () => { + const db = openDb(':memory:'); + const campanha = createCampaign(db, campanhaBase({ limiteDiarioInicial: 10, limiteDiarioTeto: 15 })); + setCampaignStatus(db, campanha.id, 'rodando'); + db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-8 days') WHERE id = ?`).run(campanha.id); + + const escaladas = escalateWeeklyLimits(db); + assert.equal(escaladas, 1); + const atualizada = getCampaign(db, campanha.id); + assert.equal(atualizada.limite_diario_atual, 15); // dobraria pra 20, mas o teto e 15 + db.close(); +}); + +test('escalateWeeklyLimits nao avanca campanha pausada nem antes de 7 dias', () => { + const db = openDb(':memory:'); + const rodando = createCampaign(db, campanhaBase({ nome: 'Rodando ha pouco' })); + setCampaignStatus(db, rodando.id, 'rodando'); + db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-2 days') WHERE id = ?`).run(rodando.id); + + const pausada = createCampaign(db, campanhaBase({ nome: 'Pausada' })); + setCampaignStatus(db, pausada.id, 'rodando'); + setCampaignStatus(db, pausada.id, 'pausado', 'manual'); + db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-10 days') WHERE id = ?`).run(pausada.id); + + const escaladas = escalateWeeklyLimits(db); + assert.equal(escaladas, 0); + db.close(); +}); + +test('opt-out: addOptOut bloqueia, removeOptOut libera', () => { + const db = openDb(':memory:'); + assert.equal(isOptedOut(db, '5511999990001'), false); + addOptOut(db, '5511999990001', 'pediu pra parar'); + assert.equal(isOptedOut(db, '5511999990001'), true); + removeOptOut(db, '5511999990001'); + assert.equal(isOptedOut(db, '5511999990001'), false); + db.close(); +}); +``` + +- [ ] **Step 2: Rodar e confirmar que falha** + +Run: `node --test tests/campaign-store.test.mjs` +Expected: FAIL — `Cannot find module '../src/outreach/campaign-store.mjs'` + +- [ ] **Step 3: Implementar** + +Criar `src/outreach/campaign-store.mjs`: + +```js +import { transaction } from '../db.mjs'; + +export function createCampaign(db, { + nome, produto, segmentos, variantes, limiteDiarioInicial = 10, limiteDiarioTeto = 80 +}) { + return transaction(db, () => { + const result = db.prepare( + `INSERT INTO outreach_campaigns + (nome, produto, segmentos_json, limite_diario_atual, limite_diario_teto) + VALUES (?, ?, ?, ?, ?)` + ).run(nome, produto, JSON.stringify(segmentos || []), limiteDiarioInicial, limiteDiarioTeto); + const campaignId = Number(result.lastInsertRowid); + const insertVariant = db.prepare( + `INSERT INTO outreach_campaign_variants (campaign_id, code, mensagem) VALUES (?, ?, ?)` + ); + for (const variante of variantes) { + insertVariant.run(campaignId, variante.code, variante.mensagem); + } + return getCampaign(db, campaignId); + }); +} + +export function getCampaign(db, id) { + const row = db.prepare('SELECT * FROM outreach_campaigns WHERE id = ?').get(id); + if (!row) return null; + return { + ...row, + segmentos: JSON.parse(row.segmentos_json || '[]'), + variants: listVariants(db, id) + }; +} + +export function listCampaigns(db) { + return db.prepare('SELECT * FROM outreach_campaigns ORDER BY id DESC').all() + .map((row) => ({ ...row, segmentos: JSON.parse(row.segmentos_json || '[]') })); +} + +export function listVariants(db, campaignId) { + return db.prepare( + 'SELECT * FROM outreach_campaign_variants WHERE campaign_id = ? AND ativo = 1 ORDER BY code' + ).all(campaignId); +} + +// status: 'rascunho' -> 'rodando' -> 'pausado' <-> 'rodando' -> 'concluido'. +// pause_reason so fica setado quando o novo status e 'pausado'. +export function setCampaignStatus(db, campaignId, status, pauseReason = null) { + db.prepare( + `UPDATE outreach_campaigns SET status = ?, pause_reason = ?, updated_at = datetime('now') WHERE id = ?` + ).run(status, status === 'pausado' ? pauseReason : null, campaignId); + return getCampaign(db, campaignId); +} + +// So avanca campanha que estiver de fato pausada — retomar uma campanha em +// rascunho, por exemplo, deveria usar setCampaignStatus(db, id, 'rodando'). +export function resumeCampaign(db, campaignId) { + db.prepare( + `UPDATE outreach_campaigns SET status = 'rodando', pause_reason = NULL, updated_at = datetime('now') + WHERE id = ? AND status = 'pausado'` + ).run(campaignId); + return getCampaign(db, campaignId); +} + +// Escalonamento semanal: dobra o limite diario a cada 7 dias corridos rodando +// sem pausa desde semana_iniciada_em, ate o teto da campanha. So campanhas com +// status='rodando' avancam — uma pausada fica congelada ate o operador retomar. +export function escalateWeeklyLimits(db, now = new Date()) { + const campaigns = db.prepare( + `SELECT id, limite_diario_atual, limite_diario_teto, semana_iniciada_em + FROM outreach_campaigns WHERE status = 'rodando'` + ).all(); + let escaladas = 0; + for (const campaign of campaigns) { + if (!campaign.semana_iniciada_em) continue; + const dias = (now.getTime() - new Date(campaign.semana_iniciada_em.replace(' ', 'T') + 'Z').getTime()) / 86400000; + if (dias < 7) continue; + const novoLimite = Math.min(campaign.limite_diario_teto, campaign.limite_diario_atual * 2); + if (novoLimite === campaign.limite_diario_atual) continue; + db.prepare( + `UPDATE outreach_campaigns + SET limite_diario_atual = ?, semana_iniciada_em = datetime('now'), updated_at = datetime('now') + WHERE id = ?` + ).run(novoLimite, campaign.id); + escaladas += 1; + } + return escaladas; +} + +export function isOptedOut(db, numero) { + return Boolean(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get(numero)); +} + +export function addOptOut(db, numero, motivo = null) { + db.prepare( + `INSERT INTO outreach_opt_out (numero, motivo) VALUES (?, ?) + ON CONFLICT(numero) DO UPDATE SET motivo = excluded.motivo` + ).run(numero, motivo); +} + +export function removeOptOut(db, numero) { + db.prepare('DELETE FROM outreach_opt_out WHERE numero = ?').run(numero); +} +``` + +Nota sobre `escalateWeeklyLimits`: `datetime('now')` do SQLite grava `"YYYY-MM-DD HH:MM:SS"` (sem `T`/`Z`); `.replace(' ', 'T') + 'Z'` converte pra um formato que `new Date(...)` do JS interpreta corretamente como UTC. + +- [ ] **Step 4: Rodar e confirmar que passa** + +Run: `node --test tests/campaign-store.test.mjs` +Expected: PASS (7 testes) + +- [ ] **Step 5: Commit** + +```bash +git add src/outreach/campaign-store.mjs tests/campaign-store.test.mjs +git commit -m "Add campaign-store: CRUD, opt-out and weekly limit escalation" +``` + +--- + +### Task 3: `campaign-messages.mjs` — seleção determinística de variante + render + +**Files:** +- Create: `src/outreach/campaign-messages.mjs` +- Test: `tests/campaign-messages.test.mjs` + +**Interfaces:** +- Consumes: `renderTemplate(mensagem, lead)` de `./templates.mjs` (já existe, faz substituição de `{empresa}`/`{cidade}`/`{produto}`/`{segmento}`). +- Produces: `variantForLead(variants, leadId) -> variant`, `renderCampaignMessage(variant, lead) -> string`. Usadas por `campaign-queue.mjs` (Task 4). + +- [ ] **Step 1: Escrever o teste que falha** + +Criar `tests/campaign-messages.test.mjs`: + +```js +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { variantForLead, renderCampaignMessage } from '../src/outreach/campaign-messages.mjs'; + +const variants = [{ id: 1, code: 'A', mensagem: 'Ola A' }, { id: 2, code: 'B', mensagem: 'Ola B' }, { id: 3, code: 'C', mensagem: 'Ola C' }]; + +test('variantForLead e deterministico (mesmo lead sempre cai na mesma variante)', () => { + const primeira = variantForLead(variants, 42); + const segunda = variantForLead(variants, 42); + assert.equal(primeira.code, segunda.code); +}); + +test('variantForLead distribui entre variantes diferentes conforme o id', () => { + assert.equal(variantForLead(variants, 0).code, 'A'); + assert.equal(variantForLead(variants, 1).code, 'B'); + assert.equal(variantForLead(variants, 2).code, 'C'); + assert.equal(variantForLead(variants, 3).code, 'A'); +}); + +test('variantForLead lanca erro sem variantes ativas', () => { + assert.throws(() => variantForLead([], 1), /sem variantes/); +}); + +test('renderCampaignMessage substitui variaveis do lead', () => { + const texto = renderCampaignMessage({ mensagem: 'Ola {empresa}, sobre {produto}' }, { empresa: 'Oficina do Ze', produto: 'ERP' }); + assert.equal(texto, 'Ola Oficina do Ze, sobre ERP'); +}); +``` + +- [ ] **Step 2: Rodar e confirmar que falha** + +Run: `node --test tests/campaign-messages.test.mjs` +Expected: FAIL — módulo não existe + +- [ ] **Step 3: Implementar** + +Criar `src/outreach/campaign-messages.mjs`: + +```js +import { renderTemplate } from './templates.mjs'; + +// Distribuicao deterministica por lead_id % variantes.length: o mesmo lead +// sempre cai na mesma variante mesmo que a fila seja regenerada depois, sem +// precisar guardar nenhum estado extra so pra isso. +export function variantForLead(variants, leadId) { + if (!variants.length) throw new Error('campanha sem variantes ativas'); + const index = Number(leadId) % variants.length; + return variants[index]; +} + +export function renderCampaignMessage(variant, lead) { + return renderTemplate(variant.mensagem, lead); +} +``` + +- [ ] **Step 4: Rodar e confirmar que passa** + +Run: `node --test tests/campaign-messages.test.mjs` +Expected: PASS (4 testes) + +- [ ] **Step 5: Commit** + +```bash +git add src/outreach/campaign-messages.mjs tests/campaign-messages.test.mjs +git commit -m "Add deterministic variant selection and message rendering" +``` + +--- + +### Task 4: `campaign-queue.mjs` — seleção de candidatos, geração de fila, aprovação + +**Files:** +- Create: `src/outreach/campaign-queue.mjs` +- Test: `tests/campaign-queue.test.mjs` + +**Interfaces:** +- Consumes: `getCampaign` de `./campaign-store.mjs` (Task 2), `variantForLead`/`renderCampaignMessage` de `./campaign-messages.mjs` (Task 3). +- Produces: `generateCampaignQueue(db, campaignId, {quantidade}) -> {candidatosEncontrados, adicionadosNaFila}`, `listQueue(db, campaignId, status|null) -> row[]`, `approveQueueRows(db, campaignId, queueIds) -> number`, `cancelQueueRows(db, campaignId, queueIds, reason) -> number`. Usadas por `routes/campaigns.mjs` (Task 8) e por `campaign-worker.mjs` indiretamente (via as linhas que essas funções produzem na tabela). + +- [ ] **Step 1: Escrever os testes que falham** + +Criar `tests/campaign-queue.test.mjs`: + +```js +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { openDb, createLeadInserter } from '../src/db.mjs'; +import { createCampaign } from '../src/outreach/campaign-store.mjs'; +import { generateCampaignQueue, listQueue, approveQueueRows, cancelQueueRows } from '../src/outreach/campaign-queue.mjs'; + +function inserirLead(db, overrides = {}) { + const inserir = createLeadInserter(db); + const dedup = overrides.dedup_key || `lead-${Math.random()}`; + inserir({ + dedup_key: dedup, empresa: 'Oficina Teste', legal_name: '', whatsapp: '5511999990001', + ddd: '11', email: '', cidade: 'SP', uf: 'SP', segmento: 'oficinas_mecanicas', segmento_label: '', + interesse: '', cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]', origem: 'teste', + telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 80, classificacao: 'bom', + motivo_classificacao: '', score_qualidade: 80, score_comercial: 80, prioridade: 1, + produto_principal: 'ERP', produtos_json: '[]', capture_run_id: null, captured_at: null, exported_at: null, + ...overrides + }); + return db.prepare('SELECT * FROM leads WHERE dedup_key = ?').get(dedup); +} + +function campanhaComVariantes(db, overrides = {}) { + return createCampaign(db, { + nome: 'ERP oficinas', produto: 'ERP', segmentos: ['oficinas_mecanicas'], + variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }], + ...overrides + }); +} + +test('generateCampaignQueue cria linhas preview so para leads do produto/segmento certos', () => { + const db = openDb(':memory:'); + inserirLead(db, { dedup_key: 'l1', whatsapp: '5511999990001' }); + inserirLead(db, { dedup_key: 'l2', segmento: 'outro_segmento', whatsapp: '5511999990002' }); + const campanha = campanhaComVariantes(db); + + const resultado = generateCampaignQueue(db, campanha.id); + assert.equal(resultado.candidatosEncontrados, 1); + assert.equal(resultado.adicionadosNaFila, 1); + + const fila = listQueue(db, campanha.id); + assert.equal(fila.length, 1); + assert.equal(fila[0].status, 'preview'); + assert.equal(fila[0].rendered_message, 'Ola Oficina Teste'); + db.close(); +}); + +test('generateCampaignQueue exclui lead com envio ja enviado para o mesmo produto (por lead_id)', () => { + const db = openDb(':memory:'); + const lead = inserirLead(db); + db.prepare( + `INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'ERP', ?, 'oi', 'enviado')` + ).run(lead.id, lead.whatsapp); + const campanha = campanhaComVariantes(db); + + const resultado = generateCampaignQueue(db, campanha.id); + assert.equal(resultado.candidatosEncontrados, 0); + db.close(); +}); + +test('generateCampaignQueue exclui numero com envio manual ja enviado (sem lead_id) para o mesmo produto', () => { + const db = openDb(':memory:'); + inserirLead(db, { whatsapp: '5511999990009' }); + db.prepare( + `INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, origem) + VALUES (NULL, 'ERP', '5511999990009', 'oi', 'enviado', 'manual')` + ).run(); + const campanha = campanhaComVariantes(db); + + const resultado = generateCampaignQueue(db, campanha.id); + assert.equal(resultado.candidatosEncontrados, 0); + db.close(); +}); + +test('generateCampaignQueue exclui numero em opt-out', () => { + const db = openDb(':memory:'); + inserirLead(db, { whatsapp: '5511999990001' }); + db.prepare(`INSERT INTO outreach_opt_out (numero) VALUES ('5511999990001')`).run(); + const campanha = campanhaComVariantes(db); + + const resultado = generateCampaignQueue(db, campanha.id); + assert.equal(resultado.candidatosEncontrados, 0); + db.close(); +}); + +test('generateCampaignQueue nao duplica lead ja na fila (preview/approved/sent) de outra campanha do mesmo produto', () => { + const db = openDb(':memory:'); + inserirLead(db, { dedup_key: 'unico', whatsapp: '5511999990001' }); + const campanha1 = campanhaComVariantes(db, { nome: 'Campanha 1' }); + generateCampaignQueue(db, campanha1.id); + + const campanha2 = campanhaComVariantes(db, { nome: 'Campanha 2' }); + const resultado = generateCampaignQueue(db, campanha2.id); + assert.equal(resultado.candidatosEncontrados, 0); + db.close(); +}); + +test('generateCampaignQueue respeita o parametro quantidade', () => { + const db = openDb(':memory:'); + for (let i = 0; i < 5; i += 1) { + inserirLead(db, { dedup_key: `lead-${i}`, whatsapp: `551199999000${i}` }); + } + const campanha = campanhaComVariantes(db); + const resultado = generateCampaignQueue(db, campanha.id, { quantidade: 3 }); + assert.equal(resultado.adicionadosNaFila, 3); + db.close(); +}); + +test('approveQueueRows so aprova linhas em preview da campanha certa', () => { + const db = openDb(':memory:'); + inserirLead(db, { whatsapp: '5511999990001' }); + const campanha = campanhaComVariantes(db); + generateCampaignQueue(db, campanha.id); + const [linha] = listQueue(db, campanha.id); + + const total = approveQueueRows(db, campanha.id, [linha.id]); + assert.equal(total, 1); + assert.equal(listQueue(db, campanha.id, 'approved').length, 1); +}); + +test('cancelQueueRows cancela e registra motivo', () => { + const db = openDb(':memory:'); + inserirLead(db, { whatsapp: '5511999990001' }); + const campanha = campanhaComVariantes(db); + generateCampaignQueue(db, campanha.id); + const [linha] = listQueue(db, campanha.id); + + const total = cancelQueueRows(db, campanha.id, [linha.id], 'nao interessa'); + assert.equal(total, 1); + const cancelada = listQueue(db, campanha.id, 'cancelled')[0]; + assert.equal(cancelada.cancel_reason, 'nao interessa'); +}); + +test('approveQueueRows/cancelQueueRows com lista vazia nao lanca erro', () => { + const db = openDb(':memory:'); + const campanha = campanhaComVariantes(db); + assert.equal(approveQueueRows(db, campanha.id, []), 0); + assert.equal(cancelQueueRows(db, campanha.id, []), 0); + db.close(); +}); +``` + +- [ ] **Step 2: Rodar e confirmar que falha** + +Run: `node --test tests/campaign-queue.test.mjs` +Expected: FAIL — módulo não existe + +- [ ] **Step 3: Implementar** + +Criar `src/outreach/campaign-queue.mjs`: + +```js +import { getCampaign } from './campaign-store.mjs'; +import { variantForLead, renderCampaignMessage } from './campaign-messages.mjs'; + +const ACTIVE_QUEUE_STATUSES = "'preview','approved','sent'"; + +function candidateQuery() { + return ` + SELECT leads.id, leads.empresa, leads.whatsapp, leads.segmento, leads.cidade, leads.uf, + leads.segmento_label, leads.prioridade, leads.score + FROM leads + WHERE leads.produto_principal = ? + AND leads.segmento = ? + AND leads.telefone_valido = 1 + AND leads.whatsapp != '' + AND NOT EXISTS ( + SELECT 1 FROM outreach_sends + WHERE outreach_sends.produto = ? AND outreach_sends.status = 'enviado' + AND (outreach_sends.lead_id = leads.id OR outreach_sends.numero = leads.whatsapp) + ) + AND NOT EXISTS ( + SELECT 1 FROM outreach_campaign_queue q + JOIN outreach_campaigns c ON c.id = q.campaign_id + WHERE q.lead_id = leads.id AND c.produto = ? AND q.status IN (${ACTIVE_QUEUE_STATUSES}) + ) + AND NOT EXISTS (SELECT 1 FROM outreach_opt_out WHERE numero = leads.whatsapp) + ORDER BY leads.prioridade ASC, leads.score DESC, leads.id ASC`; +} + +// Gera ate `quantidade` linhas novas de fila (status 'preview') para a +// campanha, buscando candidatos em todos os segmentos configurados. Nao e +// limitado ao limite diario de envio (isso so controla a VELOCIDADE de envio +// do worker) — o operador pode gerar e revisar um lote maior do que vai +// enviar hoje. +export function generateCampaignQueue(db, campaignId, { quantidade = 50 } = {}) { + const campaign = getCampaign(db, campaignId); + if (!campaign) throw new Error('campanha nao encontrada'); + if (!campaign.variants.length) throw new Error('campanha sem variantes ativas'); + if (!campaign.segmentos.length) throw new Error('campanha sem segmentos configurados'); + + const statement = db.prepare(candidateQuery()); + const candidatos = campaign.segmentos + .flatMap((segmento) => statement.all(campaign.produto, segmento, campaign.produto, campaign.produto)) + .sort((a, b) => a.prioridade - b.prioridade || b.score - a.score || a.id - b.id) + .slice(0, quantidade); + + const insert = db.prepare( + `INSERT INTO outreach_campaign_queue + (campaign_id, lead_id, variant_id, segmento, numero, rendered_message, status) + VALUES (?, ?, ?, ?, ?, ?, 'preview')` + ); + let adicionados = 0; + for (const lead of candidatos) { + const variant = variantForLead(campaign.variants, lead.id); + const renderedMessage = renderCampaignMessage(variant, lead); + insert.run(campaignId, lead.id, variant.id, lead.segmento, lead.whatsapp, renderedMessage); + adicionados += 1; + } + return { candidatosEncontrados: candidatos.length, adicionadosNaFila: adicionados }; +} + +export function listQueue(db, campaignId, status = null) { + if (status) { + return db.prepare( + 'SELECT * FROM outreach_campaign_queue WHERE campaign_id = ? AND status = ? ORDER BY id' + ).all(campaignId, status); + } + return db.prepare( + 'SELECT * FROM outreach_campaign_queue WHERE campaign_id = ? ORDER BY id' + ).all(campaignId); +} + +export function approveQueueRows(db, campaignId, queueIds) { + if (!queueIds.length) return 0; + const placeholders = queueIds.map(() => '?').join(', '); + return db.prepare( + `UPDATE outreach_campaign_queue SET status = 'approved', updated_at = datetime('now') + WHERE campaign_id = ? AND status = 'preview' AND id IN (${placeholders})` + ).run(campaignId, ...queueIds).changes; +} + +export function cancelQueueRows(db, campaignId, queueIds, reason = 'operador') { + if (!queueIds.length) return 0; + const placeholders = queueIds.map(() => '?').join(', '); + return db.prepare( + `UPDATE outreach_campaign_queue SET status = 'cancelled', cancel_reason = ?, updated_at = datetime('now') + WHERE campaign_id = ? AND status IN ('preview','approved') AND id IN (${placeholders})` + ).run(reason, campaignId, ...queueIds).changes; +} +``` + +- [ ] **Step 4: Rodar e confirmar que passa** + +Run: `node --test tests/campaign-queue.test.mjs` +Expected: PASS (9 testes) + +- [ ] **Step 5: Commit** + +```bash +git add src/outreach/campaign-queue.mjs tests/campaign-queue.test.mjs +git commit -m "Add campaign candidate selection, queue generation and approval" +``` + +--- + +### Task 5: `campaign-metrics.mjs` — saúde/auto-pausa e comparação de variantes + +**Files:** +- Create: `src/outreach/campaign-metrics.mjs` +- Test: `tests/campaign-metrics.test.mjs` + +**Interfaces:** +- Produces: `evaluateCampaignHealth(db, campaignId, now) -> {pause: boolean, reason: string|null}`, `variantStats(db, campaignId) -> [{code, enviados, respostas, taxaResposta, elegivelParaComparacao}]`. Usadas por `campaign-worker.mjs` (Task 6) e `routes/campaigns.mjs` (Task 8). + +- [ ] **Step 1: Escrever os testes que falham** + +Criar `tests/campaign-metrics.test.mjs`: + +```js +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { openDb, createLeadInserter } from '../src/db.mjs'; +import { createCampaign, setCampaignStatus } from '../src/outreach/campaign-store.mjs'; +import { evaluateCampaignHealth, variantStats } from '../src/outreach/campaign-metrics.mjs'; + +function inserirLead(db, dedup) { + const inserir = createLeadInserter(db); + inserir({ + dedup_key: dedup, empresa: 'E', legal_name: '', whatsapp: `55119999${dedup.padStart(5, '0')}`, + ddd: '11', email: '', cidade: '', uf: '', segmento: 'x', segmento_label: '', interesse: '', + cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]', origem: 't', telefone_valido: 1, + cnpj_valido: 0, empresa_valida: 1, score: 50, classificacao: 'bom', motivo_classificacao: '', + score_qualidade: 50, score_comercial: 50, prioridade: 1, produto_principal: 'ERP', + produtos_json: '[]', capture_run_id: null, captured_at: null, exported_at: null + }); + return db.prepare('SELECT id, whatsapp FROM leads WHERE dedup_key = ?').get(dedup); +} + +function campanhaRodando(db) { + const campanha = createCampaign(db, { + nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }] + }); + setCampaignStatus(db, campanha.id, 'rodando'); + return campanha; +} + +function simularEnvio(db, campanhaId, variantId, leadId, numero, status) { + const sendId = db.prepare( + `INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'ERP', ?, 'oi', ?)` + ).run(leadId, numero, status).lastInsertRowid; + db.prepare( + `INSERT INTO outreach_campaign_queue (campaign_id, lead_id, variant_id, numero, rendered_message, status, outreach_send_id) + VALUES (?, ?, ?, ?, 'oi', ?, ?)` + ).run(campanhaId, leadId, variantId, numero, status === 'erro' ? 'error' : 'sent', sendId); +} + +test('evaluateCampaignHealth nao pausa campanha saudavel', () => { + const db = openDb(':memory:'); + const campanha = campanhaRodando(db); + const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id); + for (let i = 0; i < 10; i += 1) { + const lead = inserirLead(db, `l${i}`); + simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado'); + } + const resultado = evaluateCampaignHealth(db, campanha.id); + assert.equal(resultado.pause, false); + db.close(); +}); + +test('evaluateCampaignHealth pausa por high_failure_rate quando erro > 10% com 10+ tentativas', () => { + const db = openDb(':memory:'); + const campanha = campanhaRodando(db); + const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id); + for (let i = 0; i < 8; i += 1) { + const lead = inserirLead(db, `ok${i}`); + simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado'); + } + for (let i = 0; i < 2; i += 1) { + const lead = inserirLead(db, `err${i}`); + simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'erro'); + } + const resultado = evaluateCampaignHealth(db, campanha.id); + assert.equal(resultado.pause, true); + assert.equal(resultado.reason, 'high_failure_rate'); + const atualizada = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id); + assert.equal(atualizada.status, 'pausado'); + db.close(); +}); + +test('evaluateCampaignHealth nao pausa por falha com menos de 10 tentativas no dia', () => { + const db = openDb(':memory:'); + const campanha = campanhaRodando(db); + const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id); + const lead = inserirLead(db, 'so1'); + simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'erro'); + const resultado = evaluateCampaignHealth(db, campanha.id); + assert.equal(resultado.pause, false); + db.close(); +}); + +test('evaluateCampaignHealth pausa por high_negative_rate quando >10% dos 20+ contatados estao em sem_interesse/opt_out', () => { + const db = openDb(':memory:'); + const campanha = campanhaRodando(db); + const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id); + for (let i = 0; i < 20; i += 1) { + const lead = inserirLead(db, `neg${i}`); + simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado'); + if (i < 3) { + db.prepare(`INSERT INTO outreach_lead_states (lead_id, stage) VALUES (?, 'sem_interesse')`).run(lead.id); + } + } + const resultado = evaluateCampaignHealth(db, campanha.id); + assert.equal(resultado.pause, true); + assert.equal(resultado.reason, 'high_negative_rate'); + db.close(); +}); + +test('evaluateCampaignHealth pausa por low_reply_rate quando <5% dos 50+ contatados responderam', () => { + const db = openDb(':memory:'); + const campanha = campanhaRodando(db); + const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id); + for (let i = 0; i < 50; i += 1) { + const lead = inserirLead(db, `rep${i}`); + simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado'); + } + const resultado = evaluateCampaignHealth(db, campanha.id); + assert.equal(resultado.pause, true); + assert.equal(resultado.reason, 'low_reply_rate'); + db.close(); +}); + +test('evaluateCampaignHealth em campanha ja pausada nao faz nada', () => { + const db = openDb(':memory:'); + const campanha = campanhaRodando(db); + setCampaignStatus(db, campanha.id, 'pausado', 'manual'); + const resultado = evaluateCampaignHealth(db, campanha.id); + assert.equal(resultado.pause, false); + db.close(); +}); + +test('variantStats calcula taxa de resposta e elegibilidade (limiar 40 envios)', () => { + const db = openDb(':memory:'); + const campanha = campanhaRodando(db); + const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id); + for (let i = 0; i < 5; i += 1) { + const lead = inserirLead(db, `v${i}`); + simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado'); + if (i < 1) { + db.prepare(`INSERT INTO outreach_stage_events (lead_id, campaign_id, stage) VALUES (?, ?, 'interessado')`).run(lead.id, campanha.id); + } + } + const stats = variantStats(db, campanha.id); + assert.equal(stats.length, 1); + assert.equal(stats[0].enviados, 5); + assert.equal(stats[0].respostas, 1); + assert.equal(stats[0].taxaResposta, 0.2); + assert.equal(stats[0].elegivelParaComparacao, false); + db.close(); +}); +``` + +- [ ] **Step 2: Rodar e confirmar que falha** + +Run: `node --test tests/campaign-metrics.test.mjs` +Expected: FAIL — módulo não existe + +- [ ] **Step 3: Implementar** + +Criar `src/outreach/campaign-metrics.mjs`: + +```js +function dateKey(date) { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +function applyPause(db, campaignId, reason) { + db.prepare( + `UPDATE outreach_campaigns SET status = 'pausado', pause_reason = ?, updated_at = datetime('now') + WHERE id = ? AND status = 'rodando'` + ).run(reason, campaignId); +} + +// Mesmos 3 criterios e limiares numericos da Maquina Pessoal (10 tentativas/ +// 10% erro, 20 contatos/10% negativo+opt-out, 50 contatos/5% resposta), +// adaptados as tabelas da Comercial via JOIN por outreach_campaign_queue +// (nao por um valor de origem dedicado). +export function evaluateCampaignHealth(db, campaignId, now = new Date()) { + const campaign = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId); + if (!campaign || campaign.status !== 'rodando') return { pause: false, reason: null }; + + const todayKey = dateKey(now); + const attemptsToday = Number(db.prepare( + `SELECT COUNT(*) AS total + FROM outreach_campaign_queue q + JOIN outreach_sends s ON s.id = q.outreach_send_id + WHERE q.campaign_id = ? AND date(s.sent_at, 'localtime') = ?` + ).get(campaignId, todayKey).total); + + if (attemptsToday >= 10) { + const failureCount = Number(db.prepare( + `SELECT COUNT(*) AS total + FROM outreach_campaign_queue q + JOIN outreach_sends s ON s.id = q.outreach_send_id + WHERE q.campaign_id = ? AND s.status = 'erro' AND date(s.sent_at, 'localtime') = ?` + ).get(campaignId, todayKey).total); + if (failureCount / attemptsToday > 0.10) { + applyPause(db, campaignId, 'high_failure_rate'); + return { pause: true, reason: 'high_failure_rate' }; + } + } + + const contacts = Number(db.prepare( + `SELECT COUNT(*) AS total FROM outreach_campaign_queue WHERE campaign_id = ? AND status IN ('sent','error')` + ).get(campaignId).total); + + if (contacts >= 20) { + const negativeCount = Number(db.prepare( + `SELECT COUNT(DISTINCT q.lead_id) AS total + FROM outreach_campaign_queue q + JOIN outreach_lead_states ls ON ls.lead_id = q.lead_id + WHERE q.campaign_id = ? AND q.status IN ('sent','error') + AND ls.stage IN ('sem_interesse', 'opt_out')` + ).get(campaignId).total); + if (negativeCount / contacts > 0.10) { + applyPause(db, campaignId, 'high_negative_rate'); + return { pause: true, reason: 'high_negative_rate' }; + } + } + + if (contacts >= 50) { + const respondedCount = Number(db.prepare( + `SELECT COUNT(DISTINCT q.lead_id) AS total + FROM outreach_campaign_queue q + JOIN outreach_stage_events se ON se.lead_id = q.lead_id AND se.campaign_id = q.campaign_id + WHERE q.campaign_id = ? AND q.status IN ('sent','error')` + ).get(campaignId).total); + if (respondedCount / contacts < 0.05) { + applyPause(db, campaignId, 'low_reply_rate'); + return { pause: true, reason: 'low_reply_rate' }; + } + } + + return { pause: false, reason: null }; +} + +// Numero de leads distintos com resposta por variante, via subquery +// correlacionada (nao um JOIN direto) para nao contar duplicado quando um +// lead tem mais de um outreach_stage_events. +export function variantStats(db, campaignId) { + const rows = db.prepare( + `SELECT v.id AS variant_id, v.code, + COUNT(*) AS enviados, + (SELECT COUNT(DISTINCT q2.lead_id) + FROM outreach_campaign_queue q2 + JOIN outreach_stage_events se ON se.lead_id = q2.lead_id AND se.campaign_id = q2.campaign_id + WHERE q2.campaign_id = q.campaign_id AND q2.variant_id = v.id AND q2.status IN ('sent','error') + ) AS respostas + FROM outreach_campaign_queue q + JOIN outreach_campaign_variants v ON v.id = q.variant_id + WHERE q.campaign_id = ? AND q.status IN ('sent','error') + GROUP BY v.id, v.code` + ).all(campaignId); + return rows.map((row) => ({ + code: row.code, + enviados: row.enviados, + respostas: row.respostas, + taxaResposta: row.enviados > 0 ? row.respostas / row.enviados : 0, + elegivelParaComparacao: row.enviados >= 40 + })); +} +``` + +- [ ] **Step 4: Rodar e confirmar que passa** + +Run: `node --test tests/campaign-metrics.test.mjs` +Expected: PASS (7 testes) + +- [ ] **Step 5: Commit** + +```bash +git add src/outreach/campaign-metrics.mjs tests/campaign-metrics.test.mjs +git commit -m "Add campaign health auto-pause and variant comparison stats" +``` + +--- + +### Task 6: `campaign-worker.mjs` — envio das linhas aprovadas + +**Files:** +- Create: `src/outreach/campaign-worker.mjs` +- Test: `tests/campaign-worker.test.mjs` + +**Interfaces:** +- Consumes: `isOptedOut` de `./campaign-store.mjs` (Task 2), `evaluateCampaignHealth` de `./campaign-metrics.mjs` (Task 5), `createZproClient` de `./zpro-client.mjs` (já existe). +- Produces: `runCampaignQueue({db, zproClient, now, log}) -> {enviados, erros, pulados}`. Usada por `campaign-scheduler.mjs` (Task 7). + +- [ ] **Step 1: Escrever os testes que falham** + +Criar `tests/campaign-worker.test.mjs`: + +```js +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { openDb, createLeadInserter } from '../src/db.mjs'; +import { createCampaign, setCampaignStatus, addOptOut } from '../src/outreach/campaign-store.mjs'; +import { generateCampaignQueue, listQueue, approveQueueRows } from '../src/outreach/campaign-queue.mjs'; +import { runCampaignQueue } from '../src/outreach/campaign-worker.mjs'; + +function inserirLead(db, dedup, whatsapp) { + const inserir = createLeadInserter(db); + inserir({ + dedup_key: dedup, empresa: 'Empresa', legal_name: '', whatsapp, ddd: '11', email: '', + cidade: '', uf: '', segmento: 'x', segmento_label: '', interesse: '', cnpj: '', cnae: '', + endereco: '', website: '', pains_json: '[]', origem: 't', telefone_valido: 1, cnpj_valido: 0, + empresa_valida: 1, score: 50, classificacao: 'bom', motivo_classificacao: '', score_qualidade: 50, + score_comercial: 50, prioridade: 1, produto_principal: 'ERP', produtos_json: '[]', + capture_run_id: null, captured_at: null, exported_at: null + }); +} + +function campanhaAprovada(db, quantidadeLeads = 1) { + for (let i = 0; i < quantidadeLeads; i += 1) { + inserirLead(db, `lead-${i}`, `551199999000${i}`); + } + const campanha = createCampaign(db, { + nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }], + limiteDiarioInicial: 10 + }); + setCampaignStatus(db, campanha.id, 'rodando'); + generateCampaignQueue(db, campanha.id); + const fila = listQueue(db, campanha.id, 'preview'); + approveQueueRows(db, campanha.id, fila.map((r) => r.id)); + return campanha; +} + +function fakeZproClient(overrides = {}) { + const enviados = []; + return { + enviados, + sendText: async (args) => { enviados.push(args); return { data: { ticketId: 999 } }; }, + ...overrides + }; +} + +test('runCampaignQueue envia linhas aprovadas via sendText e marca sent', async () => { + const db = openDb(':memory:'); + const campanha = campanhaAprovada(db); + const zproClient = fakeZproClient(); + + const resumo = await runCampaignQueue({ db, zproClient }); + assert.equal(resumo.enviados, 1); + assert.equal(zproClient.enviados.length, 1); + assert.equal(zproClient.enviados[0].message, 'Ola Empresa'); + + const fila = listQueue(db, campanha.id, 'sent'); + assert.equal(fila.length, 1); + assert.ok(fila[0].outreach_send_id); + const send = db.prepare('SELECT * FROM outreach_sends WHERE id = ?').get(fila[0].outreach_send_id); + assert.equal(send.status, 'enviado'); + assert.equal(send.origem, 'automatico'); + db.close(); +}); + +test('runCampaignQueue registra erro quando o Z-PRO falha, sem travar o restante', async () => { + const db = openDb(':memory:'); + const campanha = campanhaAprovada(db, 2); + let chamada = 0; + const zproClient = fakeZproClient({ + sendText: async (args) => { + chamada += 1; + if (chamada === 1) throw new Error('Z-PRO falhou'); + return { data: { ticketId: 1 } }; + } + }); + + const resumo = await runCampaignQueue({ db, zproClient }); + assert.equal(resumo.enviados, 1); + assert.equal(resumo.erros, 1); + const erro = listQueue(db, campanha.id, 'error')[0]; + const send = db.prepare('SELECT * FROM outreach_sends WHERE id = ?').get(erro.outreach_send_id); + assert.equal(send.status, 'erro'); + db.close(); +}); + +test('runCampaignQueue respeita o limite diario da campanha', async () => { + const db = openDb(':memory:'); + for (let i = 0; i < 3; i += 1) inserirLead(db, `l${i}`, `551199999000${i}`); + const campanha = createCampaign(db, { + nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }], + limiteDiarioInicial: 1 + }); + setCampaignStatus(db, campanha.id, 'rodando'); + generateCampaignQueue(db, campanha.id); + approveQueueRows(db, campanha.id, listQueue(db, campanha.id, 'preview').map((r) => r.id)); + + const zproClient = fakeZproClient(); + const resumo = await runCampaignQueue({ db, zproClient }); + assert.equal(resumo.enviados, 1); + assert.equal(zproClient.enviados.length, 1); + assert.equal(listQueue(db, campanha.id, 'approved').length, 2); + db.close(); +}); + +test('runCampaignQueue pula e cancela linha de numero que entrou em opt-out depois da fila gerada', async () => { + const db = openDb(':memory:'); + const campanha = campanhaAprovada(db); + addOptOut(db, '5511999990000', 'pediu pra parar'); + db.prepare(`UPDATE outreach_campaign_queue SET numero = '5511999990000' WHERE campaign_id = ?`).run(campanha.id); + + const zproClient = fakeZproClient(); + const resumo = await runCampaignQueue({ db, zproClient }); + assert.equal(resumo.pulados, 1); + assert.equal(zproClient.enviados.length, 0); + const cancelada = listQueue(db, campanha.id, 'cancelled')[0]; + assert.equal(cancelada.cancel_reason, 'opt_out'); + db.close(); +}); + +test('runCampaignQueue so processa campanhas com status rodando', async () => { + const db = openDb(':memory:'); + inserirLead(db, 'l1', '5511999990001'); + const campanha = createCampaign(db, { + nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }] + }); + generateCampaignQueue(db, campanha.id); + approveQueueRows(db, campanha.id, listQueue(db, campanha.id, 'preview').map((r) => r.id)); + // campanha continua em 'rascunho' — nunca foi iniciada + + const zproClient = fakeZproClient(); + const resumo = await runCampaignQueue({ db, zproClient }); + assert.equal(resumo.enviados, 0); + assert.equal(zproClient.enviados.length, 0); + db.close(); +}); + +test('runCampaignQueue interrompe a campanha quando a saude aciona auto-pausa', async () => { + const db = openDb(':memory:'); + const campanha = campanhaAprovada(db, 10); + db.prepare(`UPDATE outreach_campaigns SET limite_diario_atual = 10 WHERE id = ?`).run(campanha.id); + let chamada = 0; + const zproClient = fakeZproClient({ + sendText: async () => { + chamada += 1; + if (chamada <= 8) return { data: { ticketId: chamada } }; + throw new Error('falha simulada'); + } + }); + + await runCampaignQueue({ db, zproClient }); + const atualizada = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id); + // 10 tentativas, 2 erros = 20% > 10% -> deveria pausar antes de esgotar a fila + assert.equal(atualizada.status, 'pausado'); + db.close(); +}); +``` + +- [ ] **Step 2: Rodar e confirmar que falha** + +Run: `node --test tests/campaign-worker.test.mjs` +Expected: FAIL — módulo não existe + +- [ ] **Step 3: Implementar** + +Criar `src/outreach/campaign-worker.mjs`: + +```js +import { createZproClient } from './zpro-client.mjs'; +import { isOptedOut } from './campaign-store.mjs'; +import { evaluateCampaignHealth } from './campaign-metrics.mjs'; + +function todayAttempts(db, campaignId) { + return Number(db.prepare( + `SELECT COUNT(*) AS total + FROM outreach_campaign_queue q + JOIN outreach_sends s ON s.id = q.outreach_send_id + WHERE q.campaign_id = ? AND date(s.sent_at, 'localtime') = date('now', 'localtime')` + ).get(campaignId).total); +} + +// Diferente da Maquina Pessoal (que reserva o envio antes de mandar, porque a +// entrega via Baileys e assincrona/incerta), aqui o envio via Z-PRO +// (zproClient.sendText) e sincrono: ou resolve (aceito pelo Z-PRO) ou lanca +// erro. Por isso o resultado ja e gravado direto como 'enviado'/'erro', sem +// um estado intermediario "reservado" nem logica de recuperacao pos-crash. +export async function runCampaignQueue({ db, zproClient, now = new Date(), log = () => {} }) { + const client = zproClient || createZproClient(db); + const summary = { enviados: 0, erros: 0, pulados: 0 }; + + const campaigns = db.prepare(`SELECT id FROM outreach_campaigns WHERE status = 'rodando'`).all(); + for (const { id: campaignId } of campaigns) { + const campaign = db.prepare('SELECT * FROM outreach_campaigns WHERE id = ?').get(campaignId); + let attemptsToday = todayAttempts(db, campaignId); + + while (attemptsToday < campaign.limite_diario_atual) { + const row = db.prepare( + `SELECT * FROM outreach_campaign_queue + WHERE campaign_id = ? AND status = 'approved' + ORDER BY id ASC LIMIT 1` + ).get(campaignId); + if (!row) break; + + if (isOptedOut(db, row.numero)) { + db.prepare( + `UPDATE outreach_campaign_queue SET status = 'cancelled', cancel_reason = 'opt_out', updated_at = datetime('now') WHERE id = ?` + ).run(row.id); + summary.pulados += 1; + continue; + } + + const jaEnviado = db.prepare( + `SELECT 1 FROM outreach_sends WHERE produto = ? AND status = 'enviado' + AND (lead_id = ? OR numero = ?) LIMIT 1` + ).get(campaign.produto, row.lead_id, row.numero); + if (jaEnviado) { + db.prepare( + `UPDATE outreach_campaign_queue SET status = 'cancelled', cancel_reason = 'ja_contatado', updated_at = datetime('now') WHERE id = ?` + ).run(row.id); + summary.pulados += 1; + continue; + } + + const externalKey = `campanha-${campaignId}-${row.id}`; + const insert = db.prepare( + `INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, erro, zpro_ticket_id, canal, origem) + VALUES (?, ?, ?, ?, ?, ?, ?, 'nao_oficial', 'automatico')` + ); + let sendId; + let queueStatus; + try { + const resposta = await client.sendText({ number: row.numero, message: row.rendered_message, externalKey }); + const ticketId = resposta?.data?.ticketId ? String(resposta.data.ticketId) : null; + sendId = Number(insert.run(row.lead_id, campaign.produto, row.numero, row.rendered_message, 'enviado', null, ticketId).lastInsertRowid); + queueStatus = 'sent'; + summary.enviados += 1; + } catch (error) { + sendId = Number(insert.run(row.lead_id, campaign.produto, row.numero, row.rendered_message, 'erro', String(error.message || error), null).lastInsertRowid); + queueStatus = 'error'; + summary.erros += 1; + log(`campanha #${campaignId}: envio falhou (fila #${row.id}): ${error.message}`); + } + db.prepare( + `UPDATE outreach_campaign_queue SET status = ?, outreach_send_id = ?, updated_at = datetime('now') WHERE id = ?` + ).run(queueStatus, sendId, row.id); + + attemptsToday += 1; + const health = evaluateCampaignHealth(db, campaignId, now); + if (health.pause) { + log(`campanha #${campaignId}: pausada automaticamente (${health.reason})`); + break; + } + } + } + return summary; +} +``` + +- [ ] **Step 4: Rodar e confirmar que passa** + +Run: `node --test tests/campaign-worker.test.mjs` +Expected: PASS (6 testes) + +- [ ] **Step 5: Rodar a suite inteira** + +Run: `node --test tests/*.test.mjs` +Expected: todos os testes passam + +- [ ] **Step 6: Commit** + +```bash +git add src/outreach/campaign-worker.mjs tests/campaign-worker.test.mjs +git commit -m "Add campaign send worker with daily limit and health auto-pause" +``` + +--- + +### Task 7: `campaign-scheduler.mjs` — timer + wiring no boot do servidor + +**Files:** +- Create: `src/outreach/campaign-scheduler.mjs` +- Modify: `scripts/servidor.mjs` +- Test: `tests/campaign-scheduler.test.mjs` + +**Interfaces:** +- Consumes: `runCampaignQueue` de `./campaign-worker.mjs` (Task 6), `escalateWeeklyLimits` de `./campaign-store.mjs` (Task 2). +- Produces: `startCampaignScheduler({db, log}) -> stopFn`. + +- [ ] **Step 1: Escrever o teste que falha** + +Criar `tests/campaign-scheduler.test.mjs`: + +```js +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { openDb } from '../src/db.mjs'; +import { startCampaignScheduler } from '../src/outreach/campaign-scheduler.mjs'; + +test('startCampaignScheduler retorna funcao de parada que limpa os timers sem lancar erro', () => { + const db = openDb(':memory:'); + const stop = startCampaignScheduler({ db, log: () => {} }); + assert.equal(typeof stop, 'function'); + assert.doesNotThrow(() => stop()); + db.close(); +}); +``` + +- [ ] **Step 2: Rodar e confirmar que falha** + +Run: `node --test tests/campaign-scheduler.test.mjs` +Expected: FAIL — módulo não existe + +- [ ] **Step 3: Implementar** + +Criar `src/outreach/campaign-scheduler.mjs`: + +```js +import { runCampaignQueue } from './campaign-worker.mjs'; +import { escalateWeeklyLimits } from './campaign-store.mjs'; + +const CICLO_MS = 20 * 60 * 1000; + +// Timer interno separado do scheduler.mjs existente (run.mjs/runOutreachBatch) +// — mesmo padrao (setInterval com .unref(), primeiro ciclo antecipado pouco +// depois do boot), rodando ao lado sem interferir um no outro. +export function startCampaignScheduler({ db, log = () => {} }) { + const ciclo = () => { + escalateWeeklyLimits(db); + runCampaignQueue({ db, log }).catch((error) => { + log(`campanhas: erro inesperado no ciclo automatico: ${error.message}`); + }); + }; + + const primeiro = setTimeout(ciclo, 45 * 1000); + primeiro.unref(); + const timer = setInterval(ciclo, CICLO_MS); + timer.unref(); + return () => { + clearTimeout(primeiro); + clearInterval(timer); + }; +} +``` + +- [ ] **Step 4: Rodar e confirmar que passa** + +Run: `node --test tests/campaign-scheduler.test.mjs` +Expected: PASS + +- [ ] **Step 5: Ligar no boot do servidor** + +Em `scripts/servidor.mjs`, adicionar o import junto aos demais (depois de `import { startOutreachScheduler } from '../src/outreach/scheduler.mjs';`): + +```js +import { startCampaignScheduler } from '../src/outreach/campaign-scheduler.mjs'; +``` + +E logo depois de `startOutreachScheduler({ db, log });`: + +```js +startCampaignScheduler({ db, log }); +``` + +- [ ] **Step 6: Rodar a suite inteira** + +Run: `node --test tests/*.test.mjs` +Expected: todos os testes passam (o boot do servidor em si não tem teste automatizado — verificação manual: `npm start` sobe sem erro) + +- [ ] **Step 7: Commit** + +```bash +git add src/outreach/campaign-scheduler.mjs scripts/servidor.mjs tests/campaign-scheduler.test.mjs +git commit -m "Wire campaign scheduler into server boot" +``` + +--- + +### Task 8: Rotas HTTP (`src/server/routes/campaigns.mjs`) + +**Files:** +- Create: `src/server/routes/campaigns.mjs` +- Modify: `src/server/index.mjs` +- Test: `tests/campaign-routes.test.mjs` + +**Interfaces:** +- Consumes: tudo das Tasks 2, 4, 5 (`campaign-store.mjs`, `campaign-queue.mjs`, `campaign-metrics.mjs`). +- Produces: `registerCampaignRoutes(router, {db})`, registrada em `createAppServer`. + +- [ ] **Step 1: Escrever os testes que falham** + +Criar `tests/campaign-routes.test.mjs`: + +```js +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { openDb, createLeadInserter } from '../src/db.mjs'; +import { createRouter } from '../src/server/router.mjs'; +import { registerCampaignRoutes } from '../src/server/routes/campaigns.mjs'; + +function setupRouter(db) { + const router = createRouter(); + registerCampaignRoutes(router, { db }); + return router; +} + +function fakeJson() { + const calls = []; + return { json: (status, data) => calls.push({ status, data }), calls }; +} + +function inserirLead(db, dedup, produto = 'ERP', segmento = 'oficinas') { + const inserir = createLeadInserter(db); + inserir({ + dedup_key: dedup, empresa: 'Empresa', legal_name: '', whatsapp: `551199990${dedup}`, ddd: '11', + email: '', cidade: '', uf: '', segmento, segmento_label: '', interesse: '', cnpj: '', cnae: '', + endereco: '', website: '', pains_json: '[]', origem: 't', telefone_valido: 1, cnpj_valido: 0, + empresa_valida: 1, score: 50, classificacao: 'bom', motivo_classificacao: '', score_qualidade: 50, + score_comercial: 50, prioridade: 1, produto_principal: produto, produtos_json: '[]', + capture_run_id: null, captured_at: null, exported_at: null + }); +} + +test('POST /api/campaigns cria campanha com segmentos e variantes', async () => { + const db = openDb(':memory:'); + const router = setupRouter(db); + const { handler } = router.match('POST', '/api/campaigns'); + const { json, calls } = fakeJson(); + + await handler(null, null, { json, body: { + nome: 'ERP oficinas', produto: 'ERP', segmentos: ['oficinas'], + variantes: [{ mensagem: 'Ola {empresa}' }] + } }); + + assert.equal(calls[0].status, 200); + assert.equal(calls[0].data.produto, 'ERP'); + assert.equal(calls[0].data.variants.length, 1); + db.close(); +}); + +test('POST /api/campaigns rejeita sem segmentos', async () => { + const db = openDb(':memory:'); + const router = setupRouter(db); + const { handler } = router.match('POST', '/api/campaigns'); + const { json, calls } = fakeJson(); + + await handler(null, null, { json, body: { nome: 'X', produto: 'ERP', segmentos: [], variantes: [{ mensagem: 'oi' }] } }); + assert.equal(calls[0].status, 400); + db.close(); +}); + +test('GET /api/campaigns/segmentos lista segmentos distintos do produto com contagem', async () => { + const db = openDb(':memory:'); + inserirLead(db, '1', 'ERP', 'oficinas'); + inserirLead(db, '2', 'ERP', 'oficinas'); + inserirLead(db, '3', 'ERP', 'padarias'); + inserirLead(db, '4', 'G-Chat', 'padarias'); + const router = setupRouter(db); + const { handler } = router.match('GET', '/api/campaigns/segmentos'); + const { json, calls } = fakeJson(); + + await handler(null, null, { json, query: new URLSearchParams({ produto: 'ERP' }) }); + assert.equal(calls[0].status, 200); + const segmentos = calls[0].data.segmentos; + assert.equal(segmentos.find((s) => s.value === 'oficinas').total, 2); + assert.equal(segmentos.find((s) => s.value === 'padarias').total, 1); + db.close(); +}); + +test('fluxo completo: criar campanha, gerar fila, aprovar e checar saude', async () => { + const db = openDb(':memory:'); + inserirLead(db, '1', 'ERP', 'oficinas'); + const router = setupRouter(db); + + const criar = router.match('POST', '/api/campaigns'); + const respCriar = fakeJson(); + await criar.handler(null, null, { json: respCriar.json, body: { + nome: 'C', produto: 'ERP', segmentos: ['oficinas'], variantes: [{ mensagem: 'Ola {empresa}' }] + } }); + const campaignId = respCriar.calls[0].data.id; + + const gerarFila = router.match('POST', `/api/campaigns/${campaignId}/gerar-fila`); + const respFila = fakeJson(); + await gerarFila.handler(null, null, { json: respFila.json, params: { id: String(campaignId) }, body: {} }); + assert.equal(respFila.calls[0].data.adicionadosNaFila, 1); + + const listarFila = router.match('GET', `/api/campaigns/${campaignId}/fila`); + const respLista = fakeJson(); + await listarFila.handler(null, null, { json: respLista.json, params: { id: String(campaignId) }, query: new URLSearchParams() }); + const queueId = respLista.calls[0].data.fila[0].id; + + const aprovar = router.match('POST', `/api/campaigns/${campaignId}/fila/aprovar`); + const respAprovar = fakeJson(); + await aprovar.handler(null, null, { json: respAprovar.json, params: { id: String(campaignId) }, body: { queueIds: [queueId] } }); + assert.equal(respAprovar.calls[0].data.aprovados, 1); + + const saude = router.match('GET', `/api/campaigns/${campaignId}/saude`); + const respSaude = fakeJson(); + await saude.handler(null, null, { json: respSaude.json, params: { id: String(campaignId) } }); + assert.equal(respSaude.calls[0].data.pause, false); + db.close(); +}); + +test('POST /api/campaigns/leads/:leadId/estagio grava estagio atual e historico', async () => { + const db = openDb(':memory:'); + inserirLead(db, '1'); + const lead = db.prepare('SELECT id FROM leads LIMIT 1').get(); + const router = setupRouter(db); + + const marcar = router.match('POST', '/api/campaigns/leads/:leadId/estagio'); + const respMarcar = fakeJson(); + await marcar.handler(null, null, { json: respMarcar.json, params: { leadId: String(lead.id) }, body: { stage: 'interessado', note: 'ligar amanha' } }); + assert.equal(respMarcar.calls[0].status, 200); + + const consultar = router.match('GET', '/api/campaigns/leads/:leadId/estagio'); + const respConsultar = fakeJson(); + await consultar.handler(null, null, { json: respConsultar.json, params: { leadId: String(lead.id) } }); + assert.equal(respConsultar.calls[0].data.atual.stage, 'interessado'); + assert.equal(respConsultar.calls[0].data.historico.length, 1); + db.close(); +}); + +test('POST /api/campaigns/opt-out bloqueia e DELETE libera', async () => { + const db = openDb(':memory:'); + const router = setupRouter(db); + + const adicionar = router.match('POST', '/api/campaigns/opt-out'); + await adicionar.handler(null, null, { json: fakeJson().json, body: { numero: '5511999990001', motivo: 'pediu' } }); + assert.ok(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('5511999990001')); + + const remover = router.match('DELETE', '/api/campaigns/opt-out/:numero'); + await remover.handler(null, null, { json: fakeJson().json, params: { numero: '5511999990001' } }); + assert.equal(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('5511999990001'), undefined); + db.close(); +}); +``` + +- [ ] **Step 2: Rodar e confirmar que falha** + +Run: `node --test tests/campaign-routes.test.mjs` +Expected: FAIL — módulo não existe + +- [ ] **Step 3: Implementar** + +Criar `src/server/routes/campaigns.mjs`: + +```js +import { + createCampaign, getCampaign, listCampaigns, setCampaignStatus, resumeCampaign, + addOptOut, removeOptOut +} from '../../outreach/campaign-store.mjs'; +import { generateCampaignQueue, listQueue, approveQueueRows, cancelQueueRows } from '../../outreach/campaign-queue.mjs'; +import { evaluateCampaignHealth, variantStats } from '../../outreach/campaign-metrics.mjs'; + +export function registerCampaignRoutes(router, { db }) { + router.add('POST', '/api/campaigns', async (req, res, { json, body }) => { + const nome = String(body?.nome || '').trim(); + const produto = String(body?.produto || '').trim(); + const segmentos = Array.isArray(body?.segmentos) ? body.segmentos.filter(Boolean) : []; + const variantesValidas = Array.isArray(body?.variantes) + ? body.variantes.filter((v) => v && String(v.mensagem || '').trim()) + : []; + if (!nome || !produto) { json(400, { erro: 'informe nome e produto' }); return; } + if (!segmentos.length) { json(400, { erro: 'escolha ao menos um segmento' }); return; } + if (!variantesValidas.length) { json(400, { erro: 'escreva ao menos uma variante de mensagem' }); return; } + + const campanha = createCampaign(db, { + nome, produto, segmentos, + variantes: variantesValidas.map((v, i) => ({ code: v.code || String.fromCharCode(65 + i), mensagem: v.mensagem })), + limiteDiarioInicial: Number(body.limiteDiarioInicial) || 10, + limiteDiarioTeto: Number(body.limiteDiarioTeto) || 80 + }); + json(200, campanha); + }); + + router.add('GET', '/api/campaigns', async (req, res, { json }) => { + json(200, { campanhas: listCampaigns(db) }); + }); + + // Precisa vir ANTES de GET /api/campaigns/:id (mesmo numero de segmentos no + // path — o router retorna a primeira rota que casar). + router.add('GET', '/api/campaigns/segmentos', async (req, res, { json, query }) => { + const produto = String(query?.get('produto') || '').trim(); + if (!produto) { json(400, { erro: 'informe o produto' }); return; } + const segmentos = db.prepare( + `SELECT segmento AS value, COUNT(*) AS total FROM leads + WHERE produto_principal = ? AND segmento != '' GROUP BY segmento ORDER BY total DESC` + ).all(produto); + json(200, { segmentos }); + }); + + router.add('GET', '/api/campaigns/produtos', async (req, res, { json }) => { + const produtos = db.prepare( + `SELECT produto_principal AS value, COUNT(*) AS total FROM leads + WHERE produto_principal != '' GROUP BY produto_principal ORDER BY total DESC` + ).all(); + json(200, { produtos }); + }); + + router.add('GET', '/api/campaigns/:id', async (req, res, { json, params }) => { + const campanha = getCampaign(db, Number(params.id)); + if (!campanha) { json(404, { erro: 'campanha nao encontrada' }); return; } + json(200, campanha); + }); + + router.add('POST', '/api/campaigns/:id/gerar-fila', async (req, res, { json, params, body }) => { + try { + const resultado = generateCampaignQueue(db, Number(params.id), { quantidade: Number(body?.quantidade) || 50 }); + json(200, resultado); + } catch (error) { + json(400, { erro: String(error.message || error) }); + } + }); + + router.add('GET', '/api/campaigns/:id/fila', async (req, res, { json, params, query }) => { + json(200, { fila: listQueue(db, Number(params.id), query?.get('status') || null) }); + }); + + router.add('POST', '/api/campaigns/:id/fila/aprovar', async (req, res, { json, params, body }) => { + const queueIds = Array.isArray(body?.queueIds) ? body.queueIds.map(Number).filter(Number.isFinite) : []; + json(200, { aprovados: approveQueueRows(db, Number(params.id), queueIds) }); + }); + + router.add('POST', '/api/campaigns/:id/fila/cancelar', async (req, res, { json, params, body }) => { + const queueIds = Array.isArray(body?.queueIds) ? body.queueIds.map(Number).filter(Number.isFinite) : []; + json(200, { cancelados: cancelQueueRows(db, Number(params.id), queueIds, body?.motivo || 'operador') }); + }); + + router.add('POST', '/api/campaigns/:id/iniciar', async (req, res, { json, params }) => { + json(200, setCampaignStatus(db, Number(params.id), 'rodando')); + }); + + router.add('POST', '/api/campaigns/:id/pausar', async (req, res, { json, params, body }) => { + json(200, setCampaignStatus(db, Number(params.id), 'pausado', body?.motivo || 'manual')); + }); + + router.add('POST', '/api/campaigns/:id/retomar', async (req, res, { json, params }) => { + json(200, resumeCampaign(db, Number(params.id))); + }); + + router.add('GET', '/api/campaigns/:id/saude', async (req, res, { json, params }) => { + json(200, evaluateCampaignHealth(db, Number(params.id))); + }); + + router.add('GET', '/api/campaigns/:id/variantes-stats', async (req, res, { json, params }) => { + json(200, { variantes: variantStats(db, Number(params.id)) }); + }); + + router.add('POST', '/api/campaigns/leads/:leadId/estagio', async (req, res, { json, params, body }) => { + const stage = String(body?.stage || '').trim(); + if (!stage) { json(400, { erro: 'informe o estagio' }); return; } + const leadId = Number(params.leadId); + db.prepare( + `INSERT INTO outreach_lead_states (lead_id, stage, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(lead_id) DO UPDATE SET stage = excluded.stage, updated_at = excluded.updated_at` + ).run(leadId, stage); + db.prepare( + `INSERT INTO outreach_stage_events (lead_id, campaign_id, stage, note) VALUES (?, ?, ?, ?)` + ).run(leadId, body?.campaignId ? Number(body.campaignId) : null, stage, body?.note || null); + json(200, { ok: true }); + }); + + router.add('GET', '/api/campaigns/leads/:leadId/estagio', async (req, res, { json, params }) => { + const leadId = Number(params.leadId); + json(200, { + atual: db.prepare('SELECT * FROM outreach_lead_states WHERE lead_id = ?').get(leadId) || null, + historico: db.prepare('SELECT * FROM outreach_stage_events WHERE lead_id = ? ORDER BY id DESC').all(leadId) + }); + }); + + router.add('POST', '/api/campaigns/leads/:leadId/agendamentos', async (req, res, { json, params, body }) => { + const kind = String(body?.kind || '').trim(); + if (!kind) { json(400, { erro: 'informe o tipo de agendamento' }); return; } + db.prepare( + `INSERT INTO outreach_appointments (lead_id, kind, scheduled_at, note) VALUES (?, ?, ?, ?)` + ).run(Number(params.leadId), kind, body?.scheduledAt || null, body?.note || null); + json(200, { ok: true }); + }); + + router.add('GET', '/api/campaigns/leads/:leadId/agendamentos', async (req, res, { json, params }) => { + json(200, { + agendamentos: db.prepare('SELECT * FROM outreach_appointments WHERE lead_id = ? ORDER BY id DESC').all(Number(params.leadId)) + }); + }); + + router.add('POST', '/api/campaigns/opt-out', async (req, res, { json, body }) => { + const numero = String(body?.numero || '').trim(); + if (!numero) { json(400, { erro: 'informe o numero' }); return; } + addOptOut(db, numero, body?.motivo || null); + json(200, { ok: true }); + }); + + router.add('DELETE', '/api/campaigns/opt-out/:numero', async (req, res, { json, params }) => { + removeOptOut(db, params.numero); + json(200, { ok: true }); + }); +} +``` + +- [ ] **Step 4: Registrar as rotas no servidor** + +Em `src/server/index.mjs`, adicionar o import junto aos outros (depois de `import { registerOutreachRoutes } from './routes/outreach.mjs';`): + +```js +import { registerCampaignRoutes } from './routes/campaigns.mjs'; +``` + +E depois de `registerOutreachRoutes(router, deps);`: + +```js +registerCampaignRoutes(router, deps); +``` + +- [ ] **Step 5: Rodar e confirmar que passa** + +Run: `node --test tests/campaign-routes.test.mjs` +Expected: PASS (6 testes) + +- [ ] **Step 6: Rodar a suite inteira** + +Run: `node --test tests/*.test.mjs` +Expected: todos os testes passam + +- [ ] **Step 7: Commit** + +```bash +git add src/server/routes/campaigns.mjs src/server/index.mjs tests/campaign-routes.test.mjs +git commit -m "Add campaign HTTP routes" +``` + +--- + +### Task 9: Front-end — aba Campanhas (esqueleto + criação de campanha) + +**Files:** +- Modify: `public/index.html` +- Modify: `public/js/app.js` +- Create: `public/js/campanhas.js` + +**Interfaces:** +- Consumes: `API.get`/`API.post` de `api.js`, `el`/`h`/`toast` de `api.js` (já existentes), rotas de `campaigns.mjs` (Task 8). +- Produces: objeto global `Campanhas` com `.ativar()`, chamado pelo listener de troca de aba em `app.js` — consumido pelas Tasks 10 e 11 que vão adicionar mais funções a esse mesmo módulo. + +Sem teste automatizado (é UI); verificação manual descrita no Step 5. + +- [ ] **Step 1: Adicionar a aba e o painel em `public/index.html`** + +Trocar a linha 18 (depois do botão "Tratar Leads") para incluir o botão da nova aba: + +```html + + +``` + +Adicionar um novo `
` logo antes de `` (perto do fim do `
`, depois da section `tab-tratamento` existente): + +```html + +
+
+

Campanhas de outreach

+ +
+
+ + +
+

Nova campanha

+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ +
+
+ + +
+

+
+ + +
+
+
+
+ +
+ +``` + +(o `` e o ``: + +```html + +``` + +Em `public/js/app.js`, dentro do loop de troca de aba (perto da linha 66), trocar: + +```js + if (tab.dataset.tab === 'tratamento') Tratamento.ativar(); +``` + +por: + +```js + if (tab.dataset.tab === 'tratamento') Tratamento.ativar(); + if (tab.dataset.tab === 'campanhas') Campanhas.ativar(); +``` + +- [ ] **Step 3: Criar `public/js/campanhas.js` com listagem e criação** + +```js +/* Aba Campanhas: criacao, fila de aprovacao, saude/variantes, estagio/agendamento. */ +const Campanhas = (() => { + let carregado = false; + let variantesCount = 0; + + const STATUS_LABELS = { rascunho: 'Rascunho', rodando: 'Rodando', pausado: 'Pausado', concluido: 'Concluido' }; + + function addVarianteRow(codigoSugerido) { + const codigo = codigoSugerido || String.fromCharCode(65 + variantesCount); + variantesCount += 1; + el('campanha-variantes').append( + h('div', { class: 'row', 'data-variante-code': codigo }, + h('span', { class: 'chip' }, codigo), + h('textarea', { class: 'campanha-variante-mensagem', rows: 2, placeholder: 'Ola {empresa}, ...' })) + ); + } + + async function carregarProdutos() { + const { produtos } = await API.get('/api/campaigns/produtos'); + el('campanha-produto').replaceChildren( + ...produtos.map((p) => h('option', { value: p.value }, `${p.value} (${fmt.format(p.total)} leads)`)) + ); + } + + async function carregarSegmentos(produto) { + const container = el('campanha-segmentos'); + if (!produto) { container.replaceChildren(); return; } + const { segmentos } = await API.get(`/api/campaigns/segmentos?produto=${encodeURIComponent(produto)}`); + container.replaceChildren(...segmentos.map((s) => + h('label', { class: 'chip' }, + h('input', { type: 'checkbox', value: s.value, class: 'campanha-segmento-check' }), + ` ${s.value} (${fmt.format(s.total)})`) + )); + } + + function segmentosSelecionados() { + return Array.from(document.querySelectorAll('.campanha-segmento-check:checked')).map((i) => i.value); + } + + function variantesPreenchidas() { + return Array.from(el('campanha-variantes').children).map((row) => ({ + code: row.dataset.varianteCode, + mensagem: row.querySelector('.campanha-variante-mensagem').value.trim() + })).filter((v) => v.mensagem); + } + + async function abrirModalNovaCampanha() { + el('campanha-nome').value = ''; + el('campanha-limite-inicial').value = 10; + el('campanha-limite-teto').value = 80; + el('campanha-erro').textContent = ''; + el('campanha-variantes').replaceChildren(); + variantesCount = 0; + addVarianteRow('A'); + await carregarProdutos(); + await carregarSegmentos(el('campanha-produto').value); + el('modal-nova-campanha').showModal(); + } + + function renderCampanhaCard(campanha) { + return h('div', { class: 'item' }, + h('div', {}, + h('strong', {}, campanha.nome), + h('span', { class: 'sub' }, ` ${campanha.produto} — ${campanha.segmentos.join(', ')}`)), + h('span', { class: `chip ${campanha.status === 'rodando' ? 'ok' : campanha.status === 'pausado' ? 'warn' : ''}` }, + STATUS_LABELS[campanha.status] || campanha.status)); + } + + async function carregarLista() { + const { campanhas } = await API.get('/api/campaigns'); + el('campanhas-lista').replaceChildren( + ...(campanhas.length ? campanhas.map(renderCampanhaCard) : [h('p', { class: 'muted' }, 'Nenhuma campanha criada ainda.')]) + ); + } + + function ativar() { + if (carregado) return; + carregado = true; + + el('btn-nova-campanha').addEventListener('click', () => { + abrirModalNovaCampanha().catch((error) => toast(`Erro: ${error.message}`)); + }); + el('btn-cancelar-campanha').addEventListener('click', () => el('modal-nova-campanha').close()); + el('btn-add-variante').addEventListener('click', () => addVarianteRow()); + el('campanha-produto').addEventListener('change', (event) => { + carregarSegmentos(event.target.value).catch((error) => toast(`Erro: ${error.message}`)); + }); + + el('btn-salvar-campanha').addEventListener('click', async () => { + try { + await API.post('/api/campaigns', { + nome: el('campanha-nome').value.trim(), + produto: el('campanha-produto').value, + segmentos: segmentosSelecionados(), + variantes: variantesPreenchidas(), + limiteDiarioInicial: Number(el('campanha-limite-inicial').value) || 10, + limiteDiarioTeto: Number(el('campanha-limite-teto').value) || 80 + }); + el('modal-nova-campanha').close(); + toast('Campanha criada'); + await carregarLista(); + } catch (error) { + el('campanha-erro').textContent = error.message; + } + }); + + carregarLista().catch((error) => toast(`Erro: ${error.message}`)); + } + + return { ativar }; +})(); +``` + +- [ ] **Step 4: Adicionar estilo mínimo para o modal de campanha (reaproveitando classes existentes)** + +Nenhuma classe CSS nova é necessária — `dialog`, `.field`, `.row`, `.chip`, `.chips`, `.item`, `.lista`, `.btn`/`.btn-primary`/`.btn-ghost` já existem em `public/css/app.css` e cobrem este formulário. Pular este step se a revisão visual (Step 5) não apontar necessidade. + +- [ ] **Step 5: Verificação manual no navegador** + +Rodar `npm start`, abrir `http://127.0.0.1:3900`, logar, clicar na aba "🎯 Campanhas", clicar em "+ Nova campanha", escolher um produto (a lista deve carregar da base de leads existente), marcar segmentos, escrever ao menos uma variante, salvar — a campanha deve aparecer na lista com status "Rascunho". + +- [ ] **Step 6: Commit** + +```bash +git add public/index.html public/js/app.js public/js/campanhas.js +git commit -m "Add Campanhas tab: listing and campaign creation" +``` + +--- + +### Task 10: Front-end — fila de aprovação, saúde e comparação de variantes + +**Files:** +- Modify: `public/js/campanhas.js` +- Modify: `public/index.html` + +**Interfaces:** +- Consumes: rotas `/api/campaigns/:id/gerar-fila`, `/api/campaigns/:id/fila`, `/api/campaigns/:id/fila/aprovar`, `/api/campaigns/:id/fila/cancelar`, `/api/campaigns/:id/iniciar`, `/api/campaigns/:id/pausar`, `/api/campaigns/:id/retomar`, `/api/campaigns/:id/saude`, `/api/campaigns/:id/variantes-stats` (todas da Task 8). +- Produces: adiciona `abrirDetalheCampanha(campanhaId)` ao módulo `Campanhas`, acionado ao clicar num card da lista (Task 9). + +- [ ] **Step 1: Adicionar o painel de detalhe em `public/index.html`** + +Dentro da `
`, logo depois de `
`, adicionar: + +```html + +``` + +- [ ] **Step 2: Implementar em `public/js/campanhas.js`** + +Adicionar, dentro do IIFE, antes do `return { ativar };` final: + +```js + let campanhaAtualId = null; + + async function carregarDetalhe(campanhaId) { + campanhaAtualId = campanhaId; + const campanha = await API.get(`/api/campaigns/${campanhaId}`); + el('detalhe-nome').textContent = campanha.nome; + el('detalhe-status').textContent = STATUS_LABELS[campanha.status] || campanha.status; + el('campanha-detalhe').classList.remove('hidden'); + await Promise.all([carregarSaude(campanhaId), carregarVariantesStats(campanhaId), carregarFila(campanhaId)]); + } + + async function carregarSaude(campanhaId) { + const saude = await API.get(`/api/campaigns/${campanhaId}/saude`); + el('detalhe-saude').textContent = saude.pause + ? `Pausada automaticamente: ${saude.reason}` + : 'Saude: sem alertas.'; + } + + async function carregarVariantesStats(campanhaId) { + const { variantes } = await API.get(`/api/campaigns/${campanhaId}/variantes-stats`); + el('detalhe-variantes').replaceChildren(...variantes.map((v) => + h('span', { class: 'chip' }, + `${v.code}: ${v.enviados} enviados, ${(v.taxaResposta * 100).toFixed(1)}% resposta${v.elegivelParaComparacao ? ' (elegivel)' : ''}`) + )); + } + + async function carregarFila(campanhaId) { + const status = el('fila-filtro-status').value; + const { fila } = await API.get(`/api/campaigns/${campanhaId}/fila?status=${status}`); + el('fila-tabela').replaceChildren(...fila.map((linha) => + h('tr', {}, + h('td', {}, h('input', { type: 'checkbox', class: 'fila-check', value: linha.id })), + h('td', {}, String(linha.lead_id)), + h('td', {}, linha.numero), + h('td', {}, linha.rendered_message), + h('td', {}, linha.status)) + )); + } + + function filaSelecionada() { + return Array.from(document.querySelectorAll('.fila-check:checked')).map((i) => Number(i.value)); + } + + function ligarDetalheCampanha() { + el('btn-fechar-detalhe').addEventListener('click', () => el('campanha-detalhe').classList.add('hidden')); + el('fila-filtro-status').addEventListener('change', () => carregarFila(campanhaAtualId).catch((e) => toast(`Erro: ${e.message}`))); + + el('btn-gerar-fila').addEventListener('click', async () => { + try { + const resultado = await API.post(`/api/campaigns/${campanhaAtualId}/gerar-fila`, {}); + toast(`${resultado.adicionadosNaFila} leads adicionados a fila`); + await carregarFila(campanhaAtualId); + } catch (error) { toast(`Erro: ${error.message}`); } + }); + + el('btn-iniciar-campanha').addEventListener('click', async () => { + await API.post(`/api/campaigns/${campanhaAtualId}/iniciar`, {}); + await carregarDetalhe(campanhaAtualId); + await carregarLista(); + }); + el('btn-pausar-campanha').addEventListener('click', async () => { + await API.post(`/api/campaigns/${campanhaAtualId}/pausar`, { motivo: 'manual' }); + await carregarDetalhe(campanhaAtualId); + await carregarLista(); + }); + el('btn-retomar-campanha').addEventListener('click', async () => { + await API.post(`/api/campaigns/${campanhaAtualId}/retomar`, {}); + await carregarDetalhe(campanhaAtualId); + await carregarLista(); + }); + + el('btn-aprovar-selecionados').addEventListener('click', async () => { + const queueIds = filaSelecionada(); + if (!queueIds.length) return; + const resultado = await API.post(`/api/campaigns/${campanhaAtualId}/fila/aprovar`, { queueIds }); + toast(`${resultado.aprovados} aprovados`); + await carregarFila(campanhaAtualId); + }); + el('btn-cancelar-selecionados').addEventListener('click', async () => { + const queueIds = filaSelecionada(); + if (!queueIds.length) return; + const resultado = await API.post(`/api/campaigns/${campanhaAtualId}/fila/cancelar`, { queueIds, motivo: 'operador' }); + toast(`${resultado.cancelados} cancelados`); + await carregarFila(campanhaAtualId); + }); + } +``` + +Trocar `renderCampanhaCard` para tornar o card clicável (adicionar `onclick` no `h('div', { class: 'item' }, ...)`): + +```js + function renderCampanhaCard(campanha) { + return h('div', { class: 'item', onclick: () => carregarDetalhe(campanha.id).catch((e) => toast(`Erro: ${e.message}`)) }, + h('div', {}, + h('strong', {}, campanha.nome), + h('span', { class: 'sub' }, ` ${campanha.produto} — ${campanha.segmentos.join(', ')}`)), + h('span', { class: `chip ${campanha.status === 'rodando' ? 'ok' : campanha.status === 'pausado' ? 'warn' : ''}` }, + STATUS_LABELS[campanha.status] || campanha.status)); + } +``` + +E, na função `ativar()`, chamar `ligarDetalheCampanha();` junto dos outros listeners (antes de `carregarLista().catch(...)`). + +- [ ] **Step 2: Verificação manual** + +Com uma campanha criada (Task 9), clicar no card dela, clicar "Iniciar", "Gerar fila", selecionar linhas e clicar "Aprovar selecionados" — a fila deve refletir o novo status e, depois de um ciclo do `campaign-scheduler` (ou chamando `runCampaignQueue` manualmente via um teste/REPL), as linhas aprovadas devem virar "sent" desde que `zpro.*` esteja configurado em Configurações. + +- [ ] **Step 3: Commit** + +```bash +git add public/index.html public/js/campanhas.js +git commit -m "Add campaign detail panel: queue approval, health and variant stats" +``` + +--- + +### Task 11: Front-end — classificação de estágio e agendamento + checagem final da suite + +**Files:** +- Modify: `public/js/campanhas.js` +- Modify: `public/index.html` +- Test: rodar a suite inteira (nenhum arquivo de teste novo — esta task é só UI + verificação) + +**Interfaces:** +- Consumes: rotas `/api/campaigns/leads/:leadId/estagio` (GET/POST) e `/api/campaigns/leads/:leadId/agendamentos` (GET/POST) da Task 8. + +- [ ] **Step 1: Adicionar controles de estágio/agendamento por linha da fila em `public/index.html`** + +Trocar o `` da tabela de fila (adicionada na Task 10) para incluir uma coluna de ação: + +```html + EmpresaNumeroMensagemStatusEstagio +``` + +- [ ] **Step 2: Implementar em `public/js/campanhas.js`** + +Trocar a função `carregarFila` (da Task 10) para incluir o seletor de estágio por linha (só faz sentido para linhas já `sent`/`error`, que de fato foram contatadas): + +```js + const STAGE_OPTIONS = [ + 'respondeu', 'interessado', 'demonstracao_agendada', 'sem_interesse', 'opt_out', 'venda_fechada' + ]; + + async function carregarFila(campanhaId) { + const status = el('fila-filtro-status').value; + const { fila } = await API.get(`/api/campaigns/${campanhaId}/fila?status=${status}`); + el('fila-tabela').replaceChildren(...fila.map((linha) => { + const podeMarcarEstagio = linha.status === 'sent' || linha.status === 'error'; + const stageSelect = h('select', { class: 'fila-estagio-select', disabled: podeMarcarEstagio ? null : 'disabled' }, + h('option', { value: '' }, '—'), + ...STAGE_OPTIONS.map((s) => h('option', { value: s }, s))); + const marcarBtn = h('button', { + class: 'btn-ghost btn-sm', type: 'button', disabled: podeMarcarEstagio ? null : 'disabled', + onclick: async () => { + if (!stageSelect.value) return; + await API.post(`/api/campaigns/leads/${linha.lead_id}/estagio`, { stage: stageSelect.value, campaignId: campanhaId }); + toast('Estagio atualizado'); + if (stageSelect.value === 'demonstracao_agendada') { + const dataAgendamento = prompt('Data/hora do agendamento (AAAA-MM-DD HH:MM):'); + if (dataAgendamento) { + await API.post(`/api/campaigns/leads/${linha.lead_id}/agendamentos`, { kind: 'demonstracao', scheduledAt: dataAgendamento }); + toast('Agendamento registrado'); + } + } + } + }, 'Marcar'); + return h('tr', {}, + h('td', {}, h('input', { type: 'checkbox', class: 'fila-check', value: linha.id })), + h('td', {}, String(linha.lead_id)), + h('td', {}, linha.numero), + h('td', {}, linha.rendered_message), + h('td', {}, linha.status), + h('td', { class: 'row' }, stageSelect, marcarBtn)); + })); + } +``` + +- [ ] **Step 3: Verificação manual** + +Com uma linha de fila em status `sent` (após um envio real ou simulado), abrir o detalhe da campanha, escolher um estágio no seletor da linha, clicar "Marcar" — checar via `GET /api/campaigns/leads/:leadId/estagio` (ou na aba Tratar Leads, se o lead for aberto lá) que o estágio foi gravado. Marcar `demonstracao_agendada` deve pedir a data e criar um agendamento. + +- [ ] **Step 4: Rodar a suite inteira e revisar `git status`** + +Run: `node --test tests/*.test.mjs` +Expected: todos os testes passam (backend Tasks 1-8 + qualquer teste pré-existente) + +Run: `git status --short` +Expected: só os arquivos desta feature aparecem como modificados/staged do lado do que este plano tocou — nada do WIP alheio (outreach existente, capture, etc.) deve ter sido alterado por engano. + +- [ ] **Step 5: Commit** + +```bash +git add public/index.html public/js/campanhas.js +git commit -m "Add stage classification and appointment scheduling UI" +``` + +--- + +## Self-Review + +**Cobertura da spec:** catálogo dinâmico (Task 2/9), fila/preview/aprovação (Task 4/10), variantes A/B/C só não-oficial (Task 3/6, `zproClient.sendText`), escalonamento semanal (Task 2/7), saúde/auto-pausa (Task 5/6), classificação de estágio e agendamento manual (Task 8/11), prevenção de contato duplicado com `run.mjs` (Task 4, cláusula `NOT EXISTS` por `lead_id OR numero`), opt-out (Task 2/6). Tudo coberto. + +**Placeholder scan:** nenhum "TBD"/"implementar depois" — todo step tem código completo. + +**Consistência de tipos:** `getCampaign` retorna `{...row, segmentos: [], variants: []}` — usado identicamente em `campaign-queue.mjs`, `campaign-worker.mjs` (via `db.prepare` direto, não via `getCampaign`, para pegar `limite_diario_atual` fresco a cada iteração do loop) e nas rotas. `variantForLead`/`renderCampaignMessage` com a mesma assinatura em `campaign-messages.mjs`, `campaign-queue.mjs`. Status de fila (`preview|approved|cancelled|sent|error`) e de campanha (`rascunho|rodando|pausado|concluido`) usados de forma consistente em todas as tasks. diff --git a/gti-maquina-comercial/docs/superpowers/specs/2026-08-01-gti-campaign-port-design.md b/gti-maquina-comercial/docs/superpowers/specs/2026-08-01-gti-campaign-port-design.md new file mode 100644 index 0000000..50ac661 --- /dev/null +++ b/gti-maquina-comercial/docs/superpowers/specs/2026-08-01-gti-campaign-port-design.md @@ -0,0 +1,213 @@ +# Campanhas de Outreach — GTI Comercial + +Data: 2026-08-01 +Status: aprovado pelo usuário, pendente de plano de implementação + +## Contexto e motivação + +A Máquina Comercial Pessoal tem um subsistema de campanhas de outreach (fila com +preview/aprovação, variantes A/B/C, escalonamento semanal de limite diário, pausa +automática por saúde, classificação de estágio comercial, agendamento de +demonstração) construído sobre o envio direto via Baileys. A GTI Comercial já tem +sua própria automação de outreach (`src/outreach/run.mjs` + `scheduler.mjs`), +que dispara um lote a cada 20 minutos, respeitando `produto_principal` de cada +lead, canal oficial (WABA/template aprovado) ou não-oficial (texto livre via +Z-PRO), e grava tudo em `outreach_sends`. + +O pedido: portar a mesma metodologia de campanhas da Pessoal para a Comercial, +mas adaptada a duas diferenças reais entre os projetos: + +1. **Meio de conexão**: a Comercial não fala com o WhatsApp diretamente (Baileys); + ela passa pelo Z-PRO (`src/outreach/zpro-client.mjs`), que oferece dois canais + distintos — não-oficial (`sendText`/`bulkIndividual`, texto livre) e oficial + (`sendTemplateWaba`, só templates pré-aprovados no Meta Business Manager, sem + texto livre nem variáveis além das que o próprio template já define). +2. **Catálogo de negócio**: a Pessoal tem 2-3 campanhas fixas, hardcoded em + `campaign-catalog.mjs`, cada uma amarrada a um segmento específico. A + Comercial tem 57 segmentos e 4 produtos (G-Chat, Sistema de Ponto, ERP, + G-Delivery) — catálogo fixo não cabe nessa escala. Aqui, campanha precisa ser + um registro que o operador cria pela tela (produto + segmentos + textos), + não código. + +Decisões já validadas com o usuário: +- Campanhas rodam **ao lado** do `runOutreachBatch` existente, sem substituí-lo. +- Variantes A/B/C só funcionam no canal **não-oficial** (o oficial não aceita + texto livre, então não há o que variar). +- Pacote completo: escalonamento semanal, fila/preview/aprovação, variantes com + eleição de vencedora, pausa automática por saúde, classificação de estágio + comercial, agendamento de demonstração. +- Classificação de estágio e registro de resposta são **manuais** por enquanto — + não existe endpoint de webhook de resposta do Z-PRO implementado nesta base. + +## Arquitetura + +Novo diretório `src/outreach/campaign-*.mjs`, paralelo a `run.mjs`/`scheduler.mjs` +— nenhum dos dois é modificado. Único ponto de integração: `outreach_sends` +ganha um novo valor de `origem` (`'campanha'`, além dos já existentes +`'automatico'`/`'manual'`) e a seleção de candidatos de campanha exclui leads +recentemente contatados para o mesmo produto **por qualquer origem**, evitando +contato duplicado entre a automação existente e as campanhas novas. + +``` +src/outreach/ + run.mjs (existente, intocado) + scheduler.mjs (existente, intocado) + zpro-client.mjs (existente, intocado — reusado) + templates.mjs (existente, intocado — renderTemplate reusado) + campaign-catalog.mjs (novo — CRUD de outreach_campaigns/variants) + campaign-queue.mjs (novo — seleção de candidatos, preview, aprovação) + campaign-worker.mjs (novo — envio das linhas aprovadas) + campaign-metrics.mjs (novo — saúde, auto-pausa, comparação de variantes) + campaign-scheduler.mjs (novo — timer próprio, chama campaign-worker) +``` + +## Modelo de dados + +Migração nova em `src/db.mjs` (mesmo padrão das migrações v5-v7 já existentes: +`ALTER TABLE`/`CREATE TABLE IF NOT EXISTS` guardado por versão do schema). + +```sql +CREATE TABLE outreach_campaigns ( + id INTEGER PRIMARY KEY, + nome TEXT NOT NULL, + produto TEXT NOT NULL, + segmentos_json TEXT NOT NULL, -- ["Oficina", "Restaurante", ...] + status TEXT NOT NULL DEFAULT 'rascunho', -- rascunho|rodando|pausado|concluido + pause_reason TEXT, + limite_diario_atual INTEGER NOT NULL DEFAULT 10, + semana_iniciada_em TEXT, -- controla quando escalar o limite + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE outreach_campaign_variants ( + id INTEGER PRIMARY KEY, + campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id), + code TEXT NOT NULL, -- A|B|C + mensagem TEXT NOT NULL, + ativo INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE outreach_campaign_queue ( + id INTEGER PRIMARY KEY, + campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id), + lead_id INTEGER NOT NULL REFERENCES leads(id), + variant_id INTEGER NOT NULL REFERENCES outreach_campaign_variants(id), + segmento TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'preview', -- preview|approved|cancelled|reserved|sent|unknown|error + outreach_send_id INTEGER REFERENCES outreach_sends(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE outreach_lead_states ( + lead_id INTEGER PRIMARY KEY REFERENCES leads(id), + stage TEXT NOT NULL, -- interessado|sem_interesse|opt_out|venda_fechada|... + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE outreach_stage_events ( + id INTEGER PRIMARY KEY, + lead_id INTEGER NOT NULL REFERENCES leads(id), + campaign_id INTEGER REFERENCES outreach_campaigns(id), + stage TEXT NOT NULL, + note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE outreach_appointments ( + id INTEGER PRIMARY KEY, + lead_id INTEGER NOT NULL REFERENCES leads(id), + kind TEXT NOT NULL, -- demonstracao|teste_g_delivery|... + scheduled_at TEXT, + note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE outreach_opt_out ( + numero TEXT PRIMARY KEY, + motivo TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +ALTER TABLE outreach_sends ADD COLUMN campaign_queue_id INTEGER REFERENCES outreach_campaign_queue(id); +``` + +`outreach_opt_out` não existe hoje na Comercial (a Pessoal tem um equivalente +mais complexo por causa da equivalência de números 8/9 dígitos). Aqui basta uma +tabela simples por `numero` já normalizado (`onlyDigits`), sem a complexidade de +variantes de número da Pessoal — a Comercial já normaliza números na validação +(`validatePhone`/`onlyDigits`). Está incluída porque uma fila que recontata o +mesmo lead ao longo de semanas sem checar opt-out é um risco real de spam/banimento, +não porque foi pedida explicitamente — sinalizando aqui para confirmação. + +## Fluxo + +1. **Criar campanha**: operador escolhe produto, segmentos (lista vindo de + `SELECT DISTINCT segmento, COUNT(*) FROM leads WHERE produto_principal = ? + GROUP BY segmento` — mesmo padrão de facet já usado em outros lugares do + projeto), escreve 1-3 variantes de mensagem (texto livre, mesmas variáveis + de `templates.mjs`: `{empresa}`, `{produto}`, `{cidade}`, `{segmento}`), + define limite diário inicial. +2. **Gerar fila**: candidatos = leads do produto/segmentos escolhidos, com + `telefone_valido=1`, sem envio recente (`outreach_sends`, qualquer origem) + para o mesmo produto, sem opt-out. Distribuição round-robin entre variantes + ativas. Linhas entram como `preview` — nada é enviado. +3. **Revisar e aprovar**: tela lista a fila em preview (empresa, telefone, + variante, mensagem já renderizada); operador aprova em lote ou individualmente + → status `approved`. Só linhas aprovadas entram no worker. +4. **Envio**: `campaign-worker.mjs`, rodando no `campaign-scheduler.mjs` (timer + próprio, mesmo padrão do `scheduler.mjs` existente — `setInterval` com + `.unref()`), pega linhas `approved` respeitando `limite_diario_atual` da + campanha, revalida o lead na hora (ainda não opt-out, ainda no segmento), + envia via `zproClient.sendText`, grava em `outreach_sends` + (`origem='campanha'`, `campaign_queue_id` preenchido), atualiza a linha da + fila para `sent`/`error`. +5. **Escalonamento semanal**: a cada 7 dias corridos sem pausa por saúde desde + `semana_iniciada_em`, `limite_diario_atual` dobra (10→20→40...), até um teto + configurável. +6. **Saúde/auto-pausa** (`campaign-metrics.mjs`, mesmos 3 critérios da Pessoal, + adaptados às tabelas daqui): + - taxa de erro de envio > 10% com pelo menos 10 tentativas no dia → pausa + `high_failure_rate` + - taxa de estágio negativo/opt-out > 10% com pelo menos 20 contatos → pausa + `high_negative_rate` + - taxa de resposta < 5% com pelo menos 50 contatos → pausa `low_reply_rate` + (mesmos limiares numéricos já usados em `campaign-metrics.mjs` da Pessoal) + Pausa muda `status` para `pausado`; o worker para de pegar novas linhas + dessa campanha até o operador retomar manualmente. +7. **Variantes e eleição de vencedora**: após um mínimo de 40 envios por + variante, a tela mostra taxa de resposta por variante e sinaliza a de melhor + desempenho — indicativo, não desliga variantes sozinho. +8. **Classificação de estágio e agendamento**: 100% manual por enquanto — + operador marca o estágio do lead pela tela (gera `outreach_stage_events` + + atualiza `outreach_lead_states`) e cria agendamentos (`outreach_appointments`) + manualmente. Sem webhook de resposta do Z-PRO nesta fase. + +## Prevenção de contato duplicado com a automação existente + +`runOutreachBatch` (`run.mjs`) já verifica `lastEnviado` antes de mandar. A +seleção de candidatos de campanha faz a checagem simétrica: exclui qualquer +lead com `outreach_sends` para o mesmo produto nos últimos N dias, **indepen- +dente de `origem`**. Isso garante que uma campanha nunca contata quem o lote +automático já contatou recentemente para aquele produto, e vice-versa — os +dois sistemas competem pelo mesmo "direito de contato" por produto, não +disparam em paralelo para o mesmo lead. + +## Fora de escopo (explicitamente adiado) + +- Webhook de resposta do Z-PRO / classificação automática de estágio. +- Envio de variantes pelo canal oficial (impossível hoje — WABA não aceita + texto livre). +- Equivalência de números 8/9 dígitos ou detecção de número compartilhado + (não existe na Comercial hoje e não foi pedido). + +## Testes + +Mesma disciplina da Pessoal: um arquivo de teste por módulo novo +(`campaign-catalog`, `campaign-queue`, `campaign-worker`, `campaign-metrics`), +cobrindo pelo menos: geração de fila respeita segmentos/produto/exclusões, +aprovação só libera linhas aprovadas para o worker, worker respeita limite +diário e revalida antes de enviar, auto-pausa dispara nos 3 critérios e +bloqueia novas linhas, escalonamento semanal avança e não avança durante +pausa, prevenção de contato duplicado entre `origem='automatico'` e +`origem='campanha'`. diff --git a/gti-maquina-comercial/launcher/GTI-Comercial.csproj b/gti-maquina-comercial/launcher/GTI-Comercial.csproj new file mode 100644 index 0000000..5ef5e90 --- /dev/null +++ b/gti-maquina-comercial/launcher/GTI-Comercial.csproj @@ -0,0 +1,10 @@ + + + Exe + net8.0 + enable + enable + GTI-Comercial + GtiComercialLauncher + + diff --git a/gti-maquina-comercial/launcher/Program.cs b/gti-maquina-comercial/launcher/Program.cs new file mode 100644 index 0000000..dc807c2 --- /dev/null +++ b/gti-maquina-comercial/launcher/Program.cs @@ -0,0 +1,198 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Net; +using System.Threading; + +// Launcher do GTI Maquina Comercial: +// 1. localiza a pasta do projeto (procura package.json + scripts/servidor.mjs); +// 2. se o servidor ja responde em http://127.0.0.1:3900, so abre o navegador; +// 3. senao, inicia "node scripts/servidor.mjs" escondido (log em data/server.log), +// espera o /api/health responder (ate 30s) e abre o navegador. +// +// Compilavel tanto pelo dotnet SDK (net8.0) quanto pelo csc.exe do +// .NET Framework 4 (C# 5): sem interpolacao de string, sem APIs novas. + +namespace GtiComercialLauncher +{ + internal static class Program + { + private static string BaseUrl() + { + string port = Environment.GetEnvironmentVariable("GTI_PORT"); + if (string.IsNullOrEmpty(port)) port = "3900"; + return "http://127.0.0.1:" + port; + } + + private static int Main() + { + Console.Title = "GTI Maquina Comercial"; + Console.WriteLine("=== GTI Maquina Comercial ==="); + + string root = LocateProjectDir(); + if (root == null) + { + return Fail("Pasta do projeto nao encontrada (procurei package.json + scripts/servidor.mjs).\n" + + "Deixe o GTI-Comercial.exe dentro da pasta do projeto."); + } + + string url = BaseUrl(); + + if (IsHealthy(url)) + { + Console.WriteLine("Servidor ja esta rodando. Abrindo o navegador..."); + OpenBrowser(url); + return 0; + } + + string node = FindNode(); + if (node == null) + { + return Fail("Node.js nao encontrado no PATH. Instale em https://nodejs.org (versao 24 ou superior)."); + } + + Console.WriteLine("Iniciando o servidor (aguarde alguns segundos)..."); + if (!StartServer(node, root)) + { + return Fail("Nao foi possivel iniciar o servidor. Veja o log em data\\server.log"); + } + + for (int i = 0; i < 60; i++) + { + Thread.Sleep(500); + if (IsHealthy(url)) + { + Console.WriteLine("Pronto! Abrindo " + url + " no navegador..."); + OpenBrowser(url); + Thread.Sleep(1500); + return 0; + } + } + + return Fail("O servidor nao respondeu em 30 segundos.\n" + + "Veja o log em data\\server.log ou rode manualmente: node scripts\\servidor.mjs"); + } + + private static string LocateProjectDir() + { + string[] candidates = new string[] + { + AppDomain.CurrentDomain.BaseDirectory, + Directory.GetCurrentDirectory() + }; + + foreach (string start in candidates) + { + DirectoryInfo dir = new DirectoryInfo(start); + for (int depth = 0; dir != null && depth < 5; depth++) + { + if (File.Exists(Path.Combine(dir.FullName, "package.json")) && + File.Exists(Path.Combine(Path.Combine(dir.FullName, "scripts"), "servidor.mjs"))) + { + return dir.FullName; + } + dir = dir.Parent; + } + } + return null; + } + + private static string FindNode() + { + try + { + ProcessStartInfo psi = new ProcessStartInfo("where.exe", "node"); + psi.UseShellExecute = false; + psi.RedirectStandardOutput = true; + psi.CreateNoWindow = true; + using (Process p = Process.Start(psi)) + { + string output = p.StandardOutput.ReadToEnd(); + p.WaitForExit(5000); + if (!string.IsNullOrEmpty(output)) + { + string first = output.Split(new char[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)[0].Trim(); + if (File.Exists(first)) return first; + } + } + } + catch (Exception) + { + // continua para o retorno nulo + } + return null; + } + + private static bool StartServer(string node, string root) + { + try + { + string dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(dataDir); + string logPath = Path.Combine(dataDir, "server.log"); + + File.AppendAllText(logPath, + "---- " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " launcher iniciou o servidor ----\r\n"); + + // O redirecionamento para o log e feito pelo proprio cmd.exe: assim o + // launcher pode encerrar sem deixar o servidor escrevendo num pipe + // sem leitor (o que derrubaria o node com EPIPE mais tarde). + string comando = "/c \"\"" + node + "\" scripts\\servidor.mjs >> \"" + logPath + "\" 2>&1\""; + ProcessStartInfo psi = new ProcessStartInfo("cmd.exe", comando); + psi.WorkingDirectory = root; + psi.UseShellExecute = false; + psi.CreateNoWindow = true; + + Process.Start(psi); + return true; + } + catch (Exception ex) + { + Console.WriteLine("Falha ao iniciar: " + ex.Message); + return false; + } + } + + private static bool IsHealthy(string baseUrl) + { + try + { + HttpWebRequest request = (HttpWebRequest)WebRequest.Create(baseUrl + "/api/health"); + request.Timeout = 1500; + request.Method = "GET"; + using (HttpWebResponse response = (HttpWebResponse)request.GetResponse()) + { + return (int)response.StatusCode == 200; + } + } + catch (Exception) + { + return false; + } + } + + private static void OpenBrowser(string url) + { + try + { + ProcessStartInfo psi = new ProcessStartInfo(url); + psi.UseShellExecute = true; + Process.Start(psi); + } + catch (Exception) + { + Console.WriteLine("Abra manualmente no navegador: " + url); + } + } + + private static int Fail(string message) + { + Console.WriteLine(); + Console.WriteLine("ERRO: " + message); + Console.WriteLine(); + Console.WriteLine("Pressione qualquer tecla para fechar..."); + try { Console.ReadKey(true); } catch (Exception) { Thread.Sleep(8000); } + return 1; + } + } +} diff --git a/gti-maquina-comercial/logo-gti-256x256.png b/gti-maquina-comercial/logo-gti-256x256.png new file mode 100644 index 0000000..574db00 Binary files /dev/null and b/gti-maquina-comercial/logo-gti-256x256.png differ diff --git a/gti-maquina-comercial/package-lock.json b/gti-maquina-comercial/package-lock.json new file mode 100644 index 0000000..be20ffa --- /dev/null +++ b/gti-maquina-comercial/package-lock.json @@ -0,0 +1,15 @@ +{ + "name": "gti-maquina-comercial", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gti-maquina-comercial", + "version": "2.0.0", + "engines": { + "node": ">=24" + } + } + } +} diff --git a/gti-maquina-comercial/package.json b/gti-maquina-comercial/package.json new file mode 100644 index 0000000..ab811a3 --- /dev/null +++ b/gti-maquina-comercial/package.json @@ -0,0 +1,19 @@ +{ + "name": "gti-maquina-comercial", + "version": "2.0.0", + "private": true, + "type": "module", + "description": "GTI Maquina Comercial: aplicativo local de captura e tratamento de leads B2B (base CNPJ da Receita Federal + fontes abertas).", + "scripts": { + "start": "node scripts/servidor.mjs", + "test": "node --test tests/*.test.mjs", + "capture": "node scripts/capturar-leads.mjs", + "extract": "node scripts/extrair-base-cnpj.mjs", + "recalc-scores": "node scripts/recalcular-scores.mjs", + "outreach": "node scripts/enviar-outreach.mjs", + "build:launcher": "powershell -File scripts/build-launcher.ps1" + }, + "engines": { + "node": ">=24" + } +} diff --git a/gti-maquina-comercial/prospeccao/cnpj/README.md b/gti-maquina-comercial/prospeccao/cnpj/README.md new file mode 100644 index 0000000..a59050e --- /dev/null +++ b/gti-maquina-comercial/prospeccao/cnpj/README.md @@ -0,0 +1,33 @@ +Coloque aqui arquivos CSV extraidos da base publica de CNPJ da Receita Federal ou uma planilha ja tratada. + +O captador aceita CSV com cabecalho e tenta reconhecer colunas comuns como: + +- `cnpj` +- `razao_social` ou `razao social` +- `nome_fantasia` ou `nome fantasia` +- `telefone`, `telefone 1`, `ddd`, `ddd 1` +- `municipio` ou `cidade` +- `uf` +- `cnae`, `cnae_fiscal` ou `cnae principal` +- `logradouro`, `numero`, `bairro` + +Comando para extrair a base de uma UF a partir dos zips brutos da Receita +(gera o CSV `estabelecimentos-.csv` usado pela captura): + +```bash +npm run extract -- --uf=BA +``` + +Comando para capturar leads usando a base já extraída: + +```bash +npm run capture -- --sources=cnpj-base --uf=BA --cities="Ilheus" +``` + +Com enriquecimento pela BrasilAPI, inclua a fonte `brasilapi`: + +```bash +npm run capture -- --sources=cnpj-base,brasilapi --uf=BA --cities="Ilheus" +``` + +O modo `brasilapi` usa os CNPJs encontrados nos CSVs locais para consultar dados atualizados por CNPJ. diff --git a/gti-maquina-comercial/prospeccao/config.json b/gti-maquina-comercial/prospeccao/config.json new file mode 100644 index 0000000..4db5e46 --- /dev/null +++ b/gti-maquina-comercial/prospeccao/config.json @@ -0,0 +1,1965 @@ +{ + "_comentario": "Catalogo de nichos (seed): carregado para o banco na primeira execucao. Cidades/UFs agora vem do IBGE (data/seeds) e os bboxes do geo_cache.", + "segments": [ + { + "name": "clinicas_medicas", + "queries": [ + "clinica medica", + "clinica de saude", + "policlinica" + ], + "cnaeCodes": [ + "8630501", + "8630502", + "8630599" + ], + "overpassTags": [ + { + "key": "amenity", + "value": "clinic" + }, + { + "key": "healthcare", + "value": "clinic" + } + ], + "interest": "gestao de TI", + "pains": [ + "atendimento", + "agenda", + "backup" + ], + "affinity": "alta", + "products": [ + { + "nome": "G-Chat", + "estrelas": 5 + }, + { + "nome": "Sistema de Ponto", + "estrelas": 4 + } + ] + }, + { + "name": "clinicas_odontologicas", + "queries": [ + "clinica odontologica", + "dentista", + "consultorio odontologico" + ], + "cnaeCodes": [ + "8630504" + ], + "overpassTags": [ + { + "key": "amenity", + "value": "dentist" + } + ], + "interest": "gestao de TI", + "pains": [ + "atendimento", + "agenda", + "backup" + ], + "affinity": "alta", + "products": [ + { + "nome": "G-Chat", + "estrelas": 5 + }, + { + "nome": "Sistema de Ponto", + "estrelas": 4 + } + ] + }, + { + "name": "clinicas_estetica", + "queries": [ + "clinica de estetica", + "estetica facial", + "estetica corporal" + ], + "cnaeCodes": [ + "9609206" + ], + "overpassTags": [ + { + "key": "shop", + "value": "beauty" + } + ], + "interest": "autoatendimento WhatsApp", + "pains": [ + "agenda", + "atendimento", + "vendas" + ], + "affinity": "alta", + "products": [ + { + "nome": "G-Chat", + "estrelas": 5 + }, + { + "nome": "Sistema de Ponto", + "estrelas": 4 + } + ] + }, + { + "name": "clinicas_fisioterapia", + "queries": [ + "clinica de fisioterapia", + "fisioterapia", + "reabilitacao" + ], + "cnaeCodes": [ + "8650003" + ], + "overpassTags": [ + { + "key": "healthcare", + "value": "physiotherapist" + }, + { + "key": "amenity", + "value": "clinic" + } + ], + "interest": "gestao de TI", + "pains": [ + "atendimento", + "agenda", + "backup" + ], + "affinity": "alta", + "products": [ + { + "nome": "G-Chat", + "estrelas": 5 + }, + { + "nome": "Sistema de Ponto", + "estrelas": 4 + } + ] + }, + { + "name": "laboratorios", + "queries": [ + "laboratorio", + "laboratorio de analises clinicas", + "exames laboratoriais" + ], + "cnaeCodes": [ + "8640202" + ], + "overpassTags": [ + { + "key": "healthcare", + "value": "laboratory" + } + ], + "interest": "gestao de TI", + "pains": [ + "atendimento", + "agenda", + "backup" + ], + "affinity": "alta", + "products": [ + { + "nome": "G-Chat", + "estrelas": 5 + }, + { + "nome": "Sistema de Ponto", + "estrelas": 4 + } + ] + }, + { + "name": "consultorios_medicos", + "queries": [ + "consultorio medico", + "consulta medica" + ], + "cnaeCodes": [ + "8630503" + ], + "overpassTags": [ + { + "key": "amenity", + "value": "doctors" + } + ], + "interest": "gestao de TI", + "pains": [ + "atendimento", + "agenda", + "backup" + ], + "affinity": "alta", + "products": [ + { + "nome": "G-Chat", + "estrelas": 5 + }, + { + "nome": "Sistema de Ponto", + "estrelas": 4 + } + ] + }, + { + "name": "clinicas_veterinarias", + "queries": [ + "clinica veterinaria", + "veterinaria", + "banho e tosa" + ], + "cnaeCodes": [ + "7500100" + ], + "overpassTags": [ + { + "key": "amenity", + "value": "veterinary" + } + ], + "interest": "autoatendimento WhatsApp", + "pains": [ + "agenda", + "atendimento", + "vendas" + ], + "affinity": "alta", + "products": [ + { + "nome": "G-Chat", + "estrelas": 5 + }, + { + "nome": "Sistema de Ponto", + "estrelas": 4 + } + ] + }, + { + "name": "restaurantes", + "queries": [ + "restaurante", + "comida caseira", + "buffet" + ], + "cnaeCodes": [ + "5611201" + ], + "overpassTags": [ + { + "key": "amenity", + "value": "restaurant" + } + ], + "interest": "autoatendimento WhatsApp", + "pains": [ + "pedidos", + "atendimento", + "vendas" + ], + "affinity": "alta", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Delivery", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 4 + } + ] + }, + { + "name": "pizzarias", + "queries": [ + "pizzaria", + "pizza delivery" + ], + "cnaeCodes": [ + "5611201", + "5612100" + ], + "overpassTags": [ + { + "key": "amenity", + "value": "restaurant" + } + ], + "interest": "autoatendimento WhatsApp", + "pains": [ + "pedidos", + "atendimento", + "vendas" + ], + "affinity": "alta", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Delivery", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 4 + } + ] + }, + { + "name": "hamburguerias", + "queries": [ + "hamburgueria", + "lanches artesanais", + "burger" + ], + "cnaeCodes": [ + "5611201", + "5612100" + ], + "overpassTags": [ + { + "key": "amenity", + "value": "fast_food" + } + ], + "interest": "autoatendimento WhatsApp", + "pains": [ + "pedidos", + "atendimento", + "vendas" + ], + "affinity": "alta", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Delivery", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 4 + } + ] + }, + { + "name": "lanchonetes", + "queries": [ + "lanchonete", + "salgados", + "pastelaria" + ], + "cnaeCodes": [ + "5611201" + ], + "overpassTags": [ + { + "key": "amenity", + "value": "fast_food" + } + ], + "interest": "autoatendimento WhatsApp", + "pains": [ + "pedidos", + "atendimento", + "vendas" + ], + "affinity": "alta", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Delivery", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 4 + } + ] + }, + { + "name": "marmitarias", + "queries": [ + "marmitaria", + "marmitex", + "quentinha" + ], + "cnaeCodes": [ + "5620104" + ], + "overpassTags": [ + { + "key": "amenity", + "value": "fast_food" + } + ], + "interest": "autoatendimento WhatsApp", + "pains": [ + "pedidos", + "atendimento", + "vendas" + ], + "affinity": "alta", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Delivery", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 4 + } + ] + }, + { + "name": "acaiterias", + "queries": [ + "acaiteria", + "acai", + "casa de acai" + ], + "cnaeCodes": [ + "5611201" + ], + "overpassTags": [ + { + "key": "amenity", + "value": "ice_cream" + } + ], + "interest": "autoatendimento WhatsApp", + "pains": [ + "pedidos", + "atendimento", + "vendas" + ], + "affinity": "alta", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Delivery", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 4 + } + ] + }, + { + "name": "sushi_restaurante_japones", + "queries": [ + "sushi", + "restaurante japones", + "temakeria" + ], + "cnaeCodes": [ + "5611201" + ], + "overpassTags": [ + { + "key": "amenity", + "value": "restaurant" + } + ], + "interest": "autoatendimento WhatsApp", + "pains": [ + "pedidos", + "atendimento", + "vendas" + ], + "affinity": "alta", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Delivery", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 4 + } + ] + }, + { + "name": "padarias", + "queries": [ + "padaria", + "panificadora" + ], + "cnaeCodes": [ + "4721102" + ], + "overpassTags": [ + { + "key": "shop", + "value": "bakery" + } + ], + "interest": "ERP", + "pains": [ + "estoque", + "vendas", + "financeiro" + ], + "affinity": "alta", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Delivery", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 4 + } + ] + }, + { + "name": "confeitarias", + "queries": [ + "confeitaria", + "doceria", + "bolos e doces" + ], + "cnaeCodes": [ + "4721102", + "4721104" + ], + "overpassTags": [ + { + "key": "shop", + "value": "confectionery" + } + ], + "interest": "ERP", + "pains": [ + "estoque", + "vendas", + "financeiro" + ], + "affinity": "alta", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Delivery", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 4 + } + ] + }, + { + "name": "sorveterias", + "queries": [ + "sorveteria", + "sorvete", + "gelateria" + ], + "cnaeCodes": [ + "5611201" + ], + "overpassTags": [ + { + "key": "amenity", + "value": "ice_cream" + } + ], + "interest": "autoatendimento WhatsApp", + "pains": [ + "pedidos", + "atendimento", + "vendas" + ], + "affinity": "alta", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Delivery", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 4 + } + ] + }, + { + "name": "cafeterias", + "queries": [ + "cafeteria", + "casa de cha", + "coffee shop" + ], + "cnaeCodes": [ + "5611201" + ], + "overpassTags": [ + { + "key": "amenity", + "value": "cafe" + } + ], + "interest": "autoatendimento WhatsApp", + "pains": [ + "pedidos", + "atendimento", + "vendas" + ], + "affinity": "alta", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Delivery", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 4 + } + ] + }, + { + "name": "food_trucks", + "queries": [ + "food truck", + "trailer de comida", + "carrinho de lanche" + ], + "cnaeCodes": [ + "5612100" + ], + "overpassTags": [ + { + "key": "amenity", + "value": "fast_food" + } + ], + "interest": "autoatendimento WhatsApp", + "pains": [ + "pedidos", + "atendimento", + "vendas" + ], + "affinity": "alta", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Delivery", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 4 + } + ] + }, + { + "name": "mercados_de_bairro", + "queries": [ + "mercado de bairro", + "supermercado", + "mercadinho" + ], + "cnaeCodes": [ + "4711302", + "4712100" + ], + "overpassTags": [ + { + "key": "shop", + "value": "supermarket" + } + ], + "interest": "ERP", + "pains": [ + "estoque", + "vendas", + "financeiro" + ], + "affinity": "alta", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + } + ] + }, + { + "name": "minimercados", + "queries": [ + "minimercado", + "mini mercado" + ], + "cnaeCodes": [ + "4712100" + ], + "overpassTags": [ + { + "key": "shop", + "value": "convenience" + } + ], + "interest": "ERP", + "pains": [ + "estoque", + "vendas", + "financeiro" + ], + "affinity": "alta", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + } + ] + }, + { + "name": "mercearias", + "queries": [ + "mercearia", + "armazem", + "quitanda" + ], + "cnaeCodes": [ + "4712100" + ], + "overpassTags": [ + { + "key": "shop", + "value": "grocery" + }, + { + "key": "shop", + "value": "convenience" + } + ], + "interest": "ERP", + "pains": [ + "estoque", + "vendas", + "financeiro" + ], + "affinity": "alta", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + } + ] + }, + { + "name": "conveniencias", + "queries": [ + "conveniencia", + "loja de conveniencia" + ], + "cnaeCodes": [ + "4712100", + "4784900" + ], + "overpassTags": [ + { + "key": "shop", + "value": "convenience" + } + ], + "interest": "ERP", + "pains": [ + "estoque", + "vendas", + "financeiro" + ], + "affinity": "alta", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + } + ] + }, + { + "name": "lojas_de_roupa", + "queries": [ + "loja de roupas", + "moda", + "boutique", + "moda feminina", + "moda masculina" + ], + "cnaeCodes": [ + "4781400" + ], + "overpassTags": [ + { + "key": "shop", + "value": "clothes" + } + ], + "interest": "ERP", + "pains": [ + "estoque", + "vendas", + "financeiro" + ], + "affinity": "media", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 3 + } + ] + }, + { + "name": "lojas_de_calcados", + "queries": [ + "loja de calcados", + "calcados" + ], + "cnaeCodes": [ + "4782201" + ], + "overpassTags": [ + { + "key": "shop", + "value": "shoes" + } + ], + "interest": "ERP", + "pains": [ + "estoque", + "vendas", + "financeiro" + ], + "affinity": "media", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 3 + } + ] + }, + { + "name": "lojas_de_cosmeticos", + "queries": [ + "loja de cosmeticos", + "perfumaria" + ], + "cnaeCodes": [ + "4772500" + ], + "overpassTags": [ + { + "key": "shop", + "value": "cosmetics" + } + ], + "interest": "ERP", + "pains": [ + "estoque", + "vendas", + "financeiro" + ], + "affinity": "media", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 3 + } + ] + }, + { + "name": "lojas_de_variedades", + "queries": [ + "loja de variedades", + "1,99", + "bazar" + ], + "cnaeCodes": [ + "4713002" + ], + "overpassTags": [ + { + "key": "shop", + "value": "variety_store" + } + ], + "interest": "ERP", + "pains": [ + "estoque", + "vendas", + "financeiro" + ], + "affinity": "media", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 3 + } + ] + }, + { + "name": "papelarias", + "queries": [ + "papelaria", + "material escolar" + ], + "cnaeCodes": [ + "4761003" + ], + "overpassTags": [ + { + "key": "shop", + "value": "stationery" + } + ], + "interest": "ERP", + "pains": [ + "estoque", + "vendas", + "financeiro" + ], + "affinity": "media", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 3 + } + ] + }, + { + "name": "pet_shops", + "queries": [ + "pet shop", + "banho e tosa", + "produtos para animais" + ], + "cnaeCodes": [ + "4789004" + ], + "overpassTags": [ + { + "key": "shop", + "value": "pet" + } + ], + "interest": "autoatendimento WhatsApp", + "pains": [ + "agenda", + "atendimento", + "vendas" + ], + "affinity": "media", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 3 + } + ] + }, + { + "name": "agropecuarias", + "queries": [ + "agropecuaria", + "casa agropecuaria", + "insumos agricolas", + "racao" + ], + "cnaeCodes": [ + "4623109" + ], + "overpassTags": [ + { + "key": "shop", + "value": "agrarian" + } + ], + "interest": "ERP", + "pains": [ + "estoque", + "vendas", + "financeiro" + ], + "affinity": "media", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 3 + } + ] + }, + { + "name": "material_de_construcao", + "queries": [ + "material de construcao", + "deposito de material de construcao", + "cimento", + "blocos" + ], + "cnaeCodes": [ + "4744099" + ], + "overpassTags": [ + { + "key": "shop", + "value": "doityourself" + } + ], + "interest": "ERP", + "pains": [ + "estoque", + "vendas", + "financeiro" + ], + "affinity": "media", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 3 + } + ] + }, + { + "name": "ferragens", + "queries": [ + "ferragens", + "loja de ferramentas" + ], + "cnaeCodes": [ + "4744003" + ], + "overpassTags": [ + { + "key": "shop", + "value": "hardware" + } + ], + "interest": "ERP", + "pains": [ + "estoque", + "vendas", + "financeiro" + ], + "affinity": "media", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 3 + } + ] + }, + { + "name": "oficinas_mecanicas", + "queries": [ + "oficina mecanica", + "auto center", + "mecanica" + ], + "cnaeCodes": [ + "4520001" + ], + "overpassTags": [ + { + "key": "shop", + "value": "car_repair" + } + ], + "interest": "ERP", + "pains": [ + "ordem de servico", + "estoque", + "vendas" + ], + "affinity": "alta", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + } + ] + }, + { + "name": "autopecas", + "queries": [ + "autopecas", + "loja de pecas", + "pecas para veiculos" + ], + "cnaeCodes": [ + "4530703" + ], + "overpassTags": [ + { + "key": "shop", + "value": "car_parts" + } + ], + "interest": "ERP", + "pains": [ + "estoque", + "vendas", + "financeiro" + ], + "affinity": "alta", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + } + ] + }, + { + "name": "lavacoes_automotivas", + "queries": [ + "lavacao automotiva", + "lava jato", + "lava rapido" + ], + "cnaeCodes": [ + "4520006" + ], + "overpassTags": [ + { + "key": "amenity", + "value": "car_wash" + } + ], + "interest": "ERP", + "pains": [ + "ordem de servico", + "estoque", + "vendas" + ], + "affinity": "media", + "products": [ + { + "nome": "G-Chat", + "estrelas": 4 + }, + { + "nome": "ERP", + "estrelas": 3 + } + ] + }, + { + "name": "estetica_automotiva", + "queries": [ + "estetica automotiva", + "polimento automotivo", + "detalhamento automotivo" + ], + "cnaeCodes": [ + "4520006" + ], + "overpassTags": [ + { + "key": "amenity", + "value": "car_wash" + }, + { + "key": "shop", + "value": "car_repair" + } + ], + "interest": "ERP", + "pains": [ + "ordem de servico", + "estoque", + "vendas" + ], + "affinity": "media", + "products": [ + { + "nome": "G-Chat", + "estrelas": 4 + }, + { + "nome": "ERP", + "estrelas": 3 + } + ] + }, + { + "name": "lojas_de_material_de_limpeza", + "queries": [ + "material de limpeza", + "produtos de limpeza", + "loja de limpeza" + ], + "cnaeCodes": [ + "4789005" + ], + "overpassTags": [ + { + "key": "shop", + "value": "variety_store" + } + ], + "interest": "ERP", + "pains": [ + "estoque", + "vendas", + "financeiro" + ], + "affinity": "media", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 3 + } + ] + }, + { + "name": "lojas_de_farinha_produtos_naturais", + "queries": [ + "farinha de mandioca", + "produtos naturais", + "loja de farinha", + "casa de farinha" + ], + "cnaeCodes": [ + "4729699" + ], + "overpassTags": [ + { + "key": "shop", + "value": "health_food" + } + ], + "interest": "ERP", + "pains": [ + "estoque", + "vendas", + "financeiro" + ], + "affinity": "media", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 3 + } + ] + }, + { + "name": "lojas_de_artigos_religiosos", + "queries": [ + "artigos religiosos", + "loja de artigos religiosos" + ], + "cnaeCodes": [ + "4789099" + ], + "overpassTags": [ + { + "key": "shop", + "value": "religion" + } + ], + "interest": "ERP", + "pains": [ + "estoque", + "vendas", + "financeiro" + ], + "affinity": "media", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 3 + } + ] + }, + { + "name": "distribuidoras_pequenas", + "queries": [ + "distribuidora", + "distribuidora de bebidas", + "atacado" + ], + "cnaeCodes": [ + "4691500" + ], + "overpassTags": [ + { + "key": "shop", + "value": "wholesale" + } + ], + "interest": "ERP", + "pains": [ + "estoque", + "distribuicao", + "vendas" + ], + "affinity": "media", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 3 + } + ] + }, + { + "name": "floriculturas", + "queries": [ + "floricultura", + "loja de flores" + ], + "cnaeCodes": [ + "4789002" + ], + "overpassTags": [ + { + "key": "shop", + "value": "florist" + } + ], + "interest": "ERP", + "pains": [ + "estoque", + "vendas", + "financeiro" + ], + "affinity": "media", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 3 + } + ] + }, + { + "name": "saloes_de_beleza", + "queries": [ + "salao de beleza", + "cabeleireiro", + "manicure" + ], + "cnaeCodes": [ + "9602501" + ], + "overpassTags": [ + { + "key": "shop", + "value": "hairdresser" + }, + { + "key": "shop", + "value": "beauty" + } + ], + "interest": "autoatendimento WhatsApp", + "pains": [ + "agenda", + "atendimento", + "vendas" + ], + "affinity": "media", + "products": [ + { + "nome": "G-Chat", + "estrelas": 5 + } + ] + }, + { + "name": "barbearias", + "queries": [ + "barbearia", + "barbeiro" + ], + "cnaeCodes": [ + "9602501" + ], + "overpassTags": [ + { + "key": "shop", + "value": "hairdresser" + } + ], + "interest": "autoatendimento WhatsApp", + "pains": [ + "agenda", + "atendimento", + "vendas" + ], + "affinity": "media", + "products": [ + { + "nome": "G-Chat", + "estrelas": 5 + } + ] + }, + { + "name": "academias", + "queries": [ + "academia", + "fitness", + "crossfit" + ], + "cnaeCodes": [ + "9313100" + ], + "overpassTags": [ + { + "key": "leisure", + "value": "fitness_centre" + } + ], + "interest": "autoatendimento WhatsApp", + "pains": [ + "agenda", + "atendimento", + "vendas" + ], + "affinity": "media", + "products": [ + { + "nome": "Sistema de Ponto", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 4 + } + ] + }, + { + "name": "imobiliarias", + "queries": [ + "imobiliaria", + "corretor de imoveis" + ], + "cnaeCodes": [ + "6821801" + ], + "overpassTags": [ + { + "key": "office", + "value": "estate_agent" + } + ], + "interest": "gestao de TI", + "pains": [ + "atendimento", + "suporte", + "processos" + ], + "affinity": "baixa", + "products": [ + { + "nome": "G-Chat", + "estrelas": 3 + }, + { + "nome": "ERP", + "estrelas": 3 + } + ] + }, + { + "name": "administradoras_de_condominio", + "queries": [ + "administradora de condominio", + "gestao de condominio" + ], + "cnaeCodes": [ + "6822600" + ], + "overpassTags": [ + { + "key": "office", + "value": "company" + } + ], + "interest": "gestao de TI", + "pains": [ + "atendimento", + "suporte", + "processos" + ], + "affinity": "baixa", + "products": [ + { + "nome": "G-Chat", + "estrelas": 3 + }, + { + "nome": "ERP", + "estrelas": 3 + } + ] + }, + { + "name": "escritorios_de_contabilidade", + "queries": [ + "escritorio de contabilidade", + "contabilidade", + "contador" + ], + "cnaeCodes": [ + "6920601" + ], + "overpassTags": [ + { + "key": "office", + "value": "accountant" + } + ], + "interest": "gestao de TI", + "pains": [ + "atendimento", + "suporte", + "processos" + ], + "affinity": "baixa", + "products": [ + { + "nome": "G-Chat", + "estrelas": 3 + }, + { + "nome": "ERP", + "estrelas": 3 + } + ] + }, + { + "name": "escritorios_de_advocacia", + "queries": [ + "escritorio de advocacia", + "advogado" + ], + "cnaeCodes": [ + "6911701" + ], + "overpassTags": [ + { + "key": "office", + "value": "lawyer" + } + ], + "interest": "gestao de TI", + "pains": [ + "atendimento", + "suporte", + "processos" + ], + "affinity": "baixa", + "products": [ + { + "nome": "G-Chat", + "estrelas": 3 + }, + { + "nome": "ERP", + "estrelas": 3 + } + ] + }, + { + "name": "corretoras_de_seguro", + "queries": [ + "corretora de seguros", + "seguros" + ], + "cnaeCodes": [ + "6622300" + ], + "overpassTags": [ + { + "key": "office", + "value": "insurance" + } + ], + "interest": "gestao de TI", + "pains": [ + "atendimento", + "suporte", + "processos" + ], + "affinity": "baixa", + "products": [ + { + "nome": "G-Chat", + "estrelas": 3 + }, + { + "nome": "ERP", + "estrelas": 3 + } + ] + }, + { + "name": "escolas_particulares", + "queries": [ + "escola particular", + "colegio" + ], + "cnaeCodes": [ + "8512100", + "8513900" + ], + "overpassTags": [ + { + "key": "amenity", + "value": "school" + } + ], + "interest": "gestao de TI", + "pains": [ + "atendimento", + "financeiro", + "suporte" + ], + "affinity": "media", + "products": [ + { + "nome": "Sistema de Ponto", + "estrelas": 4 + }, + { + "nome": "G-Chat", + "estrelas": 4 + } + ] + }, + { + "name": "cursos_profissionalizantes", + "queries": [ + "curso profissionalizante", + "curso tecnico" + ], + "cnaeCodes": [ + "8599604" + ], + "overpassTags": [ + { + "key": "amenity", + "value": "school" + } + ], + "interest": "gestao de TI", + "pains": [ + "atendimento", + "financeiro", + "suporte" + ], + "affinity": "media", + "products": [ + { + "nome": "Sistema de Ponto", + "estrelas": 4 + }, + { + "nome": "G-Chat", + "estrelas": 4 + } + ] + }, + { + "name": "empresas_de_engenharia", + "queries": [ + "empresa de engenharia", + "engenharia civil" + ], + "cnaeCodes": [ + "7112000" + ], + "overpassTags": [ + { + "key": "office", + "value": "engineer" + } + ], + "interest": "gestao de TI", + "pains": [ + "atendimento", + "suporte", + "processos" + ], + "affinity": "baixa", + "products": [ + { + "nome": "G-Chat", + "estrelas": 3 + }, + { + "nome": "ERP", + "estrelas": 3 + } + ] + }, + { + "name": "escritorios_de_arquitetura", + "queries": [ + "escritorio de arquitetura", + "arquiteto" + ], + "cnaeCodes": [ + "7111100" + ], + "overpassTags": [ + { + "key": "office", + "value": "architect" + } + ], + "interest": "gestao de TI", + "pains": [ + "atendimento", + "suporte", + "processos" + ], + "affinity": "baixa", + "products": [ + { + "nome": "G-Chat", + "estrelas": 3 + }, + { + "nome": "ERP", + "estrelas": 3 + } + ] + }, + { + "name": "transportadoras", + "queries": [ + "transportadora", + "transporte de cargas", + "mudancas", + "frete" + ], + "cnaeCodes": [ + "4930202" + ], + "overpassTags": [ + { + "key": "office", + "value": "logistics" + } + ], + "interest": "ERP", + "pains": [ + "estoque", + "distribuicao", + "vendas" + ], + "affinity": "media", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + } + ] + }, + { + "name": "empresas_de_logistica", + "queries": [ + "empresa de logistica", + "logistica", + "operador logistico" + ], + "cnaeCodes": [ + "5250802", + "5229099" + ], + "overpassTags": [ + { + "key": "office", + "value": "logistics" + } + ], + "interest": "ERP", + "pains": [ + "estoque", + "distribuicao", + "vendas" + ], + "affinity": "media", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + } + ] + }, + { + "name": "farmacias", + "queries": [ + "farmacia", + "drogaria" + ], + "cnaeCodes": [ + "4771701", + "4771702" + ], + "overpassTags": [ + { + "key": "amenity", + "value": "pharmacy" + } + ], + "interest": "ERP", + "pains": [ + "estoque", + "vendas", + "emissao fiscal" + ], + "affinity": "alta", + "products": [ + { + "nome": "G-Chat", + "estrelas": 4 + }, + { + "nome": "Sistema de Ponto", + "estrelas": 5 + } + ] + }, + { + "name": "oticas", + "queries": [ + "otica", + "oculos" + ], + "cnaeCodes": [ + "4774100" + ], + "overpassTags": [ + { + "key": "shop", + "value": "optician" + } + ], + "interest": "ERP", + "pains": [ + "estoque", + "vendas", + "financeiro" + ], + "affinity": "media", + "products": [ + { + "nome": "ERP", + "estrelas": 5 + }, + { + "nome": "G-Chat", + "estrelas": 3 + } + ] + } + ] +} diff --git a/gti-maquina-comercial/public/css/app.css b/gti-maquina-comercial/public/css/app.css new file mode 100644 index 0000000..5761c1b --- /dev/null +++ b/gti-maquina-comercial/public/css/app.css @@ -0,0 +1,710 @@ +/* GTI Maquina Comercial - interface principal */ +:root { + --page: #f5f7fb; + --page-2: #eef2f7; + --surface: #ffffff; + --surface-2: #f9fafc; + --ink: #162033; + --ink-2: #4c5870; + --muted: #7b8495; + --grid: #dce2ec; + --border: rgba(22, 32, 51, 0.11); + --accent: #f06a25; + --accent-2: #1f6feb; + --accent-ink: #ffffff; + --bar: #f06a25; + --good: #10a66a; + --good-text: #057046; + --warning: #d99a1f; + --critical: #d63d4a; + --critical-bg: #fff1f2; + --radius-sm: 10px; + --radius: 16px; + --radius-lg: 24px; + --shadow: 0 14px 40px rgba(22, 32, 51, 0.08); + --shadow-soft: 0 8px 24px rgba(22, 32, 51, 0.06); +} + +@media (prefers-color-scheme: dark) { + :root { + --page: #0d1118; + --page-2: #111827; + --surface: #171c27; + --surface-2: #1d2430; + --ink: #f5f7fb; + --ink-2: #c8d0dd; + --muted: #8e98aa; + --grid: #2c3443; + --border: rgba(255, 255, 255, 0.11); + --accent: #ff7a33; + --accent-2: #60a5fa; + --bar: #ff7a33; + --good-text: #55d99c; + --critical-bg: rgba(214, 61, 74, 0.12); + --shadow: 0 18px 50px rgba(0, 0, 0, 0.28); + --shadow-soft: 0 10px 28px rgba(0, 0, 0, 0.22); + } +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; min-height: 100%; } +body { + color: var(--ink); + font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + background: + radial-gradient(circle at 12% -10%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 34rem), + radial-gradient(circle at 86% -6%, color-mix(in srgb, var(--accent-2) 12%, transparent), transparent 30rem), + linear-gradient(180deg, var(--page), var(--page-2)); +} +button, input, select, textarea { font: inherit; } +button { color: inherit; } + +/* topo */ +.topbar { + position: sticky; + top: 0; + z-index: 20; + display: grid; + grid-template-columns: auto minmax(220px, 1fr) auto auto auto auto; + align-items: center; + gap: 12px; + padding: 12px 22px; + background: color-mix(in srgb, var(--surface) 88%, transparent); + border-bottom: 1px solid var(--border); + box-shadow: 0 8px 30px rgba(22, 32, 51, 0.04); + backdrop-filter: blur(14px); +} +.brand { display: flex; align-items: center; gap: 10px; min-width: max-content; } +.brand-mark { height: 32px; width: auto; display: block; border-radius: 8px; } +.brand-mark-lg { height: 62px; } +.brand-name { font-weight: 800; letter-spacing: -0.02em; color: var(--ink); } +.tabs { + display: inline-flex; + gap: 6px; + padding: 4px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface-2); + width: fit-content; +} +.tab { + border: 0; + border-radius: 999px; + background: transparent; + color: var(--ink-2); + cursor: pointer; + font-weight: 750; + padding: 8px 14px; + transition: 0.18s ease; +} +.tab:hover { color: var(--ink); background: color-mix(in srgb, var(--accent) 9%, transparent); } +.tab.active { background: var(--ink); color: var(--page); box-shadow: var(--shadow-soft); } + +main { max-width: 1320px; margin: 0 auto; padding: 24px 22px 70px; } +.tab-panel { display: none; } +.tab-panel.active { display: block; } + +/* layout */ +.captura-wrap { max-width: 820px; margin: 0 auto; } +.card { + position: relative; + overflow: hidden; + background: color-mix(in srgb, var(--surface) 96%, transparent); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-soft); + padding: 20px; + margin-bottom: 18px; +} +.card h2 { margin: 0 0 14px; font-size: 18px; letter-spacing: -0.02em; } +.grid-3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } + +/* formularios */ +.field { margin-bottom: 18px; } +.field > label:first-child, +.field > summary, +label.settings-title { + display: block; + margin-bottom: 7px; + color: var(--ink); + font-weight: 800; + letter-spacing: -0.01em; +} +.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; } +.row.space-between { justify-content: space-between; } +.row.wrap { flex-wrap: wrap; } +label { color: var(--ink-2); } +label > input, +label > select, +label > textarea { display: block; margin-top: 5px; } +.radio { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 4px 0; + color: var(--ink-2); + cursor: pointer; + user-select: none; +} +input[type="radio"], input[type="checkbox"] { accent-color: var(--accent); } +select, input, textarea { + width: auto; + color: var(--ink); + background: var(--surface); + border: 1px solid var(--grid); + border-radius: 12px; + padding: 10px 12px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35); + transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; +} +textarea { width: 100%; resize: vertical; min-height: 84px; } +select:hover, input:hover, textarea:hover { border-color: color-mix(in srgb, var(--accent) 38%, var(--grid)); } +select:focus, input:focus, textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 15%, transparent); +} +input[type="number"] { width: 96px; } +details summary { cursor: pointer; } + +/* botoes */ +.btn, .btn-ghost, .btn-danger { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 40px; + border-radius: 12px; + padding: 9px 15px; + border: 1px solid var(--grid); + background: var(--surface); + color: var(--ink); + cursor: pointer; + font-weight: 800; + text-decoration: none; + transition: 0.18s ease; +} +.btn:hover, .btn-ghost:hover { transform: translateY(-1px); border-color: var(--accent); box-shadow: var(--shadow-soft); } +.btn-primary { background: linear-gradient(135deg, var(--accent), #ff8b47); border-color: transparent; color: var(--accent-ink); } +.btn-primary:hover { filter: saturate(1.08); } +.btn-lg { width: 100%; min-height: 48px; font-size: 15px; } +.btn-sm { min-height: 32px; padding: 6px 10px; font-size: 12.5px; } +.btn-ghost { border-color: transparent; background: transparent; color: var(--ink-2); } +.btn-danger { color: var(--critical); border-color: color-mix(in srgb, var(--critical) 45%, var(--grid)); background: color-mix(in srgb, var(--critical) 6%, transparent); } +.btn-danger:hover { background: var(--critical); color: #fff; border-color: var(--critical); } +button:disabled { opacity: 0.55; cursor: not-allowed; transform: none !important; box-shadow: none !important; } + +/* configuracoes */ +#modal-settings { max-width: 900px; } +#modal-settings .modal-body { max-height: min(86vh, 900px); overflow-y: auto; } +.settings-header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + align-items: start; + padding-bottom: 14px; + border-bottom: 1px solid var(--border); + margin-bottom: 16px; +} +.settings-header h2 { margin: 0 0 4px; font-size: 24px; letter-spacing: -0.03em; } +.settings-header p { margin: 0; } +#outreach-status { cursor: default; white-space: nowrap; } +.settings-card, +.settings-section { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-2); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.34) inset; + margin-bottom: 12px; +} +.settings-card { padding: 16px; } +.settings-principal { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 18px; + align-items: center; + border-color: color-mix(in srgb, var(--accent) 38%, var(--border)); + background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 10%, var(--surface)), var(--surface-2)); +} +.settings-controls { display: flex; gap: 12px; align-items: end; flex-wrap: wrap; justify-content: flex-end; } +.toggle { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 42px; + border: 1px solid var(--grid); + border-radius: 999px; + padding: 8px 13px; + background: var(--surface); + color: var(--ink); + font-weight: 800; +} +.settings-section { padding: 0 16px 16px; } +.settings-section > summary { + position: relative; + list-style: none; + padding: 15px 30px 15px 0; + color: var(--ink); + font-weight: 850; + letter-spacing: -0.01em; +} +.settings-section > summary::-webkit-details-marker { display: none; } +.settings-section > summary::after { + content: "+"; + position: absolute; + right: 0; + top: 13px; + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 50%; + color: var(--ink-2); + background: var(--surface); + border: 1px solid var(--grid); +} +.settings-section[open] > summary::after { content: "-"; } +.settings-section > .muted { margin-top: -4px; } +.settings-section .lista { margin-top: 10px; } +.settings-section input[type="text"], +.settings-section input[type="password"], +.settings-section textarea, +.settings-section select { min-width: 220px; } + +.manual-send-card { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + padding: 14px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); +} +.manual-send-card .full { grid-column: 1 / -1; } +.manual-channel { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.manual-channel .radio { + border: 1px solid var(--grid); + border-radius: 999px; + padding: 8px 12px; + background: var(--surface-2); + font-weight: 750; +} +#manual-oficial-campos { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} +#manual-oficial-campos.hidden { display: none !important; } +#manual-template-nome { min-width: 0; width: 100%; } +.template-vars { + padding: 12px; + border: 1px dashed var(--grid); + border-radius: var(--radius); + background: var(--surface-2); +} +.template-vars > label { color: var(--ink); font-weight: 800; } +.template-vars p { margin: 2px 0 10px; } +.variable-helper { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; + margin-bottom: 10px; +} +.variable-helper select, .variable-picker { min-width: min(100%, 320px); } +.template-vars-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.template-vars-grid label:last-child { grid-column: 1 / -1; } +.template-preview { + margin-top: 12px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + color: var(--ink-2); + white-space: pre-wrap; +} +.manual-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-top: 4px; +} +#manual-status:not(:empty) { + display: block; + width: 100%; + padding: 10px 12px; + border-radius: 12px; + background: var(--surface-2); + border: 1px solid var(--border); +} +#manual-status:not(:empty)::first-letter { font-weight: 900; } +#manual-status:not(:empty) { color: var(--ink-2); } +#manual-status.status-ok { color: var(--good-text); background: color-mix(in srgb, var(--good) 10%, var(--surface)); border-color: color-mix(in srgb, var(--good) 28%, var(--border)); } +#manual-status.status-error { color: var(--critical); background: var(--critical-bg); border-color: color-mix(in srgb, var(--critical) 30%, var(--border)); } +#btn-toggle-outreach-log { justify-self: start; margin-top: 2px; } + +.outreach-template { flex-direction: column; align-items: stretch; gap: 8px; } +.outreach-template .row.space-between { align-items: center; } +.outreach-template textarea { width: 100%; margin-top: 2px; } +.outreach-template-oficial { display: flex; flex-direction: column; gap: 8px; } +.outreach-template-vars { margin-top: 2px; } +.outreach-template-vars .template-vars-grid { grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); } +.outreach-template-vars .template-vars-grid label:last-child { grid-column: auto; } +.outreach-template-link { color: var(--accent-2); font-size: 12.5px; font-weight: 750; text-decoration: none; } +.outreach-template-link:hover { text-decoration: underline; } +.outreach-template-aviso { color: var(--critical); font-size: 12.5px; margin: 0; } +#outreach-limite { width: 82px; } + +.zona-risco { border-color: color-mix(in srgb, var(--critical) 45%, var(--border)); background: color-mix(in srgb, var(--critical) 5%, var(--surface)); } +.zona-risco summary { color: var(--critical); } +.template-sync-box { + margin-top: 14px; + padding-top: 14px; + border-top: 1px dashed var(--grid); + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(220px, 0.45fr); + gap: 12px; +} +.template-sync-box .row { grid-column: 1 / -1; } +.template-sync-inline { + display: flex; + gap: 10px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + margin: 0 0 12px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); +} + +/* login */ +.login-body { + display: flex; + min-height: 100vh; + background: + radial-gradient(circle at 18% 12%, color-mix(in srgb, var(--accent) 22%, transparent), transparent 28rem), + radial-gradient(circle at 82% 4%, color-mix(in srgb, var(--accent-2) 18%, transparent), transparent 26rem), + linear-gradient(135deg, var(--page), var(--page-2)); +} +.login-wrap { margin: auto; width: 100%; max-width: 410px; padding: 22px; } +.login-card { display: flex; flex-direction: column; gap: 14px; padding: 28px; } +.login-brand { justify-content: center; margin-bottom: 6px; } +.login-erro { color: var(--critical); min-height: 1em; margin: 0; } +.erro { color: var(--critical); margin: 4px 0 0; font-weight: 750; } +.login-card .field input { width: 100%; } + +/* chips, avisos e listas */ +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--ink-2); + border-radius: 999px; + padding: 5px 11px; + font-size: 12px; + font-weight: 800; +} +.chip.ok { background: color-mix(in srgb, var(--good) 12%, var(--surface)); color: var(--good-text); border-color: color-mix(in srgb, var(--good) 26%, var(--border)); } +.chip.warn { background: color-mix(in srgb, var(--warning) 14%, var(--surface)); color: var(--ink); border-color: color-mix(in srgb, var(--warning) 32%, var(--border)); } +.notice { + margin-top: 12px; + padding: 14px; + border: 1px solid color-mix(in srgb, var(--warning) 42%, var(--border)); + border-radius: var(--radius); + background: color-mix(in srgb, var(--warning) 10%, var(--surface)); +} +.notice p { margin: 0 0 10px; } +.lista { display: flex; flex-direction: column; gap: 8px; } +.lista .item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + flex-wrap: wrap; + padding: 11px 12px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); +} +.lista .item .sub { color: var(--muted); font-size: 12px; } + +/* pickers */ +.picker { margin-top: 10px; } +.picker input[type="search"], .cnae-avulso input[type="search"] { width: 100%; margin-bottom: 8px; } +.sugestoes { display: flex; flex-direction: column; gap: 4px; max-height: 200px; overflow-y: auto; } +.sugestoes button { + text-align: left; + border: 0; + border-radius: 10px; + background: transparent; + color: var(--ink); + cursor: pointer; + padding: 8px 10px; +} +.sugestoes button:hover { background: var(--surface-2); } +.chips { display: flex; flex-wrap: wrap; gap: 7px; margin: 8px 0 12px; } +.chips .chip { cursor: pointer; } +.chips .chip::after { content: " x"; color: var(--muted); } +.chips .chip:hover { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--surface)); } +.segmento-lista { display: flex; flex-wrap: wrap; gap: 7px; margin: 8px 0 12px; } +.segmento-opcao { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--ink-2); + border-radius: 999px; + padding: 5px 11px; + font-size: 12px; + font-weight: 800; + cursor: pointer; +} +.nichos-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); + gap: 4px 12px; + max-height: 320px; + overflow-y: auto; + margin-top: 10px; +} +.nichos-grid label { display: flex; gap: 7px; align-items: center; padding: 6px 7px; border-radius: 10px; cursor: pointer; } +.nichos-grid label:hover { background: var(--surface-2); } +.cnae-avulso { margin-top: 14px; border-top: 1px dashed var(--grid); padding-top: 12px; } +.cnae-avulso summary { color: var(--ink-2); font-size: 13px; font-weight: 800; } +.cnae-avulso-hint { margin: 7px 0 9px; font-size: 12.5px; } +.cnae-avulso .sugestoes button { display: flex; justify-content: space-between; gap: 8px; } +.cnae-avulso .sugestoes .cod { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 12px; } +.fontes { display: flex; flex-direction: column; gap: 8px; } +.fonte { + display: flex; + gap: 11px; + align-items: flex-start; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 11px 12px; + background: var(--surface); + cursor: pointer; +} +.fonte:hover { border-color: var(--accent); box-shadow: var(--shadow-soft); } +.fonte input { margin-top: 3px; } +.fonte .desc { color: var(--ink-2); font-size: 12.5px; } +.fonte.indisponivel { opacity: 0.58; } + +/* progresso */ +.progress-bar { height: 10px; border-radius: 999px; background: var(--grid); overflow: hidden; margin: 12px 0 9px; } +.progress-fill { height: 100%; width: 3%; background: linear-gradient(90deg, var(--accent), #ffb36d); border-radius: 999px; transition: width 0.4s; } +.progress-msg { color: var(--ink-2); margin: 4px 0 11px; min-height: 1.4em; } +.stat-row { display: flex; gap: 18px; margin-bottom: 12px; flex-wrap: wrap; } +.stat-row .mini { display: flex; flex-direction: column; min-width: 96px; } +.stat-row .mini b { font-size: 23px; letter-spacing: -0.03em; } +.stat-row .mini span { color: var(--muted); font-size: 12px; } +.log { + max-height: 170px; + overflow-y: auto; + white-space: pre-wrap; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #0f172a; + color: #dbeafe; + font: 12px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; +} + +/* tratamento */ +.filtros-card { padding: 14px; } +.filtros { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 9px; align-items: center; } +.filtros select, .filtros input { width: 100%; min-width: 0; } +#f-busca { grid-column: span 2; } +.stat-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; margin-bottom: 18px; } +.tile { + position: relative; + overflow: hidden; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px; + box-shadow: var(--shadow-soft); +} +.tile::after { + content: ""; + position: absolute; + inset: auto -20px -28px auto; + width: 96px; + height: 96px; + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 10%, transparent); +} +.tile .valor { font-size: 29px; font-weight: 900; letter-spacing: -0.04em; } +.tile .rotulo { color: var(--ink-2); font-size: 12.5px; font-weight: 750; margin-top: 2px; } +.tile .detalhe { color: var(--muted); font-size: 12px; } +.tile.t-good .valor { color: var(--good-text); } +.tile.tile-wide { grid-column: span 2; } +.meta-input { width: 78px; text-align: right; padding: 5px 7px; } + +.ranking, .funil { display: flex; flex-direction: column; gap: 8px; margin-top: 6px; } +.rank-row, .funil-etapa { + display: grid; + grid-template-columns: 150px 1fr 70px; + align-items: center; + gap: 9px; + padding: 4px 6px; + border-radius: 10px; +} +.funil-etapa { grid-template-columns: 190px 1fr 70px; } +.rank-row { cursor: pointer; } +.rank-row:hover, .oportunidade-clicavel:hover { background: var(--surface-2); } +.rank-row.ativo, .oportunidade-clicavel.ativo { background: color-mix(in srgb, var(--accent) 13%, var(--surface)); font-weight: 800; } +.rank-row.nao-clicavel { cursor: default; } +.rank-row.nao-clicavel:hover { background: transparent; } +.funil-etapa.pendente { opacity: 0.43; } +.rank-label, .funil-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; } +.rank-track, .funil-barra { height: 12px; overflow: hidden; border-radius: 999px; background: var(--grid); } +.rank-fill, .funil-fill { height: 100%; min-width: 3px; border-radius: 999px; background: linear-gradient(90deg, var(--bar), #ffb36d); } +.rank-value, .funil-valor { color: var(--ink-2); text-align: right; font-size: 12.5px; font-variant-numeric: tabular-nums; } +.oportunidade-clicavel { cursor: pointer; border-radius: 12px; padding: 8px 10px; } + +/* tabela */ +.table-wrap { overflow-x: auto; margin: 0 -6px; } +table { width: 100%; border-collapse: separate; border-spacing: 0; } +th, td { text-align: left; white-space: nowrap; padding: 10px 12px; border-bottom: 1px solid var(--grid); } +th { + color: var(--muted); + background: var(--surface); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; +} +td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; } +.acao-col { + position: sticky; + left: 0; + z-index: 3; + width: 58px; + min-width: 58px; + text-align: center; + background: var(--surface); + box-shadow: 10px 0 18px -18px rgba(22, 32, 51, 0.8); +} +td.acao-col { padding-left: 8px; padding-right: 8px; } +tbody tr { cursor: pointer; } +tbody tr:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); } +tbody tr:hover .acao-col { background: color-mix(in srgb, var(--accent) 6%, var(--surface)); } +.badge { display: inline-flex; align-items: center; border-radius: 999px; padding: 3px 9px; font-size: 12px; font-weight: 850; white-space: nowrap; } +.badge.excelente { background: color-mix(in srgb, var(--good) 18%, transparent); color: var(--good-text); } +.badge.bom { background: color-mix(in srgb, var(--good) 12%, var(--warning) 8%); color: var(--good-text); } +.badge.medio { background: color-mix(in srgb, var(--warning) 20%, transparent); color: var(--ink); } +.badge.baixo { background: color-mix(in srgb, var(--warning) 16%, var(--critical) 10%); color: var(--critical); } +.badge.muito_baixo { background: color-mix(in srgb, var(--critical) 14%, transparent); color: var(--critical); } +.badge.prioridade-1 { background: color-mix(in srgb, var(--good) 18%, transparent); color: var(--good-text); } +.badge.prioridade-2 { background: color-mix(in srgb, var(--warning) 20%, transparent); color: var(--ink); } +.badge.prioridade-3 { background: var(--grid); color: var(--ink-2); } +.badge.prioridade-4 { background: var(--grid); color: var(--muted); } +.produto-cell { font-size: 12.5px; } +.estrelas { color: var(--warning); letter-spacing: 1px; white-space: nowrap; } +.empresa-cell { max-width: 280px; overflow: hidden; text-overflow: ellipsis; } +.whatsapp-action { + width: 32px; + height: 32px; + display: inline-grid; + place-items: center; + border: 1px solid color-mix(in srgb, var(--good) 35%, var(--grid)); + border-radius: 999px; + background: color-mix(in srgb, var(--good) 12%, var(--surface)); + color: var(--good-text); + cursor: pointer; + font-size: 17px; + line-height: 1; +} +.whatsapp-action:hover { background: var(--good); color: #fff; } +.pontos-fortes, .score-breakdown { list-style: none; margin: 7px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; font-size: 13px; } +.resumo-comercial, .score-breakdown-wrap { border: 1px solid var(--border); border-radius: var(--radius); padding: 13px 14px; margin-top: 12px; background: var(--surface-2); } +.resumo-comercial h3 { margin: 0 0 8px; font-size: 14px; } +.score-breakdown li.nok { color: var(--muted); } + +/* usuarios */ +#lista-usuarios { display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px; } +#lista-usuarios > div { padding: 10px 12px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface-2); } + +/* modais e toast */ +dialog { + width: calc(100vw - 40px); + max-width: 600px; + padding: 0; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); + color: var(--ink); + box-shadow: var(--shadow); +} +dialog::backdrop { background: rgba(8, 13, 22, 0.58); backdrop-filter: blur(4px); } +.modal-body { padding: 24px; } +.modal-body h2 { margin-top: 0; letter-spacing: -0.03em; } +#modal-historico { max-width: 680px; } +#modal-historico .lista { max-height: 60vh; overflow-y: auto; margin-top: 10px; } +.toast { + position: fixed; + left: 50%; + bottom: 24px; + z-index: 100; + max-width: min(86vw, 680px); + transform: translateX(-50%); + padding: 11px 16px; + border-radius: 999px; + background: var(--ink); + color: var(--page); + box-shadow: var(--shadow); + font-weight: 800; +} +.hidden { display: none !important; } +.muted { color: var(--muted); font-weight: 400; } +.detalhe-grid { display: grid; grid-template-columns: 150px 1fr; gap: 6px 14px; font-size: 13.5px; } +.detalhe-grid dt { color: var(--muted); } +.detalhe-grid dd { margin: 0; word-break: break-word; } + +@media (max-width: 1100px) { + .grid-3 { grid-template-columns: 1fr 1fr; } + .topbar { grid-template-columns: 1fr auto auto auto; } + .tabs { grid-column: 1 / -1; order: 2; width: 100%; justify-content: center; } + #usuario-logado { justify-self: end; } +} + +@media (max-width: 780px) { + main { padding: 16px 12px 50px; } + .topbar { padding: 10px 12px; grid-template-columns: 1fr auto auto; gap: 8px; } + .brand-name { display: none; } + #usuario-logado { display: none; } + .tabs { overflow-x: auto; justify-content: flex-start; } + .tab { white-space: nowrap; } + .grid-3 { grid-template-columns: 1fr; } + .card { border-radius: var(--radius); padding: 16px; } + .settings-header, .settings-principal { grid-template-columns: 1fr; } + .settings-controls { justify-content: flex-start; } + .manual-send-card, #manual-oficial-campos { grid-template-columns: 1fr; } + .settings-section input[type="text"], + .settings-section input[type="password"], + .settings-section textarea, + .settings-section select, + .manual-send-card input, + .manual-send-card select { width: 100%; min-width: 0; } + .manual-actions { align-items: stretch; flex-direction: column; } + .manual-actions .btn { width: 100%; } + #f-busca, .tile.tile-wide { grid-column: span 1; } + .rank-row, .funil-etapa { grid-template-columns: 1fr 90px; } + .rank-track, .funil-barra { grid-column: 1 / -1; order: 3; } + th { top: 107px; } + .modal-body { padding: 18px; } +} diff --git a/gti-maquina-comercial/public/img/logo-gti.png b/gti-maquina-comercial/public/img/logo-gti.png new file mode 100644 index 0000000..574db00 Binary files /dev/null and b/gti-maquina-comercial/public/img/logo-gti.png differ diff --git a/gti-maquina-comercial/public/index.html b/gti-maquina-comercial/public/index.html new file mode 100644 index 0000000..dc3c01c --- /dev/null +++ b/gti-maquina-comercial/public/index.html @@ -0,0 +1,566 @@ + + + + + +GTI Máquina Comercial + + + + +
+
+ GTI + Máquina Comercial +
+ + + + + +
+ +
+ +
+
+
+
+

Nova captura

+ +
+ +
+ +
+ + +
+ +
+ +
+ +
+ + +
+ +
+ +
+ +
+ + +
+ + +
+ Não achou o nicho? Buscar qualquer atividade da Receita Federal (CNAE) +

Os 57 nichos acima já vêm com mensagem e interesse configurados. Um CNAE avulso é adicionado só para a busca — ele participa da captura normalmente.

+ +
+
+
+
+ +
+ +
+
+ +
+ Opções avançadas +
+ + +
+
+ + +
+ + +
+
+ + +
+
+
+ + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Top cidades

+
+
+
+
+
+

Top nichos

+
+
+
+
+
+

Top produto recomendado

+
+
+
+
+ +
+
+
+
+
+ +
+ +
+
+

Leads

+ +
+
+ + + + + + + + + +
AçãoEmpresaWhatsAppCidadeNichoPrioridadeGeralClassificaçãoProdutoExportado
+
+
+ + + +
+
+ +
+

Exportações geradas

+
+
+
+ + +
+
+

Campanhas de outreach

+ +
+
+ + + + +
+

Nova campanha

+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ +
+
+ + +
+

+
+ + +
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gti-maquina-comercial/public/js/api.js b/gti-maquina-comercial/public/js/api.js new file mode 100644 index 0000000..2aa999b --- /dev/null +++ b/gti-maquina-comercial/public/js/api.js @@ -0,0 +1,99 @@ +/* Cliente da API local + utilitários compartilhados. */ +// 401 fora da tela de login = sessao expirada/inexistente: manda pro login. +function redirecionaSeSessaoExpirou(path, status) { + if (status === 401 && path !== '/api/auth/login' && !location.pathname.endsWith('/login.html')) { + window.location.href = '/login.html'; + } +} + +const API = { + async get(path) { + const res = await fetch(path); + const data = await res.json(); + if (!res.ok) { + redirecionaSeSessaoExpirou(path, res.status); + throw new Error(data.erro || `HTTP ${res.status}`); + } + return data; + }, + + async send(method, path, body) { + const res = await fetch(path, { + method, + headers: { 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body) + }); + const data = await res.json(); + if (!res.ok) { + redirecionaSeSessaoExpirou(path, res.status); + const error = new Error(data.erro || `HTTP ${res.status}`); + error.data = data; + error.status = res.status; + throw error; + } + return data; + }, + + post(path, body) { return this.send('POST', path, body); }, + put(path, body) { return this.send('PUT', path, body); }, + + // Acompanha um job via SSE; handlers: {progress, log, done, error} + watchJob(jobId, handlers) { + const source = new EventSource(`/api/jobs/${jobId}/events`); + const close = () => source.close(); + + source.addEventListener('progress', (e) => handlers.progress?.(JSON.parse(e.data))); + source.addEventListener('log', (e) => handlers.log?.(JSON.parse(e.data))); + source.addEventListener('done', (e) => { + handlers.done?.(JSON.parse(e.data)); + close(); + }); + source.addEventListener('error', (e) => { + if (e.data) { + handlers.error?.(JSON.parse(e.data)); + close(); + } + // sem e.data = queda de conexão; EventSource tenta reconectar sozinho + }); + + return close; + } +}; + +const fmt = new Intl.NumberFormat('pt-BR'); + +function el(id) { return document.getElementById(id); } + +function h(tag, attrs = {}, ...children) { + const node = document.createElement(tag); + for (const [key, value] of Object.entries(attrs)) { + if (key === 'class') node.className = value; + else if (key.startsWith('on')) node.addEventListener(key.slice(2), value); + else if (value !== null && value !== undefined) node.setAttribute(key, value); + } + for (const child of children.flat()) { + if (child === null || child === undefined) continue; + node.append(child.nodeType ? child : document.createTextNode(child)); + } + return node; +} + +let toastTimer = null; +// Aviso: um aberto via showModal() fica acima de qualquer div normal +// (top layer do navegador, z-index nao ajuda — e testamos que nem a Popover +// API resolve, o dialog continua por cima). Por isso o toast NAO deve ser a +// unica forma de mostrar erro de uma acao disparada de dentro de um modal +// aberto: use tambem uma mensagem inline no proprio modal nesses casos. +function toast(message, ms = 3500) { + const box = el('toast'); + box.textContent = message; + box.classList.remove('hidden'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => box.classList.add('hidden'), ms); +} + +function dataBr(iso) { + if (!iso) return '—'; + const d = new Date(iso.includes('T') || iso.includes(' ') ? iso.replace(' ', 'T') + (iso.endsWith('Z') ? '' : 'Z') : iso); + return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); +} diff --git a/gti-maquina-comercial/public/js/app.js b/gti-maquina-comercial/public/js/app.js new file mode 100644 index 0000000..b915b6f --- /dev/null +++ b/gti-maquina-comercial/public/js/app.js @@ -0,0 +1,685 @@ +/* Shell do aplicativo: login + abas + configurações + usuários. */ +(async function init() { + // sessao (o servidor ja bloqueia "/" sem cookie, mas precisamos dos dados do usuario) + let usuarioAtual; + try { + ({ user: usuarioAtual } = await API.get('/api/auth/me')); + } catch { + window.location.href = '/login.html'; + return; + } + el('usuario-logado').textContent = `${usuarioAtual.username} (${usuarioAtual.role === 'admin' ? 'admin' : 'comercial'})`; + el('btn-usuarios').classList.toggle('hidden', usuarioAtual.role !== 'admin'); + + el('btn-sair').addEventListener('click', async () => { + await API.post('/api/auth/logout'); + window.location.href = '/login.html'; + }); + + // usuarios (somente admin) + async function carregarListaUsuarios() { + const { users } = await API.get('/api/users'); + el('lista-usuarios').replaceChildren(...users.map((u) => + h('div', { class: 'row space-between' }, + h('span', {}, u.username), + h('span', { class: 'muted' }, u.role === 'admin' ? 'Administrador' : 'Comercial')))); + } + + el('btn-usuarios').addEventListener('click', async () => { + el('modal-usuarios').showModal(); + try { + await carregarListaUsuarios(); + } catch (error) { + toast(`Erro: ${error.message}`); + } + }); + + el('btn-fechar-usuarios').addEventListener('click', () => el('modal-usuarios').close()); + + el('form-novo-usuario').addEventListener('submit', async (event) => { + event.preventDefault(); + try { + await API.post('/api/users', { + username: el('novo-usuario-username').value, + password: el('novo-usuario-password').value, + role: el('novo-usuario-role').value + }); + el('novo-usuario-username').value = ''; + el('novo-usuario-password').value = ''; + await carregarListaUsuarios(); + toast('Usuário criado'); + } catch (error) { + toast(`Erro: ${error.message}`); + } + }); + + // abas + for (const tab of document.querySelectorAll('.tab')) { + tab.addEventListener('click', () => { + for (const t of document.querySelectorAll('.tab')) { + t.classList.toggle('active', t === tab); + t.setAttribute('aria-selected', t === tab ? 'true' : 'false'); + } + for (const panel of document.querySelectorAll('.tab-panel')) { + panel.classList.toggle('active', panel.id === `tab-${tab.dataset.tab}`); + } + if (tab.dataset.tab === 'tratamento') Tratamento.ativar(); + if (tab.dataset.tab === 'campanhas') Campanhas.ativar(); + }); + } + + // configurações + let WABA_TEMPLATE_LIBRARY = []; + let COMMERCIAL_VARIABLES = []; + let focusedVariableInput = null; + let manualLeadContext = null; + + async function carregarVariaveisComerciais() { + const { variables } = await API.get('/api/outreach/variables'); + COMMERCIAL_VARIABLES = variables || []; + const picker = el('manual-variable-picker'); + if (picker) { + picker.replaceChildren( + h('option', { value: '' }, 'Inserir variável da Máquina Comercial...'), + ...COMMERCIAL_VARIABLES.map((variable) => h('option', { value: `{{${variable.key}}}` }, `{{${variable.key}}} - ${variable.label}`)) + ); + } + } + + async function carregarBibliotecaWaba() { + const { templates, lastSync } = await API.get('/api/outreach/waba-templates'); + WABA_TEMPLATE_LIBRARY = templates || []; + const select = el('manual-template-biblioteca'); + if (select) select.replaceChildren(...templateOptions(select.value)); + const statusEl = el('waba-template-library-status'); + if (statusEl) { + statusEl.textContent = lastSync + ? `${WABA_TEMPLATE_LIBRARY.length} templates carregados. Última atualização: ${dataBr(lastSync)}` + : `${WABA_TEMPLATE_LIBRARY.length} templates locais carregados. Para trazer todos do GChat, preencha o Token GChat e clique em Atualizar.`; + } + } + + function templateLabel(template) { + return `${template.name} (${template.language})`; + } + + function templateOptions(selected = '') { + return [h('option', { value: '' }, 'Selecione um template...')].concat( + WABA_TEMPLATE_LIBRARY.map((template) => h('option', { + value: template.name, + selected: template.name === selected ? '' : null + }, templateLabel(template))) + ); + } + + function preencherVariaveis(inputs, variables, fallback = []) { + inputs.forEach((input, index) => { + input.value = variables[index] || fallback[index] || ''; + }); + } + + function templateVariableCount(template) { + if (!template) return 0; + const fromVariables = Array.isArray(template.variables) ? template.variables.length : 0; + const matches = String(template.text || '').match(/{{\s*\d+\s*}}/g) || []; + const fromText = Math.max(0, ...matches.map((match) => Number(match.replace(/\D/g, '')))); + return Math.min(5, Math.max(fromVariables, fromText)); + } + + function setVariableFields(container, template, inputs, fallback = []) { + const count = templateVariableCount(template); + inputs.forEach((input, index) => { + const field = input.closest('.template-var-field') || input.closest('label'); + const visible = index < count; + if (field) field.classList.toggle('hidden', !visible); + if (!visible) input.value = ''; + else if (!input.value) input.value = template?.variables?.[index] || fallback[index] || ''; + }); + const empty = container?.querySelector?.('.template-vars-empty'); + if (empty) empty.classList.toggle('hidden', count > 0); + } + + function previewTemplateText(template, inputs) { + const text = String(template?.text || '').trim(); + if (!template) return 'Selecione um template para ver a pré-visualização.'; + if (!text) return 'Pré-visualização indisponível para este template local.'; + return text.replace(/{{\s*(\d+)\s*}}/g, (_, index) => { + const input = inputs[Number(index) - 1]; + const value = resolverVariavelManual(input?.value?.trim()); + return value ? `[${value}]` : `[Variável ${index}]`; + }); + } + + function atualizarPreview(previewEl, template, inputs) { + if (previewEl) previewEl.textContent = previewTemplateText(template, inputs); + } + + function collectVisibleVariables(inputs) { + return inputs + .filter((input) => !(input.closest('.template-var-field') || input.closest('label'))?.classList.contains('hidden')) + .map((input) => input.value.trim()); + } + + function collectVisibleVariablesResolvidas(inputs) { + return collectVisibleVariables(inputs).map(resolverVariavelManual); + } + + function valoresVariaveisManuais(lead = manualLeadContext) { + const empresa = lead?.empresa || lead?.legal_name || lead?.businessName || lead?.name || 'Empresa Teste'; + const produto = lead?.produto || lead?.produto_principal || lead?.interesse || 'Atendimento comercial'; + const segmento = lead?.segmento_label || lead?.segmento || 'Suporte'; + const name = lead?.name || empresa; + return { + name, + empresa, + businessName: empresa, + firstName: String(name).split(/\s+/).filter(Boolean)[0] || empresa, + lastName: lead?.lastName || '', + produto, + protocol: lead?.protocol || produto, + cidade: lead?.cidade || 'Salvador', + uf: lead?.uf || 'BA', + segmento, + cnpj: lead?.cnpj || '00.000.000/0001-00', + email: lead?.email || 'cliente@exemplo.com.br', + phoneNumber: lead?.whatsapp || '5575999091780', + whatsapp: lead?.whatsapp || '5575999091780', + kanban: lead?.kanban || produto, + user: lead?.user || 'Grupo GTI', + userEmail: lead?.userEmail || 'comercial@gtigrupo.com.br', + greeting: 'Olá' + }; + } + + function resolverVariavelManual(value) { + const values = valoresVariaveisManuais(); + let output = String(value || 'Informação') + .replaceAll('{empresa}', values.empresa) + .replaceAll('{produto}', values.produto) + .replaceAll('{cidade}', values.cidade) + .replaceAll('{segmento}', values.segmento); + for (const [key, variableValue] of Object.entries(values)) { + output = output.replaceAll(`{{${key}}}`, variableValue); + } + for (const variable of COMMERCIAL_VARIABLES) { + if (!output.includes(`{{${variable.key}}}`)) continue; + output = output.replaceAll(`{{${variable.key}}}`, values[variable.key] || variable.label); + } + return output; + } + + const variavelExemplo = resolverVariavelManual; + + function missingVisibleVariables(inputs) { + return inputs + .filter((input) => !(input.closest('.template-var-field') || input.closest('label'))?.classList.contains('hidden')) + .filter((input) => !input.value.trim()); + } + + function aplicarTemplateBiblioteca(template, { nomeEl, idiomaEl, routeEl, variableInputs }) { + if (!template) return; + nomeEl.value = template.name; + idiomaEl.value = template.language; + if (routeEl) routeEl.value = template.route; + preencherVariaveis(variableInputs, template.variables); + } + + function variablePickerEl() { + return h('select', { class: 'variable-picker' }, + h('option', { value: '' }, 'Inserir variável da Máquina Comercial...'), + ...COMMERCIAL_VARIABLES.map((variable) => h('option', { value: `{{${variable.key}}}` }, `{{${variable.key}}} - ${variable.label}`)) + ); + } + + async function carregarOutreach() { + const { settings } = await API.get('/api/settings'); + el('outreach-ativo').checked = settings['outreach.ativo'] === 'true'; + el('outreach-limite').value = settings['outreach.mensagensPorDia'] || 10; + + const [{ templates }, status] = await Promise.all([ + API.get('/api/outreach/templates'), + API.get('/api/outreach/status') + ]); + el('outreach-status').textContent = `Enviado hoje: ${status.enviadosHoje}/${status.mensagensPorDia}`; + + el('outreach-templates').replaceChildren(...templates.map((t) => { + const oficial = t.canal === 'oficial'; + const mensagemEl = h('textarea', { + class: 'outreach-template-mensagem', rows: 2, + style: oficial ? 'display:none' : '' + }, t.mensagem); + const nomeEl = h('input', { type: 'text', class: 'outreach-template-waba-nome', value: t.template_waba_nome || '' }); + const idiomaEl = h('input', { type: 'text', class: 'outreach-template-waba-idioma', value: t.template_waba_idioma || 'pt_BR' }); + let variaveisSalvas = []; + try { + variaveisSalvas = JSON.parse(t.template_waba_variaveis_json || '[]'); + } catch { + variaveisSalvas = []; + } + let templateSelecionado = WABA_TEMPLATE_LIBRARY.find((item) => item.name === t.template_waba_nome) || null; + const variaveisEls = Array.from({ length: 5 }, (_, index) => { + const input = h('input', { + type: 'text', + class: 'outreach-template-waba-var', + value: variaveisSalvas[index] || (index === 0 ? '{empresa}' : index === 1 ? '{produto}' : ''), + placeholder: index === 0 ? '{empresa}' : index === 1 ? '{produto}' : '' + }); + input.addEventListener('focus', () => { focusedVariableInput = input; }); + return h('label', { class: 'template-var-field' }, `Variável {{${index + 1}}}`, input); + }); + const bibliotecaEl = h('select', { class: 'outreach-template-biblioteca' }, ...templateOptions(t.template_waba_nome || '')); + const variablePicker = variablePickerEl(); + const previewEl = h('div', { class: 'template-preview' }, 'Selecione um template para ver a pré-visualização.'); + variablePicker.addEventListener('change', () => { + if (!focusedVariableInput || !oficialEl.contains(focusedVariableInput) || !variablePicker.value) return; + focusedVariableInput.value = variablePicker.value; + focusedVariableInput.dispatchEvent(new Event('input')); + variablePicker.value = ''; + }); + bibliotecaEl.addEventListener('change', () => { + templateSelecionado = WABA_TEMPLATE_LIBRARY.find((item) => item.name === bibliotecaEl.value); + const inputs = Array.from(oficialEl.querySelectorAll('.outreach-template-waba-var')); + aplicarTemplateBiblioteca(templateSelecionado, { + nomeEl, + idiomaEl, + variableInputs: inputs + }); + setVariableFields(oficialEl, templateSelecionado, inputs, ['{empresa}', '{produto}']); + atualizarPreview(previewEl, templateSelecionado, inputs); + atualizarAviso(); + }); + const avisoEl = h('p', { class: 'muted outreach-template-aviso hidden' }, + '⚠️ Preencha o nome do template pra essa automação funcionar.'); + const oficialEl = h('div', { + class: 'outreach-template-oficial', + style: oficial ? '' : 'display:none' + }, + h('label', {}, 'Escolher modelo salvo', bibliotecaEl), + h('div', { class: 'row' }, + h('label', {}, 'Nome do template aprovado', nomeEl), + h('label', {}, 'Idioma', idiomaEl)), + h('div', { class: 'template-vars outreach-template-vars' }, + h('label', {}, 'Variáveis automáticas'), + h('p', { class: 'muted' }, 'Use placeholders como {empresa}, {produto}, {cidade}, {segmento}.'), + h('div', { class: 'variable-helper' }, + variablePicker, + h('span', { class: 'muted' }, 'Clique em um campo abaixo e escolha uma variável.')), + h('p', { class: 'muted template-vars-empty hidden' }, 'Este template não exige variáveis.'), + h('div', { class: 'template-vars-grid' }, ...variaveisEls), + previewEl), + h('a', { + href: 'https://business.facebook.com/wa/manage/message-templates/', + target: '_blank', rel: 'noopener', class: 'outreach-template-link' + }, '↗ Ver/criar templates no Meta Business Manager'), + avisoEl); + + const atualizarAviso = () => { + avisoEl.classList.toggle('hidden', radioOficial.checked ? Boolean(nomeEl.value.trim()) : true); + }; + nomeEl.addEventListener('input', atualizarAviso); + for (const input of variaveisEls.map((label) => label.querySelector('input'))) { + input.addEventListener('input', () => atualizarPreview(previewEl, templateSelecionado, Array.from(oficialEl.querySelectorAll('.outreach-template-waba-var')))); + } + + const radioNaoOficial = h('input', { + type: 'radio', name: `canal-${t.produto}`, class: 'outreach-template-canal', value: 'nao_oficial', + checked: oficial ? null : '' + }); + const radioOficial = h('input', { + type: 'radio', name: `canal-${t.produto}`, class: 'outreach-template-canal', value: 'oficial', + checked: oficial ? '' : null + }); + const alternarCanal = () => { + const usaOficial = radioOficial.checked; + mensagemEl.style.display = usaOficial ? 'none' : ''; + oficialEl.style.display = usaOficial ? '' : 'none'; + atualizarAviso(); + }; + radioNaoOficial.addEventListener('change', alternarCanal); + radioOficial.addEventListener('change', alternarCanal); + atualizarAviso(); + queueMicrotask(() => { + const inputs = Array.from(oficialEl.querySelectorAll('.outreach-template-waba-var')); + setVariableFields(oficialEl, templateSelecionado, inputs, ['{empresa}', '{produto}']); + atualizarPreview(previewEl, templateSelecionado, inputs); + }); + + return h('div', { class: 'item outreach-template', 'data-produto': t.produto }, + h('div', { class: 'row space-between' }, + h('strong', {}, t.produto), + h('label', { class: 'radio' }, + h('input', { type: 'checkbox', class: 'outreach-template-ativo', checked: t.ativo ? '' : null }), + ' Ativo')), + h('div', { class: 'row' }, + h('label', { class: 'radio' }, radioNaoOficial, ' Não-oficial (texto livre)'), + h('label', { class: 'radio' }, radioOficial, ' Oficial (Meta, template aprovado)')), + mensagemEl, + oficialEl); + })); + + el('outreach-ultimos').replaceChildren(...status.ultimos.map((s) => { + const quem = s.origem === 'manual' ? (s.numero || 'número avulso') : `${s.empresa || '?'} (${s.cidade || '?'})`; + const rotulo = s.origem === 'manual' ? `Manual → ${quem}` : `${s.produto} → ${quem}`; + const statusIcone = s.status === 'enviado' ? '✅' : '❌'; + return h('div', { class: 'item' }, + h('div', {}, `${statusIcone} ${rotulo}`), + h('div', { class: 'sub' }, `${dataBr(s.sent_at)} · canal ${s.canal === 'oficial' ? 'oficial' : 'não-oficial'}${s.erro ? ` · ${s.erro}` : ''}`)); + })); + if (!status.ultimos.length) { + el('outreach-ultimos').replaceChildren(h('p', { class: 'muted' }, 'Nenhum envio ainda.')); + } + } + + el('btn-settings').addEventListener('click', async () => { + const { settings } = await API.get('/api/settings'); + el('cfg-pipeline').value = settings['zpro.pipelineId'] || 1; + el('cfg-stage').value = settings['zpro.stageId'] || 1; + el('cfg-responsible').value = settings['zpro.responsibleId'] || 15; + el('cfg-ticket-queue').value = settings['zpro.ticketQueueId'] || 12; + el('cfg-forecast').value = settings['zpro.closingForecastDays'] || 30; + el('cfg-zpro-baseurl').value = settings['zpro.baseUrl'] || ''; + el('cfg-zpro-apiid').value = settings['zpro.apiId'] || ''; + el('cfg-zpro-token').value = settings['zpro.bearerToken'] || ''; + el('cfg-zpro-whatsappid').value = settings['zpro.whatsappId'] || ''; + el('cfg-gchat-templates-url').value = settings['gchat.templatesUrl'] || ''; + el('cfg-gchat-token').value = settings['gchat.bearerToken'] || ''; + el('zpro-credenciais-status').textContent = ''; + el('modal-settings').showModal(); + try { + await carregarBibliotecaWaba(); + await carregarVariaveisComerciais(); + await carregarOutreach(); + } catch (error) { + toast(`Erro ao carregar automação: ${error.message}`); + } + }); + + el('btn-testar-zpro').addEventListener('click', async () => { + const statusEl = el('zpro-credenciais-status'); + statusEl.textContent = 'Salvando e testando...'; + try { + await API.put('/api/settings', { + 'zpro.baseUrl': el('cfg-zpro-baseurl').value.trim(), + 'zpro.apiId': el('cfg-zpro-apiid').value.trim(), + 'zpro.bearerToken': el('cfg-zpro-token').value.trim(), + 'zpro.whatsappId': el('cfg-zpro-whatsappid').value.trim(), + 'gchat.templatesUrl': el('cfg-gchat-templates-url').value.trim(), + 'gchat.bearerToken': el('cfg-gchat-token').value.trim(), + 'outreach.ativo': el('outreach-ativo').checked ? 'true' : 'false', + 'outreach.mensagensPorDia': el('outreach-limite').value + }); + const resultado = await API.post('/api/outreach/testar-conexao'); + const diag = resultado.diagnostico + ? `\nBase: ${resultado.diagnostico.baseUrl || '-'} | API ID: ${resultado.diagnostico.apiId || '-'} | Token: ${resultado.diagnostico.token || '-'} | WhatsApp ID: ${resultado.diagnostico.whatsappId || '-'}` + : ''; + statusEl.textContent = resultado.ok ? `✅ Conexão OK${diag}` : `❌ ${resultado.erro}${diag}`; + } catch (error) { + statusEl.textContent = `❌ ${error.message}`; + } + }); + + el('btn-sync-gchat-templates').addEventListener('click', async () => { + await sincronizarTemplatesGchat(); + }); + + el('btn-sync-gchat-templates-inline').addEventListener('click', async () => { + await sincronizarTemplatesGchat(); + }); + + async function sincronizarTemplatesGchat() { + const statusEl = el('gchat-template-sync-status'); + const inlineStatusEl = el('waba-template-library-status'); + statusEl.textContent = 'Sincronizando...'; + if (inlineStatusEl) inlineStatusEl.textContent = 'Sincronizando com GChat...'; + try { + await API.put('/api/settings', { + 'gchat.templatesUrl': el('cfg-gchat-templates-url').value.trim(), + 'gchat.bearerToken': el('cfg-gchat-token').value.trim() + }); + const result = await API.post('/api/outreach/waba-templates/sync'); + statusEl.textContent = result.ok ? `✅ ${result.total} templates sincronizados` : `❌ ${result.erro}`; + if (inlineStatusEl) inlineStatusEl.textContent = result.ok + ? `✅ ${result.total} templates sincronizados do GChat` + : `❌ ${result.erro}. Preencha o Token GChat opcional em Credenciais Z-PRO.`; + await carregarBibliotecaWaba(); + await carregarOutreach(); + } catch (error) { + statusEl.textContent = `❌ ${error.message}`; + if (inlineStatusEl) inlineStatusEl.textContent = `❌ ${error.message}`; + } + } + + el('btn-salvar-outreach').addEventListener('click', async () => { + try { + await API.put('/api/settings', { + 'outreach.ativo': el('outreach-ativo').checked ? 'true' : 'false', + 'outreach.mensagensPorDia': el('outreach-limite').value + }); + for (const item of el('outreach-templates').children) { + const canalEl = item.querySelector('.outreach-template-canal:checked'); + await API.put(`/api/outreach/templates/${encodeURIComponent(item.dataset.produto)}`, { + mensagem: item.querySelector('.outreach-template-mensagem').value, + ativo: item.querySelector('.outreach-template-ativo').checked, + canal: canalEl ? canalEl.value : 'nao_oficial', + templateWabaNome: item.querySelector('.outreach-template-waba-nome').value, + templateWabaIdioma: item.querySelector('.outreach-template-waba-idioma').value, + templateWabaVariaveis: collectVisibleVariables(Array.from(item.querySelectorAll('.outreach-template-waba-var'))) + }); + } + await carregarOutreach(); + toast('Automação salva'); + } catch (error) { + toast(`Erro: ${error.message}`); + } + }); + + function alternarCanalManual() { + const oficial = el('manual-canal-oficial').checked; + el('manual-mensagem-wrap').classList.toggle('hidden', oficial); + el('manual-oficial-campos').classList.toggle('hidden', !oficial); + } + el('manual-canal-naooficial').addEventListener('change', alternarCanalManual); + el('manual-canal-oficial').addEventListener('change', alternarCanalManual); + + el('manual-template-biblioteca').replaceChildren(...templateOptions()); + el('manual-template-biblioteca').addEventListener('change', () => { + const template = WABA_TEMPLATE_LIBRARY.find((item) => item.name === el('manual-template-biblioteca').value); + const inputs = Array.from(document.querySelectorAll('.manual-template-var')); + aplicarTemplateBiblioteca(template, { + nomeEl: el('manual-template-nome'), + idiomaEl: el('manual-template-idioma'), + routeEl: el('manual-template-rota'), + variableInputs: inputs + }); + setVariableFields(el('manual-oficial-campos'), template, inputs); + atualizarPreview(el('manual-template-preview'), template, inputs); + }); + + for (const input of document.querySelectorAll('.manual-template-var')) { + input.addEventListener('focus', () => { focusedVariableInput = input; }); + input.addEventListener('input', () => { + const template = WABA_TEMPLATE_LIBRARY.find((item) => item.name === el('manual-template-biblioteca').value); + atualizarPreview(el('manual-template-preview'), template, Array.from(document.querySelectorAll('.manual-template-var'))); + }); + } + el('manual-variable-picker').addEventListener('change', () => { + if (!focusedVariableInput || !el('manual-variable-picker').value) return; + focusedVariableInput.value = el('manual-variable-picker').value; + focusedVariableInput.dispatchEvent(new Event('input')); + el('manual-variable-picker').value = ''; + }); + setVariableFields(el('manual-oficial-campos'), null, Array.from(document.querySelectorAll('.manual-template-var'))); + + el('btn-testar-template-biblioteca').addEventListener('click', async () => { + const statusEl = el('manual-status'); + const template = WABA_TEMPLATE_LIBRARY.find((item) => item.name === el('manual-template-biblioteca').value); + const numero = el('manual-numero').value.trim(); + statusEl.classList.remove('status-ok', 'status-error'); + if (!template) { + statusEl.textContent = '❌ Selecione um modelo salvo primeiro.'; + statusEl.classList.add('status-error'); + return; + } + if (!numero) { + statusEl.textContent = '❌ Informe um número de teste acima.'; + statusEl.classList.add('status-error'); + return; + } + aplicarTemplateBiblioteca(template, { + nomeEl: el('manual-template-nome'), + idiomaEl: el('manual-template-idioma'), + routeEl: el('manual-template-rota'), + variableInputs: Array.from(document.querySelectorAll('.manual-template-var')) + }); + const variaveis = template.variables.map(resolverVariavelManual); + preencherVariaveis(Array.from(document.querySelectorAll('.manual-template-var')), variaveis); + setVariableFields(el('manual-oficial-campos'), template, Array.from(document.querySelectorAll('.manual-template-var'))); + atualizarPreview(el('manual-template-preview'), template, Array.from(document.querySelectorAll('.manual-template-var'))); + statusEl.textContent = `Testando ${template.name}...`; + try { + const resultado = await API.post('/api/outreach/enviar-manual', { + numero, + canal: 'oficial', + mensagem: '', + templateWabaNome: template.name, + templateWabaIdioma: template.language, + templateRota: template.route, + templateVariaveis: variaveis + }); + statusEl.textContent = resultado.ok + ? `✅ ${template.name} funcionou${resultado.ticketId ? ` — ticket ${resultado.ticketId}` : ''}` + : `❌ ${template.name} falhou: ${resultado.erro}`; + statusEl.classList.toggle('status-ok', Boolean(resultado.ok)); + statusEl.classList.toggle('status-error', !resultado.ok); + await carregarOutreach(); + } catch (error) { + statusEl.textContent = `❌ ${template.name} falhou: ${error.message}`; + statusEl.classList.add('status-error'); + } + }); + + el('btn-toggle-outreach-log').addEventListener('click', () => { + const log = el('outreach-ultimos'); + const hidden = log.classList.toggle('hidden'); + el('btn-toggle-outreach-log').textContent = hidden ? 'Ver log de envios' : 'Ocultar log de envios'; + }); + + el('btn-enviar-manual').addEventListener('click', async () => { + const statusEl = el('manual-status'); + const oficial = el('manual-canal-oficial').checked; + statusEl.classList.remove('status-ok', 'status-error'); + if (oficial) { + const faltando = missingVisibleVariables(Array.from(document.querySelectorAll('.manual-template-var'))); + if (faltando.length) { + statusEl.textContent = `❌ Preencha todas as variáveis do template (${faltando.length} pendente${faltando.length > 1 ? 's' : ''}).`; + statusEl.classList.add('status-error'); + return; + } + } + statusEl.textContent = 'Enviando...'; + try { + const resultado = await API.post('/api/outreach/enviar-manual', { + numero: el('manual-numero').value.trim(), + canal: oficial ? 'oficial' : 'nao_oficial', + mensagem: el('manual-mensagem').value, + templateWabaNome: el('manual-template-nome').value.trim(), + templateWabaIdioma: el('manual-template-idioma').value.trim(), + templateVariaveis: collectVisibleVariablesResolvidas(Array.from(document.querySelectorAll('.manual-template-var'))), + templateRota: el('manual-template-rota').value, + lead: manualLeadContext + }); + statusEl.textContent = resultado.ok + ? `✅ Enviado${resultado.ticketId ? ` — ticket ${resultado.ticketId}` : ''}` + : `❌ ${resultado.erro}`; + statusEl.classList.toggle('status-ok', Boolean(resultado.ok)); + statusEl.classList.toggle('status-error', !resultado.ok); + await carregarOutreach(); + } catch (error) { + statusEl.textContent = `❌ ${error.message}`; + statusEl.classList.add('status-error'); + } + }); + + window.GTISelecionarLeadManual = (lead) => { + manualLeadContext = lead || null; + const template = WABA_TEMPLATE_LIBRARY.find((item) => item.name === el('manual-template-biblioteca').value); + atualizarPreview(el('manual-template-preview'), template, Array.from(document.querySelectorAll('.manual-template-var'))); + }; + + el('btn-salvar-settings').addEventListener('click', async (event) => { + event.preventDefault(); + try { + await API.put('/api/settings', { + 'zpro.pipelineId': el('cfg-pipeline').value, + 'zpro.stageId': el('cfg-stage').value, + 'zpro.responsibleId': el('cfg-responsible').value, + 'zpro.ticketQueueId': el('cfg-ticket-queue').value, + 'zpro.closingForecastDays': el('cfg-forecast').value, + 'zpro.baseUrl': el('cfg-zpro-baseurl').value.trim(), + 'zpro.apiId': el('cfg-zpro-apiid').value.trim(), + 'zpro.bearerToken': el('cfg-zpro-token').value.trim(), + 'zpro.whatsappId': el('cfg-zpro-whatsappid').value.trim(), + 'gchat.templatesUrl': el('cfg-gchat-templates-url').value.trim(), + 'gchat.bearerToken': el('cfg-gchat-token').value.trim(), + 'outreach.ativo': el('outreach-ativo').checked ? 'true' : 'false', + 'outreach.mensagensPorDia': el('outreach-limite').value + }); + await carregarOutreach(); + el('modal-settings').close(); + toast('Configurações salvas'); + } catch (error) { + toast(`Erro: ${error.message}`); + } + }); + + // limpar toda a base (zona de risco) + el('btn-limpar-base').addEventListener('click', () => { + el('reset-base-senha').value = ''; + el('reset-base-erro').classList.add('hidden'); + el('modal-settings').close(); + el('modal-reset-base').showModal(); + }); + + el('btn-cancelar-reset-base').addEventListener('click', () => { + el('modal-reset-base').close(); + }); + + // Erro aqui precisa aparecer DENTRO do modal (nao so no toast): um + // aberto via showModal() fica na "top layer" do navegador, acima de + // qualquer div normal — inclusive do toast, que ficava invisivel atras do + // modal quando a senha estava errada, dando a impressao de que nada + // acontecia (bug reportado: "limpeza nao foi feita mesmo pondo a senha"). + el('btn-confirmar-reset-base').addEventListener('click', async () => { + const erroEl = el('reset-base-erro'); + const senha = el('reset-base-senha').value; + if (!senha) { + erroEl.textContent = 'Informe a senha de segurança'; + erroEl.classList.remove('hidden'); + return; + } + try { + await API.post('/api/admin/reset', { senha }); + el('modal-reset-base').close(); + toast('Base de leads limpa com sucesso — recarregando...'); + setTimeout(() => window.location.reload(), 1200); + } catch (error) { + erroEl.textContent = `Erro: ${error.message}`; + erroEl.classList.remove('hidden'); + } + }); + + // fecha modais clicando fora + for (const dialog of document.querySelectorAll('dialog')) { + dialog.addEventListener('click', (event) => { + if (event.target === dialog) dialog.close(); + }); + } + + Tratamento.bindEvents(); + + try { + await Captura.init(); + } catch (error) { + toast(`Erro ao carregar: ${error.message}`, 8000); + } +})(); diff --git a/gti-maquina-comercial/public/js/campanhas.js b/gti-maquina-comercial/public/js/campanhas.js new file mode 100644 index 0000000..e81dae7 --- /dev/null +++ b/gti-maquina-comercial/public/js/campanhas.js @@ -0,0 +1,253 @@ +/* Aba Campanhas: criacao, fila de aprovacao, saude/variantes, estagio/agendamento. */ +const Campanhas = (() => { + let carregado = false; + let variantesCount = 0; + + const STATUS_LABELS = { rascunho: 'Rascunho', rodando: 'Rodando', pausado: 'Pausado', concluido: 'Concluido' }; + + const MAX_VARIANTES = 3; // A/B/C — mesmo limite usado na convencao do restante da UI/backend. + + function addVarianteRow(codigoSugerido) { + if (variantesCount >= MAX_VARIANTES) return; + const codigo = codigoSugerido || String.fromCharCode(65 + variantesCount); + variantesCount += 1; + el('campanha-variantes').append( + h('div', { class: 'row', 'data-variante-code': codigo }, + h('span', { class: 'chip' }, codigo), + h('textarea', { class: 'campanha-variante-mensagem', rows: 2, placeholder: 'Ola {empresa}, ...' })) + ); + atualizarBotaoAddVariante(); + } + + function atualizarBotaoAddVariante() { + el('btn-add-variante').disabled = variantesCount >= MAX_VARIANTES; + } + + async function carregarProdutos() { + const { produtos } = await API.get('/api/campaigns/produtos'); + el('campanha-produto').replaceChildren( + ...produtos.map((p) => h('option', { value: p.value }, `${p.value} (${fmt.format(p.total)} leads)`)) + ); + } + + async function carregarSegmentos(produto) { + const container = el('campanha-segmentos'); + if (!produto) { container.replaceChildren(); return; } + const { segmentos } = await API.get(`/api/campaigns/segmentos?produto=${encodeURIComponent(produto)}`); + container.replaceChildren(...segmentos.map((s) => + h('label', { class: 'segmento-opcao' }, + h('input', { type: 'checkbox', value: s.value, class: 'campanha-segmento-check' }), + ` ${s.value} (${fmt.format(s.total)})`) + )); + } + + function segmentosSelecionados() { + return Array.from(document.querySelectorAll('.campanha-segmento-check:checked')).map((i) => i.value); + } + + function variantesPreenchidas() { + return Array.from(el('campanha-variantes').children).map((row) => ({ + code: row.dataset.varianteCode, + mensagem: row.querySelector('.campanha-variante-mensagem').value.trim() + })).filter((v) => v.mensagem); + } + + async function abrirModalNovaCampanha() { + el('campanha-nome').value = ''; + el('campanha-limite-inicial').value = 10; + el('campanha-limite-teto').value = 80; + el('campanha-erro').textContent = ''; + el('campanha-variantes').replaceChildren(); + variantesCount = 0; + addVarianteRow('A'); + await carregarProdutos(); + await carregarSegmentos(el('campanha-produto').value); + el('modal-nova-campanha').showModal(); + } + + function renderCampanhaCard(campanha) { + return h('div', { class: 'item', onclick: () => carregarDetalhe(campanha.id).catch((e) => toast(`Erro: ${e.message}`)) }, + h('div', {}, + h('strong', {}, campanha.nome), + h('span', { class: 'sub' }, ` ${campanha.produto} — ${campanha.segmentos.join(', ')}`)), + h('span', { class: `chip ${campanha.status === 'rodando' ? 'ok' : campanha.status === 'pausado' ? 'warn' : ''}` }, + STATUS_LABELS[campanha.status] || campanha.status)); + } + + async function carregarLista() { + const { campanhas } = await API.get('/api/campaigns'); + el('campanhas-lista').replaceChildren( + ...(campanhas.length ? campanhas.map(renderCampanhaCard) : [h('p', { class: 'muted' }, 'Nenhuma campanha criada ainda.')]) + ); + } + + function ativar() { + if (carregado) return; + carregado = true; + + el('btn-nova-campanha').addEventListener('click', () => { + abrirModalNovaCampanha().catch((error) => toast(`Erro: ${error.message}`)); + }); + el('btn-cancelar-campanha').addEventListener('click', () => el('modal-nova-campanha').close()); + el('btn-add-variante').addEventListener('click', () => addVarianteRow()); + el('campanha-produto').addEventListener('change', (event) => { + // O modal de nova campanha esta aberto ( via showModal()) quando + // isto dispara — toast sozinho fica escondido atras do dialog (ver + // comentario em api.js), entao o erro tambem precisa aparecer no + // paragrafo inline #campanha-erro que ja existe pro erro de submit. + carregarSegmentos(event.target.value).catch((error) => { + toast(`Erro: ${error.message}`); + el('campanha-erro').textContent = `Erro: ${error.message}`; + }); + }); + + el('btn-salvar-campanha').addEventListener('click', async () => { + try { + await API.post('/api/campaigns', { + nome: el('campanha-nome').value.trim(), + produto: el('campanha-produto').value, + segmentos: segmentosSelecionados(), + variantes: variantesPreenchidas(), + limiteDiarioInicial: Number(el('campanha-limite-inicial').value) || 10, + limiteDiarioTeto: Number(el('campanha-limite-teto').value) || 80 + }); + el('modal-nova-campanha').close(); + toast('Campanha criada'); + await carregarLista(); + } catch (error) { + el('campanha-erro').textContent = error.message; + } + }); + + ligarDetalheCampanha(); + + carregarLista().catch((error) => toast(`Erro: ${error.message}`)); + } + + let campanhaAtualId = null; + + async function carregarDetalhe(campanhaId) { + campanhaAtualId = campanhaId; + const campanha = await API.get(`/api/campaigns/${campanhaId}`); + el('detalhe-nome').textContent = campanha.nome; + el('detalhe-status').textContent = STATUS_LABELS[campanha.status] || campanha.status; + el('campanha-detalhe').classList.remove('hidden'); + await Promise.all([carregarSaude(campanhaId), carregarVariantesStats(campanhaId), carregarFila(campanhaId)]); + } + + async function carregarSaude(campanhaId) { + const saude = await API.get(`/api/campaigns/${campanhaId}/saude`); + el('detalhe-saude').textContent = saude.pause + ? `Pausada automaticamente: ${saude.reason}` + : 'Saude: sem alertas.'; + } + + async function carregarVariantesStats(campanhaId) { + const { variantes } = await API.get(`/api/campaigns/${campanhaId}/variantes-stats`); + el('detalhe-variantes').replaceChildren(...variantes.map((v) => + h('span', { class: 'chip' }, + `${v.code}: ${v.enviados} enviados, ${(v.taxaResposta * 100).toFixed(1)}% resposta${v.elegivelParaComparacao ? ' (elegivel)' : ''}`) + )); + } + + const STAGE_OPTIONS = [ + 'respondeu', 'interessado', 'demonstracao_agendada', 'sem_interesse', 'opt_out', 'venda_fechada' + ]; + + async function carregarFila(campanhaId) { + const status = el('fila-filtro-status').value; + const { fila } = await API.get(`/api/campaigns/${campanhaId}/fila?status=${status}`); + el('fila-tabela').replaceChildren(...fila.map((linha) => { + const podeMarcarEstagio = linha.status === 'sent' || linha.status === 'error'; + const stageSelect = h('select', { class: 'fila-estagio-select', disabled: podeMarcarEstagio ? null : 'disabled' }, + h('option', { value: '' }, '—'), + ...STAGE_OPTIONS.map((s) => h('option', { value: s }, s))); + const marcarBtn = h('button', { + class: 'btn-ghost btn-sm', type: 'button', disabled: podeMarcarEstagio ? null : 'disabled', + onclick: async () => { + try { + if (!stageSelect.value) return; + await API.post(`/api/campaigns/leads/${linha.lead_id}/estagio`, { stage: stageSelect.value, campaignId: campanhaId }); + toast('Estagio atualizado'); + if (stageSelect.value === 'demonstracao_agendada') { + const dataAgendamento = prompt('Data/hora do agendamento (AAAA-MM-DD HH:MM):'); + if (dataAgendamento) { + await API.post(`/api/campaigns/leads/${linha.lead_id}/agendamentos`, { kind: 'demonstracao', scheduledAt: dataAgendamento }); + toast('Agendamento registrado'); + } + } + } catch (error) { + toast(`Erro: ${error.message}`); + } + } + }, 'Marcar'); + return h('tr', {}, + h('td', {}, h('input', { type: 'checkbox', class: 'fila-check', value: linha.id })), + h('td', {}, String(linha.lead_id)), + h('td', {}, linha.numero), + h('td', {}, linha.rendered_message), + h('td', {}, linha.status), + h('td', { class: 'row' }, stageSelect, marcarBtn)); + })); + } + + function filaSelecionada() { + return Array.from(document.querySelectorAll('.fila-check:checked')).map((i) => Number(i.value)); + } + + function ligarDetalheCampanha() { + el('btn-fechar-detalhe').addEventListener('click', () => el('campanha-detalhe').classList.add('hidden')); + el('fila-filtro-status').addEventListener('change', () => carregarFila(campanhaAtualId).catch((e) => toast(`Erro: ${e.message}`))); + + el('btn-gerar-fila').addEventListener('click', async () => { + try { + const resultado = await API.post(`/api/campaigns/${campanhaAtualId}/gerar-fila`, {}); + toast(`${resultado.adicionadosNaFila} leads adicionados a fila`); + await carregarFila(campanhaAtualId); + } catch (error) { toast(`Erro: ${error.message}`); } + }); + + el('btn-iniciar-campanha').addEventListener('click', async () => { + try { + await API.post(`/api/campaigns/${campanhaAtualId}/iniciar`, {}); + await carregarDetalhe(campanhaAtualId); + await carregarLista(); + } catch (error) { toast(`Erro: ${error.message}`); } + }); + el('btn-pausar-campanha').addEventListener('click', async () => { + try { + await API.post(`/api/campaigns/${campanhaAtualId}/pausar`, { motivo: 'manual' }); + await carregarDetalhe(campanhaAtualId); + await carregarLista(); + } catch (error) { toast(`Erro: ${error.message}`); } + }); + el('btn-retomar-campanha').addEventListener('click', async () => { + try { + await API.post(`/api/campaigns/${campanhaAtualId}/retomar`, {}); + await carregarDetalhe(campanhaAtualId); + await carregarLista(); + } catch (error) { toast(`Erro: ${error.message}`); } + }); + + el('btn-aprovar-selecionados').addEventListener('click', async () => { + const queueIds = filaSelecionada(); + if (!queueIds.length) return; + try { + const resultado = await API.post(`/api/campaigns/${campanhaAtualId}/fila/aprovar`, { queueIds }); + toast(`${resultado.aprovados} aprovados`); + await carregarFila(campanhaAtualId); + } catch (error) { toast(`Erro: ${error.message}`); } + }); + el('btn-cancelar-selecionados').addEventListener('click', async () => { + const queueIds = filaSelecionada(); + if (!queueIds.length) return; + try { + const resultado = await API.post(`/api/campaigns/${campanhaAtualId}/fila/cancelar`, { queueIds, motivo: 'operador' }); + toast(`${resultado.cancelados} cancelados`); + await carregarFila(campanhaAtualId); + } catch (error) { toast(`Erro: ${error.message}`); } + }); + } + + return { ativar }; +})(); diff --git a/gti-maquina-comercial/public/js/captura.js b/gti-maquina-comercial/public/js/captura.js new file mode 100644 index 0000000..32449d4 --- /dev/null +++ b/gti-maquina-comercial/public/js/captura.js @@ -0,0 +1,405 @@ +/* Aba Captura: wizard UF → cidades → nichos → fontes → rodar, com progresso SSE. */ +const Captura = (() => { + let ufs = []; + let municipios = []; // da UF selecionada + let segments = []; + let sources = []; + const cidadesEscolhidas = new Set(); + const nichosEscolhidos = new Set(); + const cnaesAvulsos = new Map(); // codigo -> descricao + let stopWatcher = null; + + // ---------- carregamento ---------- + async function init() { + const [ufsData, segData, srcData] = await Promise.all([ + API.get('/api/meta/ufs'), + API.get('/api/meta/segments'), + API.get('/api/meta/sources') + ]); + ufs = ufsData.ufs; + segments = segData.segments; + sources = srcData.sources; + + renderUfs(); + renderNichos(); + renderFontes(); + bindEvents(); + await onUfChange(); + await carregarHistorico(); + await retomarJobAtivo(); + } + + function renderUfs() { + const sel = el('sel-uf'); + sel.replaceChildren(...ufs.map((u) => + h('option', { value: u.uf, selected: u.uf === 'BA' ? '' : null }, + `${u.uf} — ${u.nome}${u.extraida ? ' ✓' : ''}`) + )); + } + + function renderNichos(filtro = '') { + const grid = el('nichos-grid'); + const termo = filtro.toLowerCase(); + grid.replaceChildren(...segments + .filter((s) => !termo || s.label.toLowerCase().includes(termo)) + .map((s) => h('label', {}, + h('input', { + type: 'checkbox', value: s.name, + checked: nichosEscolhidos.has(s.name) ? '' : null, + onchange: (e) => { + if (e.target.checked) nichosEscolhidos.add(s.name); + else nichosEscolhidos.delete(s.name); + atualizarContagem(); + } + }), + s.label + ))); + el('nichos-contagem').textContent = `(${segments.length} disponíveis)`; + } + + function atualizarContagem() { + const modo = document.querySelector('input[name="nichos-modo"]:checked').value; + el('nichos-contagem').textContent = modo === 'todos' + ? `(${segments.length} disponíveis)` + : `(${nichosEscolhidos.size} de ${segments.length} selecionados)`; + } + + function renderFontes() { + const box = el('fontes-lista'); + box.replaceChildren(...sources.map((s) => { + const input = h('input', { + type: 'checkbox', value: s.id, + checked: s.id === 'cnpj-base' ? '' : null, + disabled: s.disponivel ? null : '' + }); + return h('label', { class: `fonte${s.disponivel ? '' : ' indisponivel'}` }, + input, + h('div', {}, + h('div', {}, h('strong', {}, s.label), s.recomendada ? ' ⭐' : '', s.gratuita ? '' : ' 💰'), + h('div', { class: 'desc' }, s.descricao + (s.disponivel ? '' : ` — ${s.motivo}`)) + ) + ); + })); + } + + // ---------- UF / extração ---------- + function ufAtual() { + return ufs.find((u) => u.uf === el('sel-uf').value); + } + + async function onUfChange() { + const u = ufAtual(); + const status = el('uf-status'); + + if (u.extraindo) { + status.textContent = '⏳ extraindo base...'; + status.className = 'chip warn'; + } else if (u.extraida) { + status.textContent = '✓ base CNPJ pronta'; + status.className = 'chip ok'; + } else { + status.textContent = 'base CNPJ não extraída'; + status.className = 'chip warn'; + } + el('extracao-aviso').classList.toggle('hidden', u.extraida || u.extraindo); + el('extracao-uf').textContent = `${u.uf} — ${u.nome}`; + + cidadesEscolhidas.clear(); + renderChips(); + const data = await API.get(`/api/meta/municipios?uf=${u.uf}`); + municipios = data.municipios; + } + + async function extrairBase() { + const u = ufAtual(); + try { + const { jobId } = await API.post('/api/extractions', { uf: u.uf }); + toast(`Extração de ${u.uf} iniciada — pode levar até 1 hora`); + acompanharJob(jobId, `Extraindo base CNPJ de ${u.uf}`); + u.extraindo = true; + await onUfChange(); + } catch (error) { + toast(`Erro: ${error.message}`); + } + } + + // ---------- cidades ---------- + function renderChips() { + el('cidades-chips').replaceChildren(...[...cidadesEscolhidas].map((nome) => + h('span', { + class: 'chip', title: 'remover', + onclick: () => { cidadesEscolhidas.delete(nome); renderChips(); } + }, nome) + )); + } + + function buscarCidade(termo) { + const box = el('cidades-sugestoes'); + if (!termo || termo.length < 2) { + box.replaceChildren(); + return; + } + const t = termo.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, ''); + const achados = municipios + .filter((m) => m.nome.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').includes(t)) + .slice(0, 12); + box.replaceChildren(...achados.map((m) => + h('button', { + type: 'button', + onclick: () => { + cidadesEscolhidas.add(m.nome); + renderChips(); + el('busca-cidade').value = ''; + box.replaceChildren(); + } + }, m.nome) + )); + } + + // ---------- CNAE avulso (qualquer atividade da Receita, alem dos 57 nichos) ---------- + function renderCnaeChips() { + el('cnae-chips').replaceChildren(...[...cnaesAvulsos].map(([codigo, descricao]) => + h('span', { + class: 'chip', title: 'remover', + onclick: () => { cnaesAvulsos.delete(codigo); renderCnaeChips(); } + }, descricao) + )); + } + + let buscaCnaeTimer = null; + function buscarCnae(termo) { + clearTimeout(buscaCnaeTimer); + const box = el('cnae-sugestoes'); + if (!termo || termo.trim().length < 2) { + box.replaceChildren(); + return; + } + buscaCnaeTimer = setTimeout(async () => { + const { cnaes } = await API.get(`/api/meta/cnaes?q=${encodeURIComponent(termo)}`); + box.replaceChildren(...cnaes.map((c) => + h('button', { + type: 'button', + onclick: () => { + cnaesAvulsos.set(c.codigo, c.descricao); + renderCnaeChips(); + el('busca-cnae').value = ''; + box.replaceChildren(); + } + }, h('span', {}, c.descricao), h('span', { class: 'cod' }, c.codigo)) + )); + if (!cnaes.length) { + box.replaceChildren(h('div', { class: 'muted' }, 'Nenhuma atividade encontrada')); + } + }, 250); + } + + // ---------- captura ---------- + function montarPayload() { + const fontes = [...document.querySelectorAll('#fontes-lista input:checked')].map((i) => i.value); + const cidadesModo = document.querySelector('input[name="cidades-modo"]:checked').value; + const nichosModo = document.querySelector('input[name="nichos-modo"]:checked').value; + const cnaeIds = [...cnaesAvulsos.keys()].map((codigo) => `cnae:${codigo}`); + + return { + sources: fontes, + uf: el('sel-uf').value, + cities: cidadesModo === 'todas' ? ['all'] : [...cidadesEscolhidas], + segments: [...(nichosModo === 'todos' ? ['all'] : [...nichosEscolhidos]), ...cnaeIds], + limits: { + maxPerQuery: Number(el('inp-max').value) || 10, + queryLimit: Number(el('inp-query-limit').value) || 0 + } + }; + } + + async function iniciarCaptura() { + const payload = montarPayload(); + + if (!payload.sources.length) { + toast('Selecione pelo menos uma fonte de busca'); + return; + } + if (payload.cities.length === 0) { + toast('Escolha pelo menos uma cidade (ou marque "todas")'); + return; + } + if (payload.segments.length === 0) { + toast('Escolha pelo menos um nicho (ou marque "todos")'); + return; + } + + try { + const { jobId } = await API.post('/api/capture/runs', payload); + acompanharJob(jobId, 'Captura em andamento'); + } catch (error) { + if (error.data?.precisaExtracao) { + el('extracao-aviso').classList.remove('hidden'); + toast(error.message); + } else { + toast(`Erro: ${error.message}`); + } + } + } + + // ---------- progresso ---------- + function acompanharJob(jobId, titulo) { + const card = el('progresso-card'); + card.classList.remove('hidden'); + card.dataset.jobId = jobId; + el('progresso-titulo').textContent = titulo; + el('progresso-msg').textContent = 'iniciando...'; + el('progresso-log').textContent = ''; + el('progresso-fill').style.width = '3%'; + el('progresso-stats').replaceChildren(); + el('btn-cancelar').disabled = false; + el('btn-capturar').disabled = true; + + if (stopWatcher) stopWatcher(); + + stopWatcher = API.watchJob(jobId, { + progress(p) { + if (p.message) el('progresso-msg').textContent = p.message; + if (typeof p.pct === 'number') el('progresso-fill').style.width = `${Math.max(3, p.pct)}%`; + if (p.counts) { + el('progresso-fill').style.width = '50%'; + el('progresso-stats').replaceChildren( + mini(p.counts.raw, 'encontrados'), + mini(p.counts.new, 'novos'), + mini(p.counts.dup, 'já capturados') + ); + } + }, + log(l) { + const box = el('progresso-log'); + box.textContent += `${l.message}\n`; + box.scrollTop = box.scrollHeight; + }, + done(d) { + el('progresso-fill').style.width = '100%'; + const s = d.summary || {}; + el('progresso-msg').textContent = s.cancelled + ? 'Cancelado.' + : s.totalRows !== undefined + ? `✅ Base extraída: ${fmt.format(s.totalRows)} empresas (${fmt.format(s.withPhone)} com telefone)` + : `✅ Concluído: ${fmt.format(s.new ?? 0)} leads novos (${fmt.format(s.dup ?? 0)} já capturados antes)`; + finalizar(); + }, + error(e) { + el('progresso-msg').textContent = `❌ Erro: ${e.message}`; + finalizar(); + } + }); + + function mini(valor, rotulo) { + return h('div', { class: 'mini' }, h('b', {}, fmt.format(valor ?? 0)), h('span', {}, rotulo)); + } + + async function finalizar() { + el('btn-cancelar').disabled = true; + el('btn-capturar').disabled = false; + await Promise.all([carregarHistorico(), recarregarUfs()]); + window.Tratamento?.invalidar(); + } + } + + async function recarregarUfs() { + ufs = (await API.get('/api/meta/ufs')).ufs; + const atual = el('sel-uf').value; + renderUfs(); + el('sel-uf').value = atual; + await onUfChange(); + } + + async function cancelarJob() { + const jobId = el('progresso-card').dataset.jobId; + const ativos = (await API.get('/api/jobs/active')).jobs; + const job = ativos.find((j) => j.jobId === jobId); + if (!job) { + toast('Nenhum job ativo para cancelar'); + return; + } + const rota = job.type === 'extracao' + ? `/api/extractions/${job.meta.extractionId}/cancel` + : `/api/capture/runs/${job.meta.runId}/cancel`; + try { + await API.post(rota, {}); + toast('Cancelamento solicitado — aguardando o processo parar...'); + } catch (error) { + toast(`Erro: ${error.message}`); + } + } + + async function retomarJobAtivo() { + const ativos = (await API.get('/api/jobs/active')).jobs; + if (!ativos.length) return; + const job = ativos[0]; + acompanharJob(job.jobId, job.type === 'extracao' + ? `Extraindo base CNPJ de ${job.meta.uf}` + : 'Captura em andamento'); + } + + // ---------- histórico ---------- + async function carregarHistorico() { + const { runs } = await API.get('/api/capture/runs?limit=8'); + el('btn-ver-historico').textContent = runs.length ? `🕓 Histórico de capturas (${runs.length})` : '🕓 Histórico de capturas'; + const box = el('historico-capturas'); + if (!runs.length) { + box.replaceChildren(h('div', { class: 'muted' }, 'Nenhuma captura ainda. Configure ao lado e clique em Iniciar.')); + return; + } + const statusLabel = { + done: '✅ concluída', running: '⏳ rodando', pending: '⏳ na fila', + cancelled: '⚪ cancelada', error: '❌ erro' + }; + box.replaceChildren(...runs.map((r) => h('div', { class: 'item' }, + h('div', {}, + h('div', {}, h('strong', {}, `#${r.id} · ${(r.params.sources || []).join(', ')} · ${r.params.uf || ''}`)), + h('div', { class: 'sub' }, + `${dataBr(r.created_at)} · ${statusLabel[r.status] || r.status}`, + r.error ? ` · ${r.error.slice(0, 80)}` : '') + ), + h('div', { class: 'sub' }, + r.status === 'done' ? `${fmt.format(r.new_count)} novos / ${fmt.format(r.dup_count)} repetidos` : '') + ))); + } + + // ---------- eventos ---------- + function bindEvents() { + el('sel-uf').addEventListener('change', onUfChange); + el('btn-extrair').addEventListener('click', extrairBase); + el('btn-capturar').addEventListener('click', iniciarCaptura); + el('btn-cancelar').addEventListener('click', cancelarJob); + el('btn-ver-historico').addEventListener('click', () => el('modal-historico').showModal()); + el('btn-fechar-historico').addEventListener('click', () => el('modal-historico').close()); + + for (const radio of document.querySelectorAll('input[name="cidades-modo"]')) { + radio.addEventListener('change', () => { + el('cidades-picker').classList.toggle('hidden', radio.value !== 'escolher' || !radio.checked); + }); + } + for (const radio of document.querySelectorAll('input[name="nichos-modo"]')) { + radio.addEventListener('change', () => { + el('nichos-picker').classList.toggle('hidden', radio.value !== 'escolher' || !radio.checked); + atualizarContagem(); + }); + } + + el('busca-cidade').addEventListener('input', (e) => buscarCidade(e.target.value)); + el('busca-nicho').addEventListener('input', (e) => renderNichos(e.target.value)); + el('busca-cnae').addEventListener('input', (e) => buscarCnae(e.target.value)); + el('btn-nichos-todos').addEventListener('click', () => { + for (const input of document.querySelectorAll('#nichos-grid input')) { + input.checked = true; + nichosEscolhidos.add(input.value); + } + atualizarContagem(); + }); + el('btn-nichos-nenhum').addEventListener('click', () => { + nichosEscolhidos.clear(); + renderNichos(el('busca-nicho').value); + atualizarContagem(); + }); + } + + return { init }; +})(); diff --git a/gti-maquina-comercial/public/js/login.js b/gti-maquina-comercial/public/js/login.js new file mode 100644 index 0000000..1dc6db2 --- /dev/null +++ b/gti-maquina-comercial/public/js/login.js @@ -0,0 +1,16 @@ +/* Tela de login: autentica e redireciona para o app. */ +el('form-login').addEventListener('submit', async (event) => { + event.preventDefault(); + const erroBox = el('login-erro'); + erroBox.textContent = ''; + + try { + await API.post('/api/auth/login', { + username: el('login-username').value, + password: el('login-password').value + }); + window.location.href = '/'; + } catch (error) { + erroBox.textContent = error.message; + } +}); diff --git a/gti-maquina-comercial/public/js/tratamento.js b/gti-maquina-comercial/public/js/tratamento.js new file mode 100644 index 0000000..286a381 --- /dev/null +++ b/gti-maquina-comercial/public/js/tratamento.js @@ -0,0 +1,658 @@ +/* Aba Tratamento: dashboard, navegador de leads com filtros e exportações. */ +const Tratamento = (() => { + let page = 1; + let totalPages = 1; + let carregado = false; + let refreshTimer = null; + + const CLASSIFICACAO_LABELS = { + excelente: 'Excelente', bom: 'Bom', medio: 'Médio', baixo: 'Baixo', muito_baixo: 'Muito Baixo' + }; + const PRIORIDADE_LABELS = { + 1: 'P1 — atacar imediatamente', 2: 'P2 — trabalhar durante a semana', + 3: 'P3 — quando não houver prioritários', 4: 'P4 — só em campanhas específicas' + }; + + function estrelas(n) { + const cheias = Math.max(0, Math.min(5, Number(n) || 0)); + return '★'.repeat(cheias) + '☆'.repeat(5 - cheias); + } + + // ---------- filtros ---------- + function filtroAtual() { + const filtro = {}; + for (const input of document.querySelectorAll('#filtros [data-filtro]')) { + const value = input.value.trim(); + if (value) filtro[input.dataset.filtro] = value; + } + return filtro; + } + + function filtroQuery(extra = {}) { + return new URLSearchParams({ ...filtroAtual(), ...extra }).toString(); + } + + // Clique nos cards/barras do dashboard: aplica o filtro somando aos ja + // ativos; clicar de novo no mesmo valor remove so aquele filtro. + function toggleFiltro(inputId, valor) { + const input = el(inputId); + const novo = String(valor ?? ''); + input.value = input.value === novo ? '' : novo; + atualizar(); + } + + function descreverFiltro() { + const f = filtroAtual(); + const partes = []; + if (f.uf) partes.push(`UF ${f.uf}`); + if (f.cidade) partes.push(f.cidade); + if (f.segmento) partes.push(el('f-segmento').selectedOptions[0]?.textContent || f.segmento); + if (f.classificacao) partes.push(CLASSIFICACAO_LABELS[f.classificacao] || f.classificacao); + if (f.prioridade) partes.push(`P${f.prioridade}`); + if (f.produto) partes.push(`produto: ${f.produto}`); + if (f.interesse) partes.push(f.interesse); + if (f.telefone_valido) partes.push(f.telefone_valido === 'sim' ? 'com WhatsApp' : 'sem WhatsApp'); + if (f.website) partes.push(f.website === 'sim' ? 'com website' : 'sem website'); + if (f.email) partes.push(f.email === 'sim' ? 'com e-mail' : 'sem e-mail'); + if (f.exportado) partes.push(f.exportado === 'nao' ? 'ainda não exportados' : 'já exportados'); + if (f.q) partes.push(`busca "${f.q}"`); + return partes.length ? partes.join(' · ') : 'todos os leads'; + } + + // ---------- carregamento ---------- + async function atualizar() { + page = 1; + const [summary, evolutionRes, oportunidadeRes] = await Promise.all([ + API.get(`/api/dashboard/summary?${filtroQuery()}`), + API.get(`/api/dashboard/evolution?dias=14&${filtroQuery()}`), + API.get(`/api/dashboard/oportunidade?${filtroQuery()}`) + ]); + await Promise.all([carregarTiles(summary), carregarRankings(summary), carregarFunil(summary), carregarTabela()]); + carregarEvolucaoDiaria(evolutionRes.evolution); + carregarMaiorOportunidade(oportunidadeRes.oportunidade); + } + + function invalidar() { + carregado = false; + clearTimeout(refreshTimer); + refreshTimer = setTimeout(() => { + if (document.querySelector('#tab-tratamento.active')) ativar(); + }, 800); + } + + async function ativar() { + if (carregado) return; + carregado = true; + await Promise.all([carregarFacets(), atualizar(), carregarExports()]); + } + + async function carregarFacets() { + const facets = await API.get('/api/leads/facets'); + + const fill = (select, valores, prefixo, formatar = (v) => v) => { + const atual = select.value; + const primeira = select.options[0]; + select.replaceChildren(primeira, ...valores.map((item) => + h('option', { value: item.value }, `${formatar(item.value)} (${fmt.format(item.total)})`) + )); + select.value = atual; + }; + + fill(el('f-uf'), facets.ufs); + fill(el('f-interesse'), facets.interesses); + fill(el('f-produto'), facets.produtos); + + const segSelect = el('f-segmento'); + const atual = segSelect.value; + segSelect.replaceChildren(segSelect.options[0], ...facets.segmentos.map((s) => + h('option', { value: s.value }, `${s.value.replace(/_/g, ' ')} (${fmt.format(s.total)})`) + )); + segSelect.value = atual; + + el('dl-cidades').replaceChildren(...facets.cidades.map((c) => h('option', { value: c.value }))); + } + + async function salvarMetaDiaria(valor) { + const meta = Math.max(1, Number(valor) || 100); + await API.put('/api/settings', { meta_diaria: meta }); + const summary = await API.get(`/api/dashboard/summary?${filtroQuery()}`); + carregarTiles(summary); + } + + async function carregarTiles(s) { + const pct = (n) => s.total ? ` (${((n / s.total) * 100).toFixed(1)}%)` : ''; + + el('stat-tiles').replaceChildren( + tile(s.total, 'Leads no filtro', ''), + tile(s.com_whatsapp, 'Com WhatsApp', pct(s.com_whatsapp), 't-good'), + tile(s.p1, '🎯 Potencial Comercial Imediato', `${s.potencial_pct}% da base — atacar agora`, 't-good'), + tile(s.p4, '📦 P4 — campanhas específicas', pct(s.p4)) + ); + + el('stat-tiles').append( + cardCobertura(s), + cardPrioridades(s), + cardMeta(s) + ); + + function tile(valor, rotulo, detalhe, cls = '') { + return h('div', { class: `tile ${cls}` }, + h('div', { class: 'valor' }, fmt.format(valor)), + h('div', { class: 'rotulo' }, rotulo), + h('div', { class: 'detalhe' }, detalhe) + ); + } + + function cardCobertura(s) { + const cidadeAtiva = filtroAtual().cidade; + const titulo = cidadeAtiva ? `Cobertura em ${cidadeAtiva}` : 'Base Comercial — cobertura'; + return h('div', { class: 'tile tile-wide' }, + h('div', { class: 'rotulo' }, titulo), + h('div', { class: 'progress-bar' }, + h('div', { class: 'progress-fill', style: `width:${s.cobertura_pct}%` })), + h('div', { class: 'detalhe' }, + `${s.cobertura_pct}% trabalhada · ${fmt.format(s.exportados)} exportadas · ${fmt.format(s.disponiveis)} disponíveis`) + ); + } + + function cardPrioridades(s) { + const ativa = filtroAtual().prioridade; + const linhas = [ + ['P1', s.p1, s.p1_pct], ['P2', s.p2, s.p2_pct], ['P3', s.p3, s.p3_pct], ['P4', s.p4, s.p4_pct] + ]; + return h('div', { class: 'tile tile-wide' }, + h('div', { class: 'rotulo' }, 'Distribuição por prioridade'), + h('div', { class: 'ranking', style: 'margin-top:6px' }, ...linhas.map(([label, total, p], i) => + h('div', { + class: `rank-row${ativa === String(i + 1) ? ' ativo' : ''}`, + title: `${label}: ${fmt.format(total)} leads — clique para filtrar`, + onclick: () => toggleFiltro('f-prioridade', i + 1) + }, + h('div', { class: 'rank-label' }, label), + h('div', { class: 'rank-track' }, h('div', { class: 'rank-fill', style: `width:${Math.max(1.5, p)}%` })), + h('div', { class: 'rank-value' }, `${p}%`) + ))) + ); + } + + function cardMeta(s) { + return h('div', { class: 'tile tile-wide' }, + h('div', { class: 'row space-between' }, + h('div', { class: 'rotulo' }, '🎯 Meta diária'), + h('input', { + type: 'number', min: '1', value: String(s.meta_diaria), class: 'meta-input', + onchange: (e) => salvarMetaDiaria(e.target.value) + }) + ), + h('div', { class: 'progress-bar' }, + h('div', { class: 'progress-fill', style: `width:${Math.min(100, s.meta_pct)}%` })), + h('div', { class: 'detalhe' }, + `${fmt.format(s.hoje_exportados)} de ${fmt.format(s.meta_diaria)} hoje (${s.meta_pct}%) · ~${fmt.format(s.dias_estimados)} dias úteis restantes no ritmo atual`) + ); + } + } + + async function carregarRankings(summary) { + const cidadeAtiva = filtroAtual().cidade; + el('rank-produtos-titulo').textContent = cidadeAtiva ? `Produtos recomendados em ${cidadeAtiva}` : 'Top produto recomendado'; + + const [cidades, segmentos, produtos] = await Promise.all([ + API.get(`/api/dashboard/rankings?by=cidade&limit=10&${filtroQuery()}`), + API.get(`/api/dashboard/rankings?by=segmento&limit=10&${filtroQuery()}`), + API.get(`/api/dashboard/rankings?by=produto&limit=10&${filtroQuery()}`) + ]); + + const f = filtroAtual(); + const segmentoValuePorLabel = (label) => { + const opts = [...el('f-segmento').options]; + const porValor = opts.find((o) => o.value && o.value.replace(/_/g, ' ').toLowerCase() === label.toLowerCase()); + const porTexto = opts.find((o) => o.textContent.toLowerCase().startsWith(label.toLowerCase())); + return (porValor || porTexto)?.value || ''; + }; + + renderRanking(el('rank-cidades'), cidades.ranking, + (label) => toggleFiltro('f-cidade', label), + null, + (label) => f.cidade === label); + renderRanking(el('rank-segmentos'), segmentos.ranking, + (label) => toggleFiltro('f-segmento', segmentoValuePorLabel(label)), + null, + (label) => !!f.segmento && f.segmento === segmentoValuePorLabel(label)); + renderRanking(el('rank-produtos'), produtos.ranking, + (label) => toggleFiltro('f-produto', label), + summary.total, + (label) => f.produto === label); + + carregarOportunidadesDoDia(produtos.ranking, summary.total); + } + + function carregarOportunidadesDoDia(produtosRanking, totalGeral) { + const top3 = produtosRanking.slice(0, 3); + if (!top3.length) { + el('card-oportunidades-dia').replaceChildren( + h('h2', {}, '🔥 Oportunidades do Dia'), + h('div', { class: 'muted' }, 'Sem dados para este filtro.') + ); + return; + } + const produtoAtivo = filtroAtual().produto; + el('card-oportunidades-dia').replaceChildren( + h('h2', {}, '🔥 Oportunidades do Dia'), + h('div', { class: 'ranking', style: 'margin-top:6px' }, ...top3.map((r) => { + const p = totalGeral ? Math.round((r.total / totalGeral) * 100) : 0; + return h('div', { + class: `rank-row${produtoAtivo === r.label ? ' ativo' : ''}`, + title: `${r.label}: ${fmt.format(r.total)} leads — clique para filtrar`, + onclick: () => toggleFiltro('f-produto', r.label) + }, + h('div', { class: 'rank-label' }, r.label), + h('div', { class: 'rank-track' }, h('div', { class: 'rank-fill', style: `width:${Math.max(1.5, p)}%` })), + h('div', { class: 'rank-value' }, `${p}%`) + ); + })) + ); + } + + function dataBrCurta(diaIso) { + const [, mes, dia] = diaIso.split('-'); + return `${dia}/${mes}`; + } + + function carregarEvolucaoDiaria(evolution) { + if (!evolution.length) { + el('card-evolucao-diaria').replaceChildren( + h('h2', {}, '📈 Evolução Diária'), + h('div', { class: 'muted' }, 'Nenhuma exportação registrada ainda.') + ); + return; + } + const max = Math.max(...evolution.map((d) => d.total)); + el('card-evolucao-diaria').replaceChildren( + h('h2', {}, '📈 Evolução Diária'), + h('div', { class: 'muted', style: 'margin:2px 0 8px' }, 'Exportações por dia — única atividade com data registrada hoje.'), + h('div', { class: 'ranking' }, ...evolution.map((d) => h('div', { class: 'rank-row nao-clicavel' }, + h('div', { class: 'rank-label' }, dataBrCurta(d.dia)), + h('div', { class: 'rank-track' }, h('div', { class: 'rank-fill', style: `width:${Math.max(1.5, (d.total / max) * 100)}%` })), + h('div', { class: 'rank-value' }, fmt.format(d.total)) + ))) + ); + } + + function carregarMaiorOportunidade(oportunidade) { + if (!oportunidade) { + el('card-maior-oportunidade').replaceChildren( + h('h2', {}, '🏆 Maior Oportunidade'), + h('div', { class: 'muted' }, 'Sem dados suficientes para este filtro.') + ); + return; + } + const f = filtroAtual(); + const jaAplicado = f.cidade === oportunidade.cidade && f.produto === oportunidade.produto; + el('card-maior-oportunidade').replaceChildren( + h('h2', {}, '🏆 Maior Oportunidade'), + h('div', { + class: `row space-between oportunidade-clicavel${jaAplicado ? ' ativo' : ''}`, + style: 'margin-top:6px', + title: jaAplicado ? 'Clique para remover o filtro cidade + produto' : 'Clique para filtrar por esta cidade + produto', + onclick: () => { + el('f-cidade').value = jaAplicado ? '' : oportunidade.cidade; + el('f-produto').value = jaAplicado ? '' : oportunidade.produto; + atualizar(); + } + }, + h('div', {}, h('div', { class: 'rotulo' }, 'Cidade'), h('div', { class: 'valor', style: 'font-size:20px' }, oportunidade.cidade)), + h('div', {}, h('div', { class: 'rotulo' }, 'Produto'), h('div', { class: 'valor', style: 'font-size:20px' }, oportunidade.produto)), + h('div', {}, h('div', { class: 'rotulo' }, 'Empresas'), h('div', { class: 'valor', style: 'font-size:20px' }, fmt.format(oportunidade.total))) + ) + ); + } + + function renderRanking(box, ranking, onClick, totalGeral, isAtivo = () => false) { + if (!ranking.length) { + box.replaceChildren(h('div', { class: 'muted' }, 'Sem dados para este filtro.')); + return; + } + const max = ranking[0].total; + box.replaceChildren(...ranking.map((r) => { + const pctGeral = totalGeral ? ` · ${Math.round((r.total / totalGeral) * 100)}%` : ''; + const ativo = isAtivo(r.label); + return h('div', { + class: `rank-row${ativo ? ' ativo' : ''}`, + title: `${r.label}: ${fmt.format(r.total)} leads (${fmt.format(r.p1)} prioridade 1) — ${ativo ? 'clique para remover o filtro' : 'clique para filtrar'}`, + onclick: () => onClick(r.label) + }, + h('div', { class: 'rank-label' }, r.label), + h('div', { class: 'rank-track' }, + h('div', { class: 'rank-fill', style: `width:${Math.max(1.5, (r.total / max) * 100)}%` })), + h('div', { class: 'rank-value' }, `${fmt.format(r.total)}${pctGeral}`) + ); + })); + } + + const FUNIL_ETAPAS = [ + { key: 'total', label: 'Leads capturados' }, + { key: 'disponiveis', label: 'Leads disponíveis' }, + { key: 'exportados', label: 'Leads exportados' }, + { key: null, label: 'Empresas contatadas' }, + { key: null, label: 'Empresas interessadas' }, + { key: null, label: 'Propostas enviadas' }, + { key: null, label: 'Clientes convertidos' } + ]; + + function carregarFunil(s) { + const base = s.total || 1; + el('card-funil').replaceChildren( + h('h2', {}, 'Evolução Comercial'), + h('div', { class: 'funil' }, ...FUNIL_ETAPAS.map((etapa) => { + const pendente = etapa.key === null; + const valor = pendente ? null : s[etapa.key]; + const largura = pendente ? 0 : Math.max(1.5, (valor / base) * 100); + return h('div', { class: `funil-etapa${pendente ? ' pendente' : ''}`, + title: pendente ? 'Aguardando integração com CRM' : '' }, + h('div', { class: 'funil-label' }, etapa.label), + h('div', { class: 'funil-barra' }, h('div', { class: 'funil-fill', style: `width:${largura}%` })), + h('div', { class: 'funil-valor' }, pendente ? '—' : fmt.format(valor)) + ); + })) + ); + } + + async function carregarTabela() { + const data = await API.get(`/api/leads?${filtroQuery({ page, pageSize: 50 })}`); + totalPages = Math.max(1, Math.ceil(data.total / data.pageSize)); + el('leads-total').textContent = `— ${fmt.format(data.total)} encontrados`; + el('pg-info').textContent = `página ${data.page} de ${fmt.format(totalPages)}`; + el('pg-prev').disabled = page <= 1; + el('pg-next').disabled = page >= totalPages; + + const tbody = document.querySelector('#tabela-leads tbody'); + if (!data.rows.length) { + tbody.replaceChildren(h('tr', {}, h('td', { colspan: '10', class: 'muted' }, 'Nenhum lead com este filtro.'))); + return; + } + + const abrirWhatsAppLead = async (event, lead) => { + event.stopPropagation(); + if (!lead.whatsapp) { + toast('Este lead não tem WhatsApp cadastrado'); + return; + } + let leadCompleto = lead; + try { + leadCompleto = await API.get(`/api/leads/${lead.id}`); + } catch { + leadCompleto = lead; + } + const settingsButton = el('btn-settings'); + settingsButton.click(); + setTimeout(() => { + window.GTISelecionarLeadManual?.(leadCompleto); + el('manual-numero').value = leadCompleto.whatsapp || lead.whatsapp; + el('manual-canal-naooficial').checked = true; + el('manual-canal-naooficial').dispatchEvent(new Event('change')); + el('manual-mensagem').value = `Olá ${leadCompleto.empresa || lead.empresa}! Somos do Grupo GTI. Podemos conversar?`; + el('manual-status').textContent = ''; + }, 250); + }; + + tbody.replaceChildren(...data.rows.map((lead) => h('tr', { onclick: () => abrirLead(lead.id) }, + h('td', { class: 'acao-col' }, h('button', { + class: 'whatsapp-action', + title: 'Enviar WhatsApp para este lead', + onclick: (event) => abrirWhatsAppLead(event, lead) + }, '☎')), + h('td', { class: 'empresa-cell', title: lead.legal_name || lead.empresa }, lead.empresa), + h('td', {}, lead.whatsapp || '—'), + h('td', {}, lead.cidade), + h('td', {}, lead.segmento_label || lead.segmento), + h('td', {}, h('span', { class: `badge prioridade-${lead.prioridade}` }, `P${lead.prioridade}`)), + h('td', { class: 'num' }, String(lead.score)), + h('td', {}, h('span', { class: `badge ${lead.classificacao}` }, CLASSIFICACAO_LABELS[lead.classificacao] || lead.classificacao)), + h('td', { class: 'produto-cell' }, produtoCell(lead.produto_principal, lead.afinidade)), + h('td', {}, lead.exported_at ? '📤 sim' : '—') + ))); + } + + const AFINIDADE_TABELA_LABELS = { alta: 'Alta aderência', media: 'Média aderência', baixa: 'Baixa aderência' }; + + function produtoCell(produto, afinidade) { + if (!produto) return '—'; + const label = AFINIDADE_TABELA_LABELS[afinidade]; + return label ? `${produto} · ${label}` : produto; + } + + const OBSERVACAO_POR_PRIORIDADE = { + 1: 'Empresa apta para abordagem imediata.', + 2: 'Aguardar a janela de trabalho desta semana.', + 3: 'Abordar quando não houver leads de prioridade mais alta.', + 4: 'Reservar para campanhas específicas — baixa prioridade comercial.' + }; + const AFINIDADE_LABELS = { alta: 'nicho de alta aderência', media: 'nicho de média aderência', baixa: 'nicho de baixa aderência' }; + + function montarResumoComercial(lead) { + const potencial = Math.max(1, Math.min(5, Math.round((lead.score_comercial || 0) / 20))); + const pontosFortes = []; + if (lead.telefone_valido) pontosFortes.push('WhatsApp válido'); + if (lead.cnpj_valido) pontosFortes.push('CNPJ válido'); + if (lead.website) pontosFortes.push('Website'); + if (lead.email) pontosFortes.push('E-mail cadastrado'); + if (lead.afinidade) pontosFortes.push(AFINIDADE_LABELS[lead.afinidade] || 'nicho identificado'); + + return h('div', { class: 'resumo-comercial' }, + h('h3', {}, 'Resumo comercial'), + h('div', { class: 'row space-between' }, + h('span', { class: 'estrelas', title: `Potencial comercial: ${potencial}/5` }, estrelas(potencial)), + h('span', { class: `badge prioridade-${lead.prioridade}` }, PRIORIDADE_LABELS[lead.prioridade] || `P${lead.prioridade}`) + ), + lead.produtos && lead.produtos.length + ? h('div', {}, h('div', { class: 'muted', style: 'margin:8px 0 4px' }, 'Produtos recomendados'), + ...lead.produtos.map((p) => h('div', {}, h('span', { class: 'estrelas' }, estrelas(p.estrelas)), ` ${p.nome}`))) + : h('div', { class: 'muted', style: 'margin-top:8px' }, 'Nenhum produto sugerido para este nicho.'), + pontosFortes.length + ? h('ul', { class: 'pontos-fortes', style: 'margin-top:10px' }, ...pontosFortes.map((p) => h('li', {}, `✓ ${p}`))) + : null, + h('p', { class: 'muted', style: 'margin:10px 0 0' }, OBSERVACAO_POR_PRIORIDADE[lead.prioridade] || '') + ); + } + + function montarScoreBreakdown(lead) { + const nichoPontos = lead.afinidade === 'alta' ? '+40' : lead.afinidade === 'baixa' ? '+20' : '+30'; + const qualidade = [ + [lead.telefone_valido, 'WhatsApp válido', '+30'], + [lead.cnpj_valido, 'CNPJ válido', '+20'], + [lead.empresa_valida, 'Empresa válida', '+20'], + [!!lead.cidade, 'Cidade identificada', '+5'], + [!!lead.segmento, 'Nicho identificado', '+10'], + [!!lead.cnae, 'CNAE identificado', '+10'], + [!!lead.website, 'Website informado', '+5'] + ]; + const comercial = [ + [true, `Nicho ${AFINIDADE_TABELA_LABELS[lead.afinidade] || 'de aderência não identificada'}`, nichoPontos], + [!!lead.website, 'Website informado', '+15'], + [!!lead.email, 'E-mail informado', '+10'], + [lead.telefone_valido, 'WhatsApp válido', '+15'], + [lead.cnpj_valido, 'CNPJ válido', '+20'] + ]; + + const bloco = (itens, titulo, valorTotal) => h('div', {}, + h('div', { class: 'row space-between', style: 'margin-top:10px' }, + h('strong', {}, titulo), h('span', {}, String(valorTotal))), + h('ul', { class: 'score-breakdown' }, ...itens.map(([ok, label, pontos]) => + h('li', { class: ok ? 'ok' : 'nok' }, `${ok ? '✓' : '✗'} ${label} (${pontos})`))) + ); + + return h('div', { class: 'score-breakdown-wrap' }, + bloco(qualidade, 'Score Qualidade', lead.score_qualidade), + bloco(comercial, 'Score Comercial', lead.score_comercial) + ); + } + + async function abrirLead(id) { + const lead = await API.get(`/api/leads/${id}`); + const linha = (dt, dd) => [h('dt', {}, dt), h('dd', {}, dd || '—')]; + el('lead-detalhe').replaceChildren( + h('h2', {}, lead.empresa), + montarResumoComercial(lead), + montarScoreBreakdown(lead), + h('dl', { class: 'detalhe-grid', style: 'margin-top:14px' }, + linha('Razão social', lead.legal_name), + linha('WhatsApp', lead.whatsapp), + linha('Cidade/UF', `${lead.cidade}/${lead.uf}`), + linha('Endereço', lead.endereco), + linha('Nicho', lead.segmento_label || lead.segmento), + linha('Interesse', lead.interesse), + linha('CNPJ', lead.cnpj), + linha('CNAE', lead.cnae), + linha('Score geral', `${lead.score} (${CLASSIFICACAO_LABELS[lead.classificacao] || lead.classificacao})`), + linha('Dores', (lead.pains || []).join(', ')), + linha('Origem', lead.origem), + linha('Capturado em', dataBr(lead.captured_at || lead.created_at)), + linha('Exportado em', lead.exported_at ? dataBr(lead.exported_at) : 'ainda não'), + linha('Exportações', lead.exports.length + ? lead.exports.map((e) => `#${e.id} ${e.tipo}`).join(', ') + : 'nenhuma') + ), + h('div', { class: 'row space-between', style: 'margin-top:14px' }, + h('button', { class: 'btn-ghost', onclick: () => el('modal-lead').close() }, 'Fechar') + ) + ); + el('modal-lead').showModal(); + } + + // ---------- exportação ---------- + const EXPORT_ATALHOS = [ + { label: 'Só P1', set: () => { el('f-prioridade').value = '1'; } }, + { label: 'Só ERP', set: () => { el('f-produto').value = 'ERP'; } }, + { label: 'Só G-Chat', set: () => { el('f-produto').value = 'G-Chat'; } }, + { label: 'Só Sistema de Ponto', set: () => { el('f-produto').value = 'Sistema de Ponto'; } }, + { label: 'Não exportados', set: () => { el('f-exportado').value = 'nao'; } } + ]; + + async function atualizarRecapExport() { + const { total } = await API.get(`/api/dashboard/summary?${filtroQuery()}`); + el('export-recap').textContent = `${fmt.format(total)} leads · ${descreverFiltro()}`; + return total; + } + + function renderAtalhosExport() { + el('export-atalhos').replaceChildren(...EXPORT_ATALHOS.map((atalho) => + h('button', { + type: 'button', class: 'chip', onclick: async () => { + atalho.set(); + await atualizar(); + await atualizarRecapExport(); + } + }, atalho.label))); + } + + async function abrirModalExport() { + const total = await atualizarRecapExport(); + if (!total) { + toast('Nenhum lead com este filtro — ajuste os filtros antes de exportar'); + return; + } + renderAtalhosExport(); + + const { settings } = await API.get('/api/settings'); + el('zp-pipeline').value = settings['zpro.pipelineId'] || 1; + el('zp-stage').value = settings['zpro.stageId'] || 1; + el('zp-responsible').value = settings['zpro.responsibleId'] || 15; + + el('modal-export').showModal(); + } + + async function confirmarExport(event) { + event.preventDefault(); + const tipo = document.querySelector('input[name="export-tipo"]:checked').value; + + const body = { + tipo, + filtro: filtroAtual(), + params: { + marcarExportado: el('export-marcar').checked, + pipelineId: Number(el('zp-pipeline').value) || undefined, + stageId: Number(el('zp-stage').value) || undefined, + responsibleId: Number(el('zp-responsible').value) || undefined, + mensagem: tipo === 'zpro-bulk' ? el('export-mensagem').value : undefined + } + }; + + try { + const { jobId, totalEstimado } = await API.post('/api/exports', body); + el('modal-export').close(); + toast(`Gerando arquivos para ${fmt.format(totalEstimado)} leads...`); + + API.watchJob(jobId, { + done() { + toast('✅ Exportação pronta! Baixe os arquivos na lista abaixo.'); + carregarExports(); + atualizar(); + }, + error(e) { + toast(`❌ Erro na exportação: ${e.message}`); + carregarExports(); + } + }); + } catch (error) { + toast(`Erro: ${error.message}`); + } + } + + async function carregarExports() { + const { exports } = await API.get('/api/exports?limit=12'); + const box = el('historico-exports'); + if (!exports.length) { + box.replaceChildren(h('div', { class: 'muted' }, 'Nenhuma exportação gerada ainda.')); + return; + } + + const tipoLabel = { + excel: '📊 Planilha', 'zpro-oportunidades': '🗂️ Z-PRO Oportunidades', + 'zpro-bulk': '📨 Z-PRO Disparo em massa', legado: '📦 Importação antiga (Z-PRO)' + }; + + box.replaceChildren(...exports.map((e) => h('div', { class: 'item' }, + h('div', {}, + h('div', {}, h('strong', {}, `#${e.id} · ${tipoLabel[e.tipo] || e.tipo}`), + ` · ${fmt.format(e.total_leads)} leads`), + h('div', { class: 'sub' }, `${dataBr(e.created_at)} · ${e.status}${e.error ? ` · ${e.error.slice(0, 60)}` : ''}`) + ), + h('div', { class: 'row' }, ...(e.status === 'done' && e.tipo !== 'legado' + ? e.arquivos.map((nome) => + h('a', { class: 'btn-ghost', href: `/api/exports/${e.id}/download?arquivo=${encodeURIComponent(nome)}` }, + `⬇ ${nome.split('.').pop().toUpperCase()}`)) + : [])) + ))); + } + + // ---------- eventos ---------- + function bindEvents() { + let debounce = null; + for (const input of document.querySelectorAll('#filtros [data-filtro]')) { + input.addEventListener(input.tagName === 'SELECT' ? 'change' : 'input', () => { + clearTimeout(debounce); + debounce = setTimeout(atualizar, input.tagName === 'SELECT' ? 0 : 350); + }); + } + el('btn-limpar-filtros').addEventListener('click', () => { + for (const input of document.querySelectorAll('#filtros [data-filtro]')) input.value = ''; + atualizar(); + }); + el('pg-prev').addEventListener('click', () => { page -= 1; carregarTabela(); }); + el('pg-next').addEventListener('click', () => { page += 1; carregarTabela(); }); + el('btn-exportar').addEventListener('click', abrirModalExport); + el('form-export').addEventListener('submit', confirmarExport); + + for (const radio of document.querySelectorAll('input[name="export-tipo"]')) { + radio.addEventListener('change', () => { + el('export-mensagem-wrap').classList.toggle('hidden', radio.value !== 'zpro-bulk'); + el('export-zpro-params').classList.toggle('hidden', radio.value === 'excel'); + }); + } + el('export-zpro-params').classList.add('hidden'); + } + + return { ativar, invalidar, bindEvents }; +})(); + +window.Tratamento = Tratamento; diff --git a/gti-maquina-comercial/public/login.html b/gti-maquina-comercial/public/login.html new file mode 100644 index 0000000..5bb5515 --- /dev/null +++ b/gti-maquina-comercial/public/login.html @@ -0,0 +1,35 @@ + + + + + +GTI Máquina Comercial — Login + + + + + +
+ +
+ + + + + + diff --git a/gti-maquina-comercial/scripts/baixar-base-rfb.sh b/gti-maquina-comercial/scripts/baixar-base-rfb.sh new file mode 100644 index 0000000..8f20595 --- /dev/null +++ b/gti-maquina-comercial/scripts/baixar-base-rfb.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Baixa a base completa de CNPJ da Receita Federal (extrato mais recente) a partir do +# espelho publico da Casa dos Dados (mesma base oficial, servida via Cloudflare). +# Uso: bash scripts/baixar-base-rfb.sh +set -e + +BASE_URL="https://dados-abertos-rf-cnpj.casadosdados.com.br/arquivos/2026-06-14" +DEST_DIR="prospeccao/cnpj/rfb-raw" +mkdir -p "$DEST_DIR" + +FILES=( + Cnaes.zip Motivos.zip Municipios.zip Naturezas.zip Paises.zip Qualificacoes.zip Simples.zip + Empresas0.zip Empresas1.zip Empresas2.zip Empresas3.zip Empresas4.zip + Empresas5.zip Empresas6.zip Empresas7.zip Empresas8.zip Empresas9.zip + Estabelecimentos0.zip Estabelecimentos1.zip Estabelecimentos2.zip Estabelecimentos3.zip Estabelecimentos4.zip + Estabelecimentos5.zip Estabelecimentos6.zip Estabelecimentos7.zip Estabelecimentos8.zip Estabelecimentos9.zip + Socios0.zip Socios1.zip Socios2.zip Socios3.zip Socios4.zip + Socios5.zip Socios6.zip Socios7.zip Socios8.zip Socios9.zip +) + +TOTAL=${#FILES[@]} +COUNT=0 + +for FILE in "${FILES[@]}"; do + COUNT=$((COUNT+1)) + DEST="$DEST_DIR/$FILE" + + if [ -f "$DEST" ]; then + EXPECTED=$(curl -sI --max-time 20 "$BASE_URL/$FILE" | grep -i content-length | tr -d '\r' | awk '{print $2}') + ACTUAL=$(stat -c%s "$DEST" 2>/dev/null || stat -f%z "$DEST" 2>/dev/null) + if [ -n "$EXPECTED" ] && [ "$EXPECTED" == "$ACTUAL" ]; then + echo "[$COUNT/$TOTAL] $FILE ja existe e esta completo ($ACTUAL bytes). Pulando." + continue + fi + fi + + echo "[$COUNT/$TOTAL] baixando $FILE ..." + curl -sS --retry 3 --retry-delay 5 --max-time 1800 -o "$DEST" "$BASE_URL/$FILE" + SIZE=$(stat -c%s "$DEST" 2>/dev/null || stat -f%z "$DEST" 2>/dev/null) + echo "[$COUNT/$TOTAL] $FILE concluido ($SIZE bytes)" +done + +echo "download completo." +du -sh "$DEST_DIR" diff --git a/gti-maquina-comercial/scripts/build-launcher.ps1 b/gti-maquina-comercial/scripts/build-launcher.ps1 new file mode 100644 index 0000000..9093d00 --- /dev/null +++ b/gti-maquina-comercial/scripts/build-launcher.ps1 @@ -0,0 +1,22 @@ +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent $PSScriptRoot +$target = Join-Path $root "GTI-Comercial.exe" +$source = Join-Path $root "launcher\Program.cs" +$sdk = & dotnet --list-sdks 2>$null + +if ($sdk) { + $project = Join-Path $root "launcher\GTI-Comercial.csproj" + $publish = Join-Path $root "launcher\publish" + dotnet publish $project -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true -o $publish + $exe = Join-Path $publish "GTI-Comercial.exe" + Copy-Item -LiteralPath $exe -Destination $target -Force +} else { + $csc = "C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe" + if (-not (Test-Path -LiteralPath $csc)) { + throw "Compilador C# nao encontrado: $csc" + } + & $csc /nologo /target:exe /out:$target $source +} + +Write-Host "Executavel gerado em: $target" diff --git a/gti-maquina-comercial/scripts/capturar-leads.mjs b/gti-maquina-comercial/scripts/capturar-leads.mjs new file mode 100644 index 0000000..d567cc6 --- /dev/null +++ b/gti-maquina-comercial/scripts/capturar-leads.mjs @@ -0,0 +1,113 @@ +import { loadDotEnv } from '../src/env.mjs'; +import { openDb, ensureSeeds } from '../src/db.mjs'; +import { runCapture, createCaptureRun } from '../src/capture/runner.mjs'; +import { describeSources } from '../src/sources/index.mjs'; + +// CLI de captura (uso avancado / testes). A interface principal e o app: +// node scripts/servidor.mjs (ou o GTI-Comercial.exe) +// +// Exemplos: +// node scripts/capturar-leads.mjs --sources=mock +// node scripts/capturar-leads.mjs --sources=cnpj-base --uf=BA --cities="Santo Antonio de Jesus" +// node scripts/capturar-leads.mjs --sources=cnpj-base,overpass --uf=BA --cities=all --segments=all + +await loadDotEnv(); + +const args = new Map( + process.argv.slice(2).map((arg) => { + const [key, value = 'true'] = arg.replace(/^--/, '').split('='); + return [key, value]; + }) +); + +function usage() { + const fontes = describeSources().map((s) => ` ${s.id} — ${s.label}${s.disponivel ? '' : ` (indisponivel: ${s.motivo})`}`); + return [ + 'Uso: node scripts/capturar-leads.mjs --sources= [opcoes]', + '', + 'Opcoes:', + ' --sources=cnpj-base,overpass fontes separadas por virgula (ou "mock")', + ' --uf=BA estado alvo', + ' --cities=all | "A,B,C" cidades (all = todas as cidades da UF na base)', + ' --segments=all | "a,b,c" nichos pelo nome (all = todos os 57)', + ' --segment-limit=N usa so os N primeiros nichos', + ' --city-limit=N usa so as N primeiras cidades', + ' --max=10 max de resultados por consulta (fontes de API)', + ' --query-limit=0 max total de consultas (0 = sem limite)', + '', + 'Fontes:', + ...fontes + ].join('\n'); +} + +async function main() { + if (args.has('help')) { + console.log(usage()); + return; + } + + const db = openDb(); + await ensureSeeds(db, { log: (m) => console.log(`[captura] ${m}`) }); + + const uf = (args.get('uf') || 'BA').toUpperCase(); + const sources = (args.get('sources') || 'mock').split(',').map((s) => s.trim()).filter(Boolean); + + let segments = db.prepare('SELECT * FROM segments WHERE active = 1 ORDER BY id').all().map((row) => ({ + name: row.name, + label: row.label, + interest: row.interest, + queries: JSON.parse(row.queries_json), + cnaeCodes: JSON.parse(row.cnae_codes_json), + overpassTags: JSON.parse(row.overpass_tags_json), + pains: JSON.parse(row.pains_json) + })); + + const segmentsArg = args.get('segments'); + if (segmentsArg && segmentsArg !== 'all') { + const wanted = new Set(segmentsArg.split(',').map((s) => s.trim())); + segments = segments.filter((s) => wanted.has(s.name)); + } + const segmentLimit = Number(args.get('segment-limit') || 0); + if (segmentLimit > 0) segments = segments.slice(0, segmentLimit); + + let cities; + const citiesArg = args.get('cities') || 'all'; + if (citiesArg === 'all') { + cities = db.prepare('SELECT nome FROM municipios WHERE uf = ? ORDER BY nome').all(uf).map((r) => r.nome); + } else { + cities = citiesArg.split(',').map((c) => c.trim()).filter(Boolean); + } + const cityLimit = Number(args.get('city-limit') || 0); + if (cityLimit > 0) cities = cities.slice(0, cityLimit); + + const params = { + sources, + uf, + cities, + segments, + limits: { + maxPerQuery: Number(args.get('max') || 10), + queryLimit: Number(args.get('query-limit') || 0), + googlePages: Number(args.get('google-pages') || 3) + } + }; + + console.log(`[captura] fontes: ${sources.join(', ')} | UF: ${uf} | cidades: ${cities.length} | nichos: ${segments.length}`); + + const runId = createCaptureRun(db, { ...params, segments: segments.map((s) => s.name) }); + const summary = await runCapture({ + db, + runId, + params, + emit: (p) => console.log(`[captura] ${p.phase}: ${p.message || ''}`), + log: (m) => console.log(`[captura] ${m}`) + }); + + console.log(`[captura] resultado: ${summary.raw} brutos, ${summary.new} novos, ${summary.dup} duplicados (${summary.status})`); + db.close(); +} + +main().catch((error) => { + console.error(`[captura] erro: ${error.stack || error.message}`); + process.exit(1); +}); diff --git a/gti-maquina-comercial/scripts/enviar-outreach.mjs b/gti-maquina-comercial/scripts/enviar-outreach.mjs new file mode 100644 index 0000000..dfb64cf --- /dev/null +++ b/gti-maquina-comercial/scripts/enviar-outreach.mjs @@ -0,0 +1,24 @@ +import { loadDotEnv } from '../src/env.mjs'; +import { openDb, ensureSeeds } from '../src/db.mjs'; +import { runOutreachBatch } from '../src/outreach/run.mjs'; + +// Dispara um lote manual da automacao de envio WhatsApp (Z-PRO), fora do +// ciclo automatico de 20 min do servidor. Util pra testar ou forcar um lote +// extra sem reiniciar o servidor: +// node scripts/enviar-outreach.mjs (ou npm run outreach) +// +// Respeita as mesmas regras do ciclo automatico (outreach.ativo, horario +// comercial, limite diario) — nao manda nada "escondido" do que a tela +// de Configuracoes mostra. + +await loadDotEnv(); + +const log = (message) => console.log(`[outreach] ${message}`); + +const db = openDb(); +await ensureSeeds(db, { log }); + +const resumo = await runOutreachBatch({ db, log }); +console.log(JSON.stringify(resumo, null, 2)); + +db.close(); diff --git a/gti-maquina-comercial/scripts/extrair-base-cnpj.mjs b/gti-maquina-comercial/scripts/extrair-base-cnpj.mjs new file mode 100644 index 0000000..b8866f7 --- /dev/null +++ b/gti-maquina-comercial/scripts/extrair-base-cnpj.mjs @@ -0,0 +1,76 @@ +import path from 'node:path'; +import { loadDotEnv, paths } from '../src/env.mjs'; +import { openDb, ensureSeeds } from '../src/db.mjs'; +import { extractUf } from '../src/extract/rfb-extractor.mjs'; + +// Extrai a base CNPJ de uma UF inteira a partir dos zips da Receita Federal: +// node scripts/extrair-base-cnpj.mjs --uf=BA +// Pre-requisito: zips baixados em prospeccao/cnpj/rfb-raw (scripts/baixar-base-rfb.sh). + +await loadDotEnv(); + +const args = new Map( + process.argv.slice(2).map((arg) => { + const [key, value = 'true'] = arg.replace(/^--/, '').split('='); + return [key, value]; + }) +); + +const uf = (args.get('uf') || '').toUpperCase(); +if (!/^[A-Z]{2}$/.test(uf)) { + console.error('Uso: node scripts/extrair-base-cnpj.mjs --uf=BA'); + process.exit(1); +} + +const rawDir = args.get('raw-dir') || paths.rfbRaw; +const outCsv = path.join(paths.cnpjDir, `estabelecimentos-${uf}.csv`); +const checkpointPath = path.join(paths.cnpjDir, `checkpoint-estabelecimentos-${uf}.ndjson`); + +async function main() { + const db = openDb(); + await ensureSeeds(db, { log: (m) => console.log(`[extrair] ${m}`) }); + + const inserted = db.prepare( + "INSERT INTO extractions (uf, status, started_at) VALUES (?, 'running', datetime('now'))" + ).run(uf); + const extractionId = Number(inserted.lastInsertRowid); + db.prepare('UPDATE extractions SET pid = ? WHERE id = ?').run(process.pid, extractionId); + + try { + const result = await extractUf({ + uf, + rawDir, + outCsv, + checkpointPath, + db, + log: (m) => console.log(`[extrair] ${m}`), + onProgress: (p) => { + console.log(`[extrair] ${p.pct}% ${p.phase}: ${p.message}`); + db.prepare('UPDATE extractions SET progress_json = ? WHERE id = ?') + .run(JSON.stringify(p), extractionId); + } + }); + + if (result.cancelled) { + db.prepare("UPDATE extractions SET status = 'cancelled', finished_at = datetime('now') WHERE id = ?") + .run(extractionId); + } else { + db.prepare( + `UPDATE extractions SET status = 'done', csv_path = ?, total_rows = ?, with_phone = ?, + municipios_count = ?, finished_at = datetime('now') WHERE id = ?` + ).run(result.csvPath, result.totalRows, result.withPhone, result.municipiosCount, extractionId); + } + } catch (error) { + db.prepare( + "UPDATE extractions SET status = 'error', error = ?, finished_at = datetime('now') WHERE id = ?" + ).run(String(error.message || error), extractionId); + throw error; + } finally { + db.close(); + } +} + +main().catch((error) => { + console.error(`[extrair] erro: ${error.stack || error.message}`); + process.exit(1); +}); diff --git a/gti-maquina-comercial/scripts/recalcular-scores.mjs b/gti-maquina-comercial/scripts/recalcular-scores.mjs new file mode 100644 index 0000000..e533142 --- /dev/null +++ b/gti-maquina-comercial/scripts/recalcular-scores.mjs @@ -0,0 +1,85 @@ +import { loadDotEnv } from '../src/env.mjs'; +import { openDb, transaction } from '../src/db.mjs'; +import { scoreQualidade, scoreComercial, scoreGeral, classificarScoreGeral } from '../src/scoring.mjs'; + +// Recalcula score_qualidade/score_comercial/score/classificacao/prioridade/ +// produto_principal de TODOS os leads a partir dos dados ja gravados + da +// afinidade/produtos configurados por nicho (tabela segments). Nao e um +// script de uso unico: roda de novo (`npm run recalc-scores`) sempre que o +// catalogo de afinidade/produtos em prospeccao/config.json mudar. +// +// Roda com o servidor parado (evita concorrencia de escrita no WAL). + +await loadDotEnv(); + +const BATCH_SIZE = 2000; + +function produtoPrincipalDe(produtos) { + return produtos.length ? [...produtos].sort((a, b) => b.estrelas - a.estrelas)[0].nome : ''; +} + +async function main() { + const db = openDb(); + + const segmentos = new Map( + db.prepare('SELECT name, affinity, products_json FROM segments').all() + .map((row) => [row.name, { affinity: row.affinity, products: JSON.parse(row.products_json) }]) + ); + + const update = db.prepare(` + UPDATE leads SET + score = ?, classificacao = ?, motivo_classificacao = ?, + score_qualidade = ?, score_comercial = ?, prioridade = ?, + produto_principal = ?, produtos_json = ?, updated_at = datetime('now') + WHERE id = ? + `); + + const total = db.prepare('SELECT COUNT(*) AS t FROM leads').get().t; + const porPrioridade = { 1: 0, 2: 0, 3: 0, 4: 0 }; + let processed = 0; + let lastId = 0; + + const page = db.prepare('SELECT * FROM leads WHERE id > ? ORDER BY id LIMIT ?'); + + while (true) { + const rows = page.all(lastId, BATCH_SIZE); + if (!rows.length) break; + + transaction(db, () => { + for (const lead of rows) { + const info = segmentos.get(lead.segmento) || { affinity: 'media', products: [] }; + const qualidade = scoreQualidade({ + whatsapp: lead.whatsapp, cnpj: lead.cnpj, empresa: lead.empresa, + cidade: lead.cidade, segmento: lead.segmento, cnae: lead.cnae, website: lead.website + }); + const comercial = scoreComercial({ + whatsapp: lead.whatsapp, cnpj: lead.cnpj, website: lead.website, + email: lead.email, affinity: info.affinity + }); + const geral = scoreGeral(qualidade.score, comercial); + const tier = classificarScoreGeral(geral); + const produtoPrincipal = produtoPrincipalDe(info.products); + + update.run( + geral, tier.id, `score geral ${geral} (qualidade ${qualidade.score}, comercial ${comercial})`, + qualidade.score, comercial, tier.prioridade, + produtoPrincipal, JSON.stringify(info.products), + lead.id + ); + porPrioridade[tier.prioridade] += 1; + } + }); + + processed += rows.length; + lastId = rows[rows.length - 1].id; + console.log(`${processed}/${total} leads recalculados...`); + } + + console.log('Concluido. Distribuicao por prioridade:', porPrioridade); + db.close(); +} + +main().catch((error) => { + console.error('Erro no recalculo de scores:', error); + process.exit(1); +}); diff --git a/gti-maquina-comercial/scripts/servidor.mjs b/gti-maquina-comercial/scripts/servidor.mjs new file mode 100644 index 0000000..871c293 --- /dev/null +++ b/gti-maquina-comercial/scripts/servidor.mjs @@ -0,0 +1,41 @@ +import { loadDotEnv, GTI_PORT } from '../src/env.mjs'; +import { openDb, ensureSeeds } from '../src/db.mjs'; +import { createJobManager, recoverOrphans } from '../src/jobs/manager.mjs'; +import { createAppServer } from '../src/server/index.mjs'; +import { startOutreachScheduler } from '../src/outreach/scheduler.mjs'; +import { startCampaignScheduler } from '../src/outreach/campaign-scheduler.mjs'; + +// Servidor local do GTI Maquina Comercial. Inicie com: +// npm start (ou node scripts/servidor.mjs, ou o GTI-Comercial.exe) +// e abra http://127.0.0.1:3900 no navegador. + +await loadDotEnv(); + +const log = (message) => console.log(`[servidor] ${message}`); + +const db = openDb(); +await ensureSeeds(db, { log }); +recoverOrphans(db, log); + +const jobs = createJobManager(); +const server = createAppServer({ db, jobs, log }); +startOutreachScheduler({ db, log }); +startCampaignScheduler({ db, log }); + +const port = GTI_PORT; +server.listen(port, '127.0.0.1', () => { + log(`GTI Maquina Comercial rodando em http://127.0.0.1:${port}`); + log('somente este computador tem acesso (127.0.0.1). Ctrl+C para encerrar.'); +}); + +function shutdown() { + log('encerrando...'); + server.close(() => { + db.close(); + process.exit(0); + }); + setTimeout(() => process.exit(0), 3000).unref(); +} + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); diff --git a/gti-maquina-comercial/src/auth.mjs b/gti-maquina-comercial/src/auth.mjs new file mode 100644 index 0000000..c871d2f --- /dev/null +++ b/gti-maquina-comercial/src/auth.mjs @@ -0,0 +1,19 @@ +import crypto from 'node:crypto'; + +const KEY_LEN = 64; + +// Hash com salt aleatorio (scrypt) — formato armazenado: "salt:hash", ambos hex. +export function hashPassword(password) { + const salt = crypto.randomBytes(16).toString('hex'); + const hash = crypto.scryptSync(password, salt, KEY_LEN).toString('hex'); + return `${salt}:${hash}`; +} + +export function verifyPassword(password, stored) { + const [salt, hash] = String(stored || '').split(':'); + if (!salt || !hash) return false; + + const expected = Buffer.from(hash, 'hex'); + const candidate = crypto.scryptSync(password, salt, expected.length); + return candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected); +} diff --git a/gti-maquina-comercial/src/capture/capture-job.mjs b/gti-maquina-comercial/src/capture/capture-job.mjs new file mode 100644 index 0000000..390498b --- /dev/null +++ b/gti-maquina-comercial/src/capture/capture-job.mjs @@ -0,0 +1,110 @@ +import { loadDotEnv } from '../env.mjs'; +import { openDb } from '../db.mjs'; +import { runCapture } from './runner.mjs'; +import { createJobIO } from '../jobs/protocol.mjs'; + +// Processo filho de captura: le os parametros da linha em capture_runs, +// resolve nichos/cidades e roda o runner, reportando progresso via stdout. + +await loadDotEnv(); + +const args = new Map( + process.argv.slice(2).map((arg) => { + const [key, value = 'true'] = arg.replace(/^--/, '').split('='); + return [key, value]; + }) +); + +const io = createJobIO(); + +async function main() { + const runId = Number(args.get('run-id')); + if (!runId) throw new Error('capture-job: --run-id obrigatorio'); + + const db = openDb(); + const run = db.prepare('SELECT * FROM capture_runs WHERE id = ?').get(runId); + if (!run) throw new Error(`capture_run #${runId} nao encontrado`); + + const saved = JSON.parse(run.params_json); + + let segments = db.prepare('SELECT * FROM segments WHERE active = 1 ORDER BY id').all().map((row) => ({ + name: row.name, + label: row.label, + interest: row.interest, + queries: JSON.parse(row.queries_json), + cnaeCodes: JSON.parse(row.cnae_codes_json), + overpassTags: JSON.parse(row.overpass_tags_json), + pains: JSON.parse(row.pains_json), + affinity: row.affinity, + products: JSON.parse(row.products_json) + })); + + { + // Cada item selecionado pode ser o nome de um nicho pre-configurado, o + // sentinela "all" (todos os pre-configurados), ou um CNAE avulso + // escolhido na busca livre por atividade da Receita (prefixo "cnae:"). + const wanted = Array.isArray(saved.segments) && saved.segments.length ? saved.segments : ['all']; + const includeAllCurated = wanted.includes('all'); + const wantedNames = new Set(wanted.filter((id) => typeof id === 'string' && id !== 'all' && !id.startsWith('cnae:'))); + const wantedCnaes = wanted.filter((id) => typeof id === 'string' && id.startsWith('cnae:')).map((id) => id.slice('cnae:'.length)); + + if (!includeAllCurated) { + segments = segments.filter((s) => wantedNames.has(s.name)); + } + + if (wantedCnaes.length) { + const placeholders = wantedCnaes.map(() => '?').join(','); + const cnaeRows = db.prepare(`SELECT codigo, descricao FROM cnaes WHERE codigo IN (${placeholders})`).all(...wantedCnaes); + for (const row of cnaeRows) { + segments.push({ + name: `cnae_${row.codigo}`, + label: row.descricao, + interest: 'gestao de TI', + queries: [row.descricao], + cnaeCodes: [row.codigo], + overpassTags: [], + pains: ['atendimento', 'vendas'], + affinity: 'media', + products: [] + }); + } + } + } + + let cities = saved.cities; + if (!Array.isArray(cities) || !cities.length || cities[0] === 'all') { + cities = db.prepare('SELECT nome FROM municipios WHERE uf = ? ORDER BY nome') + .all(saved.uf).map((r) => r.nome); + } + + const params = { + sources: saved.sources, + uf: saved.uf, + cities, + segments, + limits: saved.limits || { maxPerQuery: 10, queryLimit: 0 } + }; + + const summary = await runCapture({ + db, + runId, + params, + emit: (p) => io.emit(p), + log: (m) => io.log(m), + isCancelled: io.isCancelled + }); + + io.done({ + cancelled: summary.status === 'cancelled', + raw: summary.raw, + new: summary.new, + dup: summary.dup, + status: summary.status + }); + db.close(); +} + +main().catch((error) => { + io.fail(error); + process.exit(1); +}); diff --git a/gti-maquina-comercial/src/capture/geocode.mjs b/gti-maquina-comercial/src/capture/geocode.mjs new file mode 100644 index 0000000..4decbf2 --- /dev/null +++ b/gti-maquina-comercial/src/capture/geocode.mjs @@ -0,0 +1,43 @@ +import { cleanText } from '../lead-engine.mjs'; +import { UA_HEADERS, UF_NAMES } from '../sources/common.mjs'; + +// Resolve a bounding box de uma cidade para as consultas Overpass. +// Substitui o antigo cityAreas fixo do config.json: consulta o Nominatim +// uma unica vez por cidade e guarda para sempre em geo_cache (os bboxes +// legados das 36 cidades da Bahia ja foram semeados como 'config-legacy'). +export function createGeocoder(db, { nominatimUrl }) { + const select = db.prepare('SELECT south, west, north, east FROM geo_cache WHERE key = ?'); + const insert = db.prepare( + `INSERT OR REPLACE INTO geo_cache (key, south, west, north, east, provider) + VALUES (?, ?, ?, ?, ?, 'nominatim')` + ); + + return async function resolveBbox(uf, cidade) { + const key = `bbox:${uf}:${cleanText(cidade)}`; + const cached = select.get(key); + if (cached) return [cached.south, cached.west, cached.north, cached.east]; + + const url = new URL(nominatimUrl); + url.searchParams.set('format', 'jsonv2'); + url.searchParams.set('q', `${cidade}, ${UF_NAMES[uf] || uf}, Brasil`); + url.searchParams.set('countrycodes', 'br'); + url.searchParams.set('limit', '1'); + + const response = await fetch(url, { headers: UA_HEADERS }); + if (!response.ok) throw new Error(`Nominatim geocode HTTP ${response.status} para ${cidade}/${uf}`); + + const results = await response.json(); + const box = results[0]?.boundingbox; + if (!box || box.length !== 4) return null; + + // Nominatim retorna [sul, norte, oeste, leste]; geo_cache guarda sul/oeste/norte/leste. + const south = Number(box[0]); + const north = Number(box[1]); + const west = Number(box[2]); + const east = Number(box[3]); + if (![south, north, west, east].every(Number.isFinite)) return null; + + insert.run(key, south, west, north, east); + return [south, west, north, east]; + }; +} diff --git a/gti-maquina-comercial/src/capture/lead-row.mjs b/gti-maquina-comercial/src/capture/lead-row.mjs new file mode 100644 index 0000000..1c0af97 --- /dev/null +++ b/gti-maquina-comercial/src/capture/lead-row.mjs @@ -0,0 +1,65 @@ +import { + scoreQualidade, scoreComercial, scoreGeral, classificarScoreGeral, + dedupKey, extractDdd, segmentLabel, onlyDigits +} from '../scoring.mjs'; + +// Converte um item processado ({payload, lead}) — formato produzido por +// processLead e tambem o formato dos JSONs legados — na linha canonica da +// tabela leads, com os scores/classificacao/dedup_key calculados. +export function leadRowFromProcessed(item, fallbackOrigin = '') { + const lead = item.lead || {}; + const prospecting = item.payload?.prospecting || lead.raw?.prospecting || {}; + const empresa = String(lead.companyName ?? '').trim(); + const whatsapp = onlyDigits(lead.phone); + const cnpj = onlyDigits(prospecting.cnpj); + const cidade = String(lead.city ?? '').trim(); + const segmento = String(lead.segment ?? '').trim(); + const cnae = String(prospecting.cnae ?? '').trim(); + const website = String(prospecting.website ?? '').trim(); + const email = String(lead.email ?? '').trim(); + const affinity = prospecting.affinity || 'media'; + const produtos = Array.isArray(prospecting.products) ? prospecting.products : []; + + const qualidade = scoreQualidade({ whatsapp, cnpj, empresa, cidade, segmento, cnae, website }); + const comercial = scoreComercial({ whatsapp, cnpj, website, email, affinity }); + const geral = scoreGeral(qualidade.score, comercial); + const tier = classificarScoreGeral(geral); + const produtoPrincipal = produtos.length + ? [...produtos].sort((a, b) => b.estrelas - a.estrelas)[0].nome + : ''; + + return { + lead_uid: lead.id || null, + dedup_key: dedupKey({ cnpj, whatsapp, empresa, cidade }), + empresa, + legal_name: String(prospecting.legal_name ?? ''), + whatsapp, + ddd: extractDdd(whatsapp), + email, + cidade, + uf: String(lead.state ?? '').trim().toUpperCase(), + segmento, + segmento_label: segmentLabel(segmento), + interesse: String(lead.serviceLabel ?? ''), + cnpj, + cnae, + endereco: String(prospecting.address ?? ''), + website, + pains_json: JSON.stringify(lead.pains || []), + origem: String(lead.source || fallbackOrigin || 'indefinida'), + telefone_valido: qualidade.telefoneValido ? 1 : 0, + cnpj_valido: qualidade.cnpjValido ? 1 : 0, + empresa_valida: qualidade.empresaValida ? 1 : 0, + score: geral, + classificacao: tier.id, + motivo_classificacao: `score geral ${geral} (qualidade ${qualidade.score}, comercial ${comercial})`, + score_qualidade: qualidade.score, + score_comercial: comercial, + prioridade: tier.prioridade, + produto_principal: produtoPrincipal, + produtos_json: JSON.stringify(produtos), + capture_run_id: null, + captured_at: lead.createdAt || null, + exported_at: null + }; +} diff --git a/gti-maquina-comercial/src/capture/runner.mjs b/gti-maquina-comercial/src/capture/runner.mjs new file mode 100644 index 0000000..855feda --- /dev/null +++ b/gti-maquina-comercial/src/capture/runner.mjs @@ -0,0 +1,133 @@ +import { processLead } from '../lead-engine.mjs'; +import { createLeadInserter, transaction } from '../db.mjs'; +import { createGeocoder } from './geocode.mjs'; +import { leadRowFromProcessed } from './lead-row.mjs'; +import { getSource } from '../sources/index.mjs'; +import { envString, envNumber, paths } from '../env.mjs'; + +export function defaultEndpoints() { + return { + overpassUrls: envString( + 'OVERPASS_API_URL', + 'https://overpass-api.de/api/interpreter,https://overpass.kumi.systems/api/interpreter' + ).split(',').map((url) => url.trim()).filter(Boolean), + nominatimUrl: envString('NOMINATIM_API_URL', 'https://nominatim.openstreetmap.org/search'), + wikidataUrl: envString('WIKIDATA_API_URL', 'https://query.wikidata.org/sparql'), + brasilApiUrl: envString('BRASILAPI_URL', 'https://brasilapi.com.br/api').replace(/\/$/, '') + }; +} + +// Executa uma captura: percorre as fontes selecionadas, processa cada lead +// (normalizacao + score) e insere no banco em lotes. A dedup acontece no +// proprio INSERT (ON CONFLICT dedup_key DO NOTHING) — leads ja capturados +// em qualquer execucao anterior contam como duplicados. +export async function runCapture({ db, runId, params, emit = () => {}, log = () => {}, isCancelled = () => false }) { + const insert = createLeadInserter(db); + const endpoints = defaultEndpoints(); + + const ctx = { + db, + emit, + log, + isCancelled, + cnpjDir: params.cnpjDir || paths.cnpjDir, + endpoints, + delays: { + overpassDelayMs: envNumber('OVERPASS_REQUEST_DELAY_MS', 5000), + overpassTimeoutMs: envNumber('OVERPASS_TIMEOUT_MS', 15000) + }, + overpassTextSearch: envString('OVERPASS_TEXT_SEARCH', 'false') === 'true', + geocode: createGeocoder(db, { nominatimUrl: endpoints.nominatimUrl }) + }; + + const counts = { raw: 0, new: 0, dup: 0 }; + const updateRun = runId + ? db.prepare( + `UPDATE capture_runs SET raw_count = ?, new_count = ?, dup_count = ?, progress_json = ? WHERE id = ?` + ) + : null; + + const persistCounts = (progress) => { + if (updateRun) { + updateRun.run(counts.raw, counts.new, counts.dup, JSON.stringify(progress || {}), runId); + } + }; + + if (runId) { + db.prepare( + `UPDATE capture_runs SET status = 'running', pid = ?, started_at = datetime('now') WHERE id = ?` + ).run(process.pid, runId); + } + + let cancelled = false; + + try { + for (const sourceId of params.sources) { + if (isCancelled()) { + cancelled = true; + break; + } + + const source = getSource(sourceId); + if (!source) { + log(`fonte desconhecida: ${sourceId} — pulada`); + continue; + } + + log(`fonte ${sourceId}: iniciando`); + emit({ phase: sourceId, message: 'iniciando', counts: { ...counts } }); + + for await (const batch of source.collect(params, ctx)) { + counts.raw += batch.leads.length; + + transaction(db, () => { + for (const payload of batch.leads) { + const result = processLead(payload); + const row = leadRowFromProcessed({ payload, lead: result.lead }, sourceId); + row.capture_run_id = runId || null; + if (insert(row) === 'inserted') counts.new += 1; + else counts.dup += 1; + } + }); + + const progress = { + phase: sourceId, + message: `${batch.label}: ${counts.new} novos, ${counts.dup} duplicados (${counts.raw} brutos)`, + counts: { ...counts } + }; + emit(progress); + persistCounts(progress); + + if (isCancelled()) { + cancelled = true; + break; + } + } + + if (cancelled) break; + log(`fonte ${sourceId}: concluida`); + } + } catch (error) { + if (runId) { + db.prepare( + `UPDATE capture_runs SET status = 'error', error = ?, raw_count = ?, new_count = ?, dup_count = ?, finished_at = datetime('now') WHERE id = ?` + ).run(String(error.message || error), counts.raw, counts.new, counts.dup, runId); + } + throw error; + } + + const finalStatus = cancelled ? 'cancelled' : 'done'; + if (runId) { + db.prepare( + `UPDATE capture_runs SET status = ?, raw_count = ?, new_count = ?, dup_count = ?, finished_at = datetime('now') WHERE id = ?` + ).run(finalStatus, counts.raw, counts.new, counts.dup, runId); + } + + return { ...counts, status: finalStatus }; +} + +export function createCaptureRun(db, params) { + const result = db.prepare('INSERT INTO capture_runs (params_json, status) VALUES (?, ?)') + .run(JSON.stringify(params), 'pending'); + return Number(result.lastInsertRowid); +} diff --git a/gti-maquina-comercial/src/db.mjs b/gti-maquina-comercial/src/db.mjs new file mode 100644 index 0000000..6ebf47a --- /dev/null +++ b/gti-maquina-comercial/src/db.mjs @@ -0,0 +1,657 @@ +import { DatabaseSync } from 'node:sqlite'; +import fs from 'node:fs'; +import path from 'node:path'; +import { paths } from './env.mjs'; +import { segmentLabel } from './scoring.mjs'; +import { cleanText } from './lead-engine.mjs'; +import { hashPassword } from './auth.mjs'; + +const MIGRATIONS = [ + // v1 — esquema inicial + ` + CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE municipios ( + codigo_ibge TEXT PRIMARY KEY, + codigo_tom TEXT, + nome TEXT NOT NULL, + nome_clean TEXT NOT NULL, + uf TEXT NOT NULL + ); + CREATE INDEX idx_municipios_uf ON municipios(uf, nome_clean); + CREATE INDEX idx_municipios_tom ON municipios(codigo_tom); + + CREATE TABLE cnaes ( + codigo TEXT PRIMARY KEY, + descricao TEXT NOT NULL + ); + + CREATE TABLE segments ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + label TEXT NOT NULL, + interest TEXT, + queries_json TEXT NOT NULL DEFAULT '[]', + cnae_codes_json TEXT NOT NULL DEFAULT '[]', + overpass_tags_json TEXT NOT NULL DEFAULT '[]', + pains_json TEXT NOT NULL DEFAULT '[]', + active INTEGER NOT NULL DEFAULT 1 + ); + + CREATE TABLE geo_cache ( + key TEXT PRIMARY KEY, + south REAL, west REAL, north REAL, east REAL, + provider TEXT NOT NULL, + resolved_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE capture_runs ( + id INTEGER PRIMARY KEY, + job_id TEXT UNIQUE, + params_json TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + progress_json TEXT, + raw_count INTEGER NOT NULL DEFAULT 0, + new_count INTEGER NOT NULL DEFAULT 0, + dup_count INTEGER NOT NULL DEFAULT 0, + error TEXT, + pid INTEGER, + started_at TEXT, + finished_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE extractions ( + id INTEGER PRIMARY KEY, + job_id TEXT UNIQUE, + uf TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + csv_path TEXT, + total_rows INTEGER, + with_phone INTEGER, + municipios_count INTEGER, + progress_json TEXT, + error TEXT, + pid INTEGER, + started_at TEXT, + finished_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE leads ( + id INTEGER PRIMARY KEY, + lead_uid TEXT, + dedup_key TEXT NOT NULL UNIQUE, + empresa TEXT NOT NULL, + legal_name TEXT DEFAULT '', + whatsapp TEXT DEFAULT '', + ddd TEXT DEFAULT '', + email TEXT DEFAULT '', + cidade TEXT DEFAULT '', + uf TEXT DEFAULT '', + segmento TEXT DEFAULT '', + segmento_label TEXT DEFAULT '', + interesse TEXT DEFAULT '', + cnpj TEXT DEFAULT '', + cnae TEXT DEFAULT '', + endereco TEXT DEFAULT '', + website TEXT DEFAULT '', + pains_json TEXT NOT NULL DEFAULT '[]', + origem TEXT NOT NULL DEFAULT '', + telefone_valido INTEGER NOT NULL DEFAULT 0, + cnpj_valido INTEGER NOT NULL DEFAULT 0, + empresa_valida INTEGER NOT NULL DEFAULT 0, + score INTEGER NOT NULL DEFAULT 0, + classificacao TEXT NOT NULL DEFAULT 'ruim', + motivo_classificacao TEXT DEFAULT '', + capture_run_id INTEGER REFERENCES capture_runs(id), + captured_at TEXT, + exported_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT + ); + CREATE INDEX idx_leads_uf_cidade ON leads(uf, cidade); + CREATE INDEX idx_leads_class_score ON leads(classificacao, score DESC); + CREATE INDEX idx_leads_segmento ON leads(segmento); + CREATE INDEX idx_leads_whatsapp ON leads(whatsapp); + CREATE INDEX idx_leads_cnpj ON leads(cnpj); + CREATE INDEX idx_leads_exported ON leads(exported_at); + CREATE INDEX idx_leads_origem ON leads(origem); + + CREATE TABLE exports ( + id INTEGER PRIMARY KEY, + job_id TEXT UNIQUE, + tipo TEXT NOT NULL, + filtro_json TEXT NOT NULL DEFAULT '{}', + params_json TEXT NOT NULL DEFAULT '{}', + arquivos_json TEXT NOT NULL DEFAULT '[]', + total_leads INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', + error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + finished_at TEXT + ); + + CREATE TABLE export_leads ( + export_id INTEGER NOT NULL REFERENCES exports(id) ON DELETE CASCADE, + lead_id INTEGER NOT NULL REFERENCES leads(id), + PRIMARY KEY (export_id, lead_id) + ) WITHOUT ROWID; + CREATE INDEX idx_export_leads_lead ON export_leads(lead_id); + `, + // v2 — score de qualidade dos dados + score comercial (aderencia de nicho, + // website, e-mail) + score geral (60% comercial + 40% qualidade), prioridade + // comercial (1-4, derivada da classificacao) e produto recomendado por nicho. + ` + ALTER TABLE segments ADD COLUMN affinity TEXT NOT NULL DEFAULT 'media'; + ALTER TABLE segments ADD COLUMN products_json TEXT NOT NULL DEFAULT '[]'; + + ALTER TABLE leads ADD COLUMN score_qualidade INTEGER NOT NULL DEFAULT 0; + ALTER TABLE leads ADD COLUMN score_comercial INTEGER NOT NULL DEFAULT 0; + ALTER TABLE leads ADD COLUMN prioridade INTEGER NOT NULL DEFAULT 4; + ALTER TABLE leads ADD COLUMN produto_principal TEXT DEFAULT ''; + ALTER TABLE leads ADD COLUMN produtos_json TEXT NOT NULL DEFAULT '[]'; + + CREATE INDEX idx_leads_prioridade ON leads(prioridade, score DESC); + CREATE INDEX idx_leads_produto ON leads(produto_principal); + `, + // v3 — login: usuarios do app (senha com hash, papel admin/comercial). + ` + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'comercial', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `, + // v4 — automacao de envio WhatsApp (Z-PRO): um template de mensagem por + // produto/servico, e o historico de disparos (o que foi mandado, pra quem, + // quando) usado tanto pra nao repetir envio quanto pra decidir quando tentar + // o proximo produto recomendado. + ` + CREATE TABLE message_templates ( + id INTEGER PRIMARY KEY, + produto TEXT NOT NULL UNIQUE, + mensagem TEXT NOT NULL, + ativo INTEGER NOT NULL DEFAULT 1, + updated_at TEXT + ); + + CREATE TABLE outreach_sends ( + id INTEGER PRIMARY KEY, + lead_id INTEGER NOT NULL REFERENCES leads(id), + produto TEXT NOT NULL, + mensagem_enviada TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'enviado', + erro TEXT, + zpro_ticket_id TEXT, + sent_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX idx_outreach_lead_produto ON outreach_sends(lead_id, produto); + CREATE INDEX idx_outreach_sent_at ON outreach_sends(sent_at); + `, + // v5 — canal de envio por template: 'nao_oficial' (texto livre, bulkIndividual, + // conexao QR/Baileys) ou 'oficial' (API oficial Meta/WABA, exige template + // ja aprovado no Meta Business Manager — nao da pra criar/aprovar por aqui). + ` + ALTER TABLE message_templates ADD COLUMN canal TEXT NOT NULL DEFAULT 'nao_oficial'; + ALTER TABLE message_templates ADD COLUMN template_waba_nome TEXT NOT NULL DEFAULT ''; + ALTER TABLE message_templates ADD COLUMN template_waba_idioma TEXT NOT NULL DEFAULT 'pt_BR'; + + ALTER TABLE outreach_sends ADD COLUMN canal TEXT NOT NULL DEFAULT 'nao_oficial'; + `, + // v6 — envio manual avulso (mandar mensagem pra um numero especifico, sem + // estar ligado a um lead da base). lead_id precisa virar opcional (SQLite + // nao remove NOT NULL com ALTER TABLE direto, por isso o rebuild da tabela) + // e numero/origem guardam pra quem foi e se foi automatico ou manual. + ` + CREATE TABLE outreach_sends_v6 ( + id INTEGER PRIMARY KEY, + lead_id INTEGER REFERENCES leads(id), + produto TEXT NOT NULL DEFAULT '', + numero TEXT, + mensagem_enviada TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'enviado', + erro TEXT, + zpro_ticket_id TEXT, + canal TEXT NOT NULL DEFAULT 'nao_oficial', + origem TEXT NOT NULL DEFAULT 'automatico', + sent_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + INSERT INTO outreach_sends_v6 + (id, lead_id, produto, mensagem_enviada, status, erro, zpro_ticket_id, canal, sent_at) + SELECT id, lead_id, produto, mensagem_enviada, status, erro, zpro_ticket_id, canal, sent_at + FROM outreach_sends; + + DROP TABLE outreach_sends; + ALTER TABLE outreach_sends_v6 RENAME TO outreach_sends; + + CREATE INDEX idx_outreach_lead_produto ON outreach_sends(lead_id, produto); + CREATE INDEX idx_outreach_sent_at ON outreach_sends(sent_at); + `, + // v7 — variaveis dos templates WABA automaticos por produto. Cada item pode + // usar placeholders resolvidos por lead: {empresa}, {produto}, {cidade}, etc. + ` + ALTER TABLE message_templates ADD COLUMN template_waba_variaveis_json TEXT NOT NULL DEFAULT '[]'; + `, + // v8 — campanhas de outreach com catalogo dinamico (a Comercial tem 57 + // segmentos e 4 produtos, catalogo fixo em codigo nao cabe nessa escala: + // o operador cria a campanha pela tela). Roda ao lado do runOutreachBatch + // existente (run.mjs) sem substitui-lo. outreach_opt_out e novo aqui (nao + // existia antes): uma fila que recontata o mesmo lead ao longo de semanas + // precisa checar quem ja pediu pra parar. + ` + CREATE TABLE outreach_campaigns ( + id INTEGER PRIMARY KEY, + nome TEXT NOT NULL, + produto TEXT NOT NULL, + segmentos_json TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'rascunho', + pause_reason TEXT, + limite_diario_atual INTEGER NOT NULL DEFAULT 10, + limite_diario_teto INTEGER NOT NULL DEFAULT 80, + semana_iniciada_em TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE outreach_campaign_variants ( + id INTEGER PRIMARY KEY, + campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id), + code TEXT NOT NULL, + mensagem TEXT NOT NULL, + ativo INTEGER NOT NULL DEFAULT 1 + ); + CREATE INDEX idx_campaign_variants_campaign ON outreach_campaign_variants(campaign_id); + + CREATE TABLE outreach_campaign_queue ( + id INTEGER PRIMARY KEY, + campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id), + lead_id INTEGER NOT NULL REFERENCES leads(id), + variant_id INTEGER NOT NULL REFERENCES outreach_campaign_variants(id), + segmento TEXT NOT NULL DEFAULT '', + numero TEXT NOT NULL, + rendered_message TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'preview', + cancel_reason TEXT, + outreach_send_id INTEGER REFERENCES outreach_sends(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX idx_campaign_queue_campaign_status ON outreach_campaign_queue(campaign_id, status); + CREATE INDEX idx_campaign_queue_lead ON outreach_campaign_queue(lead_id); + + CREATE TABLE outreach_lead_states ( + lead_id INTEGER PRIMARY KEY REFERENCES leads(id), + stage TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE outreach_stage_events ( + id INTEGER PRIMARY KEY, + lead_id INTEGER NOT NULL REFERENCES leads(id), + campaign_id INTEGER REFERENCES outreach_campaigns(id), + stage TEXT NOT NULL, + note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX idx_stage_events_lead ON outreach_stage_events(lead_id); + + CREATE TABLE outreach_appointments ( + id INTEGER PRIMARY KEY, + lead_id INTEGER NOT NULL REFERENCES leads(id), + kind TEXT NOT NULL, + scheduled_at TEXT, + note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX idx_appointments_lead ON outreach_appointments(lead_id); + + CREATE TABLE outreach_opt_out ( + numero TEXT PRIMARY KEY, + motivo TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + ` +]; + +export function openDb(dbPath = paths.db) { + if (dbPath !== ':memory:') { + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + } + + const db = new DatabaseSync(dbPath); + db.exec('PRAGMA journal_mode = WAL;'); + db.exec('PRAGMA busy_timeout = 5000;'); + db.exec('PRAGMA foreign_keys = ON;'); + migrate(db); + return db; +} + +export function migrate(db) { + db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + );`); + + const row = db.prepare('SELECT MAX(version) AS version FROM schema_migrations').get(); + const current = row?.version || 0; + + for (let version = current + 1; version <= MIGRATIONS.length; version += 1) { + db.exec('BEGIN'); + try { + db.exec(MIGRATIONS[version - 1]); + db.prepare('INSERT INTO schema_migrations (version) VALUES (?)').run(version); + db.exec('COMMIT'); + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } + } +} + +export function transaction(db, fn) { + db.exec('BEGIN'); + try { + const result = fn(); + db.exec('COMMIT'); + return result; + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } +} + +const LEAD_COLUMNS = [ + 'lead_uid', 'dedup_key', 'empresa', 'legal_name', 'whatsapp', 'ddd', 'email', + 'cidade', 'uf', 'segmento', 'segmento_label', 'interesse', 'cnpj', 'cnae', + 'endereco', 'website', 'pains_json', 'origem', 'telefone_valido', 'cnpj_valido', + 'empresa_valida', 'score', 'classificacao', 'motivo_classificacao', + 'score_qualidade', 'score_comercial', 'prioridade', 'produto_principal', 'produtos_json', + 'capture_run_id', 'captured_at', 'exported_at' +]; + +// INSERT com dedup embutida: conflitos na dedup_key sao ignorados +// (lead ja existe de captura/importacao anterior). +export function createLeadInserter(db) { + const placeholders = LEAD_COLUMNS.map((c) => `:${c}`).join(', '); + const stmt = db.prepare( + `INSERT INTO leads (${LEAD_COLUMNS.join(', ')}) VALUES (${placeholders}) + ON CONFLICT(dedup_key) DO NOTHING` + ); + + return (lead) => { + const params = {}; + for (const column of LEAD_COLUMNS) { + const value = lead[column]; + params[column] = value === undefined || value === null ? null : value; + } + if (params.empresa === null) params.empresa = ''; + if (params.score_qualidade === null) params.score_qualidade = 0; + if (params.score_comercial === null) params.score_comercial = 0; + if (params.prioridade === null) params.prioridade = 4; + if (params.produto_principal === null) params.produto_principal = ''; + if (params.produtos_json === null) params.produtos_json = '[]'; + if (params.dedup_key === null) throw new Error('lead sem dedup_key'); + const result = stmt.run(params); + return result.changes > 0 ? 'inserted' : 'duplicate'; + }; +} + +export function getSetting(db, key, fallback = null) { + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key); + return row ? row.value : fallback; +} + +export function setSetting(db, key, value) { + db.prepare( + 'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value' + ).run(key, String(value)); +} + +const DEFAULT_SETTINGS = { + 'zpro.pipelineId': '1', + 'zpro.stageId': '1', + 'zpro.responsibleId': '15', + 'zpro.ticketQueueId': '12', + 'zpro.status': 'open', + 'zpro.closingForecastDays': '30', + 'zpro.validateNumber': 'true', + // Credenciais da API externa do Z-PRO usadas pela automacao de envio + // WhatsApp (src/outreach/zpro-client.mjs). Editaveis em Configuracoes > + // Credenciais Z-PRO; vazio aqui cai pro .env (ZPRO_BASE_URL/etc) se existir. + 'zpro.baseUrl': '', + 'zpro.apiId': '', + 'zpro.bearerToken': '', + 'zpro.whatsappId': '', + 'meta_diaria': '100', + // Automacao de envio WhatsApp (Z-PRO) — comeca desligada de proposito: + // so liga depois que o .env tiver as credenciais e os templates forem revisados. + 'outreach.ativo': 'false', + 'outreach.mensagensPorDia': '10', + 'outreach.diasEntreProdutos': '5', + 'outreach.tentarProximoProduto': 'false', + 'outreach.horaInicio': '08:00', + 'outreach.horaFim': '18:00', + // Endpoint interno do GChat que lista templates WABA do canal GRUPO GTI COEX. + // O token de sessao do GChat deve ser preenchido localmente na tela; nao fica + // embutido no codigo. + 'gchat.templatesUrl': 'https://apigchat.gtigrupo.com.br/wabametaTemplate/1126322523899986', + 'gchat.bearerToken': '' +}; + +export function seedSettings(db, extra = {}) { + const entries = { ...DEFAULT_SETTINGS, ...extra }; + transaction(db, () => { + const stmt = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)'); + for (const [key, value] of Object.entries(entries)) { + stmt.run(key, String(value)); + } + }); +} + +// Upsert (nao so INSERT OR IGNORE): reaplicar o config.json com afinidade/produtos +// ajustados deve atualizar os 57 nichos ja existentes, nao so inserir os novos. +export function seedSegments(db, segments) { + transaction(db, () => { + const stmt = db.prepare( + `INSERT INTO segments (name, label, interest, queries_json, cnae_codes_json, overpass_tags_json, pains_json, affinity, products_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + label = excluded.label, + interest = excluded.interest, + queries_json = excluded.queries_json, + cnae_codes_json = excluded.cnae_codes_json, + overpass_tags_json = excluded.overpass_tags_json, + pains_json = excluded.pains_json, + affinity = excluded.affinity, + products_json = excluded.products_json` + ); + for (const segment of segments) { + stmt.run( + segment.name, + segmentLabel(segment.name), + segment.interest || '', + JSON.stringify(segment.queries || []), + JSON.stringify(segment.cnaeCodes || []), + JSON.stringify(segment.overpassTags || []), + JSON.stringify(segment.pains || []), + segment.affinity || 'media', + JSON.stringify(segment.products || []) + ); + } + }); +} + +export function seedMunicipios(db, rows) { + transaction(db, () => { + const stmt = db.prepare( + 'INSERT OR IGNORE INTO municipios (codigo_ibge, nome, nome_clean, uf) VALUES (?, ?, ?, ?)' + ); + for (const row of rows) { + stmt.run(String(row.codigo_ibge), row.nome, cleanText(row.nome), row.uf); + } + }); +} + +export function seedCnaes(db, rows) { + transaction(db, () => { + const stmt = db.prepare('INSERT OR IGNORE INTO cnaes (codigo, descricao) VALUES (?, ?)'); + for (const row of rows) { + stmt.run(String(row.codigo), row.descricao); + } + }); +} + +export function seedGeoCache(db, cityAreas) { + transaction(db, () => { + const stmt = db.prepare( + `INSERT OR IGNORE INTO geo_cache (key, south, west, north, east, provider) + VALUES (?, ?, ?, ?, ?, 'config-legacy')` + ); + for (const area of cityAreas || []) { + const [south, west, north, east] = area.bbox; + stmt.run(`bbox:${area.state}:${cleanText(area.name)}`, south, west, north, east); + } + }); +} + +// Zera a base comercial (leads, exportacoes, capturas e extracoes) mantendo +// tabelas de referencia (segments, settings, municipios, cnaes) intactas. +// Usado pelo botao "Limpar toda a base" da tela de Configuracoes. +export function resetLeadsBase(db) { + transaction(db, () => { + db.exec('DELETE FROM export_leads'); + db.exec('DELETE FROM exports'); + db.exec('DELETE FROM leads'); + db.exec('DELETE FROM capture_runs'); + db.exec('DELETE FROM extractions'); + db.exec('DELETE FROM geo_cache'); + }); + db.exec('VACUUM'); +} + +export function createUser(db, { username, passwordHash, role = 'comercial' }) { + db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)').run(username, passwordHash, role); +} + +export function findUserByUsername(db, username) { + return db.prepare('SELECT * FROM users WHERE username = ?').get(username); +} + +export function listUsers(db) { + return db.prepare('SELECT id, username, role, created_at FROM users ORDER BY id').all(); +} + +// Primeiro acesso: sem usuarios cadastrados, cria admin/admin — deve ser +// trocado ou complementado com outros usuarios pela tela de Usuarios. +export function ensureDefaultAdmin(db) { + if (countRows(db, 'users') > 0) return; + createUser(db, { username: 'admin', passwordHash: hashPassword('admin'), role: 'admin' }); +} + +function countRows(db, table) { + return db.prepare(`SELECT COUNT(*) AS total FROM ${table}`).get().total; +} + +const MENSAGEM_PADRAO = + 'Ola! Somos o Grupo GTI. Notamos que a {empresa} pode se beneficiar do nosso ' + + '{produto} — ajudamos empresas em {cidade} com isso. Podemos conversar?'; + +// Um template por produto/servico (INSERT OR IGNORE: nao sobrescreve texto ja +// editado pelo usuario na tela de Configuracoes ao reaplicar em boots seguintes). +export function seedMessageTemplates(db, produtos) { + transaction(db, () => { + const stmt = db.prepare('INSERT OR IGNORE INTO message_templates (produto, mensagem) VALUES (?, ?)'); + for (const produto of produtos) { + if (!produto) continue; + stmt.run(produto, MENSAGEM_PADRAO.replace('{produto}', produto)); + } + }); +} + +export function listMessageTemplates(db) { + return db.prepare('SELECT * FROM message_templates ORDER BY produto').all(); +} + +export function updateMessageTemplate(db, produto, { mensagem, ativo, canal, templateWabaNome, templateWabaIdioma, templateWabaVariaveis }) { + const row = db.prepare('SELECT * FROM message_templates WHERE produto = ?').get(produto); + if (!row) return null; + const novaMensagem = mensagem !== undefined ? mensagem : row.mensagem; + const novoAtivo = ativo !== undefined ? (ativo ? 1 : 0) : row.ativo; + const novoCanal = canal === 'oficial' ? 'oficial' : canal === 'nao_oficial' ? 'nao_oficial' : row.canal; + const novoNome = templateWabaNome !== undefined ? templateWabaNome : row.template_waba_nome; + const novoIdioma = templateWabaIdioma !== undefined ? templateWabaIdioma : row.template_waba_idioma; + const novasVariaveis = templateWabaVariaveis !== undefined + ? JSON.stringify((Array.isArray(templateWabaVariaveis) ? templateWabaVariaveis : []) + .map((value) => String(value || '').trim()) + .filter(Boolean)) + : row.template_waba_variaveis_json; + db.prepare( + `UPDATE message_templates + SET mensagem = ?, ativo = ?, canal = ?, template_waba_nome = ?, template_waba_idioma = ?, template_waba_variaveis_json = ?, updated_at = datetime('now') + WHERE produto = ?` + ).run(novaMensagem, novoAtivo, novoCanal, novoNome, novoIdioma, novasVariaveis, produto); + return db.prepare('SELECT * FROM message_templates WHERE produto = ?').get(produto); +} + +// Popula tabelas de referencia na primeira execucao. Tolerante a arquivos de +// seed ausentes (loga e segue) para o servidor nunca falhar no boot por isso. +export async function ensureSeeds(db, { log = () => {} } = {}) { + const fsp = await import('node:fs/promises'); + + // seedSegments faz upsert (nao so insere): sempre reaplicado no boot para que + // ajustes de afinidade/produtos no config.json cheguem ao banco so reiniciando. + try { + const config = JSON.parse(await fsp.readFile(paths.config, 'utf8')); + seedSegments(db, config.segments || []); + if (countRows(db, 'geo_cache') === 0) seedGeoCache(db, config.cityAreas || []); + const produtos = [...new Set( + (config.segments || []).flatMap((s) => (s.products || []).map((p) => p.nome)).filter(Boolean) + )]; + seedMessageTemplates(db, produtos); + log(`seeds: ${countRows(db, 'segments')} segmentos, ${countRows(db, 'geo_cache')} bboxes, ${countRows(db, 'message_templates')} templates`); + } catch (error) { + log(`seeds: config.json indisponivel (${error.message})`); + } + + if (countRows(db, 'municipios') === 0) { + try { + const seedPath = path.join(paths.seeds, 'municipios-ibge.json'); + const rows = JSON.parse(await fsp.readFile(seedPath, 'utf8')); + seedMunicipios(db, rows); + log(`seeds: ${countRows(db, 'municipios')} municipios (IBGE)`); + } catch (error) { + log(`seeds: municipios-ibge.json indisponivel (${error.message})`); + } + } + + if (countRows(db, 'cnaes') === 0) { + try { + const seedPath = path.join(paths.seeds, 'cnaes.json'); + const rows = JSON.parse(await fsp.readFile(seedPath, 'utf8')); + seedCnaes(db, rows); + log(`seeds: ${countRows(db, 'cnaes')} CNAEs`); + } catch (error) { + log(`seeds: cnaes.json indisponivel (${error.message})`); + } + } + + let templates = {}; + try { + const templatesPath = path.join(paths.root, 'whatsapp', 'templates-mensagens.json'); + templates = { 'templates.mensagens': await fsp.readFile(templatesPath, 'utf8') }; + } catch { + // pasta whatsapp/ ja removida: template ja deve estar no banco + } + seedSettings(db, templates); + + const criouAdmin = countRows(db, 'users') === 0; + ensureDefaultAdmin(db); + if (criouAdmin) log('seeds: usuario admin/admin criado (primeiro acesso)'); +} diff --git a/gti-maquina-comercial/src/env.mjs b/gti-maquina-comercial/src/env.mjs new file mode 100644 index 0000000..a8cd8db --- /dev/null +++ b/gti-maquina-comercial/src/env.mjs @@ -0,0 +1,59 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Raiz do projeto resolvida a partir deste arquivo (src/env.mjs -> pasta acima), +// para funcionar igual quando o servidor e iniciado pelo launcher (cwd diferente). +export const projectRoot = path.dirname(fileURLToPath(new URL('.', import.meta.url))); + +export const paths = { + root: projectRoot, + data: path.join(projectRoot, 'data'), + db: process.env.GTI_DB_PATH || path.join(projectRoot, 'data', 'gti.db'), + exports: path.join(projectRoot, 'data', 'exports'), + seeds: path.join(projectRoot, 'data', 'seeds'), + cnpjDir: process.env.CNPJ_BASE_DIR || path.join(projectRoot, 'prospeccao', 'cnpj'), + rfbRaw: path.join(projectRoot, 'prospeccao', 'cnpj', 'rfb-raw'), + config: path.join(projectRoot, 'prospeccao', 'config.json'), + public: path.join(projectRoot, 'public') +}; + +export let GTI_PORT = envNumber('GTI_PORT', 3900); + +function refreshEnvDerivedValues() { + paths.db = process.env.GTI_DB_PATH || path.join(projectRoot, 'data', 'gti.db'); + paths.cnpjDir = process.env.CNPJ_BASE_DIR || path.join(projectRoot, 'prospeccao', 'cnpj'); + paths.rfbRaw = path.join(paths.cnpjDir, 'rfb-raw'); + GTI_PORT = envNumber('GTI_PORT', 3900); +} + +export async function loadDotEnv(rootDir = projectRoot) { + try { + const content = await fs.readFile(path.join(rootDir, '.env'), 'utf8'); + + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const separator = trimmed.indexOf('='); + if (separator === -1) continue; + + const key = trimmed.slice(0, separator).trim(); + const value = trimmed.slice(separator + 1).trim(); + if (!process.env[key]) process.env[key] = value; + } + + refreshEnvDerivedValues(); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } +} + +export function envNumber(name, fallback) { + const value = Number(process.env[name]); + return Number.isFinite(value) && value > 0 ? value : fallback; +} + +export function envString(name, fallback) { + return process.env[name] || fallback; +} diff --git a/gti-maquina-comercial/src/export/excel.mjs b/gti-maquina-comercial/src/export/excel.mjs new file mode 100644 index 0000000..98155c5 --- /dev/null +++ b/gti-maquina-comercial/src/export/excel.mjs @@ -0,0 +1,104 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { csvEscape, excelTextCell, BOM } from '../util/csv.mjs'; +import { iterateLeads } from '../treatment/queries.mjs'; + +// Exportacao "planilha": CSV + .xls gemeo, ambos a prova de Excel — os campos +// numericos longos (whatsapp, cnpj, cnae) sao gravados como TEXTO para nunca +// virarem notacao cientifica. Mesmo formato dos arquivos TRATADOS legados: +// CSV -> formula ="valor" +// XLS -> tabela HTML com mso-number-format:"\@" + +const HEADERS = [ + 'empresa', 'whatsapp', 'ddd', 'cidade', 'uf', 'segmento', 'segmento_legivel', 'interesse', + 'cnpj', 'cnae', 'telefone_valido', 'motivo_telefone', 'cnpj_valido', 'empresa_valida', + 'motivo_empresa', 'score', 'classificacao', 'motivo_classificacao', 'chave_duplicidade', 'linha_origem', + 'score_qualidade', 'score_comercial', 'prioridade', 'produto_recomendado' +]; + +const TEXT_COLUMNS = new Set(['whatsapp', 'ddd', 'cnpj', 'cnae']); + +function leadToExportRow(lead) { + return { + empresa: lead.empresa, + whatsapp: lead.whatsapp, + ddd: lead.ddd, + cidade: lead.cidade, + uf: lead.uf, + segmento: lead.segmento, + segmento_legivel: lead.segmento_label, + interesse: lead.interesse, + cnpj: lead.cnpj, + cnae: lead.cnae, + telefone_valido: lead.telefone_valido ? 'sim' : 'nao', + motivo_telefone: lead.telefone_valido ? 'telefone valido' : 'telefone invalido ou vazio', + cnpj_valido: lead.cnpj_valido ? 'sim' : 'nao', + empresa_valida: lead.empresa_valida ? 'sim' : 'nao', + motivo_empresa: lead.empresa_valida ? 'empresa valida' : (lead.motivo_classificacao || 'empresa invalida'), + score: lead.score, + classificacao: lead.classificacao, + motivo_classificacao: lead.motivo_classificacao, + chave_duplicidade: lead.dedup_key, + linha_origem: lead.id, + score_qualidade: lead.score_qualidade, + score_comercial: lead.score_comercial, + prioridade: lead.prioridade, + produto_recomendado: lead.produto_principal + }; +} + +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>'); +} + +async function writeStream(stream, text) { + if (!stream.write(text)) { + await new Promise((resolve) => stream.once('drain', resolve)); + } +} + +export async function exportExcel({ db, filtro, dir, baseName, onProgress = () => {}, collectIds }) { + fs.mkdirSync(dir, { recursive: true }); + const csvPath = path.join(dir, `${baseName}.csv`); + const xlsPath = path.join(dir, `${baseName}.xls`); + + const csv = fs.createWriteStream(csvPath, { encoding: 'utf8' }); + const xls = fs.createWriteStream(xlsPath, { encoding: 'utf8' }); + + await writeStream(csv, BOM + HEADERS.join(';') + '\n'); + await writeStream(xls, `${BOM}${ + HEADERS.map((h) => ``).join('') + }\n`); + + let total = 0; + + for (const lead of iterateLeads(db, filtro)) { + const row = leadToExportRow(lead); + + const csvCells = HEADERS.map((h) => (TEXT_COLUMNS.has(h) && row[h] !== '' ? excelTextCell(row[h]) : csvEscape(row[h]))); + await writeStream(csv, csvCells.join(';') + '\n'); + + const xlsCells = HEADERS.map((h) => + `${escapeHtml(row[h])}` + ); + await writeStream(xls, `${xlsCells.join('')}\n`); + + if (collectIds) collectIds.push(lead.id); + total += 1; + if (total % 10000 === 0) onProgress({ message: `${total} leads gravados...` }); + } + + await writeStream(xls, '
${escapeHtml(h)}
\n'); + await Promise.all([ + new Promise((resolve, reject) => csv.end((e) => (e ? reject(e) : resolve()))), + new Promise((resolve, reject) => xls.end((e) => (e ? reject(e) : resolve()))) + ]); + + return { + total, + files: [path.basename(csvPath), path.basename(xlsPath)] + }; +} diff --git a/gti-maquina-comercial/src/export/export-job.mjs b/gti-maquina-comercial/src/export/export-job.mjs new file mode 100644 index 0000000..27e8a83 --- /dev/null +++ b/gti-maquina-comercial/src/export/export-job.mjs @@ -0,0 +1,93 @@ +import path from 'node:path'; +import { loadDotEnv, paths } from '../env.mjs'; +import { openDb, transaction } from '../db.mjs'; +import { createJobIO } from '../jobs/protocol.mjs'; +import { exportExcel } from './excel.mjs'; +import { exportOportunidades, exportBulkDataInput } from './zpro.mjs'; + +// Processo filho de exportacao: gera os arquivos do tipo pedido e, ao final, +// numa unica transacao, registra os leads exportados (export_leads) e marca +// exported_at (para o filtro "ainda nao exportado" excluir esses leads). + +await loadDotEnv(); + +const args = new Map( + process.argv.slice(2).map((arg) => { + const [key, value = 'true'] = arg.replace(/^--/, '').split('='); + return [key, value]; + }) +); + +const io = createJobIO(); + +const GENERATORS = { + excel: exportExcel, + 'zpro-oportunidades': exportOportunidades, + 'zpro-bulk': exportBulkDataInput +}; + +async function main() { + const exportId = Number(args.get('export-id')); + if (!exportId) throw new Error('export-job: --export-id obrigatorio'); + + const db = openDb(); + const row = db.prepare('SELECT * FROM exports WHERE id = ?').get(exportId); + if (!row) throw new Error(`export #${exportId} nao encontrado`); + + const generator = GENERATORS[row.tipo]; + if (!generator) throw new Error(`tipo de exportacao desconhecido: ${row.tipo}`); + + const filtro = JSON.parse(row.filtro_json || '{}'); + const params = JSON.parse(row.params_json || '{}'); + + db.prepare("UPDATE exports SET status = 'running' WHERE id = ?").run(exportId); + io.emit({ phase: row.tipo, message: 'gerando arquivos...' }); + + const dir = path.join(paths.exports, `export-${exportId}`); + const baseName = params.baseName || `leads-${row.tipo}`; + const leadIds = []; + + try { + const result = await generator({ + db, + filtro, + params, + dir, + baseName, + collectIds: leadIds, + onProgress: (p) => io.emit({ phase: row.tipo, ...p }) + }); + + const marcar = params.marcarExportado !== false; + io.emit({ phase: row.tipo, message: `registrando ${leadIds.length} leads exportados...` }); + + transaction(db, () => { + const insertLink = db.prepare('INSERT OR IGNORE INTO export_leads (export_id, lead_id) VALUES (?, ?)'); + const mark = marcar + ? db.prepare("UPDATE leads SET exported_at = COALESCE(exported_at, datetime('now')), updated_at = datetime('now') WHERE id = ?") + : null; + + for (const leadId of leadIds) { + insertLink.run(exportId, leadId); + if (mark) mark.run(leadId); + } + + db.prepare( + "UPDATE exports SET status = 'done', total_leads = ?, arquivos_json = ?, finished_at = datetime('now') WHERE id = ?" + ).run(result.total, JSON.stringify(result.files), exportId); + }); + + io.done({ total: result.total, arquivos: result.files }); + } catch (error) { + db.prepare("UPDATE exports SET status = 'error', error = ?, finished_at = datetime('now') WHERE id = ?") + .run(String(error.message || error), exportId); + throw error; + } finally { + db.close(); + } +} + +main().catch((error) => { + io.fail(error); + process.exit(1); +}); diff --git a/gti-maquina-comercial/src/export/zpro.mjs b/gti-maquina-comercial/src/export/zpro.mjs new file mode 100644 index 0000000..1530154 --- /dev/null +++ b/gti-maquina-comercial/src/export/zpro.mjs @@ -0,0 +1,122 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { iterateLeads } from '../treatment/queries.mjs'; +import { getSetting } from '../db.mjs'; + +// Exportacoes para o Z-PRO (sem envio direto — gera os arquivos prontos): +// - zpro-oportunidades: JSONL com um payload createOpportunity por linha, +// no formato exato testado na importacao manual de 2026-07-09. +// - zpro-bulk: CSV dataInput para BulkSendMessageWithVariable +// (colunas: numero,empresa,cidade,segmento) + txt com instrucoes. + +function zproDefaults(db, params = {}) { + const setting = (key, fallback) => params[key] ?? getSetting(db, `zpro.${key}`, fallback); + return { + pipelineId: Number(setting('pipelineId', 1)), + stageId: Number(setting('stageId', 1)), + responsibleId: Number(setting('responsibleId', 15)), + status: String(setting('status', 'open')), + closingForecastDays: Number(setting('closingForecastDays', 30)), + validateNumber: String(setting('validateNumber', 'true')) !== 'false' + }; +} + +function closingForecast(days) { + const date = new Date(Date.now() + days * 24 * 60 * 60 * 1000); + return date.toISOString().slice(0, 10); +} + +async function writeStream(stream, text) { + if (!stream.write(text)) { + await new Promise((resolve) => stream.once('drain', resolve)); + } +} + +export async function exportOportunidades({ db, filtro, params, dir, baseName, onProgress = () => {}, collectIds }) { + fs.mkdirSync(dir, { recursive: true }); + const zpro = zproDefaults(db, params); + const forecast = closingForecast(zpro.closingForecastDays); + + const jsonlPath = path.join(dir, `${baseName}.jsonl`); + const out = fs.createWriteStream(jsonlPath, { encoding: 'utf8' }); + let total = 0; + + for (const lead of iterateLeads(db, filtro)) { + const payload = { + number: lead.whatsapp, + contactName: lead.empresa, + email: lead.email || '', + name: `${lead.empresa} - ${lead.interesse || 'Contato comercial'}`, + value: 0, + status: zpro.status, + pipelineId: zpro.pipelineId, + stageId: zpro.stageId, + responsibleId: zpro.responsibleId, + closingForecast: forecast, + description: [ + 'Exportado pelo GTI Maquina Comercial.', + `Empresa: ${lead.empresa}`, + lead.legal_name ? `Razao social: ${lead.legal_name}` : '', + `CNPJ: ${lead.cnpj || 'nao informado'}`, + `WhatsApp: ${lead.whatsapp || 'nao informado'}`, + `Cidade/UF: ${lead.cidade}/${lead.uf}`, + `Segmento: ${lead.segmento_label || lead.segmento}`, + `Interesse: ${lead.interesse || 'a definir'}`, + `Score: ${lead.score}`, + `Classificacao: ${lead.classificacao}`, + `Lead: ${lead.id}` + ].filter(Boolean).join('\n'), + validateNumber: zpro.validateNumber + }; + + await writeStream(out, JSON.stringify(payload) + '\n'); + if (collectIds) collectIds.push(lead.id); + total += 1; + if (total % 10000 === 0) onProgress({ message: `${total} oportunidades geradas...` }); + } + + await new Promise((resolve, reject) => out.end((e) => (e ? reject(e) : resolve()))); + return { total, files: [path.basename(jsonlPath)] }; +} + +export async function exportBulkDataInput({ db, filtro, params, dir, baseName, onProgress = () => {}, collectIds }) { + fs.mkdirSync(dir, { recursive: true }); + + const dataPath = path.join(dir, `${baseName}-datainput.csv`); + const infoPath = path.join(dir, `${baseName}-instrucoes.txt`); + const out = fs.createWriteStream(dataPath, { encoding: 'utf8' }); + let total = 0; + + for (const lead of iterateLeads(db, filtro)) { + if (!lead.whatsapp) continue; + const cells = [lead.whatsapp, lead.empresa, lead.cidade, lead.segmento_label || lead.segmento] + .map((v) => String(v ?? '').replace(/[,\r\n]+/g, ' ').trim()); + await writeStream(out, cells.join(',') + '\n'); + if (collectIds) collectIds.push(lead.id); + total += 1; + if (total % 10000 === 0) onProgress({ message: `${total} numeros gravados...` }); + } + + await new Promise((resolve, reject) => out.end((e) => (e ? reject(e) : resolve()))); + + const mensagem = params?.mensagem || 'Ola {empresa}! Somos o Grupo GTI...'; + fs.writeFileSync(infoPath, [ + 'DISPARO EM MASSA NO Z-PRO (BulkSendMessageWithVariable)', + '', + `Arquivo de dados: ${path.basename(dataPath)} (${total} numeros)`, + 'Colunas (nesta ordem): numero, empresa, cidade, segmento', + '', + 'Como usar no Z-PRO:', + '1. Endpoint: POST {BaseUrl}/v2/api/external/{ApiID}/bulkSendMessageWithVariable', + '2. Campo "dataInput": cole o conteudo do CSV (ou envie por upload, conforme sua versao).', + '3. Campo "message": escreva a mensagem usando as variaveis pela posicao das colunas,', + ' ex.: "Ola {empresa}! Vimos que voces sao de {cidade}..."', + '4. Campos "min" e "max": intervalo aleatorio (em segundos) entre envios — use valores', + ' altos (ex.: 30 a 90) para reduzir risco de bloqueio do numero.', + '', + 'Mensagem sugerida:', + mensagem + ].join('\n'), 'utf8'); + + return { total, files: [path.basename(dataPath), path.basename(infoPath)] }; +} diff --git a/gti-maquina-comercial/src/extract/extract-job.mjs b/gti-maquina-comercial/src/extract/extract-job.mjs new file mode 100644 index 0000000..f0c6f62 --- /dev/null +++ b/gti-maquina-comercial/src/extract/extract-job.mjs @@ -0,0 +1,75 @@ +import path from 'node:path'; +import { loadDotEnv, paths } from '../env.mjs'; +import { openDb } from '../db.mjs'; +import { extractUf } from './rfb-extractor.mjs'; +import { createJobIO } from '../jobs/protocol.mjs'; + +// Processo filho de extracao RFB: le a UF da linha em extractions e roda a +// extracao completa, reportando progresso via stdout. + +await loadDotEnv(); + +const args = new Map( + process.argv.slice(2).map((arg) => { + const [key, value = 'true'] = arg.replace(/^--/, '').split('='); + return [key, value]; + }) +); + +const io = createJobIO(); + +async function main() { + const extractionId = Number(args.get('extraction-id')); + if (!extractionId) throw new Error('extract-job: --extraction-id obrigatorio'); + + const db = openDb(); + const extraction = db.prepare('SELECT * FROM extractions WHERE id = ?').get(extractionId); + if (!extraction) throw new Error(`extraction #${extractionId} nao encontrada`); + + const uf = extraction.uf; + db.prepare("UPDATE extractions SET status = 'running', pid = ?, started_at = datetime('now') WHERE id = ?") + .run(process.pid, extractionId); + + const outCsv = path.join(paths.cnpjDir, `estabelecimentos-${uf}.csv`); + const checkpointPath = path.join(paths.cnpjDir, `checkpoint-estabelecimentos-${uf}.ndjson`); + + try { + const result = await extractUf({ + uf, + rawDir: paths.rfbRaw, + outCsv, + checkpointPath, + db, + log: (m) => io.log(m), + isCancelled: io.isCancelled, + onProgress: (p) => { + io.emit(p); + db.prepare('UPDATE extractions SET progress_json = ? WHERE id = ?') + .run(JSON.stringify(p), extractionId); + } + }); + + if (result.cancelled) { + db.prepare("UPDATE extractions SET status = 'cancelled', finished_at = datetime('now') WHERE id = ?") + .run(extractionId); + io.done({ cancelled: true }); + } else { + db.prepare( + `UPDATE extractions SET status = 'done', csv_path = ?, total_rows = ?, with_phone = ?, + municipios_count = ?, finished_at = datetime('now') WHERE id = ?` + ).run(result.csvPath, result.totalRows, result.withPhone, result.municipiosCount, extractionId); + io.done({ totalRows: result.totalRows, withPhone: result.withPhone, municipios: result.municipiosCount }); + } + } catch (error) { + db.prepare("UPDATE extractions SET status = 'error', error = ?, finished_at = datetime('now') WHERE id = ?") + .run(String(error.message || error), extractionId); + throw error; + } finally { + db.close(); + } +} + +main().catch((error) => { + io.fail(error); + process.exit(1); +}); diff --git a/gti-maquina-comercial/src/extract/rfb-extractor.mjs b/gti-maquina-comercial/src/extract/rfb-extractor.mjs new file mode 100644 index 0000000..a07a999 --- /dev/null +++ b/gti-maquina-comercial/src/extract/rfb-extractor.mjs @@ -0,0 +1,361 @@ +import { spawn, spawnSync } from 'node:child_process'; +import readline from 'node:readline'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { cleanText, stripAccents } from '../lead-engine.mjs'; +import { copyStr } from '../util/collections.mjs'; +import { csvEscape, BOM } from '../util/csv.mjs'; + +// Extrai a base de estabelecimentos ativos de UMA UF inteira (todos os +// municipios) a partir dos zips brutos da Receita Federal, gerando +// prospeccao/cnpj/estabelecimentos-.csv. +// +// Cuidados de memoria (aprendidos nas execucoes da Bahia): +// - checkpoint em NDJSON streamado para disco: nunca guarda o array de +// matches em memoria (uma UF inteira passa de 1 milhao de linhas); +// - copyStr (Buffer.from) quebra o vinculo SlicedString do V8 entre campos +// retidos e a linha CSV original; +// - firstField evita o split completo das ~50 milhoes de linhas descartadas +// dos Empresas*.zip. + +const CANDIDATE_READERS = [ + { name: 'unzip', probeArgs: ['-v'], argsFor: (zipPath) => ['-p', zipPath] }, + { name: 'C:\\Program Files\\Git\\usr\\bin\\unzip.exe', probeArgs: ['-v'], argsFor: (zipPath) => ['-p', zipPath] }, + { name: 'tar', probeArgs: ['--version'], argsFor: (zipPath) => ['-xOf', zipPath] }, + { name: 'C:\\Windows\\System32\\tar.exe', probeArgs: ['--version'], argsFor: (zipPath) => ['-xOf', zipPath] } +]; + +export function resolveZipReader() { + for (const candidate of CANDIDATE_READERS) { + try { + const probe = spawnSync(candidate.name, candidate.probeArgs, { stdio: 'ignore' }); + if (!probe.error && probe.status === 0) return candidate; + } catch { + // tenta o proximo + } + } + throw new Error( + 'Nenhum leitor de ZIP encontrado (unzip ou tar). Instale o Git para Windows ou verifique o PATH.' + ); +} + +// Abre um zip da RFB e devolve as linhas ja decodificadas de ISO-8859-1. +function readZipLines(reader, zipPath) { + const child = spawn(reader.name, reader.argsFor(zipPath), { stdio: ['ignore', 'pipe', 'pipe'] }); + child.stderr.on('data', () => {}); + child.stdout.setEncoding('latin1'); + const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity }); + return { + rl, + close() { + rl.close(); + child.kill(); + } + }; +} + +// So o primeiro campo (antes do primeiro '";"'), sem split completo. +function firstField(line) { + const end = line.indexOf('";"', 1); + if (end === -1) return ''; + return line.slice(1, end); +} + +function parseQuotedRow(line) { + if (line.startsWith('"') && line.endsWith('"')) { + return line.slice(1, -1).split('";"'); + } + return line.split(';').map((value) => value.replace(/^"|"$/g, '')); +} + +function titleCase(name) { + return String(name).toLowerCase().replace(/(^|\s)([a-z])/g, (m, sep, ch) => sep + ch.toUpperCase()); +} + +async function buildTomMap(reader, rawDir) { + const zipPath = path.join(rawDir, 'Municipios.zip'); + if (!fs.existsSync(zipPath)) { + throw new Error(`Municipios.zip nao encontrado em ${rawDir}`); + } + + const tomToNome = new Map(); + const zip = readZipLines(reader, zipPath); + for await (const line of zip.rl) { + if (!line.trim()) continue; + const [codigo, nome] = parseQuotedRow(line); + if (codigo) tomToNome.set(copyStr(codigo), copyStr(nome)); + } + zip.close(); + return tomToNome; +} + +async function* estabelecimentoRows(reader, rawDir, uf, { onFileProgress, isCancelled }) { + for (let i = 0; i <= 9; i += 1) { + const zipPath = path.join(rawDir, `Estabelecimentos${i}.zip`); + if (!fs.existsSync(zipPath)) continue; + + const zip = readZipLines(reader, zipPath); + let lineCount = 0; + let matchCount = 0; + + for await (const line of zip.rl) { + lineCount += 1; + if (lineCount % 500000 === 0 && isCancelled()) { + zip.close(); + return; + } + if (!line.trim()) continue; + + // Filtro barato por UF antes do parse completo: a UF aparece como + // campo '";UF";"' — o indexOf evita o split das ~29M linhas de outras UFs. + if (!line.includes(`"${uf}"`)) continue; + + const cols = parseQuotedRow(line); + if (cols[19] !== uf) continue; + if (cols[5] !== '02') continue; + + matchCount += 1; + yield { + b: copyStr(cols[0]), // cnpj basico + o: copyStr(cols[1]), // ordem + d: copyStr(cols[2]), // dv + f: copyStr(cols[4]), // nome fantasia + c: copyStr(cols[11]), // cnae + l: copyStr([cols[13], cols[14]].filter(Boolean).join(' ')), // logradouro + n: copyStr(cols[15]), // numero + ba: copyStr(cols[17]), // bairro + m: copyStr(cols[20]), // codigo municipio (TOM) + d1: copyStr(cols[21]), + t1: copyStr(cols[22]), + d2: copyStr(cols[23]), + t2: copyStr(cols[24]) + }; + } + + zip.close(); + onFileProgress(`Estabelecimentos${i}.zip`, lineCount, matchCount, i); + if (global.gc) global.gc(); + if (isCancelled()) return; + } +} + +async function buildRazaoMap(reader, rawDir, cnpjBasicoSet, { onFileProgress, isCancelled }) { + const razaoByCnpj = new Map(); + + for (let i = 0; i <= 9; i += 1) { + const zipPath = path.join(rawDir, `Empresas${i}.zip`); + if (!fs.existsSync(zipPath)) continue; + + const zip = readZipLines(reader, zipPath); + let lineCount = 0; + let matchCount = 0; + + for await (const line of zip.rl) { + lineCount += 1; + if (lineCount % 500000 === 0 && isCancelled()) { + zip.close(); + return razaoByCnpj; + } + if (!line.trim()) continue; + const cnpjBasico = firstField(line); + if (!cnpjBasico || !cnpjBasicoSet.has(cnpjBasico)) continue; + const cols = parseQuotedRow(line); + razaoByCnpj.set(copyStr(cnpjBasico), copyStr(cols[1])); + matchCount += 1; + } + + zip.close(); + onFileProgress(`Empresas${i}.zip`, lineCount, matchCount, i); + if (global.gc) global.gc(); + if (isCancelled()) break; + } + + return razaoByCnpj; +} + +export async function extractUf({ + uf, + rawDir, + outCsv, + checkpointPath, + db = null, + onProgress = () => {}, + isCancelled = () => false, + log = () => {} +}) { + const ufUpper = String(uf).toUpperCase(); + const reader = resolveZipReader(); + log(`leitor de zip: ${reader.name}`); + + const tomToNome = await buildTomMap(reader, rawDir); + log(`${tomToNome.size} municipios no indice da RFB`); + + // Nome "bonito" (IBGE, sem acento, capitalizado) por codigo TOM, para o CSV + // ficar consistente com os leads ja migrados ('Santo Antonio de Jesus'). + const prettyByClean = new Map(); + if (db) { + for (const row of db.prepare('SELECT nome, nome_clean FROM municipios WHERE uf = ?').all(ufUpper)) { + prettyByClean.set(row.nome_clean, stripAccents(row.nome)); + } + } + const prettyName = (tomCode) => { + const tomNome = tomToNome.get(tomCode) || ''; + return prettyByClean.get(cleanText(tomNome)) || titleCase(tomNome); + }; + + // ---- Fase 1: Estabelecimentos -> checkpoint NDJSON em disco ---- + const cnpjBasicoSet = new Set(); + const tomSeen = new Set(); + let totalRows = 0; + let withPhone = 0; + + const checkpointDone = fs.existsSync(checkpointPath); + if (checkpointDone) { + log('checkpoint encontrado: pulando varredura de Estabelecimentos'); + await new Promise((resolve, reject) => { + const rl = readline.createInterface({ + input: fs.createReadStream(checkpointPath, { encoding: 'utf8' }), + crlfDelay: Infinity + }); + rl.on('line', (line) => { + if (!line.trim()) return; + const item = JSON.parse(line); + cnpjBasicoSet.add(item.b); + tomSeen.add(item.m); + totalRows += 1; + if (item.t1 || item.t2) withPhone += 1; + }); + rl.on('close', resolve); + rl.on('error', reject); + }); + } else { + const tmpPath = `${checkpointPath}.tmp`; + const out = fs.createWriteStream(tmpPath, { encoding: 'utf8' }); + + onProgress({ phase: 'estabelecimentos', pct: 0, message: 'varrendo Estabelecimentos0-9.zip' }); + + for await (const item of estabelecimentoRows(reader, rawDir, ufUpper, { + isCancelled, + onFileProgress: (file, lines, matches, index) => { + log(`${file}: ${lines} linhas, ${matches} ativas em ${ufUpper}`); + onProgress({ + phase: 'estabelecimentos', + pct: Math.round(((index + 1) / 10) * 55), + message: `${file}: ${totalRows} estabelecimentos ate agora` + }); + } + })) { + cnpjBasicoSet.add(item.b); + tomSeen.add(item.m); + totalRows += 1; + if (item.t1 || item.t2) withPhone += 1; + if (!out.write(JSON.stringify(item) + '\n')) { + await new Promise((resolve) => out.once('drain', resolve)); + } + } + + await new Promise((resolve, reject) => out.end((e) => (e ? reject(e) : resolve()))); + + if (isCancelled()) { + await fsp.rm(tmpPath, { force: true }); + return { cancelled: true }; + } + await fsp.rename(tmpPath, checkpointPath); + log(`checkpoint salvo: ${checkpointPath}`); + } + + log(`total de estabelecimentos ativos em ${ufUpper}: ${totalRows}`); + if (isCancelled()) return { cancelled: true }; + + // ---- Fase 2: Empresas -> razao social ---- + onProgress({ phase: 'empresas', pct: 55, message: 'varrendo Empresas0-9.zip (razao social)' }); + let razoesRecuperadas = 0; + const razaoByCnpj = await buildRazaoMap(reader, rawDir, cnpjBasicoSet, { + isCancelled, + onFileProgress: (file, lines, matches, index) => { + razoesRecuperadas += matches; + const heapMb = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(0); + log(`${file}: ${lines} linhas, ${matches} razoes sociais, heap=${heapMb}MB`); + onProgress({ + phase: 'empresas', + pct: 55 + Math.round(((index + 1) / 10) * 35), + message: `${file}: ${razoesRecuperadas} razoes sociais` + }); + } + }); + if (isCancelled()) return { cancelled: true }; + + // ---- Fase 3: CSV final (streaming do checkpoint) ---- + onProgress({ phase: 'csv', pct: 90, message: 'gravando CSV final' }); + const headers = [ + 'cnpj', 'razao_social', 'nome_fantasia', 'ddd_1', 'telefone_1', 'ddd_2', 'telefone_2', + 'municipio', 'municipio_codigo', 'uf', 'cnae', 'logradouro', 'numero', 'bairro' + ]; + const csvOut = fs.createWriteStream(outCsv, { encoding: 'utf8' }); + csvOut.write(BOM + headers.join(';') + '\n'); + + await new Promise((resolve, reject) => { + const rl = readline.createInterface({ + input: fs.createReadStream(checkpointPath, { encoding: 'utf8' }), + crlfDelay: Infinity + }); + // readline ainda entrega linhas ja em buffer depois do pause(); o flag + // evita empilhar um listener de drain por linha excedente. + let waitingDrain = false; + rl.on('line', (line) => { + if (!line.trim()) return; + const item = JSON.parse(line); + const row = [ + `${item.b}${item.o}${item.d}`, + razaoByCnpj.get(item.b) || '', + item.f, + item.d1, item.t1, item.d2, item.t2, + prettyName(item.m), + item.m, + ufUpper, + item.c, + item.l, item.n, item.ba + ].map(csvEscape).join(';'); + if (!csvOut.write(row + '\n') && !waitingDrain) { + waitingDrain = true; + rl.pause(); + csvOut.once('drain', () => { + waitingDrain = false; + rl.resume(); + }); + } + }); + rl.on('close', resolve); + rl.on('error', reject); + }); + await new Promise((resolve, reject) => csvOut.end((e) => (e ? reject(e) : resolve()))); + + // ---- Fase 4: aprende codigo TOM dos municipios no banco ---- + let municipiosCount = tomSeen.size; + if (db) { + const update = db.prepare('UPDATE municipios SET codigo_tom = ? WHERE uf = ? AND nome_clean = ?'); + db.exec('BEGIN'); + try { + for (const tom of tomSeen) { + const nome = tomToNome.get(tom); + if (nome) update.run(tom, ufUpper, cleanText(nome)); + } + db.exec('COMMIT'); + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } + } + + // O checkpoint so serve para retomar uma extracao interrompida. Mantido apos + // concluir, faria uma re-extracao futura (base nova da Receita) reutilizar + // dados velhos da fase 1 — entao e apagado no sucesso. + await fsp.rm(checkpointPath, { force: true }); + + onProgress({ phase: 'done', pct: 100, message: `${totalRows} estabelecimentos, ${withPhone} com telefone` }); + log(`CSV gravado: ${outCsv}`); + log(`com telefone: ${withPhone} (${totalRows ? ((withPhone / totalRows) * 100).toFixed(1) : 0}%)`); + + return { cancelled: false, totalRows, withPhone, municipiosCount, csvPath: outCsv }; +} diff --git a/gti-maquina-comercial/src/jobs/manager.mjs b/gti-maquina-comercial/src/jobs/manager.mjs new file mode 100644 index 0000000..1df7259 --- /dev/null +++ b/gti-maquina-comercial/src/jobs/manager.mjs @@ -0,0 +1,160 @@ +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import readline from 'node:readline'; +import path from 'node:path'; +import { paths } from '../env.mjs'; +import { parseJobLine } from './protocol.mjs'; + +const KILL_GRACE_MS = 10000; + +// Gerencia os processos filhos de trabalho pesado (captura, extracao RFB, +// exportacao). O filho e dono das escritas de leads no banco (conexao +// propria, WAL); o servidor so acompanha o progresso via stdout e repassa +// para os assinantes SSE. +export function createJobManager() { + const jobs = new Map(); + + function fanout(job, event) { + job.lastEvent = event; + for (const send of job.subscribers) { + try { + send(event); + } catch { + // assinante ja desconectou + } + } + } + + function startJob({ type, script, args = [], meta = {} }) { + const jobId = randomUUID(); + const child = spawn( + process.execPath, + [path.join(paths.root, script), `--job-id=${jobId}`, ...args], + { cwd: paths.root, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true } + ); + + const job = { + id: jobId, + type, + meta, + child, + status: 'running', + startedAt: new Date().toISOString(), + finishedAt: null, + lastEvent: { t: 'progress', phase: type, message: 'iniciando...' }, + stderrTail: '', + subscribers: new Set(), + killTimer: null + }; + jobs.set(jobId, job); + + const rl = readline.createInterface({ input: child.stdout }); + rl.on('line', (line) => { + const event = parseJobLine(line); + if (!event) return; + if (event.t === 'done') { + job.status = event.summary?.cancelled ? 'cancelled' : 'done'; + job.finishedAt = new Date().toISOString(); + } else if (event.t === 'error') { + job.status = 'error'; + job.finishedAt = new Date().toISOString(); + } + fanout(job, event); + }); + + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { + job.stderrTail = (job.stderrTail + chunk).slice(-4096); + }); + + child.on('exit', (code) => { + if (job.killTimer) clearTimeout(job.killTimer); + if (job.status === 'running') { + job.status = 'error'; + job.finishedAt = new Date().toISOString(); + fanout(job, { + t: 'error', + message: `processo encerrou inesperadamente (codigo ${code}). ${job.stderrTail.slice(-500)}` + }); + } + // guarda o job finalizado por 10 minutos para consultas tardias + setTimeout(() => jobs.delete(jobId), 10 * 60 * 1000).unref(); + }); + + return jobId; + } + + function cancelJob(jobId) { + const job = jobs.get(jobId); + if (!job || job.status !== 'running') return false; + + try { + job.child.stdin.write('cancel\n'); + } catch { + // stdin ja fechado + } + job.killTimer = setTimeout(() => { + if (job.status === 'running') job.child.kill(); + }, KILL_GRACE_MS); + job.killTimer.unref(); + return true; + } + + function subscribe(jobId, send) { + const job = jobs.get(jobId); + if (!job) return null; + + // replay do ultimo evento para quem conecta no meio (refresh da pagina) + send(job.lastEvent); + if (job.status !== 'running') { + return () => {}; + } + + job.subscribers.add(send); + return () => job.subscribers.delete(send); + } + + function getJob(jobId) { + return jobs.get(jobId) || null; + } + + function activeJobs() { + return [...jobs.values()] + .filter((job) => job.status === 'running') + .map((job) => ({ + jobId: job.id, + type: job.type, + meta: job.meta, + startedAt: job.startedAt, + lastEvent: job.lastEvent + })); + } + + return { startJob, cancelJob, subscribe, getJob, activeJobs }; +} + +// No boot do servidor: execucoes marcadas como running cujo processo morreu +// (queda de energia, kill) viram erro para nao travar a interface. +export function recoverOrphans(db, log = () => {}) { + const isAlive = (pid) => { + if (!pid) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }; + + for (const table of ['capture_runs', 'extractions']) { + const running = db.prepare(`SELECT id, pid FROM ${table} WHERE status IN ('running', 'pending')`).all(); + for (const row of running) { + if (!isAlive(row.pid)) { + db.prepare( + `UPDATE ${table} SET status = 'error', error = 'interrompido (processo nao esta mais rodando)', finished_at = datetime('now') WHERE id = ?` + ).run(row.id); + log(`${table} #${row.id} marcado como interrompido`); + } + } + } +} diff --git a/gti-maquina-comercial/src/jobs/protocol.mjs b/gti-maquina-comercial/src/jobs/protocol.mjs new file mode 100644 index 0000000..b3ae16a --- /dev/null +++ b/gti-maquina-comercial/src/jobs/protocol.mjs @@ -0,0 +1,40 @@ +import readline from 'node:readline'; + +// Protocolo entre o servidor e os processos filhos de job: +// - filho imprime UMA mensagem JSON por linha no stdout: +// {"t":"progress", ...} | {"t":"log","message":...} | +// {"t":"done","summary":{...}} | {"t":"error","message":...} +// - servidor escreve "cancel\n" no stdin do filho para pedir cancelamento +// cooperativo (o filho checa isCancelled() entre lotes). + +export function createJobIO() { + let cancelled = false; + + const rl = readline.createInterface({ input: process.stdin }); + rl.on('line', (line) => { + if (line.trim() === 'cancel') cancelled = true; + }); + // stdin nao deve segurar o processo vivo apos o trabalho terminar + process.stdin.unref?.(); + + const write = (obj) => { + process.stdout.write(JSON.stringify(obj) + '\n'); + }; + + return { + isCancelled: () => cancelled, + emit: (progress) => write({ t: 'progress', ...progress }), + log: (message) => write({ t: 'log', message: String(message) }), + done: (summary = {}) => write({ t: 'done', summary }), + fail: (error) => write({ t: 'error', message: String(error?.message || error) }) + }; +} + +export function parseJobLine(line) { + try { + const obj = JSON.parse(line); + return obj && typeof obj === 'object' && obj.t ? obj : null; + } catch { + return null; + } +} diff --git a/gti-maquina-comercial/src/lead-engine.mjs b/gti-maquina-comercial/src/lead-engine.mjs new file mode 100644 index 0000000..ab5bb74 --- /dev/null +++ b/gti-maquina-comercial/src/lead-engine.mjs @@ -0,0 +1,411 @@ +const TARGET_CITY_ALIASES = new Set([ + 'santo antonio de jesus', + 'santo antonio de jesus ba', + 'saj' +]); + +const TARGET_SEGMENTS = new Set([ + 'mercado', + 'supermercado', + 'farmacia', + 'drogaria', + 'restaurante', + 'delivery', + 'distribuidora', + 'autopecas', + 'material de construcao', + 'clinica', + 'loja', + 'varejo', + 'atacado', + 'oficina', + 'escola', + 'salao de beleza', + 'barbearia', + 'estetica', + 'celular', + 'transportadora', + 'logistica' +]); + +const SEGMENT_STOPWORDS = new Set(['de', 'da', 'do', 'e']); + +function segmentTokens(value = '') { + return cleanText(value) + .split(/[^a-z0-9]+/) + .filter((token) => token.length > 2 && !SEGMENT_STOPWORDS.has(token)); +} + +const TARGET_SEGMENT_TOKENS = new Set( + [...TARGET_SEGMENTS].flatMap((term) => segmentTokens(term)) +); + +function tokenMatches(token, targetToken) { + return token === targetToken + || (token.length > 4 && targetToken.length > 4 && (token.startsWith(targetToken) || targetToken.startsWith(token))); +} + +function isTargetSegmentMatch(segmentKey) { + if (!segmentKey) return false; + if (TARGET_SEGMENTS.has(segmentKey)) return true; + return segmentTokens(segmentKey).some((token) => + [...TARGET_SEGMENT_TOKENS].some((targetToken) => tokenMatches(token, targetToken)) + ); +} + +const SERVICE_LABELS = { + erp: 'ERP / sistema de gestao', + ti: 'Gestao de TI', + whatsapp: 'Autoatendimento para WhatsApp', + indefinido: 'A definir' +}; + +const URGENCY_LABELS = { + alta: 'Alta', + media: 'Media', + baixa: 'Baixa', + indefinida: 'Indefinida' +}; + +export function stripAccents(value = '') { + return String(value) + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, ''); +} + +export function cleanText(value = '') { + return stripAccents(value) + .toLowerCase() + .replace(/[^a-z0-9\s@._+-]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function firstDefined(...values) { + return values.find((value) => value !== undefined && value !== null && value !== '') ?? ''; +} + +function numberFrom(value) { + if (value === undefined || value === null || value === '') return 0; + if (typeof value === 'number' && Number.isFinite(value)) return value; + const match = String(value).match(/\d+/); + return match ? Number(match[0]) : 0; +} + +function toArray(value) { + if (Array.isArray(value)) return value.filter(Boolean).map(String); + if (!value) return []; + return String(value) + .split(/[,;|]/) + .map((item) => item.trim()) + .filter(Boolean); +} + +export function normalizePhone(value = '') { + let digits = String(value).replace(/\D/g, ''); + + while (digits.startsWith('0')) digits = digits.slice(1); + + if (digits.length === 10 || digits.length === 11) { + digits = `55${digits}`; + } + + return digits; +} + +export function detectService(value = '') { + const text = cleanText(value); + + if (!text) return 'indefinido'; + + if (/\b(1|erp|sistema|gestao empresarial|estoque|financeiro|fiscal|nfce|nfe|pdv)\b/.test(text)) { + return 'erp'; + } + + if (/\b(2|ti|suporte|servidor|rede|backup|seguranca|computador|infraestrutura)\b/.test(text)) { + return 'ti'; + } + + if (/\b(3|whatsapp|autoatendimento|chatbot|bot|atendimento automatico|mensagem)\b/.test(text)) { + return 'whatsapp'; + } + + return 'indefinido'; +} + +export function normalizeUrgency(value = '') { + const text = cleanText(value); + + if (!text) return 'indefinida'; + if (/\b(alta|urgente|imediato|hoje|agora|essa semana|parado|problema serio|1)\b/.test(text)) return 'alta'; + if (/\b(media|medio|proximo mes|em breve|avaliando|2)\b/.test(text)) return 'media'; + if (/\b(baixa|sem pressa|futuro|pesquisa|so pesquisando|3)\b/.test(text)) return 'baixa'; + + return 'indefinida'; +} + +export function normalizeCurrentSystem(value = '') { + const text = cleanText(value); + + if (!text) return 'nao_informado'; + if (/\b(trocar|insatisfeito|migrar|outro sistema|uso outro|4)\b/.test(text)) return 'quer_trocar'; + if (/\b(planilha|excel|manual|papel|3)\b/.test(text)) return 'planilhas'; + if (/\b(sim|tenho|uso sistema|ja uso|1)\b/.test(text)) return 'usa_sistema'; + if (/\b(nao|nenhum|nao tenho|sem sistema|2)\b/.test(text)) return 'sem_sistema'; + + return 'nao_informado'; +} + +export function normalizeLead(input = {}) { + const body = input.body && typeof input.body === 'object' ? input.body : input; + const contact = body.contact ?? body.contato ?? {}; + const company = body.company ?? body.empresa_dados ?? {}; + const message = body.message ?? body.mensagem ?? body.text ?? body.texto ?? body.description ?? ''; + const serviceRaw = firstDefined( + body.service, + body.servico, + body.interest, + body.interesse, + body.solution, + body.solucao, + message + ); + + const city = firstDefined(company.city, company.cidade, body.city, body.cidade); + const state = firstDefined(company.state, company.uf, body.state, body.uf, body.estado); + const segment = firstDefined(company.segment, company.segmento, body.segment, body.segmento); + const phone = normalizePhone(firstDefined(contact.phone, contact.whatsapp, body.phone, body.telefone, body.whatsapp, body.from)); + const name = firstDefined(contact.name, contact.nome, body.name, body.nome, body.contact_name, 'Lead sem nome'); + const email = firstDefined(contact.email, body.email); + const companyName = firstDefined(company.name, company.nome, body.company_name, body.empresa, body.nome_empresa, 'Empresa nao informada'); + const pains = toArray(firstDefined(body.pains, body.dores, body.pain, body.dor, body.maior_dificuldade)); + const source = firstDefined(body.source, body.origem, body.canal, 'indefinida'); + const service = detectService(serviceRaw); + const urgency = normalizeUrgency(firstDefined(body.urgency, body.urgencia, body.prazo, message)); + const currentSystem = normalizeCurrentSystem(firstDefined(body.current_system, body.sistema_atual, body.usa_sistema, body.sistema, message)); + const employees = numberFrom(firstDefined(company.employees, company.funcionarios, body.employees, body.funcionarios)); + const computers = numberFrom(firstDefined(company.computers, company.computadores, body.computers, body.computadores, body.usuarios)); + const whatsappVolume = numberFrom(firstDefined(body.whatsapp_volume, body.volume_whatsapp, body.mensagens_dia)); + const consent = Boolean(firstDefined(body.consent, body.consentimento, body.opt_in, body.lgpd, false)); + const cityKey = cleanText(city); + const segmentKey = cleanText(segment); + + return { + id: body.id ?? body.lead_id ?? `GTI-${Date.now()}-${Math.random().toString(36).slice(2, 8).toUpperCase()}`, + createdAt: body.created_at ?? body.createdAt ?? new Date().toISOString(), + source: String(source || 'indefinida'), + name: String(name), + phone, + email: String(email || ''), + companyName: String(companyName), + city: String(city || ''), + state: String(state || ''), + segment: String(segment || ''), + service, + serviceLabel: SERVICE_LABELS[service], + urgency, + urgencyLabel: URGENCY_LABELS[urgency], + currentSystem, + pains, + message: String(message || ''), + employees, + computers, + whatsappVolume, + consent, + isLocal: TARGET_CITY_ALIASES.has(cityKey) || (cityKey.includes('santo antonio') && cityKey.includes('jesus')), + isTargetSegment: isTargetSegmentMatch(segmentKey), + raw: body + }; +} + +export function prioritizeLead(lead) { + const hasWhatsapp = Boolean(lead.phone); + + return { + priority: hasWhatsapp ? 'com_whatsapp' : 'sem_whatsapp', + hasWhatsapp, + reasons: hasWhatsapp ? ['Possui WhatsApp/telefone para contato'] : [] + }; +} + +export function buildNextQuestion(lead) { + if (lead.service === 'indefinido') { + return [ + 'Para te direcionar melhor, escolha uma opcao:', + '1 - Sistema ERP / gestao empresarial', + '2 - Suporte e gestao de TI', + '3 - Autoatendimento para WhatsApp', + '4 - Ainda nao sei, preciso de orientacao' + ].join('\n'); + } + + if (lead.service === 'erp') { + if (!lead.segment) { + return [ + 'Qual o segmento da sua empresa?', + '1 - Comercio / loja', + '2 - Restaurante / delivery', + '3 - Mercado / supermercado', + '4 - Distribuidora', + '5 - Servicos', + '6 - Outro' + ].join('\n'); + } + + if (lead.currentSystem === 'nao_informado') { + return [ + 'Hoje voce ja usa algum sistema?', + '1 - Sim', + '2 - Nao', + '3 - Uso planilhas', + '4 - Uso outro sistema, mas quero trocar' + ].join('\n'); + } + + if (!lead.pains.length) { + return [ + 'Qual sua maior dificuldade hoje?', + '1 - Controle financeiro', + '2 - Estoque', + '3 - Vendas', + '4 - Emissao fiscal', + '5 - Relatorios', + '6 - Tudo muito manual' + ].join('\n'); + } + } + + if (lead.service === 'ti') { + if (!lead.computers) { + return [ + 'Sua empresa possui quantos computadores ou usuarios?', + '1 - Ate 5', + '2 - 6 a 15', + '3 - 16 a 30', + '4 - Mais de 30' + ].join('\n'); + } + + if (!lead.pains.length) { + return [ + 'Qual sua maior necessidade de TI?', + '1 - Suporte tecnico', + '2 - Servidores', + '3 - Rede / internet', + '4 - Backup', + '5 - Seguranca', + '6 - Terceirizar o TI' + ].join('\n'); + } + } + + if (lead.service === 'whatsapp') { + if (!lead.pains.length) { + return [ + 'Voce quer automatizar qual tipo de atendimento?', + '1 - Vendas', + '2 - Suporte', + '3 - Agendamento', + '4 - Cardapio / pedidos', + '5 - Triagem de mensagens', + '6 - Financeiro / segunda via' + ].join('\n'); + } + + if (!lead.whatsappVolume) { + return [ + 'Em media, quantas mensagens chegam no WhatsApp da empresa por dia?', + '1 - Ate 20', + '2 - 21 a 50', + '3 - 51 a 100', + '4 - Mais de 100' + ].join('\n'); + } + } + + if (lead.urgency === 'indefinida') { + return [ + 'Qual a urgencia para resolver isso?', + '1 - Alta, preciso resolver agora', + '2 - Media, quero avaliar para breve', + '3 - Baixa, estou pesquisando' + ].join('\n'); + } + + return [ + 'Pelo que voce informou, faz sentido um diagnostico rapido com a GTI.', + 'Escolha o melhor horario:', + '1 - Hoje pela manha', + '2 - Hoje a tarde', + '3 - Amanha pela manha', + '4 - Amanha a tarde' + ].join('\n'); +} + +export function buildInternalWhatsAppMessage(lead, priorityInfo) { + const pains = lead.pains.length ? lead.pains.join(', ') : 'Nao informado'; + const phone = lead.phone || 'Nao informado'; + const priority = priorityInfo.priority === 'com_whatsapp' ? 'COM WHATSAPP' : 'SEM WHATSAPP'; + + return [ + `Novo lead captado - ${priority}`, + '', + `Empresa: ${lead.companyName}`, + `Contato: ${lead.name}`, + `WhatsApp: ${phone}`, + `Cidade/UF: ${lead.city || 'Nao informada'}${lead.state ? `/${lead.state}` : ''}`, + `Segmento: ${lead.segment || 'Nao informado'}`, + `Origem: ${lead.source}`, + `Dores: ${pains}`, + '', + 'Acao recomendada: tratar manualmente pela equipe GTI.' + ].join('\n'); +} + +export function buildCrmRecord(lead, priorityInfo) { + return { + lead_id: lead.id, + data_criacao: lead.createdAt, + status: 'Captado', + prioridade: priorityInfo.priority, + tem_whatsapp: Boolean(lead.phone), + origem: lead.source, + servico: lead.service, + servico_label: lead.serviceLabel, + empresa: lead.companyName, + contato: lead.name, + whatsapp: lead.phone, + email: lead.email, + cidade: lead.city, + uf: lead.state, + segmento: lead.segment, + urgencia: lead.urgency, + sistema_atual: lead.currentSystem, + dores: lead.pains.join('; '), + funcionarios: lead.employees, + computadores: lead.computers, + volume_whatsapp: lead.whatsappVolume, + consentimento: lead.consent, + proxima_acao: 'Tratamento manual', + observacao: lead.message + }; +} + +export function processLead(input = {}, options = {}) { + const lead = normalizeLead(input); + const priority = prioritizeLead(lead); + const commercialWhatsApp = options.commercialWhatsApp || process.env.GTI_COMMERCIAL_WHATSAPP || '{{GTI_COMMERCIAL_WHATSAPP}}'; + + return { + lead, + priority, + crmRecord: buildCrmRecord(lead, priority), + whatsapp: { + internal: { + to: commercialWhatsApp, + message: buildInternalWhatsAppMessage(lead, priority) + } + }, + nextAction: 'tratamento_manual' + }; +} diff --git a/gti-maquina-comercial/src/outreach/campaign-messages.mjs b/gti-maquina-comercial/src/outreach/campaign-messages.mjs new file mode 100644 index 0000000..a77c5b0 --- /dev/null +++ b/gti-maquina-comercial/src/outreach/campaign-messages.mjs @@ -0,0 +1,14 @@ +import { renderTemplate } from './templates.mjs'; + +// Distribuicao deterministica por lead_id % variantes.length: o mesmo lead +// sempre cai na mesma variante mesmo que a fila seja regenerada depois, sem +// precisar guardar nenhum estado extra so pra isso. +export function variantForLead(variants, leadId) { + if (!variants.length) throw new Error('campanha sem variantes ativas'); + const index = Number(leadId) % variants.length; + return variants[index]; +} + +export function renderCampaignMessage(variant, lead) { + return renderTemplate(variant.mensagem, lead); +} diff --git a/gti-maquina-comercial/src/outreach/campaign-metrics.mjs b/gti-maquina-comercial/src/outreach/campaign-metrics.mjs new file mode 100644 index 0000000..6357512 --- /dev/null +++ b/gti-maquina-comercial/src/outreach/campaign-metrics.mjs @@ -0,0 +1,116 @@ +function dateKey(date) { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +function applyPause(db, campaignId, reason) { + db.prepare( + `UPDATE outreach_campaigns SET status = 'pausado', pause_reason = ?, updated_at = datetime('now') + WHERE id = ? AND status = 'rodando'` + ).run(reason, campaignId); +} + +// Mesmos 3 criterios e limiares numericos da Maquina Pessoal (10 tentativas/ +// 10% erro, 20 contatos/10% negativo+opt-out, 50 contatos/5% resposta), +// adaptados as tabelas da Comercial via JOIN por outreach_campaign_queue +// (nao por um valor de origem dedicado). +// +// PURA/read-only: so calcula e retorna {pause, reason}, nunca escreve no +// banco. Quem precisa da mutacao (pausar de verdade quando pause=true) deve +// chamar applyCampaignHealth, nao esta funcao — assim uma rota GET (como +// /api/campaigns/:id/saude, chamada so de abrir o painel na UI) nunca pausa +// uma campanha como efeito colateral de uma leitura. +export function evaluateCampaignHealth(db, campaignId, now = new Date()) { + const campaign = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId); + if (!campaign || campaign.status !== 'rodando') return { pause: false, reason: null }; + + const todayKey = dateKey(now); + const attemptsToday = Number(db.prepare( + `SELECT COUNT(*) AS total + FROM outreach_campaign_queue q + JOIN outreach_sends s ON s.id = q.outreach_send_id + WHERE q.campaign_id = ? AND date(s.sent_at, 'localtime') = ?` + ).get(campaignId, todayKey).total); + + if (attemptsToday >= 10) { + const failureCount = Number(db.prepare( + `SELECT COUNT(*) AS total + FROM outreach_campaign_queue q + JOIN outreach_sends s ON s.id = q.outreach_send_id + WHERE q.campaign_id = ? AND s.status = 'erro' AND date(s.sent_at, 'localtime') = ?` + ).get(campaignId, todayKey).total); + if (failureCount / attemptsToday > 0.10) { + return { pause: true, reason: 'high_failure_rate' }; + } + } + + const contacts = Number(db.prepare( + `SELECT COUNT(*) AS total FROM outreach_campaign_queue WHERE campaign_id = ? AND status IN ('sent','error')` + ).get(campaignId).total); + + if (contacts >= 20) { + const negativeCount = Number(db.prepare( + `SELECT COUNT(DISTINCT q.lead_id) AS total + FROM outreach_campaign_queue q + JOIN outreach_lead_states ls ON ls.lead_id = q.lead_id + WHERE q.campaign_id = ? AND q.status IN ('sent','error') + AND ls.stage IN ('sem_interesse', 'opt_out')` + ).get(campaignId).total); + if (negativeCount / contacts > 0.10) { + return { pause: true, reason: 'high_negative_rate' }; + } + } + + if (contacts >= 50) { + const respondedCount = Number(db.prepare( + `SELECT COUNT(DISTINCT q.lead_id) AS total + FROM outreach_campaign_queue q + JOIN outreach_stage_events se ON se.lead_id = q.lead_id AND se.campaign_id = q.campaign_id + WHERE q.campaign_id = ? AND q.status IN ('sent','error')` + ).get(campaignId).total); + if (respondedCount / contacts < 0.05) { + return { pause: true, reason: 'low_reply_rate' }; + } + } + + return { pause: false, reason: null }; +} + +// MUTANTE: chama a avaliacao pura acima e, se ela mandar pausar, persiste a +// pausa de verdade via applyPause. E isto que o worker deve chamar apos cada +// envio real — e so o worker, nunca uma rota de leitura. +export function applyCampaignHealth(db, campaignId, now = new Date()) { + const resultado = evaluateCampaignHealth(db, campaignId, now); + if (resultado.pause) { + applyPause(db, campaignId, resultado.reason); + } + return resultado; +} + +// Numero de leads distintos com resposta por variante, via subquery +// correlacionada (nao um JOIN direto) para nao contar duplicado quando um +// lead tem mais de um outreach_stage_events. +export function variantStats(db, campaignId) { + const rows = db.prepare( + `SELECT v.id AS variant_id, v.code, + COUNT(*) AS enviados, + (SELECT COUNT(DISTINCT q2.lead_id) + FROM outreach_campaign_queue q2 + JOIN outreach_stage_events se ON se.lead_id = q2.lead_id AND se.campaign_id = q2.campaign_id + WHERE q2.campaign_id = q.campaign_id AND q2.variant_id = v.id AND q2.status IN ('sent','error') + ) AS respostas + FROM outreach_campaign_queue q + JOIN outreach_campaign_variants v ON v.id = q.variant_id + WHERE q.campaign_id = ? AND q.status IN ('sent','error') + GROUP BY v.id, v.code` + ).all(campaignId); + return rows.map((row) => ({ + code: row.code, + enviados: row.enviados, + respostas: row.respostas, + taxaResposta: row.enviados > 0 ? row.respostas / row.enviados : 0, + elegivelParaComparacao: row.enviados >= 40 + })); +} diff --git a/gti-maquina-comercial/src/outreach/campaign-queue.mjs b/gti-maquina-comercial/src/outreach/campaign-queue.mjs new file mode 100644 index 0000000..e648d14 --- /dev/null +++ b/gti-maquina-comercial/src/outreach/campaign-queue.mjs @@ -0,0 +1,94 @@ +import { getCampaign } from './campaign-store.mjs'; +import { variantForLead, renderCampaignMessage } from './campaign-messages.mjs'; + +const ACTIVE_QUEUE_STATUSES = "'preview','approved','sent'"; + +function candidateQuery() { + return ` + SELECT leads.id, leads.empresa, leads.whatsapp, leads.segmento, leads.cidade, leads.uf, + leads.segmento_label, leads.prioridade, leads.score + FROM leads + WHERE leads.produto_principal = ? + AND leads.segmento = ? + AND leads.telefone_valido = 1 + AND leads.whatsapp != '' + AND NOT EXISTS ( + SELECT 1 FROM outreach_sends + WHERE outreach_sends.produto = ? AND outreach_sends.status = 'enviado' + AND outreach_sends.lead_id = leads.id + ) + AND NOT EXISTS ( + SELECT 1 FROM outreach_sends + WHERE outreach_sends.status = 'enviado' + AND outreach_sends.numero = leads.whatsapp + AND outreach_sends.origem = 'manual' + ) + AND NOT EXISTS ( + SELECT 1 FROM outreach_campaign_queue q + JOIN outreach_campaigns c ON c.id = q.campaign_id + WHERE q.lead_id = leads.id AND c.produto = ? AND q.status IN (${ACTIVE_QUEUE_STATUSES}) + ) + AND NOT EXISTS (SELECT 1 FROM outreach_opt_out WHERE numero = leads.whatsapp) + ORDER BY leads.prioridade ASC, leads.score DESC, leads.id ASC`; +} + +// Gera ate `quantidade` linhas novas de fila (status 'preview') para a +// campanha, buscando candidatos em todos os segmentos configurados. Nao e +// limitado ao limite diario de envio (isso so controla a VELOCIDADE de envio +// do worker) — o operador pode gerar e revisar um lote maior do que vai +// enviar hoje. +export function generateCampaignQueue(db, campaignId, { quantidade = 50 } = {}) { + const campaign = getCampaign(db, campaignId); + if (!campaign) throw new Error('campanha nao encontrada'); + if (!campaign.variants.length) throw new Error('campanha sem variantes ativas'); + if (!campaign.segmentos.length) throw new Error('campanha sem segmentos configurados'); + + const statement = db.prepare(candidateQuery()); + const todosCandidatos = campaign.segmentos + .flatMap((segmento) => statement.all(campaign.produto, segmento, campaign.produto, campaign.produto)) + .sort((a, b) => a.prioridade - b.prioridade || b.score - a.score || a.id - b.id); + const candidatos = todosCandidatos.slice(0, quantidade); + + const insert = db.prepare( + `INSERT INTO outreach_campaign_queue + (campaign_id, lead_id, variant_id, segmento, numero, rendered_message, status) + VALUES (?, ?, ?, ?, ?, ?, 'preview')` + ); + let adicionados = 0; + for (const lead of candidatos) { + const variant = variantForLead(campaign.variants, lead.id); + const renderedMessage = renderCampaignMessage(variant, lead); + insert.run(campaignId, lead.id, variant.id, lead.segmento, lead.whatsapp, renderedMessage); + adicionados += 1; + } + return { candidatosEncontrados: todosCandidatos.length, adicionadosNaFila: adicionados }; +} + +export function listQueue(db, campaignId, status = null) { + if (status) { + return db.prepare( + 'SELECT * FROM outreach_campaign_queue WHERE campaign_id = ? AND status = ? ORDER BY id' + ).all(campaignId, status); + } + return db.prepare( + 'SELECT * FROM outreach_campaign_queue WHERE campaign_id = ? ORDER BY id' + ).all(campaignId); +} + +export function approveQueueRows(db, campaignId, queueIds) { + if (!queueIds.length) return 0; + const placeholders = queueIds.map(() => '?').join(', '); + return db.prepare( + `UPDATE outreach_campaign_queue SET status = 'approved', updated_at = datetime('now') + WHERE campaign_id = ? AND status = 'preview' AND id IN (${placeholders})` + ).run(campaignId, ...queueIds).changes; +} + +export function cancelQueueRows(db, campaignId, queueIds, reason = 'operador') { + if (!queueIds.length) return 0; + const placeholders = queueIds.map(() => '?').join(', '); + return db.prepare( + `UPDATE outreach_campaign_queue SET status = 'cancelled', cancel_reason = ?, updated_at = datetime('now') + WHERE campaign_id = ? AND status IN ('preview','approved') AND id IN (${placeholders})` + ).run(reason, campaignId, ...queueIds).changes; +} diff --git a/gti-maquina-comercial/src/outreach/campaign-scheduler.mjs b/gti-maquina-comercial/src/outreach/campaign-scheduler.mjs new file mode 100644 index 0000000..ed0407e --- /dev/null +++ b/gti-maquina-comercial/src/outreach/campaign-scheduler.mjs @@ -0,0 +1,25 @@ +import { runCampaignQueue } from './campaign-worker.mjs'; +import { escalateWeeklyLimits } from './campaign-store.mjs'; + +const CICLO_MS = 20 * 60 * 1000; + +// Timer interno separado do scheduler.mjs existente (run.mjs/runOutreachBatch) +// — mesmo padrao (setInterval com .unref(), primeiro ciclo antecipado pouco +// depois do boot), rodando ao lado sem interferir um no outro. +export function startCampaignScheduler({ db, log = () => {} }) { + const ciclo = () => { + escalateWeeklyLimits(db); + runCampaignQueue({ db, log }).catch((error) => { + log(`campanhas: erro inesperado no ciclo automatico: ${error.message}`); + }); + }; + + const primeiro = setTimeout(ciclo, 45 * 1000); + primeiro.unref(); + const timer = setInterval(ciclo, CICLO_MS); + timer.unref(); + return () => { + clearTimeout(primeiro); + clearInterval(timer); + }; +} diff --git a/gti-maquina-comercial/src/outreach/campaign-store.mjs b/gti-maquina-comercial/src/outreach/campaign-store.mjs new file mode 100644 index 0000000..799c0bf --- /dev/null +++ b/gti-maquina-comercial/src/outreach/campaign-store.mjs @@ -0,0 +1,115 @@ +import { transaction } from '../db.mjs'; + +export function createCampaign(db, { + nome, produto, segmentos, variantes, limiteDiarioInicial = 10, limiteDiarioTeto = 80 +}) { + return transaction(db, () => { + const result = db.prepare( + `INSERT INTO outreach_campaigns + (nome, produto, segmentos_json, limite_diario_atual, limite_diario_teto) + VALUES (?, ?, ?, ?, ?)` + ).run(nome, produto, JSON.stringify(segmentos || []), limiteDiarioInicial, limiteDiarioTeto); + const campaignId = Number(result.lastInsertRowid); + const insertVariant = db.prepare( + `INSERT INTO outreach_campaign_variants (campaign_id, code, mensagem) VALUES (?, ?, ?)` + ); + for (const variante of variantes) { + insertVariant.run(campaignId, variante.code, variante.mensagem); + } + return getCampaign(db, campaignId); + }); +} + +export function getCampaign(db, id) { + const row = db.prepare('SELECT * FROM outreach_campaigns WHERE id = ?').get(id); + if (!row) return null; + return { + ...row, + segmentos: JSON.parse(row.segmentos_json || '[]'), + variants: listVariants(db, id) + }; +} + +export function listCampaigns(db) { + return db.prepare('SELECT * FROM outreach_campaigns ORDER BY id DESC').all() + .map((row) => ({ ...row, segmentos: JSON.parse(row.segmentos_json || '[]') })); +} + +export function listVariants(db, campaignId) { + return db.prepare( + 'SELECT * FROM outreach_campaign_variants WHERE campaign_id = ? AND ativo = 1 ORDER BY code' + ).all(campaignId); +} + +// status: 'rascunho' -> 'rodando' -> 'pausado' <-> 'rodando' -> 'concluido'. +// pause_reason so fica setado quando o novo status e 'pausado'. +export function setCampaignStatus(db, campaignId, status, pauseReason = null) { + if (status === 'rodando') { + // When transitioning to 'rodando', stamp semana_iniciada_em only on first run (if currently NULL) + db.prepare( + `UPDATE outreach_campaigns + SET status = ?, pause_reason = ?, semana_iniciada_em = COALESCE(semana_iniciada_em, datetime('now')), updated_at = datetime('now') + WHERE id = ?` + ).run(status, status === 'pausado' ? pauseReason : null, campaignId); + } else { + db.prepare( + `UPDATE outreach_campaigns SET status = ?, pause_reason = ?, updated_at = datetime('now') WHERE id = ?` + ).run(status, status === 'pausado' ? pauseReason : null, campaignId); + } + return getCampaign(db, campaignId); +} + +// So avanca campanha que estiver de fato pausada — retomar uma campanha em +// rascunho, por exemplo, deveria usar setCampaignStatus(db, id, 'rodando'). +export function resumeCampaign(db, campaignId) { + // Reseta semana_iniciada_em pra agora: uma campanha que ficou pausada (por + // saude ruim ou motivo manual) por varios dias nao pode, ao ser retomada, + // já satisfazer o "rodando ha 7+ dias" do escalateWeeklyLimits e dobrar o + // limite diario logo de cara — isso inverteria a logica de seguranca. + db.prepare( + `UPDATE outreach_campaigns + SET status = 'rodando', pause_reason = NULL, semana_iniciada_em = datetime('now'), updated_at = datetime('now') + WHERE id = ? AND status = 'pausado'` + ).run(campaignId); + return getCampaign(db, campaignId); +} + +// Escalonamento semanal: dobra o limite diario a cada 7 dias corridos rodando +// sem pausa desde semana_iniciada_em, ate o teto da campanha. So campanhas com +// status='rodando' avancam — uma pausada fica congelada ate o operador retomar. +export function escalateWeeklyLimits(db, now = new Date()) { + const campaigns = db.prepare( + `SELECT id, limite_diario_atual, limite_diario_teto, semana_iniciada_em + FROM outreach_campaigns WHERE status = 'rodando'` + ).all(); + let escaladas = 0; + for (const campaign of campaigns) { + if (!campaign.semana_iniciada_em) continue; + const dias = (now.getTime() - new Date(campaign.semana_iniciada_em.replace(' ', 'T') + 'Z').getTime()) / 86400000; + if (dias < 7) continue; + const novoLimite = Math.min(campaign.limite_diario_teto, campaign.limite_diario_atual * 2); + if (novoLimite === campaign.limite_diario_atual) continue; + db.prepare( + `UPDATE outreach_campaigns + SET limite_diario_atual = ?, semana_iniciada_em = datetime('now'), updated_at = datetime('now') + WHERE id = ?` + ).run(novoLimite, campaign.id); + escaladas += 1; + } + return escaladas; +} + +export function isOptedOut(db, numero) { + return Boolean(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get(numero)); +} + +export function addOptOut(db, numero, motivo = null) { + db.prepare( + `INSERT INTO outreach_opt_out (numero, motivo) VALUES (?, ?) + ON CONFLICT(numero) DO UPDATE SET motivo = excluded.motivo` + ).run(numero, motivo); +} + +export function removeOptOut(db, numero) { + db.prepare('DELETE FROM outreach_opt_out WHERE numero = ?').run(numero); +} diff --git a/gti-maquina-comercial/src/outreach/campaign-worker.mjs b/gti-maquina-comercial/src/outreach/campaign-worker.mjs new file mode 100644 index 0000000..cf7b4eb --- /dev/null +++ b/gti-maquina-comercial/src/outreach/campaign-worker.mjs @@ -0,0 +1,131 @@ +import { createZproClient } from './zpro-client.mjs'; +import { isOptedOut } from './campaign-store.mjs'; +import { applyCampaignHealth } from './campaign-metrics.mjs'; +import { getSetting } from '../db.mjs'; + +// Mesmo intervalo aleatorio entre envios do run.mjs (INTERVALO_MIN_MS/ +// INTERVALO_MAX_MS), duplicado aqui de proposito — os dois workers devem se +// comportar de forma consistente contra o mesmo numero de WhatsApp, mas sao +// modulos independentes (nao importar um do outro). +const SLEEP_MIN_MS = 3000; +const SLEEP_MAX_MS = 8000; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function randomSleepMs() { + return SLEEP_MIN_MS + Math.random() * (SLEEP_MAX_MS - SLEEP_MIN_MS); +} + +// Mesma logica de comparacao de string HH:MM do run.mjs. +function withinBusinessHours(horaInicio, horaFim, now = new Date()) { + const hhmm = now.toTimeString().slice(0, 5); + return hhmm >= horaInicio && hhmm <= horaFim; +} + +function todayAttempts(db, campaignId) { + return Number(db.prepare( + `SELECT COUNT(*) AS total + FROM outreach_campaign_queue q + JOIN outreach_sends s ON s.id = q.outreach_send_id + WHERE q.campaign_id = ? AND date(s.sent_at, 'localtime') = date('now', 'localtime')` + ).get(campaignId).total); +} + +// Diferente da Maquina Pessoal (que reserva o envio antes de mandar, porque a +// entrega via Baileys e assincrona/incerta), aqui o envio via Z-PRO +// (zproClient.sendText) e sincrono: ou resolve (aceito pelo Z-PRO) ou lanca +// erro. Por isso o resultado ja e gravado direto como 'enviado'/'erro', sem +// um estado intermediario "reservado" nem logica de recuperacao pos-crash. +export async function runCampaignQueue({ db, zproClient, now = new Date(), log = () => {}, sleepMs = null }) { + const client = zproClient || createZproClient(db); + const summary = { enviados: 0, erros: 0, pulados: 0 }; + + // Mesmas duas travas de seguranca do disparo automatico (run.mjs): + // interruptor geral e janela de horario comercial. Sem elas, o worker de + // campanha mandaria mensagem 24h/dia mesmo com o operador tendo desligado + // o outreach nas Configuracoes. + if (getSetting(db, 'outreach.ativo', 'false') !== 'true') { + log('campanhas: desativado (outreach.ativo=false)'); + return summary; + } + + const horaInicio = getSetting(db, 'outreach.horaInicio', '08:00'); + const horaFim = getSetting(db, 'outreach.horaFim', '18:00'); + if (!withinBusinessHours(horaInicio, horaFim, now)) { + log(`campanhas: fora do horario comercial (${horaInicio}-${horaFim})`); + return summary; + } + + const campaigns = db.prepare(`SELECT id FROM outreach_campaigns WHERE status = 'rodando'`).all(); + for (const { id: campaignId } of campaigns) { + const campaign = db.prepare('SELECT * FROM outreach_campaigns WHERE id = ?').get(campaignId); + let attemptsToday = todayAttempts(db, campaignId); + + while (attemptsToday < campaign.limite_diario_atual) { + const row = db.prepare( + `SELECT * FROM outreach_campaign_queue + WHERE campaign_id = ? AND status = 'approved' + ORDER BY id ASC LIMIT 1` + ).get(campaignId); + if (!row) break; + + if (isOptedOut(db, row.numero)) { + db.prepare( + `UPDATE outreach_campaign_queue SET status = 'cancelled', cancel_reason = 'opt_out', updated_at = datetime('now') WHERE id = ?` + ).run(row.id); + summary.pulados += 1; + continue; + } + + const jaEnviado = db.prepare( + `SELECT 1 FROM outreach_sends WHERE produto = ? AND status = 'enviado' + AND (lead_id = ? OR numero = ?) LIMIT 1` + ).get(campaign.produto, row.lead_id, row.numero); + if (jaEnviado) { + db.prepare( + `UPDATE outreach_campaign_queue SET status = 'cancelled', cancel_reason = 'ja_contatado', updated_at = datetime('now') WHERE id = ?` + ).run(row.id); + summary.pulados += 1; + continue; + } + + const externalKey = `campanha-${campaignId}-${row.id}`; + const insert = db.prepare( + `INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, erro, zpro_ticket_id, canal, origem) + VALUES (?, ?, ?, ?, ?, ?, ?, 'nao_oficial', 'automatico')` + ); + let sendId; + let queueStatus; + try { + const resposta = await client.sendText({ number: row.numero, message: row.rendered_message, externalKey }); + const ticketId = resposta?.data?.ticketId ? String(resposta.data.ticketId) : null; + sendId = Number(insert.run(row.lead_id, campaign.produto, row.numero, row.rendered_message, 'enviado', null, ticketId).lastInsertRowid); + queueStatus = 'sent'; + summary.enviados += 1; + } catch (error) { + sendId = Number(insert.run(row.lead_id, campaign.produto, row.numero, row.rendered_message, 'erro', String(error.message || error), null).lastInsertRowid); + queueStatus = 'error'; + summary.erros += 1; + log(`campanha #${campaignId}: envio falhou (fila #${row.id}): ${error.message}`); + } + db.prepare( + `UPDATE outreach_campaign_queue SET status = ?, outreach_send_id = ?, updated_at = datetime('now') WHERE id = ?` + ).run(queueStatus, sendId, row.id); + + attemptsToday += 1; + // Jitter entre tentativas reais de envio (nao entre pulos/cancelamentos, + // que nao batem no Z-PRO) — mesmo padrao de run.mjs, pra nao parecer + // disparo automatizado agressivo pro mesmo numero de WhatsApp. + await sleep(sleepMs != null ? sleepMs : randomSleepMs()); + + const health = applyCampaignHealth(db, campaignId, now); + if (health.pause) { + log(`campanha #${campaignId}: pausada automaticamente (${health.reason})`); + break; + } + } + } + return summary; +} diff --git a/gti-maquina-comercial/src/outreach/run.mjs b/gti-maquina-comercial/src/outreach/run.mjs new file mode 100644 index 0000000..6c15f42 --- /dev/null +++ b/gti-maquina-comercial/src/outreach/run.mjs @@ -0,0 +1,214 @@ +import { getSetting, listMessageTemplates } from '../db.mjs'; +import { renderTemplate } from './templates.mjs'; +import { createZproClient } from './zpro-client.mjs'; + +// Intervalo aleatorio entre envios (mesmo padrao dos endpoints de bulk do +// proprio Z-PRO) para nao parecer disparo automatizado agressivo. +const INTERVALO_MIN_MS = 3000; +const INTERVALO_MAX_MS = 8000; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function withinBusinessHours(horaInicio, horaFim, now = new Date()) { + const hhmm = now.toTimeString().slice(0, 5); + return hhmm >= horaInicio && hhmm <= horaFim; +} + +function lastEnviado(db, leadId, produto) { + return db.prepare( + "SELECT * FROM outreach_sends WHERE lead_id = ? AND produto = ? AND status = 'enviado' ORDER BY sent_at DESC LIMIT 1" + ).get(leadId, produto); +} + +// Decide qual produto tentar pra um lead, olhando o historico de envios: +// - cascata desligada (padrao): so tenta o produto principal, uma vez. +// - cascata ligada: percorre os produtos em ordem de estrelas; usa o primeiro +// nunca enviado, ou pula pro proximo se o ultimo envio ja passou do prazo +// de espera. Se o produto atual ainda esta "no prazo", o lead e pulado +// nessa rodada (nao aumenta o consumo do limite diario). +function pickProduto(db, lead, { tentarProximoProduto, diasEntreProdutos }) { + const produtos = JSON.parse(lead.produtos_json || '[]') + .slice() + .sort((a, b) => (b.estrelas || 0) - (a.estrelas || 0)) + .map((p) => p.nome) + .filter(Boolean); + if (!produtos.length) return null; + + if (tentarProximoProduto !== 'true') { + const produto = produtos[0]; + return lastEnviado(db, lead.id, produto) ? null : produto; + } + + for (const produto of produtos) { + const ultimo = lastEnviado(db, lead.id, produto); + if (!ultimo) return produto; + const diasPassados = (Date.now() - new Date(`${ultimo.sent_at}Z`).getTime()) / 86400000; + if (diasPassados < Number(diasEntreProdutos)) return null; + } + return null; +} + +function priorityTagName(prioridade) { + return `GTI P${prioridade || 4}`; +} + +function offerTagName(produto) { + return `GTI Oferta: ${produto}`; +} + +function templateVariablesForLead(template, lead, produto) { + let configured = []; + try { + configured = JSON.parse(template.template_waba_variaveis_json || '[]'); + } catch { + configured = []; + } + const variables = Array.isArray(configured) && configured.length + ? configured + : ['{empresa}', '{produto}']; + const data = { ...lead, produto }; + return variables + .map((value) => renderTemplate(String(value || ''), data).trim()) + .filter(Boolean); +} + +// Cache em memoria (por lote) de nome de tag -> id: evita chamar listTags a +// cada lead e evita criar a mesma tag duas vezes. +async function ensureTagIds(zproClient, nomes, cache) { + if (!cache.loaded) { + const resposta = await zproClient.listTags({ isActive: true }); + const lista = Array.isArray(resposta) ? resposta : resposta?.data || resposta?.tags || []; + for (const tag of lista) { + if (tag?.tag) cache.map.set(tag.tag, tag.id); + } + cache.loaded = true; + } + + const ids = []; + for (const nome of nomes) { + let id = cache.map.get(nome); + if (!id) { + const criada = await zproClient.createTag({ tag: nome }); + id = criada?.id || criada?.data?.id; + if (id) cache.map.set(nome, id); + } + if (id) ids.push(id); + } + return ids; +} + +// Um lote de disparo automatico: verifica se esta ligado/dentro do horario/ +// dentro do limite diario, escolhe candidatos elegiveis e manda uma mensagem +// por lead via bulkIndividual, registrando tudo em outreach_sends e aplicando +// tags de prioridade + oferta no contato do Z-PRO. +export async function runOutreachBatch({ db, zproClient = createZproClient(db), log = () => {} }) { + const resumo = { tentados: 0, enviados: 0, erros: 0, pulados: 0 }; + + if (getSetting(db, 'outreach.ativo', 'false') !== 'true') { + log('outreach: desativado (outreach.ativo=false)'); + return resumo; + } + + const horaInicio = getSetting(db, 'outreach.horaInicio', '08:00'); + const horaFim = getSetting(db, 'outreach.horaFim', '18:00'); + if (!withinBusinessHours(horaInicio, horaFim)) { + log(`outreach: fora do horario comercial (${horaInicio}-${horaFim})`); + return resumo; + } + + const limiteDiario = Number(getSetting(db, 'outreach.mensagensPorDia', '10')) || 0; + const jaEnviadosHoje = db.prepare( + "SELECT COUNT(*) AS t FROM outreach_sends WHERE status = 'enviado' AND date(sent_at) = date('now')" + ).get().t; + let restante = limiteDiario - jaEnviadosHoje; + if (restante <= 0) { + log(`outreach: limite diario atingido (${jaEnviadosHoje}/${limiteDiario})`); + return resumo; + } + + const tentarProximoProduto = getSetting(db, 'outreach.tentarProximoProduto', 'false'); + const diasEntreProdutos = getSetting(db, 'outreach.diasEntreProdutos', '5'); + const templates = new Map(listMessageTemplates(db).map((t) => [t.produto, t])); + + const candidatos = db.prepare( + `SELECT id, empresa, cidade, segmento, segmento_label, whatsapp, prioridade, produtos_json + FROM leads + WHERE exported_at IS NULL AND telefone_valido = 1 AND whatsapp != '' AND produto_principal != '' + ORDER BY prioridade ASC, score DESC` + ).all(); + + const insertSend = db.prepare( + `INSERT INTO outreach_sends (lead_id, produto, mensagem_enviada, status, erro, zpro_ticket_id, canal) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ); + const tagCache = { loaded: false, map: new Map() }; + + for (let i = 0; i < candidatos.length; i += 1) { + const lead = candidatos[i]; + if (restante <= 0) break; + + const produto = pickProduto(db, lead, { tentarProximoProduto, diasEntreProdutos }); + if (!produto) { + resumo.pulados += 1; + continue; + } + + const template = templates.get(produto); + if (!template || !template.ativo) { + resumo.pulados += 1; + continue; + } + + const canal = template.canal === 'oficial' ? 'oficial' : 'nao_oficial'; + if (canal === 'oficial' && !template.template_waba_nome) { + log(`outreach: produto "${produto}" no canal oficial sem template aprovado configurado — lead #${lead.id} pulado`); + resumo.pulados += 1; + continue; + } + + resumo.tentados += 1; + const externalKey = `gti-lead-${lead.id}-${produto}-${Date.now()}`; + const mensagem = canal === 'oficial' + ? `template:${template.template_waba_nome}/${template.template_waba_idioma}` + : renderTemplate(template.mensagem, lead); + + try { + const resposta = canal === 'oficial' + ? await zproClient.sendTemplateWaba({ + number: lead.whatsapp, + templateName: template.template_waba_nome, + languageCode: template.template_waba_idioma, + variables: templateVariablesForLead(template, lead, produto) + }) + : await zproClient.sendText({ number: lead.whatsapp, message: mensagem, externalKey }); + const ticketId = resposta?.ticketId || resposta?.data?.ticketId ? String(resposta.ticketId || resposta.data.ticketId) : null; + insertSend.run(lead.id, produto, mensagem, 'enviado', null, ticketId, canal); + resumo.enviados += 1; + restante -= 1; + + try { + const tagIds = await ensureTagIds( + zproClient, + [priorityTagName(lead.prioridade), offerTagName(produto)], + tagCache + ); + if (tagIds.length) await zproClient.addTagContact({ number: lead.whatsapp, tagIds }); + } catch (tagError) { + log(`outreach: falha ao aplicar tags do lead #${lead.id}: ${tagError.message}`); + } + } catch (error) { + insertSend.run(lead.id, produto, mensagem, 'erro', String(error.message || error), null, canal); + resumo.erros += 1; + log(`outreach: erro ao enviar lead #${lead.id} (${produto}): ${error.message}`); + } + + if (restante > 0 && i < candidatos.length - 1) { + await sleep(INTERVALO_MIN_MS + Math.random() * (INTERVALO_MAX_MS - INTERVALO_MIN_MS)); + } + } + + log(`outreach: ${resumo.enviados} enviados, ${resumo.erros} erros, ${resumo.pulados} pulados (tentados ${resumo.tentados})`); + return resumo; +} diff --git a/gti-maquina-comercial/src/outreach/scheduler.mjs b/gti-maquina-comercial/src/outreach/scheduler.mjs new file mode 100644 index 0000000..0b5a574 --- /dev/null +++ b/gti-maquina-comercial/src/outreach/scheduler.mjs @@ -0,0 +1,29 @@ +import { runOutreachBatch } from './run.mjs'; + +const CICLO_MS = 20 * 60 * 1000; + +// Timer interno no processo do servidor: nao depende de agendador do sistema +// operacional (Task Scheduler/cron), entao funciona identico local (Windows) +// ou dentro de um container Docker apos a migracao — so precisa do servidor +// estar de pe, o que ja e verdade hoje. runOutreachBatch e idempotente e sai +// cedo quando desligado/fora de horario/limite batido, entao rodar a cada +// 20 min e seguro sem lock ou fila. +export function startOutreachScheduler({ db, log = () => {} }) { + const ciclo = () => { + runOutreachBatch({ db, log }).catch((error) => { + log(`outreach: erro inesperado no ciclo automatico: ${error.message}`); + }); + }; + + // roda um primeiro ciclo pouco depois do boot (idempotente/seguro), sem + // esperar os 20 min inteiros para o primeiro lote do dia. + const primeiro = setTimeout(ciclo, 30 * 1000); + primeiro.unref(); + + const timer = setInterval(ciclo, CICLO_MS); + timer.unref(); + return () => { + clearTimeout(primeiro); + clearInterval(timer); + }; +} diff --git a/gti-maquina-comercial/src/outreach/templates.mjs b/gti-maquina-comercial/src/outreach/templates.mjs new file mode 100644 index 0000000..97713dc --- /dev/null +++ b/gti-maquina-comercial/src/outreach/templates.mjs @@ -0,0 +1,65 @@ +export const COMMERCIAL_VARIABLES = [ + { key: 'name', label: 'Nome do WhatsApp/contato' }, + { key: 'empresa', label: 'Nome da empresa' }, + { key: 'businessName', label: 'Nome da empresa (GChat)' }, + { key: 'firstName', label: 'Primeiro nome' }, + { key: 'produto', label: 'Produto recomendado' }, + { key: 'protocol', label: 'Assunto/protocolo' }, + { key: 'cidade', label: 'Cidade' }, + { key: 'uf', label: 'UF' }, + { key: 'segmento', label: 'Nicho/segmento' }, + { key: 'cnpj', label: 'CNPJ' }, + { key: 'email', label: 'E-mail' }, + { key: 'phoneNumber', label: 'WhatsApp/telefone' }, + { key: 'whatsapp', label: 'WhatsApp/telefone' }, + { key: 'kanban', label: 'Produto/interesse' }, + { key: 'user', label: 'Usuário/remetente' }, + { key: 'userEmail', label: 'E-mail do remetente' }, + { key: 'greeting', label: 'Saudação' } +]; + +export function commercialVariableValues(lead = {}) { + const empresa = lead.empresa || lead.businessName || lead.name || 'sua empresa'; + const produto = lead.produto || lead.produto_principal || lead.interesse || 'Atendimento comercial'; + const cidade = lead.cidade || 'sua regiao'; + const segmento = lead.segmento_label || lead.segmento || ''; + const phoneNumber = lead.whatsapp || lead.phoneNumber || ''; + const firstName = String(lead.name || empresa).split(/\s+/).filter(Boolean)[0] || empresa; + return { + name: lead.name || empresa, + empresa, + businessName: empresa, + firstName, + lastName: lead.lastName || '', + produto, + protocol: lead.protocol || produto, + cidade, + uf: lead.uf || '', + segmento, + cnpj: lead.cnpj || '', + email: lead.email || '', + phoneNumber, + whatsapp: phoneNumber, + kanban: lead.kanban || produto, + user: lead.user || 'Grupo GTI', + userEmail: lead.userEmail || 'comercial@gtigrupo.com.br', + greeting: 'Ola' + }; +} + +// Substituicao simples de variaveis no texto do template. Cada mensagem ja e +// resolvida por completo antes de chamar o Z-PRO (bulkIndividual manda texto +// pronto), entao nao dependemos da substituicao posicional por CSV que o +// bulkSendMessageWithVariable do Z-PRO usa. +export function renderTemplate(mensagem, lead) { + const values = commercialVariableValues(lead); + let output = String(mensagem || '') + .replaceAll('{empresa}', values.empresa) + .replaceAll('{cidade}', values.cidade) + .replaceAll('{produto}', values.produto) + .replaceAll('{segmento}', values.segmento); + for (const [key, value] of Object.entries(values)) { + output = output.replaceAll(`{{${key}}}`, value); + } + return output; +} diff --git a/gti-maquina-comercial/src/outreach/waba-template-library.mjs b/gti-maquina-comercial/src/outreach/waba-template-library.mjs new file mode 100644 index 0000000..9147ffe --- /dev/null +++ b/gti-maquina-comercial/src/outreach/waba-template-library.mjs @@ -0,0 +1,104 @@ +import { getSetting, setSetting } from '../db.mjs'; + +export const DEFAULT_WABA_TEMPLATES = [ + { name: 'gti_inicio_atendimento_solicitado', label: 'Inicio de atendimento solicitado', language: 'pt_BR', route: 'templateBody', variables: ['{empresa}', 'Atendimento sobre {produto}'] }, + { name: 'gti_agendamento_suporte_remoto', label: 'Agendamento de suporte remoto', language: 'pt_BR', route: 'templateBody', variables: ['{empresa}'] }, + { name: 'gti_alerta_instabilidade_cliente', label: 'Alerta de instabilidade', language: 'pt_BR', route: 'templateBody', variables: ['{empresa}', '{produto}'] }, + { name: 'gti_aviso_pendente_usuario', label: 'Aviso pendente usuario', language: 'pt_BR', route: 'templateBody', variables: ['{empresa}', '{produto}'] }, + { name: 'gti_chamado_em_andamento', label: 'Chamado em andamento', language: 'pt_BR', route: 'templateBody', variables: ['{empresa}', '{produto}'] }, + { name: 'confirma_suporte', label: 'Confirma suporte', language: 'pt_BR', route: 'templateBody', variables: ['{empresa}'] } +]; + +function defaultVariable(index) { + if (index === 0) return '{empresa}'; + if (index === 1) return '{produto}'; + return 'Informacao'; +} + +function normalizeTemplate(raw) { + const name = raw?.name || raw?.templateName || raw?.elementName; + if (!name || typeof name !== 'string') return null; + const language = raw?.language || raw?.languageCode || raw?.lang || raw?.locale || 'pt_BR'; + const category = raw?.category || raw?.templateCategory || ''; + const bodyComponent = Array.isArray(raw?.components) + ? raw.components.find((component) => String(component?.type || '').toUpperCase() === 'BODY') + : null; + const text = raw?.text || raw?.body || raw?.content || raw?.templateText || bodyComponent?.text || ''; + const matches = String(text).match(/{{\s*\d+\s*}}/g) || []; + const count = Math.max(...matches.map((match) => Number(match.replace(/\D/g, ''))), 0); + const variables = Array.from({ length: count }, (_, index) => defaultVariable(index)); + return { + name, + label: raw?.label || raw?.title || name, + language, + category, + status: raw?.status || raw?.templateStatus || '', + route: String(category).toUpperCase() === 'MARKETING' ? 'templateMarketingBody' : 'templateBody', + variables, + text + }; +} + +function collectTemplates(value, out = []) { + if (Array.isArray(value)) { + for (const item of value) collectTemplates(item, out); + return out; + } + if (!value || typeof value !== 'object') return out; + const normalized = normalizeTemplate(value); + if (normalized) out.push(normalized); + for (const child of Object.values(value)) { + if (child && typeof child === 'object') collectTemplates(child, out); + } + return out; +} + +function uniqueByName(templates) { + const map = new Map(); + for (const template of templates) { + if (!template?.name) continue; + const count = Math.max( + Array.isArray(template.variables) ? template.variables.length : 0, + ...((String(template.text || '').match(/{{\s*\d+\s*}}/g) || []).map((match) => Number(match.replace(/\D/g, '')))), + 0 + ); + template.variables = Array.from({ length: count }, (_, index) => String(template.variables?.[index] || '').trim() || defaultVariable(index)); + map.set(`${template.name}:${template.language || 'pt_BR'}`, template); + } + return [...map.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +export function listWabaTemplateLibrary(db) { + let synced = []; + try { + synced = JSON.parse(getSetting(db, 'gchat.wabaTemplatesJson', '[]')); + } catch { + synced = []; + } + return uniqueByName([...DEFAULT_WABA_TEMPLATES, ...synced]); +} + +export async function syncWabaTemplateLibrary(db) { + const url = getSetting(db, 'gchat.templatesUrl', '').trim(); + const token = getSetting(db, 'gchat.bearerToken', '').trim().replace(/^Bearer\s+/i, ''); + if (!url) { + return { ok: false, erro: 'configure gchat.templatesUrl com a URL de listagem de templates do GChat' }; + } + const headers = { accept: 'application/json' }; + if (token) headers.authorization = `Bearer ${token}`; + const response = await fetch(url, { headers }); + const text = await response.text(); + let data; + try { + data = text ? JSON.parse(text) : {}; + } catch { + return { ok: false, erro: `GChat retornou resposta nao-JSON (${response.status})` }; + } + if (!response.ok) { + return { ok: false, erro: `GChat templates falhou (${response.status}): ${JSON.stringify(data).slice(0, 300)}` }; + } + const templates = uniqueByName(collectTemplates(data)); + setSetting(db, 'gchat.wabaTemplatesJson', JSON.stringify(templates)); + setSetting(db, 'gchat.templatesLastSync', new Date().toISOString()); + return { ok: true, total: templates.length, templates }; +} diff --git a/gti-maquina-comercial/src/outreach/zpro-client.mjs b/gti-maquina-comercial/src/outreach/zpro-client.mjs new file mode 100644 index 0000000..fe4277d --- /dev/null +++ b/gti-maquina-comercial/src/outreach/zpro-client.mjs @@ -0,0 +1,212 @@ +import { envString } from '../env.mjs'; +import { getSetting } from '../db.mjs'; + +// Cliente fino para a API externa do Z-PRO (docs/referencia/Z-PRO.postman_collection.json). +// Todas as rotas: POST/GET https://{BaseUrl}/v2/api/external/{ApiID}/, auth Bearer. +// Credenciais vem da tela de Configuracoes > Credenciais Z-PRO (tabela settings, +// editavel sem reiniciar o servidor) — se estiverem vazias la, cai pro .env +// (ZPRO_BASE_URL/ZPRO_API_ID/ZPRO_BEARER_TOKEN/ZPRO_WHATSAPP_ID), util pra +// deploy via variavel de ambiente (Docker/Proxmox) sem depender da tela. + +// Aceita tanto "seu-dominio.com.br" quanto "https://seu-dominio.com.br/" (o +// que o usuario colar) — sem isso, colar a URL com protocolo virava +// "https://https://..." na hora de montar a chamada e todo teste falhava. +function normalizeBaseUrl(value) { + return value.trim().replace(/^https?:\/\//i, '').replace(/\/+$/, ''); +} + +function normalizeBearerToken(value) { + return String(value || '').trim().replace(/^Bearer\s+/i, '').trim(); +} + +function parseTicketId(data) { + if (data?.data?.ticketId) return String(data.data.ticketId); + if (data?.data?.id) return String(data.data.id); + + const message = data?.data?.message; + if (typeof message === 'string') { + const jsonStart = message.indexOf('{'); + if (jsonStart !== -1) { + try { + const parsed = JSON.parse(message.slice(jsonStart)); + if (parsed?.id) return String(parsed.id); + if (parsed?.ticketId) return String(parsed.ticketId); + } catch { + // Algumas respostas do Z-PRO embutem JSON em texto livre; se falhar, + // seguimos sem ticketId em vez de rejeitar uma chamada bem-sucedida. + } + } + } + + return null; +} + +function normalizeTemplateRoute(route) { + const allowed = new Set(['template', 'templateBody', 'templateMarketingBody']); + return allowed.has(route) ? route : 'template'; +} + +function config(db) { + const fromDbOrEnv = (settingKey, envKey) => { + const fromDb = db ? getSetting(db, settingKey, '') : ''; + return fromDb || envString(envKey, ''); + }; + return { + baseUrl: normalizeBaseUrl(fromDbOrEnv('zpro.baseUrl', 'ZPRO_BASE_URL')), + apiId: fromDbOrEnv('zpro.apiId', 'ZPRO_API_ID'), + bearerToken: normalizeBearerToken(fromDbOrEnv('zpro.bearerToken', 'ZPRO_BEARER_TOKEN')), + whatsappId: fromDbOrEnv('zpro.whatsappId', 'ZPRO_WHATSAPP_ID') + }; +} + +export function zproDiagnostics(db) { + const { baseUrl, apiId, bearerToken, whatsappId } = config(db); + return { + baseUrl: baseUrl || '', + apiId: apiId ? `${apiId.slice(0, 8)}...` : '', + token: bearerToken ? `${bearerToken.slice(0, 4)}...${bearerToken.slice(-4)} (${bearerToken.length} chars)` : '', + whatsappId: whatsappId || '', + configured: Boolean(baseUrl && apiId && bearerToken), + hasWhatsappId: Boolean(whatsappId) + }; +} + +export function zproConfigured(db) { + const { baseUrl, apiId, bearerToken } = config(db); + return Boolean(baseUrl && apiId && bearerToken); +} + +async function call(db, action, { method = 'POST', body, query } = {}) { + const { baseUrl, apiId, bearerToken } = config(db); + if (!baseUrl || !apiId || !bearerToken) { + throw new Error('Z-PRO nao configurado: preencha as credenciais em Configuracoes > Credenciais Z-PRO (ou ZPRO_BASE_URL/ZPRO_API_ID/ZPRO_BEARER_TOKEN no .env)'); + } + + const path = action ? `${apiId}/${action}` : apiId; + const url = new URL(`https://${baseUrl}/v2/api/external/${path}`); + if (query) { + for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value); + } + + const response = await fetch(url, { + method, + headers: { + authorization: `Bearer ${bearerToken}`, + 'content-type': 'application/json' + }, + body: method === 'GET' ? undefined : JSON.stringify(body ?? {}) + }); + + const text = await response.text(); + let data; + try { + data = text ? JSON.parse(text) : {}; + } catch { + data = { raw: text }; + } + + if (!response.ok) { + const apiError = data?.error || data?.message || data?.erro || ''; + if (response.status === 401 || response.status === 403 || apiError === 'ERR_SESSION_NOT_AUTH_TOKEN') { + throw new Error([ + `Z-PRO recusou a autenticacao (${response.status}${apiError ? ` / ${apiError}` : ''}).`, + 'Confira se o Token e o API ID sao os de Admin > API do Z-PRO, nao o token de sessao/login.', + 'Depois clique em "Testar conexao" antes de enviar mensagem.' + ].join(' ')); + } + if (apiError === 'ERR_SEND_TEMPLATE') { + throw new Error('Z-PRO recusou o template (ERR_SEND_TEMPLATE). Confirme se o API ID pertence ao mesmo canal WABA onde o template foi aprovado, se o idioma esta correto e se todas as variaveis do template foram preenchidas.'); + } + throw new Error(`Z-PRO ${action} falhou (${response.status}): ${JSON.stringify(data).slice(0, 500)}`); + } + if (data?.success === false || data?.error) { + const apiError = data?.error || data?.message || data?.erro || ''; + if (apiError === 'ERR_SEND_TEMPLATE') { + throw new Error('Z-PRO recusou o template (ERR_SEND_TEMPLATE). Confirme se o API ID pertence ao mesmo canal WABA onde o template foi aprovado, se o idioma esta correto e se todas as variaveis do template foram preenchidas.'); + } + throw new Error(`Z-PRO ${action} falhou: ${JSON.stringify(data).slice(0, 500)}`); + } + return data; +} + +// Cliente com as credenciais (settings + fallback .env) resolvidas uma vez e +// presas via closure — assim cada chamada do lote de outreach ja usa as +// credenciais atuais da tela de Configuracoes, sem precisar reiniciar o servidor. +export function createZproClient(db) { + return { + // Manda uma unica mensagem de texto pronta para um numero (docs: bulkIndividual). + // externalKey permite correlacionar a resposta/webhook do Z-PRO com o envio. + bulkIndividual({ number, message, externalKey }) { + const { whatsappId } = config(db); + return call(db, 'bulkIndividual', { body: { whatsappId, number, message, externalKey } }); + }, + + sendText({ number, message, externalKey, isClosed = false }) { + return call(db, '', { body: { body: message, number, externalKey, isClosed, validateNumber: true } }); + }, + + async createTicket({ number, message, externalKey, userId, channelId, queueId, status = 'pending' }) { + const body = { + body: message, + number, + externalKey, + userId: Number(userId), + status, + validateNumber: true + }; + if (channelId) body.channelId = Number(channelId); + if (queueId) body.queueId = Number(queueId); + + let response; + try { + response = await call(db, 'createTicket', { body }); + } catch (error) { + if (!channelId || !String(error.message || '').includes('createTicket falhou')) throw error; + const fallbackBody = { ...body }; + delete fallbackBody.channelId; + response = await call(db, 'createTicket', { body: fallbackBody }); + } + return { ...response, ticketId: parseTicketId(response) }; + }, + + listTags({ isActive = true } = {}) { + return call(db, 'listTags', { method: 'GET', query: { isActive: String(isActive) } }); + }, + + createTag({ tag, color = '#2A78D6', isActive = true }) { + return call(db, 'createTag', { body: { tag, color, isActive } }); + }, + + addTagContact({ number, tagIds }) { + return call(db, 'addTagContact', { body: { number, tagIds, validateNumber: true } }); + }, + + // Manda um template ja aprovado no Meta Business Manager (canal oficial/WABA). + // Diferente de bulkIndividual: nao aceita texto livre, so nome+idioma de um + // template que ja existe e ja foi aprovado (a aprovacao e feita fora do app). + sendTemplateWaba({ number, templateName, languageCode = 'pt_BR', route = 'template', variables = [] }) { + const template = { name: templateName, language: { code: languageCode } }; + const bodyParameters = variables + .filter((value) => value !== undefined && value !== null && String(value).trim() !== '') + .map((value) => ({ type: 'text', text: String(value) })); + + if (bodyParameters.length) { + template.components = [{ type: 'body', parameters: bodyParameters }]; + } + + return call(db, normalizeTemplateRoute(route), { + body: { + number, + isClosed: false, + validateNumber: true, + templateData: { + messaging_product: 'whatsapp', + to: number, + type: 'template', + template + } + } + }); + } + }; +} diff --git a/gti-maquina-comercial/src/scoring.mjs b/gti-maquina-comercial/src/scoring.mjs new file mode 100644 index 0000000..d620e82 --- /dev/null +++ b/gti-maquina-comercial/src/scoring.mjs @@ -0,0 +1,152 @@ +import { cleanText } from './lead-engine.mjs'; + +// Regras de validacao, score e deduplicacao do tratamento de leads. +// +// Dois indicadores separados: +// Score Qualidade (0-100, "os dados sao confiaveis?"): +30 WhatsApp valido, +// +20 CNPJ valido, +20 empresa valida, +5 cidade, +10 segmento, +10 CNAE, +// +5 website. +// Score Comercial (0-100, "qual a chance de comprar?"): aderencia do nicho +// (alta 40 / media 30 / baixa 20) + 15 website + 10 e-mail + 15 WhatsApp +// valido + 20 CNPJ valido. +// Score Geral = round(Comercial*0.6 + Qualidade*0.4) — usado pra ordenar a tela. +// Classificacao (por Score Geral): 90-100 excelente, 75-89 bom, 55-74 medio, +// 35-54 baixo, 0-34 muito_baixo. Prioridade comercial deriva 1:1 dela. +// Duplicidade: CNPJ valido > WhatsApp valido > empresa + cidade. + +export function onlyDigits(value = '') { + return String(value).replace(/\D/g, ''); +} + +export function validatePhone(value = '') { + const digits = onlyDigits(value); + if (!digits) return { valid: false, motivo: 'telefone vazio' }; + if (!digits.startsWith('55') || digits.length < 12 || digits.length > 13) { + return { valid: false, motivo: 'telefone invalido' }; + } + + const ddd = Number(digits.slice(2, 4)); + if (ddd < 11 || ddd > 99) return { valid: false, motivo: 'ddd invalido' }; + + const local = digits.slice(4); + if (/^(\d)\1+$/.test(local)) return { valid: false, motivo: 'telefone invalido' }; + + return { valid: true, motivo: 'telefone valido' }; +} + +export function validateCnpj(value = '') { + const cnpj = onlyDigits(value); + if (cnpj.length !== 14 || /^(\d)\1+$/.test(cnpj)) return false; + + const digit = (base) => { + let weight = base.length - 7; + let sum = 0; + for (const char of base) { + sum += Number(char) * weight; + weight -= 1; + if (weight < 2) weight = 9; + } + const rest = sum % 11; + return rest < 2 ? 0 : 11 - rest; + }; + + return digit(cnpj.slice(0, 12)) === Number(cnpj[12]) + && digit(cnpj.slice(0, 13)) === Number(cnpj[13]); +} + +export function validateEmpresa(nome = '') { + const texto = String(nome ?? '').trim(); + const limpo = cleanText(texto); + + if (!limpo || limpo.length < 2) { + return { valid: false, motivo: 'empresa vazia ou com lixo' }; + } + if (/^\d/.test(texto)) { + return { valid: false, motivo: 'empresa comeca com numero' }; + } + if (!/[a-z]/.test(limpo)) { + return { valid: false, motivo: 'empresa vazia ou com lixo' }; + } + + return { valid: true, motivo: 'empresa valida' }; +} + +// "Os dados deste lead sao confiaveis?" — nao mede potencial de compra. +export function scoreQualidade({ whatsapp, cnpj, empresa, cidade, segmento, cnae, website }) { + const telefone = validatePhone(whatsapp); + const cnpjValido = validateCnpj(cnpj); + const empresaCheck = validateEmpresa(empresa); + + let score = 0; + if (telefone.valid) score += 30; + if (cnpjValido) score += 20; + if (empresaCheck.valid) score += 20; + if (String(cidade ?? '').trim()) score += 5; + if (String(segmento ?? '').trim()) score += 10; + if (onlyDigits(cnae)) score += 10; + if (String(website ?? '').trim()) score += 5; + + return { + score, + telefoneValido: telefone.valid, + motivoTelefone: telefone.motivo, + cnpjValido, + empresaValida: empresaCheck.valid, + motivoEmpresa: empresaCheck.motivo + }; +} + +const AFFINITY_POINTS = { alta: 40, media: 30, baixa: 20 }; + +// "Qual a chance desta empresa contratar uma solucao da GTI?" +export function scoreComercial({ whatsapp, cnpj, website, email, affinity }) { + let score = AFFINITY_POINTS[affinity] ?? AFFINITY_POINTS.media; + if (String(website ?? '').trim()) score += 15; + if (String(email ?? '').trim()) score += 10; + if (validatePhone(whatsapp).valid) score += 15; + if (validateCnpj(cnpj)) score += 20; + return score; +} + +export function scoreGeral(scoreQualidadeValor, scoreComercialValor) { + return Math.round(scoreComercialValor * 0.6 + scoreQualidadeValor * 0.4); +} + +// Prioridade comercial: 1 = atacar imediatamente, 4 = so em campanhas especificas. +export const CLASSIFICACAO_TIERS = [ + { min: 90, id: 'excelente', label: 'Excelente', prioridade: 1 }, + { min: 75, id: 'bom', label: 'Bom', prioridade: 1 }, + { min: 55, id: 'medio', label: 'Médio', prioridade: 2 }, + { min: 35, id: 'baixo', label: 'Baixo', prioridade: 3 }, + { min: 0, id: 'muito_baixo', label: 'Muito Baixo', prioridade: 4 } +]; + +export function classificarScoreGeral(score) { + return CLASSIFICACAO_TIERS.find((tier) => score >= tier.min); +} + +// Chave byte-compativel com a chave_duplicidade do TRATADOS (cnpj:...) e com o +// registro legado de envios (phone:... / name::). +export function dedupKey({ cnpj, whatsapp, empresa, cidade }) { + const cnpjDigits = onlyDigits(cnpj); + if (validateCnpj(cnpjDigits)) return `cnpj:${cnpjDigits}`; + + const phoneDigits = onlyDigits(whatsapp); + if (validatePhone(phoneDigits).valid) return `phone:${phoneDigits}`; + + return `name:${cleanText(empresa)}:${cleanText(cidade)}`; +} + +export function extractDdd(whatsapp = '') { + const digits = onlyDigits(whatsapp); + return digits.startsWith('55') && digits.length >= 12 ? digits.slice(2, 4) : ''; +} + +// 'lojas_de_calcados' -> 'Lojas De Calcados' (mesmo formato do segmento_legivel legado) +export function segmentLabel(name = '') { + return String(name) + .split('_') + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} diff --git a/gti-maquina-comercial/src/server/index.mjs b/gti-maquina-comercial/src/server/index.mjs new file mode 100644 index 0000000..37a4e77 --- /dev/null +++ b/gti-maquina-comercial/src/server/index.mjs @@ -0,0 +1,130 @@ +import http from 'node:http'; +import { paths } from '../env.mjs'; +import { createRouter } from './router.mjs'; +import { serveStatic } from './static.mjs'; +import { openSse } from './sse.mjs'; +import { registerMetaRoutes } from './routes/meta.mjs'; +import { registerCaptureRoutes } from './routes/capture.mjs'; +import { registerExtractionRoutes } from './routes/extractions.mjs'; +import { registerLeadRoutes } from './routes/leads.mjs'; +import { registerExportRoutes } from './routes/exports.mjs'; +import { registerAuthRoutes } from './routes/auth.mjs'; +import { registerOutreachRoutes } from './routes/outreach.mjs'; +import { registerCampaignRoutes } from './routes/campaigns.mjs'; +import { getCookie, getSession, SESSION_COOKIE } from './session.mjs'; + +const PUBLIC_API_PATHS = new Set(['/api/auth/login', '/api/health']); + +const BODY_LIMIT = 1024 * 1024; // 1MB + +function readJsonBody(req) { + return new Promise((resolve, reject) => { + let size = 0; + const chunks = []; + req.on('data', (chunk) => { + size += chunk.length; + if (size > BODY_LIMIT) { + reject(new Error('corpo da requisicao muito grande')); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => { + if (!chunks.length) { + resolve(null); + return; + } + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + } catch { + reject(new Error('JSON invalido no corpo da requisicao')); + } + }); + req.on('error', reject); + }); +} + +export function createAppServer({ db, jobs, log = () => {} }) { + const router = createRouter(); + const deps = { db, jobs }; + + registerAuthRoutes(router, deps); + registerMetaRoutes(router, deps); + registerCaptureRoutes(router, deps); + registerExtractionRoutes(router, deps); + registerLeadRoutes(router, deps); + registerExportRoutes(router, deps); + registerOutreachRoutes(router, deps); + registerCampaignRoutes(router, deps); + + // SSE de progresso dos jobs (rota especial: gerencia a resposta manualmente) + router.add('GET', '/api/jobs/:jobId/events', async (req, res, { params }) => { + const sse = openSse(res); + const unsubscribe = jobs.subscribe(params.jobId, (event) => { + const name = event.t === 'done' ? 'done' : event.t === 'error' ? 'error' : event.t; + sse.send(name, event); + }); + + if (unsubscribe === null) { + sse.send('error', { t: 'error', message: 'job nao encontrado (o servidor pode ter sido reiniciado)' }); + sse.close(); + return; + } + + req.on('close', () => { + unsubscribe(); + sse.close(); + }); + }); + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://127.0.0.1'); + const pathname = url.pathname; + + const json = (status, data) => { + res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(data)); + }; + + const session = getSession(getCookie(req, SESSION_COOKIE)); + const user = session?.user || null; + + try { + if (pathname.startsWith('/api/') && !PUBLIC_API_PATHS.has(pathname) && !user) { + json(401, { erro: 'não autenticado' }); + return; + } + + const matched = router.match(req.method, pathname); + if (matched) { + let body = null; + if (req.method === 'POST' || req.method === 'PUT') { + body = await readJsonBody(req); + } + await matched.handler(req, res, { params: matched.params, query: url.searchParams, body, json, user }); + return; + } + + if (req.method === 'GET' && !pathname.startsWith('/api/')) { + if ((pathname === '/' || pathname === '/index.html') && !user) { + res.writeHead(302, { location: '/login.html' }); + res.end(); + return; + } + if (serveStatic(paths.public, pathname, res)) return; + } + + json(404, { erro: `rota nao encontrada: ${req.method} ${pathname}` }); + } catch (error) { + log(`erro em ${req.method} ${pathname}: ${error.stack || error.message}`); + if (!res.headersSent) { + json(500, { erro: String(error.message || error) }); + } else { + res.end(); + } + } + }); + + return server; +} diff --git a/gti-maquina-comercial/src/server/router.mjs b/gti-maquina-comercial/src/server/router.mjs new file mode 100644 index 0000000..8dcb263 --- /dev/null +++ b/gti-maquina-comercial/src/server/router.mjs @@ -0,0 +1,33 @@ +// Roteador minimo: metodo + padrao com :parametros. Sem dependencias. +export function createRouter() { + const routes = []; + + function add(method, pattern, handler) { + const parts = pattern.split('/').filter(Boolean); + routes.push({ method, parts, handler, pattern }); + } + + function match(method, pathname) { + const parts = pathname.split('/').filter(Boolean); + + for (const route of routes) { + if (route.method !== method || route.parts.length !== parts.length) continue; + + const params = {}; + let ok = true; + for (let i = 0; i < parts.length; i += 1) { + const expected = route.parts[i]; + if (expected.startsWith(':')) { + params[expected.slice(1)] = decodeURIComponent(parts[i]); + } else if (expected !== parts[i]) { + ok = false; + break; + } + } + if (ok) return { handler: route.handler, params }; + } + return null; + } + + return { add, match }; +} diff --git a/gti-maquina-comercial/src/server/routes/auth.mjs b/gti-maquina-comercial/src/server/routes/auth.mjs new file mode 100644 index 0000000..f46f6b5 --- /dev/null +++ b/gti-maquina-comercial/src/server/routes/auth.mjs @@ -0,0 +1,74 @@ +import { findUserByUsername, createUser, listUsers } from '../../db.mjs'; +import { hashPassword, verifyPassword } from '../../auth.mjs'; +import { createSession, destroySession, getCookie, sessionCookieHeader, clearSessionCookieHeader, SESSION_COOKIE } from '../session.mjs'; + +function publicUser(user) { + return { id: user.id, username: user.username, role: user.role }; +} + +export function registerAuthRoutes(router, { db }) { + router.add('POST', '/api/auth/login', async (req, res, { json, body }) => { + const username = String(body?.username || '').trim(); + const password = String(body?.password || ''); + const user = username ? findUserByUsername(db, username) : null; + + if (!user || !verifyPassword(password, user.password_hash)) { + json(401, { erro: 'usuário ou senha inválidos' }); + return; + } + + const token = createSession(publicUser(user)); + res.setHeader('Set-Cookie', sessionCookieHeader(token)); + json(200, { user: publicUser(user) }); + }); + + router.add('POST', '/api/auth/logout', async (req, res, { json }) => { + const token = getCookie(req, SESSION_COOKIE); + if (token) destroySession(token); + res.setHeader('Set-Cookie', clearSessionCookieHeader()); + json(200, { ok: true }); + }); + + router.add('GET', '/api/auth/me', async (req, res, { json, user }) => { + if (!user) { + json(401, { erro: 'não autenticado' }); + return; + } + json(200, { user }); + }); + + router.add('GET', '/api/users', async (req, res, { json, user }) => { + if (user?.role !== 'admin') { + json(403, { erro: 'apenas administradores podem ver os usuários' }); + return; + } + json(200, { users: listUsers(db) }); + }); + + router.add('POST', '/api/users', async (req, res, { json, user, body }) => { + if (user?.role !== 'admin') { + json(403, { erro: 'apenas administradores podem criar usuários' }); + return; + } + + const username = String(body?.username || '').trim(); + const password = String(body?.password || ''); + const role = body?.role === 'admin' ? 'admin' : 'comercial'; + + if (username.length < 3) { + json(400, { erro: 'usuário deve ter ao menos 3 caracteres' }); + return; + } + if (password.length < 4) { + json(400, { erro: 'senha deve ter ao menos 4 caracteres' }); + return; + } + if (findUserByUsername(db, username)) { + json(409, { erro: 'já existe um usuário com esse nome' }); + return; + } + + createUser(db, { username, passwordHash: hashPassword(password), role }); + json(201, { ok: true }); + }); +} diff --git a/gti-maquina-comercial/src/server/routes/campaigns.mjs b/gti-maquina-comercial/src/server/routes/campaigns.mjs new file mode 100644 index 0000000..8ccc8f0 --- /dev/null +++ b/gti-maquina-comercial/src/server/routes/campaigns.mjs @@ -0,0 +1,162 @@ +import { + createCampaign, getCampaign, listCampaigns, setCampaignStatus, resumeCampaign, + addOptOut, removeOptOut +} from '../../outreach/campaign-store.mjs'; +import { generateCampaignQueue, listQueue, approveQueueRows, cancelQueueRows } from '../../outreach/campaign-queue.mjs'; +import { evaluateCampaignHealth, variantStats } from '../../outreach/campaign-metrics.mjs'; +import { onlyDigits } from '../../scoring.mjs'; + +export function registerCampaignRoutes(router, { db }) { + router.add('POST', '/api/campaigns', async (req, res, { json, body }) => { + const nome = String(body?.nome || '').trim(); + const produto = String(body?.produto || '').trim(); + const segmentos = Array.isArray(body?.segmentos) ? body.segmentos.filter(Boolean) : []; + const variantesValidas = Array.isArray(body?.variantes) + ? body.variantes.filter((v) => v && String(v.mensagem || '').trim()) + : []; + if (!nome || !produto) { json(400, { erro: 'informe nome e produto' }); return; } + if (!segmentos.length) { json(400, { erro: 'escolha ao menos um segmento' }); return; } + if (!variantesValidas.length) { json(400, { erro: 'escreva ao menos uma variante de mensagem' }); return; } + + const campanha = createCampaign(db, { + nome, produto, segmentos, + variantes: variantesValidas.map((v, i) => ({ code: v.code || String.fromCharCode(65 + i), mensagem: v.mensagem })), + limiteDiarioInicial: Number(body.limiteDiarioInicial) || 10, + limiteDiarioTeto: Number(body.limiteDiarioTeto) || 80 + }); + json(200, campanha); + }); + + router.add('GET', '/api/campaigns', async (req, res, { json }) => { + json(200, { campanhas: listCampaigns(db) }); + }); + + // Precisa vir ANTES de GET /api/campaigns/:id (mesmo numero de segmentos no + // path — o router retorna a primeira rota que casar). + router.add('GET', '/api/campaigns/segmentos', async (req, res, { json, query }) => { + const produto = String(query?.get('produto') || '').trim(); + if (!produto) { json(400, { erro: 'informe o produto' }); return; } + const segmentos = db.prepare( + `SELECT segmento AS value, COUNT(*) AS total FROM leads + WHERE produto_principal = ? AND segmento != '' GROUP BY segmento ORDER BY total DESC` + ).all(produto); + json(200, { segmentos }); + }); + + router.add('GET', '/api/campaigns/produtos', async (req, res, { json }) => { + const produtos = db.prepare( + `SELECT produto_principal AS value, COUNT(*) AS total FROM leads + WHERE produto_principal != '' GROUP BY produto_principal ORDER BY total DESC` + ).all(); + json(200, { produtos }); + }); + + router.add('GET', '/api/campaigns/:id', async (req, res, { json, params }) => { + const campanha = getCampaign(db, Number(params.id)); + if (!campanha) { json(404, { erro: 'campanha nao encontrada' }); return; } + json(200, campanha); + }); + + router.add('POST', '/api/campaigns/:id/gerar-fila', async (req, res, { json, params, body }) => { + try { + const resultado = generateCampaignQueue(db, Number(params.id), { quantidade: Number(body?.quantidade) || 50 }); + json(200, resultado); + } catch (error) { + json(400, { erro: String(error.message || error) }); + } + }); + + router.add('GET', '/api/campaigns/:id/fila', async (req, res, { json, params, query }) => { + json(200, { fila: listQueue(db, Number(params.id), query?.get('status') || null) }); + }); + + router.add('POST', '/api/campaigns/:id/fila/aprovar', async (req, res, { json, params, body }) => { + const queueIds = Array.isArray(body?.queueIds) ? body.queueIds.map(Number).filter(Number.isFinite) : []; + json(200, { aprovados: approveQueueRows(db, Number(params.id), queueIds) }); + }); + + router.add('POST', '/api/campaigns/:id/fila/cancelar', async (req, res, { json, params, body }) => { + const queueIds = Array.isArray(body?.queueIds) ? body.queueIds.map(Number).filter(Number.isFinite) : []; + json(200, { cancelados: cancelQueueRows(db, Number(params.id), queueIds, body?.motivo || 'operador') }); + }); + + router.add('POST', '/api/campaigns/:id/iniciar', async (req, res, { json, params }) => { + json(200, setCampaignStatus(db, Number(params.id), 'rodando')); + }); + + router.add('POST', '/api/campaigns/:id/pausar', async (req, res, { json, params, body }) => { + json(200, setCampaignStatus(db, Number(params.id), 'pausado', body?.motivo || 'manual')); + }); + + router.add('POST', '/api/campaigns/:id/retomar', async (req, res, { json, params }) => { + json(200, resumeCampaign(db, Number(params.id))); + }); + + // So le o status atual — nao aplica pausa. A aplicacao de pausa (mutacao) + // acontece exclusivamente no worker (applyCampaignHealth), nunca como efeito + // colateral de abrir o painel de detalhe da campanha. + router.add('GET', '/api/campaigns/:id/saude', async (req, res, { json, params }) => { + json(200, evaluateCampaignHealth(db, Number(params.id))); + }); + + router.add('GET', '/api/campaigns/:id/variantes-stats', async (req, res, { json, params }) => { + json(200, { variantes: variantStats(db, Number(params.id)) }); + }); + + router.add('POST', '/api/campaigns/leads/:leadId/estagio', async (req, res, { json, params, body }) => { + const stage = String(body?.stage || '').trim(); + if (!stage) { json(400, { erro: 'informe o estagio' }); return; } + const leadId = Number(params.leadId); + db.prepare( + `INSERT INTO outreach_lead_states (lead_id, stage, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(lead_id) DO UPDATE SET stage = excluded.stage, updated_at = excluded.updated_at` + ).run(leadId, stage); + db.prepare( + `INSERT INTO outreach_stage_events (lead_id, campaign_id, stage, note) VALUES (?, ?, ?, ?)` + ).run(leadId, body?.campaignId ? Number(body.campaignId) : null, stage, body?.note || null); + // Marcar 'opt_out' via estagio precisa REALMENTE tirar o lead de circulacao + // (senao ele continua elegivel na candidateQuery/worker) — nao basta + // registrar o estagio, tem que gravar em outreach_opt_out tambem. + if (stage === 'opt_out') { + const lead = db.prepare('SELECT whatsapp FROM leads WHERE id = ?').get(leadId); + const numero = onlyDigits(lead?.whatsapp || ''); + if (numero) addOptOut(db, numero, 'marcado via estagio'); + } + json(200, { ok: true }); + }); + + router.add('GET', '/api/campaigns/leads/:leadId/estagio', async (req, res, { json, params }) => { + const leadId = Number(params.leadId); + json(200, { + atual: db.prepare('SELECT * FROM outreach_lead_states WHERE lead_id = ?').get(leadId) || null, + historico: db.prepare('SELECT * FROM outreach_stage_events WHERE lead_id = ? ORDER BY id DESC').all(leadId) + }); + }); + + router.add('POST', '/api/campaigns/leads/:leadId/agendamentos', async (req, res, { json, params, body }) => { + const kind = String(body?.kind || '').trim(); + if (!kind) { json(400, { erro: 'informe o tipo de agendamento' }); return; } + db.prepare( + `INSERT INTO outreach_appointments (lead_id, kind, scheduled_at, note) VALUES (?, ?, ?, ?)` + ).run(Number(params.leadId), kind, body?.scheduledAt || null, body?.note || null); + json(200, { ok: true }); + }); + + router.add('GET', '/api/campaigns/leads/:leadId/agendamentos', async (req, res, { json, params }) => { + json(200, { + agendamentos: db.prepare('SELECT * FROM outreach_appointments WHERE lead_id = ? ORDER BY id DESC').all(Number(params.leadId)) + }); + }); + + router.add('POST', '/api/campaigns/opt-out', async (req, res, { json, body }) => { + const numero = onlyDigits(body?.numero || ''); + if (!numero) { json(400, { erro: 'informe o numero' }); return; } + addOptOut(db, numero, body?.motivo || null); + json(200, { ok: true }); + }); + + router.add('DELETE', '/api/campaigns/opt-out/:numero', async (req, res, { json, params }) => { + removeOptOut(db, onlyDigits(params.numero)); + json(200, { ok: true }); + }); +} diff --git a/gti-maquina-comercial/src/server/routes/capture.mjs b/gti-maquina-comercial/src/server/routes/capture.mjs new file mode 100644 index 0000000..2473373 --- /dev/null +++ b/gti-maquina-comercial/src/server/routes/capture.mjs @@ -0,0 +1,112 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { paths } from '../../env.mjs'; +import { UF_NAMES } from '../../sources/common.mjs'; +import { getSource } from '../../sources/index.mjs'; + +function hasCnpjBaseForUf(uf) { + return fs.existsSync(path.join(paths.cnpjDir, `estabelecimentos-${uf}.csv`)) + || (uf === 'BA' && fs.existsSync(path.join(paths.cnpjDir, 'estabelecimentos-bahia.csv'))); +} + +export function registerCaptureRoutes(router, { db, jobs }) { + router.add('POST', '/api/capture/runs', async (req, res, { json, body }) => { + const sources = Array.isArray(body?.sources) ? body.sources.filter(Boolean) : []; + const uf = String(body?.uf || '').toUpperCase(); + const cities = Array.isArray(body?.cities) && body.cities.length ? body.cities : ['all']; + const segments = Array.isArray(body?.segments) && body.segments.length ? body.segments : ['all']; + const limits = { + maxPerQuery: Math.max(1, Number(body?.limits?.maxPerQuery) || 10), + queryLimit: Math.max(0, Number(body?.limits?.queryLimit) || 0), + googlePages: Math.min(3, Math.max(1, Number(body?.limits?.googlePages) || 3)) + }; + + if (!sources.length) { + json(400, { erro: 'selecione pelo menos uma fonte' }); + return; + } + for (const id of sources) { + if (!getSource(id)) { + json(400, { erro: `fonte desconhecida: ${id}` }); + return; + } + } + if (!UF_NAMES[uf]) { + json(400, { erro: 'informe uma UF valida' }); + return; + } + if ((sources.includes('cnpj-base') || sources.includes('brasilapi')) && !hasCnpjBaseForUf(uf)) { + json(409, { + erro: `a base CNPJ de ${uf} ainda nao foi extraida`, + precisaExtracao: true, + uf + }); + return; + } + + const running = db.prepare("SELECT id FROM capture_runs WHERE status IN ('pending','running')").get(); + if (running) { + json(409, { erro: `ja existe uma captura em andamento (#${running.id}). Aguarde ou cancele.` }); + return; + } + + const params = { sources, uf, cities, segments, limits }; + const inserted = db.prepare("INSERT INTO capture_runs (params_json, status) VALUES (?, 'pending')") + .run(JSON.stringify(params)); + const runId = Number(inserted.lastInsertRowid); + + const jobId = jobs.startJob({ + type: 'captura', + script: 'src/capture/capture-job.mjs', + args: [`--run-id=${runId}`], + meta: { runId, uf, sources } + }); + db.prepare('UPDATE capture_runs SET job_id = ? WHERE id = ?').run(jobId, runId); + + json(202, { runId, jobId }); + }); + + router.add('GET', '/api/capture/runs', async (req, res, { json, query }) => { + const limit = Math.min(100, Math.max(1, Number(query.get('limit')) || 20)); + const rows = db.prepare( + `SELECT id, job_id, params_json, status, progress_json, raw_count, new_count, dup_count, + error, started_at, finished_at, created_at + FROM capture_runs ORDER BY id DESC LIMIT ?` + ).all(limit); + + json(200, { + runs: rows.map((row) => ({ + ...row, + params: JSON.parse(row.params_json || '{}'), + progress: JSON.parse(row.progress_json || 'null'), + params_json: undefined, + progress_json: undefined + })) + }); + }); + + router.add('GET', '/api/capture/runs/:id', async (req, res, { json, params }) => { + const row = db.prepare('SELECT * FROM capture_runs WHERE id = ?').get(Number(params.id)); + if (!row) { + json(404, { erro: 'execucao nao encontrada' }); + return; + } + json(200, { + ...row, + params: JSON.parse(row.params_json || '{}'), + progress: JSON.parse(row.progress_json || 'null'), + params_json: undefined, + progress_json: undefined + }); + }); + + router.add('POST', '/api/capture/runs/:id/cancel', async (req, res, { json, params }) => { + const row = db.prepare('SELECT job_id, status FROM capture_runs WHERE id = ?').get(Number(params.id)); + if (!row) { + json(404, { erro: 'execucao nao encontrada' }); + return; + } + const ok = row.job_id ? jobs.cancelJob(row.job_id) : false; + json(ok ? 202 : 409, ok ? { cancelando: true } : { erro: `nao ha job ativo para cancelar (status: ${row.status})` }); + }); +} diff --git a/gti-maquina-comercial/src/server/routes/exports.mjs b/gti-maquina-comercial/src/server/routes/exports.mjs new file mode 100644 index 0000000..c85bf87 --- /dev/null +++ b/gti-maquina-comercial/src/server/routes/exports.mjs @@ -0,0 +1,96 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { paths } from '../../env.mjs'; +import { countLeads } from '../../treatment/queries.mjs'; + +const TIPOS = new Set(['excel', 'zpro-oportunidades', 'zpro-bulk']); + +function parseExportRow(row) { + return { + ...row, + filtro: JSON.parse(row.filtro_json || '{}'), + params: JSON.parse(row.params_json || '{}'), + arquivos: JSON.parse(row.arquivos_json || '[]'), + filtro_json: undefined, + params_json: undefined, + arquivos_json: undefined + }; +} + +export function registerExportRoutes(router, { db, jobs }) { + router.add('POST', '/api/exports', async (req, res, { json, body }) => { + const tipo = String(body?.tipo || ''); + if (!TIPOS.has(tipo)) { + json(400, { erro: `tipo invalido. Use: ${[...TIPOS].join(', ')}` }); + return; + } + + const filtro = body?.filtro && typeof body.filtro === 'object' ? body.filtro : {}; + const params = body?.params && typeof body.params === 'object' ? body.params : {}; + + const total = countLeads(db, filtro); + if (!total) { + json(400, { erro: 'nenhum lead corresponde ao filtro selecionado' }); + return; + } + + const inserted = db.prepare( + "INSERT INTO exports (tipo, filtro_json, params_json, status) VALUES (?, ?, ?, 'pending')" + ).run(tipo, JSON.stringify(filtro), JSON.stringify(params)); + const exportId = Number(inserted.lastInsertRowid); + + const jobId = jobs.startJob({ + type: 'exportacao', + script: 'src/export/export-job.mjs', + args: [`--export-id=${exportId}`], + meta: { exportId, tipo, total } + }); + db.prepare('UPDATE exports SET job_id = ? WHERE id = ?').run(jobId, exportId); + + json(202, { exportId, jobId, totalEstimado: total }); + }); + + router.add('GET', '/api/exports', async (req, res, { json, query }) => { + const limit = Math.min(100, Math.max(1, Number(query.get('limit')) || 20)); + const rows = db.prepare('SELECT * FROM exports ORDER BY id DESC LIMIT ?').all(limit); + json(200, { exports: rows.map(parseExportRow) }); + }); + + router.add('GET', '/api/exports/:id', async (req, res, { json, params }) => { + const row = db.prepare('SELECT * FROM exports WHERE id = ?').get(Number(params.id)); + if (!row) { + json(404, { erro: 'exportacao nao encontrada' }); + return; + } + json(200, parseExportRow(row)); + }); + + router.add('GET', '/api/exports/:id/download', async (req, res, { json, params, query }) => { + const row = db.prepare('SELECT * FROM exports WHERE id = ?').get(Number(params.id)); + if (!row) { + json(404, { erro: 'exportacao nao encontrada' }); + return; + } + + const arquivos = JSON.parse(row.arquivos_json || '[]'); + const nome = String(query.get('arquivo') || arquivos[0] || ''); + if (!arquivos.includes(nome)) { + json(400, { erro: 'arquivo nao pertence a esta exportacao' }); + return; + } + + const dir = path.resolve(paths.exports, `export-${row.id}`); + const resolved = path.resolve(dir, nome); + if (!resolved.startsWith(dir + path.sep) || !fs.existsSync(resolved)) { + json(404, { erro: 'arquivo nao encontrado' }); + return; + } + + res.writeHead(200, { + 'content-type': 'application/octet-stream', + 'content-disposition': `attachment; filename="${encodeURIComponent(nome)}"`, + 'content-length': fs.statSync(resolved).size + }); + fs.createReadStream(resolved).pipe(res); + }); +} diff --git a/gti-maquina-comercial/src/server/routes/extractions.mjs b/gti-maquina-comercial/src/server/routes/extractions.mjs new file mode 100644 index 0000000..c778401 --- /dev/null +++ b/gti-maquina-comercial/src/server/routes/extractions.mjs @@ -0,0 +1,72 @@ +import fs from 'node:fs'; +import { paths } from '../../env.mjs'; +import { UF_NAMES } from '../../sources/common.mjs'; +import { resolveZipReader } from '../../extract/rfb-extractor.mjs'; + +export function registerExtractionRoutes(router, { db, jobs }) { + router.add('POST', '/api/extractions', async (req, res, { json, body }) => { + const uf = String(body?.uf || '').toUpperCase(); + if (!UF_NAMES[uf]) { + json(400, { erro: 'informe uma UF valida' }); + return; + } + + if (!fs.existsSync(paths.rfbRaw)) { + json(409, { erro: `zips da Receita Federal nao encontrados em ${paths.rfbRaw}. Rode scripts/baixar-base-rfb.sh primeiro.` }); + return; + } + + try { + resolveZipReader(); + } catch (error) { + json(409, { erro: error.message }); + return; + } + + const running = db.prepare( + "SELECT id, uf FROM extractions WHERE status IN ('pending','running')" + ).get(); + if (running) { + json(409, { erro: `ja existe uma extracao em andamento (${running.uf}). Aguarde terminar.` }); + return; + } + + const inserted = db.prepare("INSERT INTO extractions (uf, status) VALUES (?, 'pending')").run(uf); + const extractionId = Number(inserted.lastInsertRowid); + + const jobId = jobs.startJob({ + type: 'extracao', + script: 'src/extract/extract-job.mjs', + args: [`--extraction-id=${extractionId}`], + meta: { extractionId, uf } + }); + db.prepare('UPDATE extractions SET job_id = ? WHERE id = ?').run(jobId, extractionId); + + json(202, { extractionId, jobId }); + }); + + router.add('GET', '/api/extractions', async (req, res, { json }) => { + const rows = db.prepare( + `SELECT id, job_id, uf, status, csv_path, total_rows, with_phone, municipios_count, + progress_json, error, started_at, finished_at, created_at + FROM extractions ORDER BY id DESC LIMIT 20` + ).all(); + json(200, { + extractions: rows.map((row) => ({ + ...row, + progress: JSON.parse(row.progress_json || 'null'), + progress_json: undefined + })) + }); + }); + + router.add('POST', '/api/extractions/:id/cancel', async (req, res, { json, params }) => { + const row = db.prepare('SELECT job_id, status FROM extractions WHERE id = ?').get(Number(params.id)); + if (!row) { + json(404, { erro: 'extracao nao encontrada' }); + return; + } + const ok = row.job_id ? jobs.cancelJob(row.job_id) : false; + json(ok ? 202 : 409, ok ? { cancelando: true } : { erro: `nao ha job ativo para cancelar (status: ${row.status})` }); + }); +} diff --git a/gti-maquina-comercial/src/server/routes/leads.mjs b/gti-maquina-comercial/src/server/routes/leads.mjs new file mode 100644 index 0000000..15aac45 --- /dev/null +++ b/gti-maquina-comercial/src/server/routes/leads.mjs @@ -0,0 +1,53 @@ +import { listLeads, getLead, leadFacets } from '../../treatment/queries.mjs'; +import { + dashboardSummary, dashboardRanking, dashboardDailyEvolution, dashboardMaiorOportunidade +} from '../../treatment/dashboard.mjs'; + +function filterFromQuery(query) { + const filtro = {}; + for (const key of ['uf', 'cidade', 'segmento', 'classificacao', 'interesse', 'origem', + 'telefone_valido', 'exportado', 'q', 'score_min', 'page', 'pageSize', 'sort', + 'prioridade', 'produto', 'website', 'email']) { + const value = query.get(key); + if (value !== null && value !== '') filtro[key] = value; + } + return filtro; +} + +export function registerLeadRoutes(router, { db }) { + router.add('GET', '/api/leads', async (req, res, { json, query }) => { + json(200, listLeads(db, filterFromQuery(query))); + }); + + router.add('GET', '/api/leads/facets', async (req, res, { json, query }) => { + json(200, leadFacets(db, filterFromQuery(query))); + }); + + router.add('GET', '/api/leads/:id', async (req, res, { json, params }) => { + const lead = getLead(db, Number(params.id)); + if (!lead) { + json(404, { erro: 'lead nao encontrado' }); + return; + } + json(200, lead); + }); + + router.add('GET', '/api/dashboard/summary', async (req, res, { json, query }) => { + json(200, dashboardSummary(db, filterFromQuery(query))); + }); + + router.add('GET', '/api/dashboard/rankings', async (req, res, { json, query }) => { + const by = query.get('by') || 'cidade'; + const limit = query.get('limit') || 15; + json(200, { by, ranking: dashboardRanking(db, by, filterFromQuery(query), limit) }); + }); + + router.add('GET', '/api/dashboard/evolution', async (req, res, { json, query }) => { + const dias = query.get('dias') || 14; + json(200, { evolution: dashboardDailyEvolution(db, filterFromQuery(query), dias) }); + }); + + router.add('GET', '/api/dashboard/oportunidade', async (req, res, { json, query }) => { + json(200, { oportunidade: dashboardMaiorOportunidade(db, filterFromQuery(query)) }); + }); +} diff --git a/gti-maquina-comercial/src/server/routes/meta.mjs b/gti-maquina-comercial/src/server/routes/meta.mjs new file mode 100644 index 0000000..6d39f3d --- /dev/null +++ b/gti-maquina-comercial/src/server/routes/meta.mjs @@ -0,0 +1,155 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { paths } from '../../env.mjs'; +import { UF_NAMES } from '../../sources/common.mjs'; +import { describeSources } from '../../sources/index.mjs'; +import { getSetting, setSetting, resetLeadsBase } from '../../db.mjs'; +import { cleanText } from '../../lead-engine.mjs'; + +// Senha de confirmacao para a limpeza total da base (acao destrutiva e +// irreversivel) — pedida no modal de Configuracoes antes de zerar leads, +// exportacoes, capturas e extracoes. Vem do .env (nunca versionado). +// Funcao (nao const) de proposito: os imports deste arquivo rodam antes do +// "await loadDotEnv()" em scripts/servidor.mjs (imports sao hoisted acima de +// qualquer top-level await), entao ler process.env.GTI_RESET_PASSWORD direto +// num const no topo do modulo sempre capturava undefined — nenhuma senha +// nunca batia, mesmo a correta. Lendo dentro da rota (call-time) o .env ja +// foi carregado quando a chamada realmente acontece. +function resetBasePassword() { + return process.env.GTI_RESET_PASSWORD; +} + +function extractedCsvForUf(uf) { + const candidates = [ + path.join(paths.cnpjDir, `estabelecimentos-${uf}.csv`), + // nome antigo da primeira extracao (Bahia) + ...(uf === 'BA' ? [path.join(paths.cnpjDir, 'estabelecimentos-bahia.csv')] : []) + ]; + return candidates.find((file) => fs.existsSync(file)) || null; +} + +export function registerMetaRoutes(router, { db, jobs }) { + router.add('GET', '/api/health', async (req, res, { json }) => { + json(200, { ok: true, app: 'gti-maquina-comercial', pid: process.pid }); + }); + + router.add('GET', '/api/meta/ufs', async (req, res, { json }) => { + const doneByUf = new Map( + db.prepare( + `SELECT uf, MAX(finished_at) AS finished_at, total_rows, municipios_count + FROM extractions WHERE status = 'done' GROUP BY uf` + ).all().map((row) => [row.uf, row]) + ); + const municipiosByUf = new Map( + db.prepare('SELECT uf, COUNT(*) AS total FROM municipios GROUP BY uf').all() + .map((row) => [row.uf, row.total]) + ); + const runningUfs = new Set( + db.prepare("SELECT uf FROM extractions WHERE status IN ('pending','running')").all().map((r) => r.uf) + ); + + const ufs = Object.entries(UF_NAMES).map(([uf, nome]) => { + const csv = extractedCsvForUf(uf); + const done = doneByUf.get(uf); + return { + uf, + nome, + municipios: municipiosByUf.get(uf) || 0, + extraida: Boolean(csv), + extraindo: runningUfs.has(uf), + extracao: done ? { finished_at: done.finished_at, total_rows: done.total_rows } : null + }; + }); + + json(200, { ufs }); + }); + + router.add('GET', '/api/meta/municipios', async (req, res, { json, query }) => { + const uf = String(query.get('uf') || '').toUpperCase(); + if (!UF_NAMES[uf]) { + json(400, { erro: 'informe uf valida, ex.: ?uf=BA' }); + return; + } + + const q = String(query.get('q') || '').toLowerCase(); + let rows = db.prepare('SELECT codigo_ibge, nome, nome_clean, uf FROM municipios WHERE uf = ? ORDER BY nome').all(uf); + if (q) rows = rows.filter((row) => row.nome_clean.includes(q)); + + json(200, { municipios: rows.map((r) => ({ codigo: r.codigo_ibge, nome: r.nome })) }); + }); + + router.add('GET', '/api/meta/segments', async (req, res, { json }) => { + const rows = db.prepare( + 'SELECT id, name, label, interest, cnae_codes_json FROM segments WHERE active = 1 ORDER BY label' + ).all(); + json(200, { + segments: rows.map((row) => ({ + id: row.id, + name: row.name, + label: row.label, + interesse: row.interest, + cnaes: JSON.parse(row.cnae_codes_json).length + })) + }); + }); + + // Busca livre em todas as ~1359 atividades (CNAE) da Receita Federal — usada + // para adicionar nichos avulsos alem dos 57 pre-configurados em segments. + router.add('GET', '/api/meta/cnaes', async (req, res, { json, query }) => { + const q = cleanText(String(query.get('q') || '')); + if (q.length < 2) { + json(200, { cnaes: [] }); + return; + } + const rows = db.prepare('SELECT codigo, descricao FROM cnaes').all(); + const achados = rows + .filter((r) => cleanText(r.descricao).includes(q) || r.codigo.startsWith(q)) + .slice(0, 30); + json(200, { cnaes: achados }); + }); + + router.add('GET', '/api/meta/sources', async (req, res, { json }) => { + json(200, { sources: describeSources() }); + }); + + router.add('GET', '/api/settings', async (req, res, { json }) => { + const rows = db.prepare('SELECT key, value FROM settings ORDER BY key').all(); + json(200, { settings: Object.fromEntries(rows.map((r) => [r.key, r.value])) }); + }); + + router.add('PUT', '/api/settings', async (req, res, { json, body }) => { + if (!body || typeof body !== 'object') { + json(400, { erro: 'corpo JSON esperado: {"chave": "valor"}' }); + return; + } + for (const [key, value] of Object.entries(body)) { + setSetting(db, key, value); + } + json(200, { ok: true, atualizadas: Object.keys(body).length }); + }); + + router.add('GET', '/api/jobs/active', async (req, res, { json }) => { + json(200, { jobs: jobs.activeJobs() }); + }); + + // Zera toda a base de leads (irreversivel): exige a senha de confirmacao. + // 403 (nao 401): o usuario ja esta autenticado, so a senha de confirmacao + // da acao esta errada. 401 aqui disparava o redirect global de "sessao + // expirada" do api.js antes do erro aparecer na tela — o usuario via a + // tela de login e achava que so tinha caido a sessao, sem saber que a + // limpeza nunca rodou. + router.add('POST', '/api/admin/reset', async (req, res, { json, body }) => { + if (!body || body.senha !== resetBasePassword()) { + json(403, { erro: 'senha incorreta' }); + return; + } + + resetLeadsBase(db); + + for (const entry of fs.readdirSync(paths.exports, { withFileTypes: true })) { + fs.rmSync(path.join(paths.exports, entry.name), { recursive: true, force: true }); + } + + json(200, { ok: true }); + }); +} diff --git a/gti-maquina-comercial/src/server/routes/outreach.mjs b/gti-maquina-comercial/src/server/routes/outreach.mjs new file mode 100644 index 0000000..049056f --- /dev/null +++ b/gti-maquina-comercial/src/server/routes/outreach.mjs @@ -0,0 +1,205 @@ +import { getSetting, listMessageTemplates, updateMessageTemplate } from '../../db.mjs'; +import { createZproClient, zproConfigured, zproDiagnostics } from '../../outreach/zpro-client.mjs'; +import { listWabaTemplateLibrary, syncWabaTemplateLibrary } from '../../outreach/waba-template-library.mjs'; +import { COMMERCIAL_VARIABLES, renderTemplate } from '../../outreach/templates.mjs'; +import { validatePhone, onlyDigits } from '../../scoring.mjs'; + +function dailyLimitStatus(db) { + const limite = Number(getSetting(db, 'outreach.mensagensPorDia', '10')) || 0; + const enviadosHoje = db.prepare( + "SELECT COUNT(*) AS t FROM outreach_sends WHERE status = 'enviado' AND date(sent_at) = date('now')" + ).get().t; + + return { limite, enviadosHoje, restante: Math.max(0, limite - enviadosHoje) }; +} + +export function registerOutreachRoutes(router, { db }) { + router.add('GET', '/api/outreach/status', async (req, res, { json }) => { + const ativo = getSetting(db, 'outreach.ativo', 'false') === 'true'; + const { limite, enviadosHoje, restante } = dailyLimitStatus(db); + + const porProduto = db.prepare( + `SELECT produto, COUNT(*) AS total + FROM outreach_sends + WHERE status = 'enviado' AND date(sent_at) = date('now') + GROUP BY produto ORDER BY total DESC` + ).all(); + + // LEFT JOIN (nao JOIN): envio manual nao tem lead_id, senao sumiria da lista. + const ultimos = db.prepare( + `SELECT outreach_sends.id, outreach_sends.produto, outreach_sends.status, + outreach_sends.sent_at, outreach_sends.canal, outreach_sends.origem, + outreach_sends.numero, outreach_sends.erro, leads.empresa, leads.cidade + FROM outreach_sends LEFT JOIN leads ON leads.id = outreach_sends.lead_id + ORDER BY outreach_sends.id DESC LIMIT 20` + ).all(); + + json(200, { + ativo, + configurado: zproConfigured(db), + mensagensPorDia: limite, + enviadosHoje, + restante, + porProduto, + ultimos + }); + }); + + // Testa as credenciais Z-PRO atuais (settings ou .env) com uma chamada real + // e barata (listTags), sem mandar nenhuma mensagem — pra validar a tela de + // Credenciais Z-PRO antes de ativar a automacao de verdade. + router.add('POST', '/api/outreach/testar-conexao', async (req, res, { json }) => { + if (!zproConfigured(db)) { + json(400, { ok: false, erro: 'preencha Base URL, API ID e Token antes de testar', diagnostico: zproDiagnostics(db) }); + return; + } + try { + await createZproClient(db).listTags({ isActive: true }); + json(200, { ok: true, diagnostico: zproDiagnostics(db) }); + } catch (error) { + json(200, { ok: false, erro: String(error.message || error), diagnostico: zproDiagnostics(db) }); + } + }); + + // Envio avulso pra um numero especifico, fora do lote automatico e sem + // depender de um lead cadastrado — util pra mandar uma mensagem pontual + // ou testar um template antes de ligar a automacao pra valer. Fica + // registrado em outreach_sends com origem='manual' (lead_id/produto + // ficam vazios), aparece nos "ultimos" igual aos envios automaticos. + router.add('POST', '/api/outreach/enviar-manual', async (req, res, { json, body }) => { + const numeroDigitado = String(body?.numero || '').trim(); + if (!numeroDigitado) { + json(400, { erro: 'informe o numero (com DDI+DDD, ex.: 5511999999999)' }); + return; + } + // O Z-PRO aceita (200 OK) numero mal formatado e so falha depois, de forma + // assincrona, sem avisar o app — por isso valida aqui ANTES de mandar, + // em vez de confiar na resposta da API pra saber se o numero e valido. + const { valid, motivo } = validatePhone(numeroDigitado); + if (!valid) { + json(400, { erro: `numero invalido (${motivo}): use DDI+DDD+numero, so digitos, ex. 5511999999999` }); + return; + } + const numero = onlyDigits(numeroDigitado); + const canal = body?.canal === 'oficial' ? 'oficial' : 'nao_oficial'; + const mensagem = String(body?.mensagem || '').trim(); + const templateWabaNome = String(body?.templateWabaNome || '').trim(); + const templateWabaIdioma = String(body?.templateWabaIdioma || 'pt_BR').trim() || 'pt_BR'; + const templateRoute = String(body?.templateRota || 'template').trim() || 'template'; + const rawTemplateVariables = Array.isArray(body?.templateVariaveis) + ? body.templateVariaveis.map((value) => String(value || '').trim()).filter(Boolean) + : [String(body?.templateVariavel1 || '').trim()].filter(Boolean); + const leadContext = body?.lead && typeof body.lead === 'object' ? body.lead : {}; + const templateVariables = rawTemplateVariables.map((value) => renderTemplate(value, { + empresa: 'Gustavo', + produto: 'Atendimento comercial', + cidade: 'Salvador', + segmento_label: 'Suporte', + email: 'cliente@exemplo.com.br', + ...leadContext, + whatsapp: leadContext.whatsapp || onlyDigits(numeroDigitado) + })); + + if (canal === 'nao_oficial' && !mensagem) { + json(400, { erro: 'escreva a mensagem pro canal nao-oficial' }); + return; + } + if (canal === 'oficial' && !templateWabaNome) { + json(400, { erro: 'informe o nome do template aprovado pro canal oficial' }); + return; + } + + const { limite, enviadosHoje, restante } = dailyLimitStatus(db); + if (restante <= 0) { + json(429, { + ok: false, + erro: `limite diario atingido (${enviadosHoje}/${limite}). Aumente o limite por dia ou aguarde virar o dia.`, + limite, + enviadosHoje, + restante + }); + return; + } + + const insert = db.prepare( + `INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, erro, zpro_ticket_id, canal, origem) + VALUES (NULL, '', ?, ?, ?, ?, ?, ?, 'manual')` + ); + const mensagemEnviada = canal === 'oficial' ? `template:${templateWabaNome}/${templateWabaIdioma}` : mensagem; + + try { + const zproClient = createZproClient(db); + const externalKey = `gti-manual-${Date.now()}`; + const resposta = canal === 'oficial' + ? await zproClient.sendTemplateWaba({ + number: numero, + templateName: templateWabaNome, + languageCode: templateWabaIdioma, + route: templateRoute, + variables: templateVariables + }) + : await zproClient.sendText({ number: numero, message: mensagem, externalKey }); + const ticketId = resposta?.ticketId || resposta?.data?.ticketId ? String(resposta.ticketId || resposta.data.ticketId) : null; + insert.run(numero, mensagemEnviada, 'enviado', null, ticketId, canal); + json(200, { ok: true, ticketId }); + } catch (error) { + insert.run(numero, mensagemEnviada, 'erro', String(error.message || error), null, canal); + json(200, { ok: false, erro: String(error.message || error) }); + } + }); + + router.add('GET', '/api/outreach/templates', async (req, res, { json }) => { + json(200, { templates: listMessageTemplates(db) }); + }); + + router.add('GET', '/api/outreach/waba-templates', async (req, res, { json }) => { + const syncUrl = getSetting(db, 'gchat.templatesUrl', '').trim(); + const lastSync = getSetting(db, 'gchat.templatesLastSync', ''); + const lastSyncAgeMs = lastSync ? Date.now() - new Date(lastSync).getTime() : Infinity; + if (syncUrl && lastSyncAgeMs > 10 * 60 * 1000) { + try { + await syncWabaTemplateLibrary(db); + } catch { + // A biblioteca local continua funcionando mesmo se o GChat estiver fora. + } + } + json(200, { + templates: listWabaTemplateLibrary(db), + lastSync: getSetting(db, 'gchat.templatesLastSync', '') + }); + }); + + router.add('GET', '/api/outreach/variables', async (req, res, { json }) => { + json(200, { variables: COMMERCIAL_VARIABLES }); + }); + + router.add('POST', '/api/outreach/waba-templates/sync', async (req, res, { json }) => { + try { + json(200, await syncWabaTemplateLibrary(db)); + } catch (error) { + json(200, { ok: false, erro: String(error.message || error) }); + } + }); + + router.add('PUT', '/api/outreach/templates/:produto', async (req, res, { json, params, body }) => { + const produto = params.produto; + if (!body || typeof body !== 'object') { + json(400, { erro: 'corpo JSON esperado: {"mensagem": "...", "ativo": true}' }); + return; + } + + const atualizado = updateMessageTemplate(db, produto, { + mensagem: body.mensagem, + ativo: body.ativo, + canal: body.canal, + templateWabaNome: body.templateWabaNome, + templateWabaIdioma: body.templateWabaIdioma, + templateWabaVariaveis: body.templateWabaVariaveis + }); + if (!atualizado) { + json(404, { erro: `template do produto "${produto}" nao encontrado` }); + return; + } + json(200, atualizado); + }); +} diff --git a/gti-maquina-comercial/src/server/session.mjs b/gti-maquina-comercial/src/server/session.mjs new file mode 100644 index 0000000..d25b12f --- /dev/null +++ b/gti-maquina-comercial/src/server/session.mjs @@ -0,0 +1,51 @@ +import crypto from 'node:crypto'; + +// Sessoes em memoria: simples e suficiente para um app local de usuario unico +// por vez. Reiniciar o servidor derruba as sessoes (usuarios logam de novo). +export const SESSION_COOKIE = 'gti_session'; +const SESSION_TTL_MS = 12 * 60 * 60 * 1000; // 12h, renovada a cada requisicao + +const sessions = new Map(); + +export function createSession(user) { + const token = crypto.randomBytes(32).toString('hex'); + sessions.set(token, { user, expiresAt: Date.now() + SESSION_TTL_MS }); + return token; +} + +export function getSession(token) { + if (!token) return null; + const session = sessions.get(token); + if (!session) return null; + if (session.expiresAt < Date.now()) { + sessions.delete(token); + return null; + } + session.expiresAt = Date.now() + SESSION_TTL_MS; + return session; +} + +export function destroySession(token) { + sessions.delete(token); +} + +export function getCookie(req, name) { + const header = req.headers.cookie; + if (!header) return null; + for (const part of header.split(';')) { + const separator = part.indexOf('='); + if (separator === -1) continue; + if (part.slice(0, separator).trim() === name) { + return decodeURIComponent(part.slice(separator + 1).trim()); + } + } + return null; +} + +export function sessionCookieHeader(token) { + return `${SESSION_COOKIE}=${token}; HttpOnly; Path=/; SameSite=Strict; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`; +} + +export function clearSessionCookieHeader() { + return `${SESSION_COOKIE}=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0`; +} diff --git a/gti-maquina-comercial/src/server/sse.mjs b/gti-maquina-comercial/src/server/sse.mjs new file mode 100644 index 0000000..a7d0dda --- /dev/null +++ b/gti-maquina-comercial/src/server/sse.mjs @@ -0,0 +1,33 @@ +// Server-Sent Events: canal de progresso ao vivo dos jobs para a tela. +export function openSse(res) { + res.writeHead(200, { + 'content-type': 'text/event-stream; charset=utf-8', + 'cache-control': 'no-cache, no-transform', + connection: 'keep-alive', + 'x-accel-buffering': 'no' + }); + res.write(':ok\n\n'); + + const keepAlive = setInterval(() => { + try { + res.write(':ping\n\n'); + } catch { + clearInterval(keepAlive); + } + }, 15000); + keepAlive.unref(); + + return { + send(event, data) { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + }, + close() { + clearInterval(keepAlive); + try { + res.end(); + } catch { + // ja fechado + } + } + }; +} diff --git a/gti-maquina-comercial/src/server/static.mjs b/gti-maquina-comercial/src/server/static.mjs new file mode 100644 index 0000000..59ba465 --- /dev/null +++ b/gti-maquina-comercial/src/server/static.mjs @@ -0,0 +1,39 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', + '.woff2': 'font/woff2' +}; + +// Servidor de arquivos do frontend (pasta public/), com protecao contra +// path traversal: qualquer caminho resolvido fora da pasta e rejeitado. +export function serveStatic(publicDir, urlPath, res) { + const relative = urlPath === '/' ? 'index.html' : urlPath.replace(/^\/+/, ''); + const resolved = path.resolve(publicDir, relative); + + if (!resolved.startsWith(path.resolve(publicDir) + path.sep) && resolved !== path.resolve(publicDir, 'index.html')) { + res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' }); + res.end('Acesso negado'); + return true; + } + + if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) { + return false; + } + + const ext = path.extname(resolved).toLowerCase(); + res.writeHead(200, { + 'content-type': MIME[ext] || 'application/octet-stream', + 'cache-control': 'no-cache' + }); + fs.createReadStream(resolved).pipe(res); + return true; +} diff --git a/gti-maquina-comercial/src/sources/cnpj-base.mjs b/gti-maquina-comercial/src/sources/cnpj-base.mjs new file mode 100644 index 0000000..a4db927 --- /dev/null +++ b/gti-maquina-comercial/src/sources/cnpj-base.mjs @@ -0,0 +1,172 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { cleanText } from '../lead-engine.mjs'; +import { onlyDigits } from '../scoring.mjs'; +import { streamCsvRows } from '../util/csv.mjs'; +import { buildLeadFromPlace, UA_HEADERS } from './common.mjs'; + +async function brasilApiCnpj(baseUrl, cnpj) { + if (!cnpj || cnpj.length !== 14) return null; + const response = await fetch(`${baseUrl}/cnpj/v1/${cnpj}`, { headers: UA_HEADERS }); + if (!response.ok) return null; + return response.json(); +} + +function mergeBrasilApi(place, data) { + if (!data) return place; + return { + ...place, + name: data.nome_fantasia || data.razao_social || place.name, + legal_name: data.razao_social || place.legal_name, + phone: [data.ddd_telefone_1, data.ddd_telefone_2].filter(Boolean)[0] || place.phone, + city: data.municipio || place.city, + state: data.uf || place.state, + cnae: data.cnae_fiscal ? String(data.cnae_fiscal) : place.cnae, + address: [data.logradouro, data.numero, data.bairro].filter(Boolean).join(', ') || place.address + }; +} + +function normalizeHeader(header) { + return cleanText(header).replace(/\s+/g, '_'); +} + +function getAny(row, keys) { + for (const key of keys) { + const normalized = normalizeHeader(key); + if (row[normalized]) return row[normalized]; + } + return ''; +} + +function cnpjMatchesSegment(row, segment, textCache) { + const cnae = onlyDigits(getAny(row, ['cnae', 'cnae_fiscal', 'cnae principal', 'cnae_principal'])); + const cnaeCodes = segment._cnaeCodes; + + for (const code of cnaeCodes) { + if (cnae.startsWith(code)) return true; + } + + if (textCache.value === null) { + textCache.value = cleanText([ + getAny(row, ['nome fantasia', 'nome_fantasia', 'fantasia']), + getAny(row, ['razao social', 'razao_social', 'nome empresarial']), + getAny(row, ['atividade principal', 'atividade_principal', 'descricao cnae', 'descricao_cnae']) + ].join(' ')); + } + + const text = textCache.value; + if (!text) return false; + return segment._queryTerms.some((term) => text.includes(term)); +} + +function cnpjRowToPlace(row, segment, city, state) { + const cnpj = onlyDigits(getAny(row, ['cnpj', 'cnpj_basico', 'cnpj completo', 'cnpj_completo'])); + const phone = [ + getAny(row, ['ddd 1', 'ddd1', 'ddd']), + getAny(row, ['telefone 1', 'telefone1', 'telefone', 'whatsapp']) + ].filter(Boolean).join(''); + const name = getAny(row, ['nome fantasia', 'nome_fantasia', 'fantasia']) + || getAny(row, ['razao social', 'razao_social', 'nome empresarial']); + + if (!name) return null; + + return { + name, + legal_name: getAny(row, ['razao social', 'razao_social', 'nome empresarial']), + phone, + city: getAny(row, ['municipio', 'cidade']) || city, + state: getAny(row, ['uf', 'estado']) || state, + segment: segment.name, + website: '', + cnpj, + cnae: getAny(row, ['cnae', 'cnae_fiscal', 'cnae principal', 'cnae_principal']), + address: [ + getAny(row, ['logradouro']), + getAny(row, ['numero']), + getAny(row, ['bairro']) + ].filter(Boolean).join(', '), + query: `cnpj-base ${segment.name} em ${city} ${state}` + }; +} + +// Fonte cnpj-base: le os CSVs locais extraidos da Receita Federal +// (prospeccao/cnpj/estabelecimentos-*.csv) em streaming, filtrando por +// UF + cidades e casando cada linha com o primeiro nicho compativel. +export async function* collect(params, ctx) { + const { uf, cities, segments } = params; + const allCities = !cities || !cities.length || cities.includes('*'); + const citySet = allCities ? null : new Set(cities.map((c) => cleanText(c))); + const ufClean = cleanText(uf); + + // Pre-processa termos por segmento uma unica vez (o loop interno roda + // milhoes de vezes na base de uma UF inteira). + const preparedSegments = segments.map((segment) => ({ + ...segment, + _cnaeCodes: (segment.cnaeCodes || []).map(onlyDigits).filter(Boolean), + _queryTerms: (segment.queries || []).map((term) => cleanText(term)).filter(Boolean) + })); + + let files = []; + try { + files = (await fs.readdir(ctx.cnpjDir)) + .filter((file) => /\.csv$/i.test(file)) + .map((file) => path.join(ctx.cnpjDir, file)); + } catch (error) { + if (error.code === 'ENOENT') { + ctx.log(`base CNPJ nao encontrada em ${ctx.cnpjDir} — fonte cnpj-base pulada`); + return; + } + throw error; + } + + let batch = []; + let scanned = 0; + + for (const file of files) { + ctx.log(`cnpj-base: lendo ${path.basename(file)}`); + + for await (const row of streamCsvRows(file, { normalizeHeader })) { + if (ctx.isCancelled()) return; + scanned += 1; + + if (scanned % 100000 === 0) { + ctx.emit({ phase: 'cnpj-base', message: `${scanned} linhas lidas`, detail: path.basename(file) }); + } + + const rowState = cleanText(getAny(row, ['uf', 'estado'])); + if (rowState && ufClean && rowState !== ufClean) continue; + + const rowCity = cleanText(getAny(row, ['municipio', 'cidade'])); + if (citySet && !citySet.has(rowCity)) continue; + + const textCache = { value: null }; + for (const segment of preparedSegments) { + if (!cnpjMatchesSegment(row, segment, textCache)) continue; + + let place = cnpjRowToPlace(row, segment, rowCity, uf); + if (place) { + if (params.enrichBrasilApi && place.cnpj) { + try { + place = mergeBrasilApi(place, await brasilApiCnpj(ctx.endpoints.brasilApiUrl, place.cnpj)); + } catch { + // enriquecimento e opcional: falha de rede nao derruba a captura + } + } + const origin = params.enrichBrasilApi ? 'cnpj-base+brasilapi' : 'cnpj-base'; + batch.push(buildLeadFromPlace(place, segment, place.city, uf, origin)); + } + break; + } + + if (batch.length >= 2000) { + yield { origin: 'cnpj-base', label: path.basename(file), leads: batch }; + batch = []; + } + } + } + + if (batch.length) { + yield { origin: 'cnpj-base', label: 'cnpj-base', leads: batch }; + } + ctx.log(`cnpj-base: ${scanned} linhas da base local processadas`); +} diff --git a/gti-maquina-comercial/src/sources/common.mjs b/gti-maquina-comercial/src/sources/common.mjs new file mode 100644 index 0000000..d9488e9 --- /dev/null +++ b/gti-maquina-comercial/src/sources/common.mjs @@ -0,0 +1,74 @@ +import { normalizePhone } from '../lead-engine.mjs'; + +export const UA_HEADERS = { + accept: 'application/json,text/plain,*/*', + 'user-agent': 'Grupo-GTI-Comercial/1.0 contato@grupogti.local' +}; + +export const UF_NAMES = { + AC: 'Acre', AL: 'Alagoas', AP: 'Amapa', AM: 'Amazonas', BA: 'Bahia', + CE: 'Ceara', DF: 'Distrito Federal', ES: 'Espirito Santo', GO: 'Goias', + MA: 'Maranhao', MT: 'Mato Grosso', MS: 'Mato Grosso do Sul', MG: 'Minas Gerais', + PA: 'Para', PB: 'Paraiba', PR: 'Parana', PE: 'Pernambuco', PI: 'Piaui', + RJ: 'Rio de Janeiro', RN: 'Rio Grande do Norte', RS: 'Rio Grande do Sul', + RO: 'Rondonia', RR: 'Roraima', SC: 'Santa Catarina', SP: 'Sao Paulo', + SE: 'Sergipe', TO: 'Tocantins' +}; + +export const UFS = Object.keys(UF_NAMES); + +export function splitCityState(cityText = '') { + const parts = String(cityText).trim().split(/\s+/); + const last = parts.at(-1); + if (last && last.length === 2 && UF_NAMES[last.toUpperCase()]) { + return { city: parts.slice(0, -1).join(' '), state: last.toUpperCase() }; + } + return { city: String(cityText).trim(), state: '' }; +} + +// Monta o payload padrao de lead (mesmo formato consumido por processLead) +// a partir de um estabelecimento/lugar encontrado em qualquer fonte. +export function buildLeadFromPlace(place, segment, city, state, origin) { + const phone = normalizePhone(place.phone || place.formatted_phone_number || place.international_phone_number || ''); + const segmentName = place.segment || segment.name; + + return { + source: origin, + contact: { + name: 'Responsavel comercial', + phone + }, + company: { + name: place.name, + city: place.city || city, + state: place.state || state, + segment: segmentName + }, + interest: segment.interest, + pains: segment.pains, + urgency: 'media', + message: `Lead prospectado automaticamente em ${origin}. Segmento: ${segmentName}. Website: ${place.website || 'nao informado'}.`, + consent: false, + send_external_whatsapp: false, + prospecting: { + source: origin, + query: place.query, + place_id: place.place_id || '', + osm_id: place.osm_id || '', + osm_type: place.osm_type || '', + website: place.website || '', + cnpj: place.cnpj || '', + cnae: place.cnae || '', + legal_name: place.legal_name || '', + address: place.formatted_address || place.address || '', + rating: place.rating || '', + user_ratings_total: place.user_ratings_total || '', + affinity: segment.affinity || 'media', + products: segment.products || [] + } + }; +} + +export function applyLimits(items, limit) { + return limit > 0 ? items.slice(0, limit) : items; +} diff --git a/gti-maquina-comercial/src/sources/google-places.mjs b/gti-maquina-comercial/src/sources/google-places.mjs new file mode 100644 index 0000000..4211fa7 --- /dev/null +++ b/gti-maquina-comercial/src/sources/google-places.mjs @@ -0,0 +1,103 @@ +import { sleep } from '../util/collections.mjs'; +import { buildLeadFromPlace } from './common.mjs'; + +async function searchPage(apiKey, params) { + const url = new URL('https://maps.googleapis.com/maps/api/place/textsearch/json'); + for (const [key, value] of Object.entries(params)) { + if (value) url.searchParams.set(key, value); + } + url.searchParams.set('key', apiKey); + url.searchParams.set('language', 'pt-BR'); + url.searchParams.set('region', 'br'); + + const response = await fetch(url); + if (!response.ok) throw new Error(`Google Places HTTP ${response.status}`); + const data = await response.json(); + + if (data.status && !['OK', 'ZERO_RESULTS'].includes(data.status)) { + throw new Error(`Google Places ${data.status}: ${data.error_message || 'sem detalhes'}`); + } + return data; +} + +async function search(query, apiKey, { maxPerQuery, pages }) { + const places = []; + let page = 0; + let pageToken = ''; + + while (page < pages && places.length < maxPerQuery) { + if (pageToken) await sleep(2500); + const data = await searchPage(apiKey, pageToken ? { pagetoken: pageToken } : { query }); + // Resultados por pagina sao no maximo 20 — spread aqui e seguro. + places.push(...(data.results || [])); + page += 1; + pageToken = data.next_page_token || ''; + if (!pageToken) break; + } + + return places.slice(0, maxPerQuery); +} + +async function placeDetails(placeId, apiKey) { + const url = new URL('https://maps.googleapis.com/maps/api/place/details/json'); + url.searchParams.set('place_id', placeId); + url.searchParams.set('key', apiKey); + url.searchParams.set('language', 'pt-BR'); + url.searchParams.set('fields', 'place_id,name,formatted_address,formatted_phone_number,international_phone_number,website,rating,user_ratings_total'); + + const response = await fetch(url); + if (!response.ok) throw new Error(`Google Details HTTP ${response.status}`); + const data = await response.json(); + return data.result || {}; +} + +// Fonte google-places: exige GOOGLE_MAPS_API_KEY (paga). Traz telefone e +// website com boa cobertura. +export async function* collect(params, ctx) { + const apiKey = process.env.GOOGLE_MAPS_API_KEY; + if (!apiKey || apiKey === 'preencher_google_maps_api_key') { + throw new Error('GOOGLE_MAPS_API_KEY ausente: configure a chave para usar a fonte Google Places'); + } + + const { uf, cities, segments, limits } = params; + const pages = Math.min(3, Math.max(1, limits.googlePages || 3)); + let executedQueries = 0; + + for (const city of cities || []) { + const label = `${city} ${uf}`; + const cityLeads = []; + + for (const segment of segments) { + for (const queryTerm of segment.queries || []) { + if (ctx.isCancelled()) return; + if (limits.queryLimit > 0 && executedQueries >= limits.queryLimit) { + if (cityLeads.length) yield { origin: 'google-places', label, leads: cityLeads }; + return; + } + + const query = `${queryTerm} em ${label}`; + ctx.emit({ phase: 'google-places', message: query }); + executedQueries += 1; + + let places = []; + try { + places = await search(query, apiKey, { maxPerQuery: limits.maxPerQuery || 10, pages }); + } catch (error) { + ctx.log(`google-places falhou em ${query}: ${error.message}`); + continue; + } + + for (const place of places) { + try { + const details = place.place_id ? await placeDetails(place.place_id, apiKey) : {}; + cityLeads.push(buildLeadFromPlace({ ...place, ...details, query }, segment, city, uf, 'google-places')); + } catch (error) { + ctx.log(`google-places falhou ao detalhar "${place.name || place.place_id}": ${error.message}`); + } + } + } + } + + if (cityLeads.length) yield { origin: 'google-places', label, leads: cityLeads }; + } +} diff --git a/gti-maquina-comercial/src/sources/index.mjs b/gti-maquina-comercial/src/sources/index.mjs new file mode 100644 index 0000000..de3312b --- /dev/null +++ b/gti-maquina-comercial/src/sources/index.mjs @@ -0,0 +1,89 @@ +import * as cnpjBase from './cnpj-base.mjs'; +import * as overpass from './overpass.mjs'; +import * as nominatim from './nominatim.mjs'; +import * as wikidata from './wikidata.mjs'; +import * as googlePlaces from './google-places.mjs'; +import * as mock from './mock.mjs'; + +// Registro central de fontes de captura. `describe()` alimenta a tela de +// captura (o front nunca conhece detalhes das fontes, so este catalogo). +export const SOURCES = [ + { + id: 'cnpj-base', + label: 'Base CNPJ (Receita Federal)', + descricao: 'Base oficial local: todas as empresas ativas da UF com telefone. Fonte principal.', + gratuita: true, + recomendada: true, + collect: cnpjBase.collect + }, + { + id: 'brasilapi', + label: 'Base CNPJ + BrasilAPI', + descricao: 'Mesma base CNPJ, enriquecida online pela BrasilAPI (mais lenta).', + gratuita: true, + recomendada: false, + collect: (params, ctx) => cnpjBase.collect({ ...params, enrichBrasilApi: true }, ctx) + }, + { + id: 'overpass', + label: 'OpenStreetMap (Overpass)', + descricao: 'Estabelecimentos mapeados no OpenStreetMap, com telefone/site quando cadastrados.', + gratuita: true, + recomendada: true, + collect: overpass.collect + }, + { + id: 'nominatim', + label: 'OpenStreetMap (Nominatim)', + descricao: 'Busca textual no OpenStreetMap por termo de nicho.', + gratuita: true, + recomendada: false, + collect: nominatim.collect + }, + { + id: 'wikidata', + label: 'Wikidata', + descricao: 'Empresas registradas no Wikidata (volume baixo, util para sites).', + gratuita: true, + recomendada: false, + collect: wikidata.collect + }, + { + id: 'google-places', + label: 'Google Places', + descricao: 'Google Maps (paga, exige GOOGLE_MAPS_API_KEY no .env).', + gratuita: false, + recomendada: false, + needsKey: 'GOOGLE_MAPS_API_KEY', + collect: googlePlaces.collect + }, + { + id: 'mock', + label: 'Teste (mock)', + descricao: '3 leads ficticios para testar o fluxo sem tocar em APIs.', + gratuita: true, + recomendada: false, + oculta: true, + collect: mock.collect + } +]; + +export function getSource(id) { + return SOURCES.find((source) => source.id === id) || null; +} + +export function describeSources() { + return SOURCES.filter((s) => !s.oculta).map((source) => { + const keyMissing = source.needsKey + && (!process.env[source.needsKey] || process.env[source.needsKey].startsWith('preencher')); + return { + id: source.id, + label: source.label, + descricao: source.descricao, + gratuita: source.gratuita, + recomendada: source.recomendada, + disponivel: !keyMissing, + motivo: keyMissing ? `configure ${source.needsKey} no arquivo .env` : '' + }; + }); +} diff --git a/gti-maquina-comercial/src/sources/mock.mjs b/gti-maquina-comercial/src/sources/mock.mjs new file mode 100644 index 0000000..194aa29 --- /dev/null +++ b/gti-maquina-comercial/src/sources/mock.mjs @@ -0,0 +1,49 @@ +import { buildLeadFromPlace } from './common.mjs'; + +// Fonte mock: 3 leads ficticios para testar o fluxo completo (captura -> +// banco -> tratamento -> exportacao) sem tocar em nenhuma API externa. +const MOCK_PLACES = [ + { + name: 'Mercado Central SAJ', + phone: '(75) 99999-0001', + city: 'Santo Antonio de Jesus', + state: 'BA', + segment: 'mercados_de_bairro', + website: 'https://mercadocentral.example' + }, + { + name: 'Clinica Vida Plena', + phone: '(75) 99999-0002', + city: 'Santo Antonio de Jesus', + state: 'BA', + segment: 'clinicas_medicas', + website: '' + }, + { + name: 'Restaurante Sabor da Terra', + phone: '(75) 99999-0003', + city: 'Santo Antonio de Jesus', + state: 'BA', + segment: 'restaurantes', + website: '' + } +]; + +export async function* collect(params, ctx) { + const segments = params.segments || []; + const leads = MOCK_PLACES.map((place) => { + const segment = segments.find((item) => item.name === place.segment) + || segments[0] + || { name: place.segment, interest: 'ERP / sistema de gestao', pains: [] }; + return buildLeadFromPlace( + { ...place, query: `mock ${place.segment}` }, + segment, + place.city, + place.state, + 'mock' + ); + }); + + ctx.emit({ phase: 'mock', message: `${leads.length} leads de teste gerados` }); + yield { origin: 'mock', label: 'mock', leads }; +} diff --git a/gti-maquina-comercial/src/sources/nominatim.mjs b/gti-maquina-comercial/src/sources/nominatim.mjs new file mode 100644 index 0000000..b5e11d7 --- /dev/null +++ b/gti-maquina-comercial/src/sources/nominatim.mjs @@ -0,0 +1,103 @@ +import { cleanText } from '../lead-engine.mjs'; +import { sleep } from '../util/collections.mjs'; +import { UA_HEADERS, UF_NAMES, buildLeadFromPlace } from './common.mjs'; + +async function nominatimSearch(baseUrl, query, limit) { + const url = new URL(baseUrl); + url.searchParams.set('format', 'jsonv2'); + url.searchParams.set('q', query); + url.searchParams.set('countrycodes', 'br'); + url.searchParams.set('addressdetails', '1'); + url.searchParams.set('limit', String(Math.max(1, limit))); + + const response = await fetch(url, { headers: UA_HEADERS }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`Nominatim HTTP ${response.status}: ${text.slice(0, 300)}`); + } + return response.json(); +} + +function isResultInTarget(result, city, uf) { + const address = result.address || {}; + const resultCity = address.city || address.town || address.village || address.municipality || ''; + const resultState = cleanText(address.state || ''); + const expectedState = cleanText(UF_NAMES[uf] || uf); + const currentCity = cleanText(resultCity); + const expectedCity = cleanText(city); + + return resultState === expectedState + && (currentCity === expectedCity || currentCity.includes(expectedCity) || expectedCity.includes(currentCity)); +} + +function resultToPlace(result, segment, city, uf, query) { + const name = result.name || result.display_name?.split(',')[0]; + if (!name) return null; + + const address = result.address || {}; + + return { + name, + phone: '', + city: address.city || address.town || address.village || city, + state: uf, + segment: segment.name, + website: '', + address: result.display_name || '', + formatted_address: result.display_name || '', + query, + osm_id: String(result.osm_id || ''), + osm_type: result.osm_type || '' + }; +} + +// Fonte nominatim (OpenStreetMap): busca textual termo a termo por cidade, +// validando que o resultado pertence mesmo a cidade/UF pedida. +export async function* collect(params, ctx) { + const { uf, cities, segments, limits } = params; + if (!cities || !cities.length) { + ctx.log('nominatim: nenhuma cidade selecionada — fonte pulada'); + return; + } + + let executedQueries = 0; + + for (const city of cities) { + const label = `${city} ${uf}`; + const cityLeads = []; + + for (const segment of segments) { + for (const queryTerm of segment.queries || []) { + if (ctx.isCancelled()) return; + if (limits.queryLimit > 0 && executedQueries >= limits.queryLimit) { + if (cityLeads.length) yield { origin: 'nominatim', label, leads: cityLeads }; + return; + } + + const query = `${queryTerm} ${city} ${uf}`; + executedQueries += 1; + ctx.emit({ phase: 'nominatim', message: query }); + + let results = []; + try { + results = await nominatimSearch(ctx.endpoints.nominatimUrl, query, limits.maxPerQuery || 10); + } catch (error) { + ctx.log(`nominatim falhou em ${query}: ${error.message}`); + await sleep(Math.max(ctx.delays.overpassDelayMs, 5000)); + continue; + } + + for (const result of results) { + if (!isResultInTarget(result, city, uf)) continue; + const place = resultToPlace(result, segment, city, uf, query); + if (!place) continue; + cityLeads.push(buildLeadFromPlace(place, segment, city, uf, 'nominatim')); + } + + await sleep(Math.max(ctx.delays.overpassDelayMs, 1200)); + } + } + + if (cityLeads.length) yield { origin: 'nominatim', label, leads: cityLeads }; + } +} diff --git a/gti-maquina-comercial/src/sources/overpass.mjs b/gti-maquina-comercial/src/sources/overpass.mjs new file mode 100644 index 0000000..a29dff3 --- /dev/null +++ b/gti-maquina-comercial/src/sources/overpass.mjs @@ -0,0 +1,228 @@ +import { cleanText } from '../lead-engine.mjs'; +import { sleep } from '../util/collections.mjs'; +import { UA_HEADERS, buildLeadFromPlace } from './common.mjs'; + +const OVERPASS_COOLDOWN_MS = 60000; + +function overpassSelector(tag, bbox) { + const [south, west, north, east] = bbox; + const key = String(tag.key).replace(/"/g, '\\"'); + const value = String(tag.value).replace(/"/g, '\\"'); + const selector = `["${key}"="${value}"](${south},${west},${north},${east});`; + return [`node${selector}`, `way${selector}`, `relation${selector}`].join('\n'); +} + +export function escapeOverpassRegex(value) { + return String(value).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&').replace(/"/g, '\\"'); +} + +function overpassTextSelector(key, regex, bbox) { + const [south, west, north, east] = bbox; + const selector = `["${key}"~"${regex}",i](${south},${west},${north},${east});`; + return [`node${selector}`, `way${selector}`].join('\n'); +} + +function segmentTextRegex(segment) { + return (segment.queries || []) + .filter((term) => String(term).trim().length >= 6) + .slice(0, 8) + .map(escapeOverpassRegex) + .join('|'); +} + +function buildCombinedQuery(segments, bbox, includeText) { + const seen = new Set(); + const selectors = []; + + for (const segment of segments) { + for (const tag of segment.overpassTags || []) { + const key = `tag:${tag.key}=${tag.value}`; + if (seen.has(key)) continue; + seen.add(key); + selectors.push(overpassSelector(tag, bbox)); + } + } + + if (includeText) { + for (const segment of segments) { + const textRegex = segmentTextRegex(segment); + const key = `text:${segment.name}`; + if (!textRegex || seen.has(key)) continue; + seen.add(key); + selectors.push(overpassTextSelector('name', textRegex, bbox)); + selectors.push(overpassTextSelector('brand', textRegex, bbox)); + selectors.push(overpassTextSelector('operator', textRegex, bbox)); + } + } + + return ['[out:json][timeout:40];', '(', selectors.join('\n'), ');', 'out body center;'].join('\n'); +} + +function matchSegmentForElement(tags, segments, includeText) { + for (const segment of segments) { + for (const tag of segment.overpassTags || []) { + if (tags[tag.key] === tag.value) return segment; + } + } + + if (includeText) { + const haystack = cleanText([tags.name, tags.brand, tags.operator].filter(Boolean).join(' ')); + if (!haystack) return null; + + for (const segment of segments) { + const terms = (segment.queries || []).filter((term) => String(term).trim().length >= 6); + if (terms.some((term) => haystack.includes(cleanText(term)))) return segment; + } + } + + return null; +} + +function valueFromTags(tags = {}, keys = []) { + for (const key of keys) { + if (tags[key]) return tags[key]; + } + return ''; +} + +function elementToPlace(element, segment, city, uf, query) { + const tags = element.tags || {}; + const name = tags.name || tags.brand || tags.operator; + if (!name) return null; + + const street = valueFromTags(tags, ['addr:street']); + const housenumber = valueFromTags(tags, ['addr:housenumber']); + const address = [street, housenumber].filter(Boolean).join(', '); + + return { + name, + phone: valueFromTags(tags, ['contact:phone', 'phone', 'mobile', 'contact:mobile']), + city: valueFromTags(tags, ['addr:city']) || city, + state: uf, + segment: segment.name, + website: valueFromTags(tags, ['contact:website', 'website', 'url']), + address, + formatted_address: address, + query, + osm_id: String(element.id || ''), + osm_type: element.type || '' + }; +} + +function createOverpassClient({ urls, timeoutMs }) { + const failures = new Map(); + + return async function request(query) { + let lastError; + + for (const apiUrl of urls) { + const state = failures.get(apiUrl); + if (state?.until && Date.now() < state.until) continue; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + const recordFailure = () => { + const current = failures.get(apiUrl) || { count: 0, until: 0 }; + const count = current.count + 1; + failures.set(apiUrl, { count, until: count >= 3 ? Date.now() + OVERPASS_COOLDOWN_MS : 0 }); + }; + + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + ...UA_HEADERS, + 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8' + }, + body: new URLSearchParams({ data: query }), + signal: controller.signal + }).catch((error) => { + lastError = error.name === 'AbortError' + ? new Error(`Overpass timeout apos ${timeoutMs}ms em ${apiUrl}`) + : error; + recordFailure(); + return null; + }).finally(() => clearTimeout(timeout)); + + if (!response) continue; + + if (!response.ok) { + const text = await response.text(); + lastError = new Error(`Overpass HTTP ${response.status} em ${apiUrl}: ${text.slice(0, 300)}`); + recordFailure(); + if (response.status === 429) await sleep(5000); + continue; + } + + const data = await response.json(); + failures.set(apiUrl, { count: 0, until: 0 }); + return data.elements || []; + } + + throw lastError || new Error('Overpass indisponivel: todos os endpoints em cooldown'); + }; +} + +// Fonte overpass (OpenStreetMap): uma consulta combinada por cidade cobrindo +// todos os nichos selecionados, com failover entre endpoints e cooldown. +export async function* collect(params, ctx) { + const { uf, cities, segments, limits } = params; + if (!cities || !cities.length) { + ctx.log('overpass: nenhuma cidade selecionada — fonte pulada'); + return; + } + + const request = createOverpassClient({ + urls: ctx.endpoints.overpassUrls, + timeoutMs: ctx.delays.overpassTimeoutMs + }); + + for (const city of cities) { + if (ctx.isCancelled()) return; + const label = `${city} ${uf}`; + + let bbox = null; + try { + bbox = await ctx.geocode(uf, city); + } catch (error) { + ctx.log(`overpass: geocode falhou em ${label}: ${error.message}`); + } + if (!bbox) { + ctx.log(`overpass: sem bounding box para ${label} — cidade pulada`); + continue; + } + + ctx.emit({ phase: 'overpass', message: `consultando ${label}` }); + + let elements = []; + try { + elements = await request(buildCombinedQuery(segments, bbox, ctx.overpassTextSearch)); + } catch (error) { + ctx.log(`overpass falhou em ${label}: ${error.message}`); + await sleep(Math.max(ctx.delays.overpassDelayMs, 15000)); + continue; + } + + const perSegmentCount = new Map(); + const cityLeads = []; + + for (const element of elements) { + const segment = matchSegmentForElement(element.tags || {}, segments, ctx.overpassTextSearch); + if (!segment) continue; + + if (limits.maxPerQuery > 0) { + const count = perSegmentCount.get(segment.name) || 0; + if (count >= limits.maxPerQuery) continue; + perSegmentCount.set(segment.name, count + 1); + } + + const place = elementToPlace(element, segment, city, uf, `${segment.name} em ${label}`); + if (!place) continue; + cityLeads.push(buildLeadFromPlace(place, segment, city, uf, 'overpass')); + } + + ctx.log(`overpass: ${elements.length} elementos brutos, ${cityLeads.length} leads em ${label}`); + if (cityLeads.length) yield { origin: 'overpass', label, leads: cityLeads }; + await sleep(ctx.delays.overpassDelayMs); + } +} diff --git a/gti-maquina-comercial/src/sources/wikidata.mjs b/gti-maquina-comercial/src/sources/wikidata.mjs new file mode 100644 index 0000000..74f2422 --- /dev/null +++ b/gti-maquina-comercial/src/sources/wikidata.mjs @@ -0,0 +1,104 @@ +import { cleanText } from '../lead-engine.mjs'; +import { UA_HEADERS, buildLeadFromPlace } from './common.mjs'; +import { escapeOverpassRegex } from './overpass.mjs'; + +async function findWikidataCityId(city) { + // Busca por rotulo exato em SPARQL falha para nomes sem acento (base usa + // "Santo Antonio", Wikidata registra "Santo Antônio"). wbsearchentities + // e tolerante a acentos. + try { + const url = new URL('https://www.wikidata.org/w/api.php'); + url.searchParams.set('action', 'wbsearchentities'); + url.searchParams.set('search', city); + url.searchParams.set('language', 'pt'); + url.searchParams.set('type', 'item'); + url.searchParams.set('format', 'json'); + + const response = await fetch(url, { headers: UA_HEADERS }); + if (!response.ok) return ''; + const data = await response.json(); + + const match = (data.search || []).find((item) => { + const description = cleanText(item.description || ''); + return description.includes('municipality') || description.includes('municipio') || description.includes('munic'); + }); + + return match?.id || ''; + } catch { + return ''; + } +} + +// Fonte wikidata: empresas registradas no Wikidata por municipio (SPARQL). +// Volume baixo, mas gratuito e util para achar empresas com website. +export async function* collect(params, ctx) { + const { uf, cities, segments, limits } = params; + let executedQueries = 0; + + for (const city of cities || []) { + if (ctx.isCancelled()) return; + + const cityId = await findWikidataCityId(city); + if (!cityId) { + ctx.log(`wikidata: municipio nao encontrado para ${city} — pulando`); + continue; + } + + const label = `${city} ${uf}`; + const cityLeads = []; + + for (const segment of segments) { + if (ctx.isCancelled()) return; + if (limits.queryLimit > 0 && executedQueries >= limits.queryLimit) break; + + const regex = (segment.queries || []) + .filter((term) => term.length >= 4) + .slice(0, 10) + .map(escapeOverpassRegex) + .join('|'); + if (!regex) continue; + + const sparql = ` + SELECT ?item ?itemLabel ?website WHERE { + ?item wdt:P31/wdt:P279* wd:Q4830453. + { ?item wdt:P131 wd:${cityId}. } UNION { ?item wdt:P159/wdt:P131* wd:${cityId}. } + OPTIONAL { ?item wdt:P856 ?website. } + SERVICE wikibase:label { bd:serviceParam wikibase:language "pt,en". } + FILTER(REGEX(?itemLabel, "${regex}", "i")) + } + LIMIT ${limits.maxPerQuery || 10} + `; + + ctx.emit({ phase: 'wikidata', message: `${segment.name} em ${label}` }); + executedQueries += 1; + + try { + const url = new URL(ctx.endpoints.wikidataUrl); + url.searchParams.set('query', sparql); + url.searchParams.set('format', 'json'); + const response = await fetch(url, { + headers: { ...UA_HEADERS, accept: 'application/sparql-results+json' } + }); + if (!response.ok) throw new Error(`Wikidata HTTP ${response.status}`); + const data = await response.json(); + + for (const result of data.results?.bindings || []) { + const place = { + name: result.itemLabel?.value || '', + city, + state: uf, + segment: segment.name, + website: result.website?.value || '', + query: `wikidata ${segment.name} em ${label}`, + place_id: result.item?.value || '' + }; + if (place.name) cityLeads.push(buildLeadFromPlace(place, segment, city, uf, 'wikidata')); + } + } catch (error) { + ctx.log(`wikidata falhou em ${segment.name} ${label}: ${error.message}`); + } + } + + if (cityLeads.length) yield { origin: 'wikidata', label, leads: cityLeads }; + } +} diff --git a/gti-maquina-comercial/src/treatment/dashboard.mjs b/gti-maquina-comercial/src/treatment/dashboard.mjs new file mode 100644 index 0000000..51f3b0b --- /dev/null +++ b/gti-maquina-comercial/src/treatment/dashboard.mjs @@ -0,0 +1,111 @@ +import { buildLeadFilter } from './queries.mjs'; +import { getSetting } from '../db.mjs'; + +function pct(parte, total) { + return total > 0 ? Math.round((parte / total) * 100) : 0; +} + +export function dashboardSummary(db, filtro = {}) { + const { whereSql, params } = buildLeadFilter(filtro); + + const row = db.prepare( + `SELECT + COUNT(*) AS total, + SUM(CASE WHEN prioridade = 1 THEN 1 ELSE 0 END) AS p1, + SUM(CASE WHEN prioridade = 2 THEN 1 ELSE 0 END) AS p2, + SUM(CASE WHEN prioridade = 3 THEN 1 ELSE 0 END) AS p3, + SUM(CASE WHEN prioridade = 4 THEN 1 ELSE 0 END) AS p4, + SUM(telefone_valido) AS com_whatsapp, + SUM(CASE WHEN exported_at IS NOT NULL THEN 1 ELSE 0 END) AS exportados, + SUM(CASE WHEN exported_at IS NOT NULL AND date(exported_at) = date('now') THEN 1 ELSE 0 END) AS hoje_exportados + FROM leads ${whereSql}` + ).get(...params); + + const total = row.total || 0; + const exportados = row.exportados || 0; + const hojeExportados = row.hoje_exportados || 0; + const metaDiaria = Number(getSetting(db, 'meta_diaria', '100')) || 100; + const disponiveis = total - exportados; + + return { + total, + p1: row.p1 || 0, + p2: row.p2 || 0, + p3: row.p3 || 0, + p4: row.p4 || 0, + p1_pct: pct(row.p1 || 0, total), + p2_pct: pct(row.p2 || 0, total), + p3_pct: pct(row.p3 || 0, total), + p4_pct: pct(row.p4 || 0, total), + potencial_pct: pct(row.p1 || 0, total), + com_whatsapp: row.com_whatsapp || 0, + sem_whatsapp: total - (row.com_whatsapp || 0), + exportados, + disponiveis, + cobertura_pct: pct(exportados, total), + hoje_exportados: hojeExportados, + meta_diaria: metaDiaria, + meta_pct: pct(hojeExportados, metaDiaria), + dias_estimados: metaDiaria > 0 ? Math.ceil(disponiveis / metaDiaria) : 0 + }; +} + +const RANKING_COLUMNS = { + cidade: 'cidade', + segmento: 'segmento_label', + uf: 'uf', + interesse: 'interesse', + origem: 'origem', + produto: 'produto_principal' +}; + +export function dashboardRanking(db, by, filtro = {}, limit = 15) { + const column = RANKING_COLUMNS[by] || 'cidade'; + const { whereSql, params } = buildLeadFilter(filtro); + const capped = Math.min(50, Math.max(1, Number(limit) || 15)); + + return db.prepare( + `SELECT ${column} AS label, + COUNT(*) AS total, + SUM(CASE WHEN prioridade = 1 THEN 1 ELSE 0 END) AS p1 + FROM leads ${whereSql} + ${whereSql ? 'AND' : 'WHERE'} ${column} != '' + GROUP BY ${column} + ORDER BY total DESC + LIMIT ?` + ).all(...params, capped); +} + +// Historico de exportacoes por dia — unico evento comercial com timestamp +// que o app registra (nao ha rastreio de contato/interesse/proposta). +export function dashboardDailyEvolution(db, filtro = {}, dias = 14) { + const { whereSql, params } = buildLeadFilter(filtro); + const capped = Math.min(90, Math.max(1, Number(dias) || 14)); + + const rows = db.prepare( + `SELECT date(exported_at) AS dia, COUNT(*) AS total + FROM leads ${whereSql} + ${whereSql ? 'AND' : 'WHERE'} exported_at IS NOT NULL + GROUP BY dia + ORDER BY dia DESC + LIMIT ?` + ).all(...params, capped); + + return rows.reverse(); +} + +// Combinacao cidade+produto com mais empresas dentro do filtro atual. +export function dashboardMaiorOportunidade(db, filtro = {}) { + const { whereSql, params } = buildLeadFilter(filtro); + + const row = db.prepare( + `SELECT cidade, produto_principal AS produto, COUNT(*) AS total + FROM leads ${whereSql} + ${whereSql ? 'AND' : 'WHERE'} cidade != '' AND produto_principal != '' + GROUP BY cidade, produto_principal + ORDER BY total DESC + LIMIT 1` + ).get(...params); + + return row || null; +} diff --git a/gti-maquina-comercial/src/treatment/queries.mjs b/gti-maquina-comercial/src/treatment/queries.mjs new file mode 100644 index 0000000..95db219 --- /dev/null +++ b/gti-maquina-comercial/src/treatment/queries.mjs @@ -0,0 +1,157 @@ +// Filtros de leads compartilhados entre a listagem, o dashboard e as +// exportacoes: o MESMO objeto de filtro que a tela usa para navegar e o que +// define quais leads entram num relatorio/exportacao. + +const SORTS = { + 'prioridade:asc': 'prioridade ASC, score DESC, cidade ASC, empresa ASC', + 'score:desc': 'score DESC, empresa ASC', + 'score:asc': 'score ASC, empresa ASC', + 'empresa:asc': 'empresa ASC', + 'empresa:desc': 'empresa DESC', + 'cidade:asc': 'cidade ASC, empresa ASC', + 'captured_at:desc': "COALESCE(captured_at, created_at) DESC", + 'captured_at:asc': "COALESCE(captured_at, created_at) ASC" +}; + +export function buildLeadFilter(filtro = {}) { + const where = []; + const params = []; + + const eq = (column, value) => { + if (value === undefined || value === null || value === '') return; + where.push(`${column} = ?`); + params.push(value); + }; + + eq('uf', filtro.uf ? String(filtro.uf).toUpperCase() : ''); + eq('cidade', filtro.cidade); + eq('segmento', filtro.segmento); + eq('classificacao', filtro.classificacao); + eq('interesse', filtro.interesse); + eq('origem', filtro.origem); + + if (filtro.telefone_valido === 'sim') where.push('telefone_valido = 1'); + if (filtro.telefone_valido === 'nao') where.push('telefone_valido = 0'); + + if (filtro.exportado === 'sim') where.push('exported_at IS NOT NULL'); + if (filtro.exportado === 'nao') where.push('exported_at IS NULL'); + + if (filtro.website === 'sim') where.push("website != ''"); + if (filtro.website === 'nao') where.push("website = ''"); + + if (filtro.email === 'sim') where.push("email != ''"); + if (filtro.email === 'nao') where.push("email = ''"); + + eq('produto_principal', filtro.produto); + + if (filtro.prioridade !== undefined && filtro.prioridade !== '') { + where.push('prioridade = ?'); + params.push(Number(filtro.prioridade) || 4); + } + + if (filtro.score_min !== undefined && filtro.score_min !== '') { + where.push('score >= ?'); + params.push(Number(filtro.score_min) || 0); + } + + if (filtro.q) { + const like = `%${String(filtro.q).trim()}%`; + where.push('(empresa LIKE ? OR legal_name LIKE ? OR whatsapp LIKE ? OR cnpj LIKE ?)'); + params.push(like, like, like, like); + } + + return { + whereSql: where.length ? `WHERE ${where.join(' AND ')}` : '', + params + }; +} + +export function listLeads(db, filtro = {}) { + const { whereSql, params } = buildLeadFilter(filtro); + const pageSize = Math.min(200, Math.max(1, Number(filtro.pageSize) || 50)); + const page = Math.max(1, Number(filtro.page) || 1); + const orderBy = SORTS[filtro.sort] || SORTS['prioridade:asc']; + + const total = db.prepare(`SELECT COUNT(*) AS t FROM leads ${whereSql}`).get(...params).t; + const rows = db.prepare( + `SELECT leads.id AS id, empresa, legal_name, whatsapp, ddd, cidade, uf, segmento, segmento_label, + interesse, cnpj, cnae, score, score_qualidade, score_comercial, classificacao, + prioridade, produto_principal, telefone_valido, origem, + exported_at, captured_at, segments.affinity AS afinidade + FROM leads LEFT JOIN segments ON segments.name = leads.segmento + ${whereSql} + ORDER BY ${orderBy} + LIMIT ? OFFSET ?` + ).all(...params, pageSize, (page - 1) * pageSize); + + return { total, page, pageSize, rows }; +} + +export function getLead(db, id) { + const lead = db.prepare('SELECT * FROM leads WHERE id = ?').get(id); + if (!lead) return null; + + const exportHistory = db.prepare( + `SELECT e.id, e.tipo, e.created_at, e.total_leads + FROM export_leads el JOIN exports e ON e.id = el.export_id + WHERE el.lead_id = ? + ORDER BY e.created_at DESC` + ).all(id); + + const segmentInfo = lead.segmento + ? db.prepare('SELECT affinity FROM segments WHERE name = ?').get(lead.segmento) + : null; + + return { + ...lead, + pains: JSON.parse(lead.pains_json || '[]'), + produtos: JSON.parse(lead.produtos_json || '[]'), + afinidade: segmentInfo?.affinity || null, + exports: exportHistory + }; +} + +export function countLeads(db, filtro = {}) { + const { whereSql, params } = buildLeadFilter(filtro); + return db.prepare(`SELECT COUNT(*) AS t FROM leads ${whereSql}`).get(...params).t; +} + +// Percorre todos os leads do filtro em blocos paginados por id — usado pelas +// exportacoes para nunca carregar dezenas de milhares de linhas de uma vez. +export function* iterateLeads(db, filtro = {}, chunkSize = 5000) { + const { whereSql, params } = buildLeadFilter(filtro); + const sql = `SELECT * FROM leads ${whereSql ? `${whereSql} AND` : 'WHERE'} id > ? + ORDER BY id LIMIT ?`; + const stmt = db.prepare(sql); + let lastId = 0; + + while (true) { + const rows = stmt.all(...params, lastId, chunkSize); + if (!rows.length) return; + for (const row of rows) yield row; + lastId = rows[rows.length - 1].id; + } +} + +// Valores distintos para os seletores de filtro da tela. +export function leadFacets(db, filtro = {}) { + const base = buildLeadFilter({ uf: filtro.uf }); + + const facet = (column) => db.prepare( + `SELECT ${column} AS value, COUNT(*) AS total FROM leads ${base.whereSql} + ${base.whereSql ? 'AND' : 'WHERE'} ${column} != '' + GROUP BY ${column} ORDER BY total DESC LIMIT 300` + ).all(...base.params); + + return { + ufs: db.prepare("SELECT uf AS value, COUNT(*) AS total FROM leads WHERE uf != '' GROUP BY uf ORDER BY total DESC").all(), + cidades: facet('cidade'), + segmentos: facet('segmento'), + interesses: facet('interesse'), + origens: facet('origem'), + produtos: facet('produto_principal'), + classificacoes: db.prepare( + `SELECT classificacao AS value, COUNT(*) AS total FROM leads ${base.whereSql} GROUP BY classificacao` + ).all(...base.params) + }; +} diff --git a/gti-maquina-comercial/src/util/collections.mjs b/gti-maquina-comercial/src/util/collections.mjs new file mode 100644 index 0000000..5092e42 --- /dev/null +++ b/gti-maquina-comercial/src/util/collections.mjs @@ -0,0 +1,27 @@ +// Evita `target.push(...source)`: com arrays de dezenas/centenas de milhares de itens +// (bases CNPJ completas, varias cidades) o spread operator em argumentos de funcao +// estoura a pilha de chamadas do V8 (RangeError: Maximum call stack size exceeded). +export function pushAll(target, source) { + for (const item of source) target.push(item); + return target; +} + +export function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Quebra o vinculo do V8 entre uma substring (fatia) e o buffer da string original. +// Sem isso, qualquer campo guardado a longo prazo mantem a linha CSV/JSON inteira +// viva na memoria (SlicedString), o que com centenas de milhares de linhas retidas +// simultaneamente estoura o heap. +export function copyStr(value) { + return Buffer.from(String(value ?? ''), 'utf8').toString('utf8'); +} + +export function chunk(array, size) { + const chunks = []; + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)); + } + return chunks; +} diff --git a/gti-maquina-comercial/src/util/csv.mjs b/gti-maquina-comercial/src/util/csv.mjs new file mode 100644 index 0000000..099671b --- /dev/null +++ b/gti-maquina-comercial/src/util/csv.mjs @@ -0,0 +1,117 @@ +import fs from 'node:fs'; +import readline from 'node:readline'; + +export const BOM = ''; + +export function splitCsvLine(line, separator = ';') { + const values = []; + let current = ''; + let quoted = false; + + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + const next = line[index + 1]; + + if (char === '"' && quoted && next === '"') { + current += '"'; + index += 1; + } else if (char === '"') { + quoted = !quoted; + } else if (char === separator && !quoted) { + values.push(current.trim()); + current = ''; + } else { + current += char; + } + } + + values.push(current.trim()); + return values; +} + +export function csvEscape(value) { + const text = String(value ?? ''); + if (/[",\n;]/.test(text)) return `"${text.replace(/"/g, '""')}"`; + return text; +} + +// Formula de texto do Excel: impede que numeros longos (whatsapp, cnpj) virem +// notacao cientifica ao abrir o CSV. Mesmo truque usado nos arquivos TRATADOS. +export function excelTextCell(value) { + const text = String(value ?? '').replace(/"/g, '""'); + return `"=""${text}"""`; +} + +export function stripBom(text) { + return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; +} + +function detectSeparator(headerLine) { + return headerLine.includes(';') ? ';' : ','; +} + +// Le um CSV linha a linha sem carregar o arquivo inteiro em memoria +// (a base CNPJ de uma UF inteira passa de 100MB). Emite um objeto por linha, +// chaveado pelos headers (opcionalmente normalizados). Campos entre aspas +// podem conter quebras de linha (ex.: descricoes de exportacao) — a paridade +// de aspas detecta o registro incompleto e acumula ate fechar. +export async function* streamCsvRows(filePath, { separator, normalizeHeader } = {}) { + const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + let headers = null; + let sep = separator; + let pending = ''; + let pendingQuotes = 0; + + try { + for await (const rawLine of rl) { + const piece = headers === null && !pending ? stripBom(rawLine) : rawLine; + pending = pending ? `${pending}\n${piece}` : piece; + pendingQuotes += (piece.match(/"/g) || []).length; + + if (pendingQuotes % 2 === 1) continue; + + const line = pending; + pending = ''; + pendingQuotes = 0; + if (!line.trim()) continue; + + if (headers === null) { + sep = sep || detectSeparator(line); + headers = splitCsvLine(line, sep).map((header) => + normalizeHeader ? normalizeHeader(header) : header + ); + continue; + } + + const values = splitCsvLine(line, sep); + const row = {}; + for (let i = 0; i < headers.length; i += 1) { + row[headers[i]] = values[i] || ''; + } + yield row; + } + } finally { + rl.close(); + stream.destroy(); + } +} + +// Writer de CSV em stream: grava BOM + header e depois linha a linha, +// sem montar o arquivo inteiro em memoria. +export function createCsvWriter(filePath, headers, { separator = ';' } = {}) { + const stream = fs.createWriteStream(filePath, { encoding: 'utf8' }); + stream.write(BOM + headers.join(separator) + '\n'); + + return { + writeRow(values) { + return stream.write(values.join(separator) + '\n'); + }, + async end() { + await new Promise((resolve, reject) => { + stream.end((error) => (error ? reject(error) : resolve())); + }); + } + }; +} diff --git a/gti-maquina-comercial/src/util/stream-json.mjs b/gti-maquina-comercial/src/util/stream-json.mjs new file mode 100644 index 0000000..602dae7 --- /dev/null +++ b/gti-maquina-comercial/src/util/stream-json.mjs @@ -0,0 +1,78 @@ +import fs from 'node:fs'; + +// Le um arquivo JSON que contem um array de objetos SEM carregar o arquivo todo +// em memoria (o export legado de leads tem 472MB — JSON.parse direto estoura o heap). +// Scanner de profundidade: acha cada objeto de nivel superior do array e faz +// JSON.parse elemento a elemento (~3KB cada), mantendo a memoria constante. +// Suporta apenas arrays cujos elementos sao objetos (caso deste projeto). +export async function* streamJsonArray(filePath, { highWaterMark = 64 * 1024 } = {}) { + const stream = fs.createReadStream(filePath, { encoding: 'utf8', highWaterMark }); + + let arrayStarted = false; + let depth = 0; + let inString = false; + let escapeNext = false; + let parts = []; + let first = true; + + try { + for await (let chunkText of stream) { + if (first) { + first = false; + if (chunkText.charCodeAt(0) === 0xfeff) chunkText = chunkText.slice(1); + } + + let elementStart = -1; + + for (let i = 0; i < chunkText.length; i += 1) { + const char = chunkText[i]; + + if (inString) { + if (escapeNext) { + escapeNext = false; + } else if (char === '\\') { + escapeNext = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + if (depth > 0) inString = true; + continue; + } + + if (!arrayStarted) { + if (char === '[') arrayStarted = true; + continue; + } + + if (char === '{') { + if (depth === 0) elementStart = i; + depth += 1; + } else if (char === '}') { + depth -= 1; + if (depth === 0) { + const startIndex = elementStart >= 0 ? elementStart : 0; + parts.push(chunkText.slice(startIndex, i + 1)); + const text = parts.length === 1 ? parts[0] : parts.join(''); + parts = []; + elementStart = -1; + yield JSON.parse(text); + } + } + } + + if (depth > 0) { + parts.push(elementStart >= 0 ? chunkText.slice(elementStart) : chunkText); + } + } + } finally { + stream.destroy(); + } + + if (depth !== 0) { + throw new Error(`JSON truncado em ${filePath}: elemento incompleto no fim do arquivo`); + } +} diff --git a/gti-maquina-comercial/tests/auth.test.mjs b/gti-maquina-comercial/tests/auth.test.mjs new file mode 100644 index 0000000..186a1e7 --- /dev/null +++ b/gti-maquina-comercial/tests/auth.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { hashPassword, verifyPassword } from '../src/auth.mjs'; +import { openDb, createUser, findUserByUsername, listUsers, ensureDefaultAdmin } from '../src/db.mjs'; + +test('hashPassword/verifyPassword aceita a senha certa e rejeita a errada', () => { + const stored = hashPassword('minhaSenha123'); + assert.ok(verifyPassword('minhaSenha123', stored)); + assert.ok(!verifyPassword('senhaErrada', stored)); +}); + +test('hashPassword gera salt diferente a cada chamada (hashes distintos p/ mesma senha)', () => { + assert.notEqual(hashPassword('abc'), hashPassword('abc')); +}); + +test('ensureDefaultAdmin cria admin/admin somente no primeiro acesso', () => { + const db = openDb(':memory:'); + ensureDefaultAdmin(db); + + const admin = findUserByUsername(db, 'admin'); + assert.ok(admin); + assert.equal(admin.role, 'admin'); + assert.ok(verifyPassword('admin', admin.password_hash)); + + createUser(db, { username: 'vendedor1', passwordHash: hashPassword('123456'), role: 'comercial' }); + ensureDefaultAdmin(db); // nao deve recriar nem duplicar + + assert.equal(listUsers(db).length, 2); + db.close(); +}); + +test('createUser rejeita username duplicado (constraint UNIQUE)', () => { + const db = openDb(':memory:'); + createUser(db, { username: 'joao', passwordHash: hashPassword('123456'), role: 'comercial' }); + + assert.throws(() => createUser(db, { username: 'joao', passwordHash: hashPassword('outra'), role: 'admin' })); + db.close(); +}); diff --git a/gti-maquina-comercial/tests/campaign-messages.test.mjs b/gti-maquina-comercial/tests/campaign-messages.test.mjs new file mode 100644 index 0000000..fad8e65 --- /dev/null +++ b/gti-maquina-comercial/tests/campaign-messages.test.mjs @@ -0,0 +1,27 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { variantForLead, renderCampaignMessage } from '../src/outreach/campaign-messages.mjs'; + +const variants = [{ id: 1, code: 'A', mensagem: 'Ola A' }, { id: 2, code: 'B', mensagem: 'Ola B' }, { id: 3, code: 'C', mensagem: 'Ola C' }]; + +test('variantForLead e deterministico (mesmo lead sempre cai na mesma variante)', () => { + const primeira = variantForLead(variants, 42); + const segunda = variantForLead(variants, 42); + assert.equal(primeira.code, segunda.code); +}); + +test('variantForLead distribui entre variantes diferentes conforme o id', () => { + assert.equal(variantForLead(variants, 0).code, 'A'); + assert.equal(variantForLead(variants, 1).code, 'B'); + assert.equal(variantForLead(variants, 2).code, 'C'); + assert.equal(variantForLead(variants, 3).code, 'A'); +}); + +test('variantForLead lanca erro sem variantes ativas', () => { + assert.throws(() => variantForLead([], 1), /sem variantes/); +}); + +test('renderCampaignMessage substitui variaveis do lead', () => { + const texto = renderCampaignMessage({ mensagem: 'Ola {empresa}, sobre {produto}' }, { empresa: 'Oficina do Ze', produto: 'ERP' }); + assert.equal(texto, 'Ola Oficina do Ze, sobre ERP'); +}); diff --git a/gti-maquina-comercial/tests/campaign-metrics.test.mjs b/gti-maquina-comercial/tests/campaign-metrics.test.mjs new file mode 100644 index 0000000..cae20ed --- /dev/null +++ b/gti-maquina-comercial/tests/campaign-metrics.test.mjs @@ -0,0 +1,170 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { openDb, createLeadInserter } from '../src/db.mjs'; +import { createCampaign, setCampaignStatus } from '../src/outreach/campaign-store.mjs'; +import { evaluateCampaignHealth, applyCampaignHealth, variantStats } from '../src/outreach/campaign-metrics.mjs'; + +function inserirLead(db, dedup) { + const inserir = createLeadInserter(db); + inserir({ + dedup_key: dedup, empresa: 'E', legal_name: '', whatsapp: `55119999${dedup.padStart(5, '0')}`, + ddd: '11', email: '', cidade: '', uf: '', segmento: 'x', segmento_label: '', interesse: '', + cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]', origem: 't', telefone_valido: 1, + cnpj_valido: 0, empresa_valida: 1, score: 50, classificacao: 'bom', motivo_classificacao: '', + score_qualidade: 50, score_comercial: 50, prioridade: 1, produto_principal: 'ERP', + produtos_json: '[]', capture_run_id: null, captured_at: null, exported_at: null + }); + return db.prepare('SELECT id, whatsapp FROM leads WHERE dedup_key = ?').get(dedup); +} + +function campanhaRodando(db) { + const campanha = createCampaign(db, { + nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }] + }); + setCampaignStatus(db, campanha.id, 'rodando'); + return campanha; +} + +function simularEnvio(db, campanhaId, variantId, leadId, numero, status) { + const sendId = db.prepare( + `INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'ERP', ?, 'oi', ?)` + ).run(leadId, numero, status).lastInsertRowid; + db.prepare( + `INSERT INTO outreach_campaign_queue (campaign_id, lead_id, variant_id, numero, rendered_message, status, outreach_send_id) + VALUES (?, ?, ?, ?, 'oi', ?, ?)` + ).run(campanhaId, leadId, variantId, numero, status === 'erro' ? 'error' : 'sent', sendId); +} + +test('evaluateCampaignHealth nao pausa campanha saudavel', () => { + const db = openDb(':memory:'); + const campanha = campanhaRodando(db); + const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id); + for (let i = 0; i < 10; i += 1) { + const lead = inserirLead(db, `l${i}`); + simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado'); + } + const resultado = evaluateCampaignHealth(db, campanha.id); + assert.equal(resultado.pause, false); + db.close(); +}); + +test('evaluateCampaignHealth pausa por high_failure_rate quando erro > 10% com 10+ tentativas', () => { + const db = openDb(':memory:'); + const campanha = campanhaRodando(db); + const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id); + for (let i = 0; i < 8; i += 1) { + const lead = inserirLead(db, `ok${i}`); + simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado'); + } + for (let i = 0; i < 2; i += 1) { + const lead = inserirLead(db, `err${i}`); + simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'erro'); + } + const resultado = evaluateCampaignHealth(db, campanha.id); + assert.equal(resultado.pause, true); + assert.equal(resultado.reason, 'high_failure_rate'); + // evaluateCampaignHealth e read-only: so retorna o veredito, nao mexe no banco. + const semMutacao = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id); + assert.equal(semMutacao.status, 'rodando'); + + // A mutacao (pausar de verdade) so acontece via applyCampaignHealth. + const aplicado = applyCampaignHealth(db, campanha.id); + assert.equal(aplicado.pause, true); + assert.equal(aplicado.reason, 'high_failure_rate'); + const atualizada = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id); + assert.equal(atualizada.status, 'pausado'); + db.close(); +}); + +test('evaluateCampaignHealth nunca muda outreach_campaigns.status, mesmo quando retornaria pause:true (GET /saude nao deve pausar como efeito colateral de uma leitura)', () => { + const db = openDb(':memory:'); + const campanha = campanhaRodando(db); + const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id); + for (let i = 0; i < 8; i += 1) { + const lead = inserirLead(db, `puro-ok${i}`); + simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado'); + } + for (let i = 0; i < 2; i += 1) { + const lead = inserirLead(db, `puro-err${i}`); + simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'erro'); + } + + for (let i = 0; i < 5; i += 1) { + const resultado = evaluateCampaignHealth(db, campanha.id); + assert.equal(resultado.pause, true, 'deveria continuar sinalizando pause:true a cada chamada'); + } + const aindaRodando = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id); + assert.equal(aindaRodando.status, 'rodando', 'chamar evaluateCampaignHealth repetidamente nao deveria ter pausado a campanha'); + db.close(); +}); + +test('evaluateCampaignHealth nao pausa por falha com menos de 10 tentativas no dia', () => { + const db = openDb(':memory:'); + const campanha = campanhaRodando(db); + const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id); + const lead = inserirLead(db, 'so1'); + simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'erro'); + const resultado = evaluateCampaignHealth(db, campanha.id); + assert.equal(resultado.pause, false); + db.close(); +}); + +test('evaluateCampaignHealth pausa por high_negative_rate quando >10% dos 20+ contatados estao em sem_interesse/opt_out', () => { + const db = openDb(':memory:'); + const campanha = campanhaRodando(db); + const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id); + for (let i = 0; i < 20; i += 1) { + const lead = inserirLead(db, `neg${i}`); + simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado'); + if (i < 3) { + db.prepare(`INSERT INTO outreach_lead_states (lead_id, stage) VALUES (?, 'sem_interesse')`).run(lead.id); + } + } + const resultado = evaluateCampaignHealth(db, campanha.id); + assert.equal(resultado.pause, true); + assert.equal(resultado.reason, 'high_negative_rate'); + db.close(); +}); + +test('evaluateCampaignHealth pausa por low_reply_rate quando <5% dos 50+ contatados responderam', () => { + const db = openDb(':memory:'); + const campanha = campanhaRodando(db); + const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id); + for (let i = 0; i < 50; i += 1) { + const lead = inserirLead(db, `rep${i}`); + simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado'); + } + const resultado = evaluateCampaignHealth(db, campanha.id); + assert.equal(resultado.pause, true); + assert.equal(resultado.reason, 'low_reply_rate'); + db.close(); +}); + +test('evaluateCampaignHealth em campanha ja pausada nao faz nada', () => { + const db = openDb(':memory:'); + const campanha = campanhaRodando(db); + setCampaignStatus(db, campanha.id, 'pausado', 'manual'); + const resultado = evaluateCampaignHealth(db, campanha.id); + assert.equal(resultado.pause, false); + db.close(); +}); + +test('variantStats calcula taxa de resposta e elegibilidade (limiar 40 envios)', () => { + const db = openDb(':memory:'); + const campanha = campanhaRodando(db); + const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id); + for (let i = 0; i < 5; i += 1) { + const lead = inserirLead(db, `v${i}`); + simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado'); + if (i < 1) { + db.prepare(`INSERT INTO outreach_stage_events (lead_id, campaign_id, stage) VALUES (?, ?, 'interessado')`).run(lead.id, campanha.id); + } + } + const stats = variantStats(db, campanha.id); + assert.equal(stats.length, 1); + assert.equal(stats[0].enviados, 5); + assert.equal(stats[0].respostas, 1); + assert.equal(stats[0].taxaResposta, 0.2); + assert.equal(stats[0].elegivelParaComparacao, false); + db.close(); +}); diff --git a/gti-maquina-comercial/tests/campaign-queue.test.mjs b/gti-maquina-comercial/tests/campaign-queue.test.mjs new file mode 100644 index 0000000..e2789b9 --- /dev/null +++ b/gti-maquina-comercial/tests/campaign-queue.test.mjs @@ -0,0 +1,155 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { openDb, createLeadInserter } from '../src/db.mjs'; +import { createCampaign } from '../src/outreach/campaign-store.mjs'; +import { generateCampaignQueue, listQueue, approveQueueRows, cancelQueueRows } from '../src/outreach/campaign-queue.mjs'; + +function inserirLead(db, overrides = {}) { + const inserir = createLeadInserter(db); + const dedup = overrides.dedup_key || `lead-${Math.random()}`; + inserir({ + dedup_key: dedup, empresa: 'Oficina Teste', legal_name: '', whatsapp: '5511999990001', + ddd: '11', email: '', cidade: 'SP', uf: 'SP', segmento: 'oficinas_mecanicas', segmento_label: '', + interesse: '', cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]', origem: 'teste', + telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 80, classificacao: 'bom', + motivo_classificacao: '', score_qualidade: 80, score_comercial: 80, prioridade: 1, + produto_principal: 'ERP', produtos_json: '[]', capture_run_id: null, captured_at: null, exported_at: null, + ...overrides + }); + return db.prepare('SELECT * FROM leads WHERE dedup_key = ?').get(dedup); +} + +function campanhaComVariantes(db, overrides = {}) { + return createCampaign(db, { + nome: 'ERP oficinas', produto: 'ERP', segmentos: ['oficinas_mecanicas'], + variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }], + ...overrides + }); +} + +test('generateCampaignQueue cria linhas preview so para leads do produto/segmento certos', () => { + const db = openDb(':memory:'); + inserirLead(db, { dedup_key: 'l1', whatsapp: '5511999990001' }); + inserirLead(db, { dedup_key: 'l2', segmento: 'outro_segmento', whatsapp: '5511999990002' }); + const campanha = campanhaComVariantes(db); + + const resultado = generateCampaignQueue(db, campanha.id); + assert.equal(resultado.candidatosEncontrados, 1); + assert.equal(resultado.adicionadosNaFila, 1); + + const fila = listQueue(db, campanha.id); + assert.equal(fila.length, 1); + assert.equal(fila[0].status, 'preview'); + assert.equal(fila[0].rendered_message, 'Ola Oficina Teste'); + db.close(); +}); + +test('generateCampaignQueue exclui lead com envio ja enviado para o mesmo produto (por lead_id)', () => { + const db = openDb(':memory:'); + const lead = inserirLead(db); + db.prepare( + `INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'ERP', ?, 'oi', 'enviado')` + ).run(lead.id, lead.whatsapp); + const campanha = campanhaComVariantes(db); + + const resultado = generateCampaignQueue(db, campanha.id); + assert.equal(resultado.candidatosEncontrados, 0); + db.close(); +}); + +test('generateCampaignQueue exclui numero com envio manual ja enviado (sem lead_id, produto vazio, como o envio manual real grava)', () => { + const db = openDb(':memory:'); + inserirLead(db, { whatsapp: '5511999990009' }); + // Reproduz exatamente como src/server/routes/outreach.mjs grava um envio + // manual: lead_id NULL, produto '' (nunca o produto da campanha) e + // origem='manual'. A exclusao precisa valer mesmo assim, por numero. + db.prepare( + `INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, origem) + VALUES (NULL, '', '5511999990009', 'oi', 'enviado', 'manual')` + ).run(); + const campanha = campanhaComVariantes(db); + + const resultado = generateCampaignQueue(db, campanha.id); + assert.equal(resultado.candidatosEncontrados, 0); + db.close(); +}); + +test('generateCampaignQueue exclui numero em opt-out', () => { + const db = openDb(':memory:'); + inserirLead(db, { whatsapp: '5511999990001' }); + db.prepare(`INSERT INTO outreach_opt_out (numero) VALUES ('5511999990001')`).run(); + const campanha = campanhaComVariantes(db); + + const resultado = generateCampaignQueue(db, campanha.id); + assert.equal(resultado.candidatosEncontrados, 0); + db.close(); +}); + +test('generateCampaignQueue nao duplica lead ja na fila (preview/approved/sent) de outra campanha do mesmo produto', () => { + const db = openDb(':memory:'); + inserirLead(db, { dedup_key: 'unico', whatsapp: '5511999990001' }); + const campanha1 = campanhaComVariantes(db, { nome: 'Campanha 1' }); + generateCampaignQueue(db, campanha1.id); + + const campanha2 = campanhaComVariantes(db, { nome: 'Campanha 2' }); + const resultado = generateCampaignQueue(db, campanha2.id); + assert.equal(resultado.candidatosEncontrados, 0); + db.close(); +}); + +test('generateCampaignQueue respeita o parametro quantidade', () => { + const db = openDb(':memory:'); + for (let i = 0; i < 5; i += 1) { + inserirLead(db, { dedup_key: `lead-${i}`, whatsapp: `551199999000${i}` }); + } + const campanha = campanhaComVariantes(db); + const resultado = generateCampaignQueue(db, campanha.id, { quantidade: 3 }); + assert.equal(resultado.adicionadosNaFila, 3); + db.close(); +}); + +test('generateCampaignQueue reporta candidatosEncontrados como o pool elegivel completo, mesmo com quantidade menor', () => { + const db = openDb(':memory:'); + for (let i = 0; i < 5; i += 1) { + inserirLead(db, { dedup_key: `pool-${i}`, whatsapp: `551199998000${i}` }); + } + const campanha = campanhaComVariantes(db); + const resultado = generateCampaignQueue(db, campanha.id, { quantidade: 2 }); + assert.equal(resultado.candidatosEncontrados, 5); + assert.equal(resultado.adicionadosNaFila, 2); + assert.equal(listQueue(db, campanha.id).length, 2); + db.close(); +}); + +test('approveQueueRows so aprova linhas em preview da campanha certa', () => { + const db = openDb(':memory:'); + inserirLead(db, { whatsapp: '5511999990001' }); + const campanha = campanhaComVariantes(db); + generateCampaignQueue(db, campanha.id); + const [linha] = listQueue(db, campanha.id); + + const total = approveQueueRows(db, campanha.id, [linha.id]); + assert.equal(total, 1); + assert.equal(listQueue(db, campanha.id, 'approved').length, 1); +}); + +test('cancelQueueRows cancela e registra motivo', () => { + const db = openDb(':memory:'); + inserirLead(db, { whatsapp: '5511999990001' }); + const campanha = campanhaComVariantes(db); + generateCampaignQueue(db, campanha.id); + const [linha] = listQueue(db, campanha.id); + + const total = cancelQueueRows(db, campanha.id, [linha.id], 'nao interessa'); + assert.equal(total, 1); + const cancelada = listQueue(db, campanha.id, 'cancelled')[0]; + assert.equal(cancelada.cancel_reason, 'nao interessa'); +}); + +test('approveQueueRows/cancelQueueRows com lista vazia nao lanca erro', () => { + const db = openDb(':memory:'); + const campanha = campanhaComVariantes(db); + assert.equal(approveQueueRows(db, campanha.id, []), 0); + assert.equal(cancelQueueRows(db, campanha.id, []), 0); + db.close(); +}); diff --git a/gti-maquina-comercial/tests/campaign-routes.test.mjs b/gti-maquina-comercial/tests/campaign-routes.test.mjs new file mode 100644 index 0000000..f252983 --- /dev/null +++ b/gti-maquina-comercial/tests/campaign-routes.test.mjs @@ -0,0 +1,217 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { openDb, createLeadInserter } from '../src/db.mjs'; +import { createRouter } from '../src/server/router.mjs'; +import { registerCampaignRoutes } from '../src/server/routes/campaigns.mjs'; + +function setupRouter(db) { + const router = createRouter(); + registerCampaignRoutes(router, { db }); + return router; +} + +function fakeJson() { + const calls = []; + return { json: (status, data) => calls.push({ status, data }), calls }; +} + +function inserirLead(db, dedup, produto = 'ERP', segmento = 'oficinas') { + const inserir = createLeadInserter(db); + inserir({ + dedup_key: dedup, empresa: 'Empresa', legal_name: '', whatsapp: `551199990${dedup}`, ddd: '11', + email: '', cidade: '', uf: '', segmento, segmento_label: '', interesse: '', cnpj: '', cnae: '', + endereco: '', website: '', pains_json: '[]', origem: 't', telefone_valido: 1, cnpj_valido: 0, + empresa_valida: 1, score: 50, classificacao: 'bom', motivo_classificacao: '', score_qualidade: 50, + score_comercial: 50, prioridade: 1, produto_principal: produto, produtos_json: '[]', + capture_run_id: null, captured_at: null, exported_at: null + }); +} + +test('POST /api/campaigns cria campanha com segmentos e variantes', async () => { + const db = openDb(':memory:'); + const router = setupRouter(db); + const { handler } = router.match('POST', '/api/campaigns'); + const { json, calls } = fakeJson(); + + await handler(null, null, { json, body: { + nome: 'ERP oficinas', produto: 'ERP', segmentos: ['oficinas'], + variantes: [{ mensagem: 'Ola {empresa}' }] + } }); + + assert.equal(calls[0].status, 200); + assert.equal(calls[0].data.produto, 'ERP'); + assert.equal(calls[0].data.variants.length, 1); + db.close(); +}); + +test('POST /api/campaigns rejeita sem segmentos', async () => { + const db = openDb(':memory:'); + const router = setupRouter(db); + const { handler } = router.match('POST', '/api/campaigns'); + const { json, calls } = fakeJson(); + + await handler(null, null, { json, body: { nome: 'X', produto: 'ERP', segmentos: [], variantes: [{ mensagem: 'oi' }] } }); + assert.equal(calls[0].status, 400); + db.close(); +}); + +test('GET /api/campaigns/segmentos lista segmentos distintos do produto com contagem', async () => { + const db = openDb(':memory:'); + inserirLead(db, '1', 'ERP', 'oficinas'); + inserirLead(db, '2', 'ERP', 'oficinas'); + inserirLead(db, '3', 'ERP', 'padarias'); + inserirLead(db, '4', 'G-Chat', 'padarias'); + const router = setupRouter(db); + const { handler } = router.match('GET', '/api/campaigns/segmentos'); + const { json, calls } = fakeJson(); + + await handler(null, null, { json, query: new URLSearchParams({ produto: 'ERP' }) }); + assert.equal(calls[0].status, 200); + const segmentos = calls[0].data.segmentos; + assert.equal(segmentos.find((s) => s.value === 'oficinas').total, 2); + assert.equal(segmentos.find((s) => s.value === 'padarias').total, 1); + db.close(); +}); + +test('fluxo completo: criar campanha, gerar fila, aprovar e checar saude', async () => { + const db = openDb(':memory:'); + inserirLead(db, '1', 'ERP', 'oficinas'); + const router = setupRouter(db); + + const criar = router.match('POST', '/api/campaigns'); + const respCriar = fakeJson(); + await criar.handler(null, null, { json: respCriar.json, body: { + nome: 'C', produto: 'ERP', segmentos: ['oficinas'], variantes: [{ mensagem: 'Ola {empresa}' }] + } }); + const campaignId = respCriar.calls[0].data.id; + + const gerarFila = router.match('POST', `/api/campaigns/${campaignId}/gerar-fila`); + const respFila = fakeJson(); + await gerarFila.handler(null, null, { json: respFila.json, params: { id: String(campaignId) }, body: {} }); + assert.equal(respFila.calls[0].data.adicionadosNaFila, 1); + + const listarFila = router.match('GET', `/api/campaigns/${campaignId}/fila`); + const respLista = fakeJson(); + await listarFila.handler(null, null, { json: respLista.json, params: { id: String(campaignId) }, query: new URLSearchParams() }); + const queueId = respLista.calls[0].data.fila[0].id; + + const aprovar = router.match('POST', `/api/campaigns/${campaignId}/fila/aprovar`); + const respAprovar = fakeJson(); + await aprovar.handler(null, null, { json: respAprovar.json, params: { id: String(campaignId) }, body: { queueIds: [queueId] } }); + assert.equal(respAprovar.calls[0].data.aprovados, 1); + + const saude = router.match('GET', `/api/campaigns/${campaignId}/saude`); + const respSaude = fakeJson(); + await saude.handler(null, null, { json: respSaude.json, params: { id: String(campaignId) } }); + assert.equal(respSaude.calls[0].data.pause, false); + db.close(); +}); + +test('POST /api/campaigns/leads/:leadId/estagio grava estagio atual e historico', async () => { + const db = openDb(':memory:'); + inserirLead(db, '1'); + const lead = db.prepare('SELECT id FROM leads LIMIT 1').get(); + const router = setupRouter(db); + + const marcar = router.match('POST', '/api/campaigns/leads/:leadId/estagio'); + const respMarcar = fakeJson(); + await marcar.handler(null, null, { json: respMarcar.json, params: { leadId: String(lead.id) }, body: { stage: 'interessado', note: 'ligar amanha' } }); + assert.equal(respMarcar.calls[0].status, 200); + + const consultar = router.match('GET', '/api/campaigns/leads/:leadId/estagio'); + const respConsultar = fakeJson(); + await consultar.handler(null, null, { json: respConsultar.json, params: { leadId: String(lead.id) } }); + assert.equal(respConsultar.calls[0].data.atual.stage, 'interessado'); + assert.equal(respConsultar.calls[0].data.historico.length, 1); + db.close(); +}); + +test('POST /api/campaigns/opt-out bloqueia e DELETE libera', async () => { + const db = openDb(':memory:'); + const router = setupRouter(db); + + const adicionar = router.match('POST', '/api/campaigns/opt-out'); + await adicionar.handler(null, null, { json: fakeJson().json, body: { numero: '5511999990001', motivo: 'pediu' } }); + assert.ok(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('5511999990001')); + + const remover = router.match('DELETE', '/api/campaigns/opt-out/:numero'); + await remover.handler(null, null, { json: fakeJson().json, params: { numero: '5511999990001' } }); + assert.equal(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('5511999990001'), undefined); + db.close(); +}); + +test('POST/DELETE /api/campaigns/opt-out normalizam o numero pra digitos, igual ao resto do app', async () => { + const db = openDb(':memory:'); + const router = setupRouter(db); + + const adicionar = router.match('POST', '/api/campaigns/opt-out'); + await adicionar.handler(null, null, { json: fakeJson().json, body: { numero: '+55 (11) 99999-0001', motivo: 'pediu' } }); + // Tem que ter gravado so digitos, senao isOptedOut/candidateQuery (que comparam + // contra leads.whatsapp, ja digit-only) nunca vao casar com este registro. + assert.ok(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('5511999990001')); + assert.equal(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('+55 (11) 99999-0001'), undefined); + + const remover = router.match('DELETE', '/api/campaigns/opt-out/:numero'); + await remover.handler(null, null, { json: fakeJson().json, params: { numero: '+55 (11) 99999-0001' } }); + assert.equal(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('5511999990001'), undefined); + db.close(); +}); + +test('POST /api/campaigns/leads/:leadId/estagio com stage=opt_out tambem grava em outreach_opt_out (numero digit-only)', async () => { + const db = openDb(':memory:'); + inserirLead(db, '1'); + const lead = db.prepare('SELECT id, whatsapp FROM leads LIMIT 1').get(); + const router = setupRouter(db); + + const marcar = router.match('POST', '/api/campaigns/leads/:leadId/estagio'); + const respMarcar = fakeJson(); + await marcar.handler(null, null, { json: respMarcar.json, params: { leadId: String(lead.id) }, body: { stage: 'opt_out' } }); + assert.equal(respMarcar.calls[0].status, 200); + + assert.ok( + db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get(lead.whatsapp), + 'marcar estagio opt_out deveria ter chamado addOptOut com o numero do lead' + ); + db.close(); +}); + +test('GET /:id/saude nao muta o status da campanha (leitura pura); o worker e quem aplica a pausa', async () => { + const db = openDb(':memory:'); + inserirLead(db, '1', 'ERP', 'oficinas'); + const router = setupRouter(db); + + const criar = router.match('POST', '/api/campaigns'); + const respCriar = fakeJson(); + await criar.handler(null, null, { json: respCriar.json, body: { + nome: 'C', produto: 'ERP', segmentos: ['oficinas'], variantes: [{ mensagem: 'Ola {empresa}' }] + } }); + const campaignId = respCriar.calls[0].data.id; + + const iniciar = router.match('POST', `/api/campaigns/${campaignId}/iniciar`); + await iniciar.handler(null, null, { json: fakeJson().json, params: { id: String(campaignId) } }); + + // Simula 10 tentativas hoje, todas com erro (100% > 10%) -> evaluateCampaignHealth + // deveria retornar pause:true, mas a rota GET nao pode persistir essa pausa. + const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campaignId); + for (let i = 0; i < 10; i += 1) { + const dedup = `saude-${i}`; + inserirLead(db, dedup, 'ERP', 'oficinas'); + const l = db.prepare('SELECT id, whatsapp FROM leads WHERE dedup_key = ?').get(dedup); + const sendId = db.prepare( + `INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'ERP', ?, 'oi', 'erro')` + ).run(l.id, l.whatsapp).lastInsertRowid; + db.prepare( + `INSERT INTO outreach_campaign_queue (campaign_id, lead_id, variant_id, numero, rendered_message, status, outreach_send_id) + VALUES (?, ?, ?, ?, 'oi', 'error', ?)` + ).run(campaignId, l.id, variant.id, l.whatsapp, sendId); + } + + const saude = router.match('GET', `/api/campaigns/${campaignId}/saude`); + const respSaude = fakeJson(); + await saude.handler(null, null, { json: respSaude.json, params: { id: String(campaignId) } }); + assert.equal(respSaude.calls[0].data.pause, true, 'a avaliacao deveria sinalizar pause:true dado 100% de erro'); + + const aindaRodando = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId); + assert.equal(aindaRodando.status, 'rodando', 'abrir o painel de saude (GET) nao deveria ter pausado a campanha'); + db.close(); +}); diff --git a/gti-maquina-comercial/tests/campaign-scheduler.test.mjs b/gti-maquina-comercial/tests/campaign-scheduler.test.mjs new file mode 100644 index 0000000..544cec3 --- /dev/null +++ b/gti-maquina-comercial/tests/campaign-scheduler.test.mjs @@ -0,0 +1,12 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { openDb } from '../src/db.mjs'; +import { startCampaignScheduler } from '../src/outreach/campaign-scheduler.mjs'; + +test('startCampaignScheduler retorna funcao de parada que limpa os timers sem lancar erro', () => { + const db = openDb(':memory:'); + const stop = startCampaignScheduler({ db, log: () => {} }); + assert.equal(typeof stop, 'function'); + assert.doesNotThrow(() => stop()); + db.close(); +}); diff --git a/gti-maquina-comercial/tests/campaign-store.test.mjs b/gti-maquina-comercial/tests/campaign-store.test.mjs new file mode 100644 index 0000000..3de6f56 --- /dev/null +++ b/gti-maquina-comercial/tests/campaign-store.test.mjs @@ -0,0 +1,144 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { openDb } from '../src/db.mjs'; +import { + createCampaign, getCampaign, listCampaigns, setCampaignStatus, resumeCampaign, + escalateWeeklyLimits, isOptedOut, addOptOut, removeOptOut +} from '../src/outreach/campaign-store.mjs'; + +function campanhaBase(overrides = {}) { + return { + nome: 'ERP oficinas', + produto: 'ERP', + segmentos: ['oficinas_mecanicas', 'autopecas'], + variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }, { code: 'B', mensagem: 'Oi {empresa}' }], + ...overrides + }; +} + +test('createCampaign grava campanha, segmentos e variantes', () => { + const db = openDb(':memory:'); + const campanha = createCampaign(db, campanhaBase()); + assert.equal(campanha.status, 'rascunho'); + assert.equal(campanha.produto, 'ERP'); + assert.deepEqual(campanha.segmentos, ['oficinas_mecanicas', 'autopecas']); + assert.equal(campanha.variants.length, 2); + assert.equal(campanha.variants[0].code, 'A'); + db.close(); +}); + +test('listCampaigns retorna mais recente primeiro', () => { + const db = openDb(':memory:'); + createCampaign(db, campanhaBase({ nome: 'Primeira' })); + createCampaign(db, campanhaBase({ nome: 'Segunda' })); + const campanhas = listCampaigns(db); + assert.equal(campanhas.length, 2); + assert.equal(campanhas[0].nome, 'Segunda'); + db.close(); +}); + +test('setCampaignStatus pausa e resumeCampaign retoma limpando o motivo', () => { + const db = openDb(':memory:'); + const campanha = createCampaign(db, campanhaBase()); + setCampaignStatus(db, campanha.id, 'rodando'); + const pausada = setCampaignStatus(db, campanha.id, 'pausado', 'high_failure_rate'); + assert.equal(pausada.status, 'pausado'); + assert.equal(pausada.pause_reason, 'high_failure_rate'); + const retomada = resumeCampaign(db, campanha.id); + assert.equal(retomada.status, 'rodando'); + assert.equal(retomada.pause_reason, null); + db.close(); +}); + +test('setCampaignStatus rodando stamps semana_iniciada_em on first transition', () => { + const db = openDb(':memory:'); + const campanha = createCampaign(db, campanhaBase()); + assert.equal(campanha.semana_iniciada_em, null); + + const rodando = setCampaignStatus(db, campanha.id, 'rodando'); + assert.notEqual(rodando.semana_iniciada_em, null, 'semana_iniciada_em should be set on first rodando transition'); + db.close(); +}); + +test('setCampaignStatus rodando does not reset already-set semana_iniciada_em', () => { + const db = openDb(':memory:'); + const campanha = createCampaign(db, campanhaBase()); + + // Set to rodando first time, which stamps semana_iniciada_em + setCampaignStatus(db, campanha.id, 'rodando'); + + // Manually set to a known past value via raw SQL + const knownTime = '2026-07-15 10:00:00'; + db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = ? WHERE id = ?`).run(knownTime, campanha.id); + + // Call setCampaignStatus again while already 'rodando' + const unchanged = setCampaignStatus(db, campanha.id, 'rodando'); + + // Verify semana_iniciada_em was not changed + assert.equal(unchanged.semana_iniciada_em, knownTime, 'semana_iniciada_em should not be reset on subsequent rodando calls'); + db.close(); +}); + +test('resumeCampaign reseta semana_iniciada_em, evitando escalonamento imediato apos retomar de uma pausa longa', () => { + const db = openDb(':memory:'); + const campanha = createCampaign(db, campanhaBase({ limiteDiarioInicial: 10, limiteDiarioTeto: 80 })); + setCampaignStatus(db, campanha.id, 'rodando'); + setCampaignStatus(db, campanha.id, 'pausado', 'high_failure_rate'); + // Simula que a campanha ficou pausada por muito tempo: semana_iniciada_em + // backdated 10 dias, o que satisfaria os "7 dias corridos" do escalonamento. + db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-10 days') WHERE id = ?`).run(campanha.id); + + resumeCampaign(db, campanha.id); + const escaladas = escalateWeeklyLimits(db); + assert.equal(escaladas, 0, 'retomar deveria ter resetado o relogio semanal, entao nao deveria escalonar imediatamente'); + const atualizada = getCampaign(db, campanha.id); + assert.equal(atualizada.limite_diario_atual, 10); + db.close(); +}); + +test('resumeCampaign nao faz nada se a campanha nao estiver pausada', () => { + const db = openDb(':memory:'); + const campanha = createCampaign(db, campanhaBase()); + const resultado = resumeCampaign(db, campanha.id); + assert.equal(resultado.status, 'rascunho'); + db.close(); +}); + +test('escalateWeeklyLimits dobra o limite apos 7 dias rodando, respeitando o teto', () => { + const db = openDb(':memory:'); + const campanha = createCampaign(db, campanhaBase({ limiteDiarioInicial: 10, limiteDiarioTeto: 15 })); + setCampaignStatus(db, campanha.id, 'rodando'); + db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-8 days') WHERE id = ?`).run(campanha.id); + + const escaladas = escalateWeeklyLimits(db); + assert.equal(escaladas, 1); + const atualizada = getCampaign(db, campanha.id); + assert.equal(atualizada.limite_diario_atual, 15); // dobraria pra 20, mas o teto e 15 + db.close(); +}); + +test('escalateWeeklyLimits nao avanca campanha pausada nem antes de 7 dias', () => { + const db = openDb(':memory:'); + const rodando = createCampaign(db, campanhaBase({ nome: 'Rodando ha pouco' })); + setCampaignStatus(db, rodando.id, 'rodando'); + db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-2 days') WHERE id = ?`).run(rodando.id); + + const pausada = createCampaign(db, campanhaBase({ nome: 'Pausada' })); + setCampaignStatus(db, pausada.id, 'rodando'); + setCampaignStatus(db, pausada.id, 'pausado', 'manual'); + db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-10 days') WHERE id = ?`).run(pausada.id); + + const escaladas = escalateWeeklyLimits(db); + assert.equal(escaladas, 0); + db.close(); +}); + +test('opt-out: addOptOut bloqueia, removeOptOut libera', () => { + const db = openDb(':memory:'); + assert.equal(isOptedOut(db, '5511999990001'), false); + addOptOut(db, '5511999990001', 'pediu pra parar'); + assert.equal(isOptedOut(db, '5511999990001'), true); + removeOptOut(db, '5511999990001'); + assert.equal(isOptedOut(db, '5511999990001'), false); + db.close(); +}); diff --git a/gti-maquina-comercial/tests/campaign-worker.test.mjs b/gti-maquina-comercial/tests/campaign-worker.test.mjs new file mode 100644 index 0000000..8ae5f02 --- /dev/null +++ b/gti-maquina-comercial/tests/campaign-worker.test.mjs @@ -0,0 +1,201 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { openDb, createLeadInserter, setSetting } from '../src/db.mjs'; +import { createCampaign, setCampaignStatus, addOptOut } from '../src/outreach/campaign-store.mjs'; +import { generateCampaignQueue, listQueue, approveQueueRows } from '../src/outreach/campaign-queue.mjs'; +import { runCampaignQueue } from '../src/outreach/campaign-worker.mjs'; + +// Testes de banco em memoria nao seedam settings.outreach.* (fica no fallback +// 'false'), entao por padrao o worker nao faria nada — precisa ligar +// explicitamente e abrir a janela de horario pro dia todo pra nao depender do +// horario real em que o teste roda. +function ativarWorker(db) { + setSetting(db, 'outreach.ativo', 'true'); + setSetting(db, 'outreach.horaInicio', '00:00'); + setSetting(db, 'outreach.horaFim', '23:59'); +} + +function inserirLead(db, dedup, whatsapp) { + const inserir = createLeadInserter(db); + inserir({ + dedup_key: dedup, empresa: 'Empresa', legal_name: '', whatsapp, ddd: '11', email: '', + cidade: '', uf: '', segmento: 'x', segmento_label: '', interesse: '', cnpj: '', cnae: '', + endereco: '', website: '', pains_json: '[]', origem: 't', telefone_valido: 1, cnpj_valido: 0, + empresa_valida: 1, score: 50, classificacao: 'bom', motivo_classificacao: '', score_qualidade: 50, + score_comercial: 50, prioridade: 1, produto_principal: 'ERP', produtos_json: '[]', + capture_run_id: null, captured_at: null, exported_at: null + }); +} + +function campanhaAprovada(db, quantidadeLeads = 1) { + for (let i = 0; i < quantidadeLeads; i += 1) { + inserirLead(db, `lead-${i}`, `551199999000${i}`); + } + const campanha = createCampaign(db, { + nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }], + limiteDiarioInicial: 10 + }); + setCampaignStatus(db, campanha.id, 'rodando'); + generateCampaignQueue(db, campanha.id); + const fila = listQueue(db, campanha.id, 'preview'); + approveQueueRows(db, campanha.id, fila.map((r) => r.id)); + return campanha; +} + +function fakeZproClient(overrides = {}) { + const enviados = []; + return { + enviados, + sendText: async (args) => { enviados.push(args); return { data: { ticketId: 999 } }; }, + ...overrides + }; +} + +test('runCampaignQueue envia linhas aprovadas via sendText e marca sent', async () => { + const db = openDb(':memory:'); + ativarWorker(db); + const campanha = campanhaAprovada(db); + const zproClient = fakeZproClient(); + + const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 }); + assert.equal(resumo.enviados, 1); + assert.equal(zproClient.enviados.length, 1); + assert.equal(zproClient.enviados[0].message, 'Ola Empresa'); + + const fila = listQueue(db, campanha.id, 'sent'); + assert.equal(fila.length, 1); + assert.ok(fila[0].outreach_send_id); + const send = db.prepare('SELECT * FROM outreach_sends WHERE id = ?').get(fila[0].outreach_send_id); + assert.equal(send.status, 'enviado'); + assert.equal(send.origem, 'automatico'); + db.close(); +}); + +test('runCampaignQueue registra erro quando o Z-PRO falha, sem travar o restante', async () => { + const db = openDb(':memory:'); + ativarWorker(db); + const campanha = campanhaAprovada(db, 2); + let chamada = 0; + const zproClient = fakeZproClient({ + sendText: async (args) => { + chamada += 1; + if (chamada === 1) throw new Error('Z-PRO falhou'); + return { data: { ticketId: 1 } }; + } + }); + + const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 }); + assert.equal(resumo.enviados, 1); + assert.equal(resumo.erros, 1); + const erro = listQueue(db, campanha.id, 'error')[0]; + const send = db.prepare('SELECT * FROM outreach_sends WHERE id = ?').get(erro.outreach_send_id); + assert.equal(send.status, 'erro'); + db.close(); +}); + +test('runCampaignQueue respeita o limite diario da campanha', async () => { + const db = openDb(':memory:'); + ativarWorker(db); + for (let i = 0; i < 3; i += 1) inserirLead(db, `l${i}`, `551199999000${i}`); + const campanha = createCampaign(db, { + nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }], + limiteDiarioInicial: 1 + }); + setCampaignStatus(db, campanha.id, 'rodando'); + generateCampaignQueue(db, campanha.id); + approveQueueRows(db, campanha.id, listQueue(db, campanha.id, 'preview').map((r) => r.id)); + + const zproClient = fakeZproClient(); + const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 }); + assert.equal(resumo.enviados, 1); + assert.equal(zproClient.enviados.length, 1); + assert.equal(listQueue(db, campanha.id, 'approved').length, 2); + db.close(); +}); + +test('runCampaignQueue pula e cancela linha de numero que entrou em opt-out depois da fila gerada', async () => { + const db = openDb(':memory:'); + ativarWorker(db); + const campanha = campanhaAprovada(db); + addOptOut(db, '5511999990000', 'pediu pra parar'); + db.prepare(`UPDATE outreach_campaign_queue SET numero = '5511999990000' WHERE campaign_id = ?`).run(campanha.id); + + const zproClient = fakeZproClient(); + const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 }); + assert.equal(resumo.pulados, 1); + assert.equal(zproClient.enviados.length, 0); + const cancelada = listQueue(db, campanha.id, 'cancelled')[0]; + assert.equal(cancelada.cancel_reason, 'opt_out'); + db.close(); +}); + +test('runCampaignQueue so processa campanhas com status rodando', async () => { + const db = openDb(':memory:'); + ativarWorker(db); + inserirLead(db, 'l1', '5511999990001'); + const campanha = createCampaign(db, { + nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }] + }); + generateCampaignQueue(db, campanha.id); + approveQueueRows(db, campanha.id, listQueue(db, campanha.id, 'preview').map((r) => r.id)); + // campanha continua em 'rascunho' — nunca foi iniciada + + const zproClient = fakeZproClient(); + const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 }); + assert.equal(resumo.enviados, 0); + assert.equal(zproClient.enviados.length, 0); + db.close(); +}); + +test('runCampaignQueue interrompe a campanha quando a saude aciona auto-pausa', async () => { + const db = openDb(':memory:'); + ativarWorker(db); + const campanha = campanhaAprovada(db, 10); + db.prepare(`UPDATE outreach_campaigns SET limite_diario_atual = 10 WHERE id = ?`).run(campanha.id); + let chamada = 0; + const zproClient = fakeZproClient({ + sendText: async () => { + chamada += 1; + if (chamada <= 8) return { data: { ticketId: chamada } }; + throw new Error('falha simulada'); + } + }); + + await runCampaignQueue({ db, zproClient, sleepMs: 0 }); + const atualizada = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id); + // 10 tentativas, 2 erros = 20% > 10% -> deveria pausar antes de esgotar a fila + assert.equal(atualizada.status, 'pausado'); + db.close(); +}); + +test('runCampaignQueue nao envia nada quando outreach.ativo esta desligado', async () => { + const db = openDb(':memory:'); + // Nao chama ativarWorker: settings.outreach.ativo fica no fallback 'false'. + const campanha = campanhaAprovada(db); + const zproClient = fakeZproClient(); + + const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 }); + assert.equal(resumo.enviados, 0); + assert.equal(zproClient.enviados.length, 0); + assert.equal(listQueue(db, campanha.id, 'approved').length, 1); + db.close(); +}); + +test('runCampaignQueue nao envia nada fora do horario comercial configurado', async () => { + const db = openDb(':memory:'); + setSetting(db, 'outreach.ativo', 'true'); + setSetting(db, 'outreach.horaInicio', '08:00'); + setSetting(db, 'outreach.horaFim', '18:00'); + const campanha = campanhaAprovada(db); + const zproClient = fakeZproClient(); + + // 03:00 (horario local), bem fora da janela 08:00-18:00 configurada acima. + const foraDoHorario = new Date(); + foraDoHorario.setHours(3, 0, 0, 0); + + const resumo = await runCampaignQueue({ db, zproClient, now: foraDoHorario, sleepMs: 0 }); + assert.equal(resumo.enviados, 0); + assert.equal(zproClient.enviados.length, 0); + assert.equal(listQueue(db, campanha.id, 'approved').length, 1); + db.close(); +}); diff --git a/gti-maquina-comercial/tests/csv.test.mjs b/gti-maquina-comercial/tests/csv.test.mjs new file mode 100644 index 0000000..027d848 --- /dev/null +++ b/gti-maquina-comercial/tests/csv.test.mjs @@ -0,0 +1,90 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { splitCsvLine, csvEscape, excelTextCell, streamCsvRows, createCsvWriter, stripBom, BOM } from '../src/util/csv.mjs'; + +test('splitCsvLine separa campos com separador dentro de aspas', () => { + assert.deepEqual(splitCsvLine('a;"b;c";d'), ['a', 'b;c', 'd']); +}); + +test('splitCsvLine trata aspas duplas escapadas', () => { + assert.deepEqual(splitCsvLine('"x""y";z'), ['x"y', 'z']); +}); + +test('splitCsvLine aceita separador virgula', () => { + assert.deepEqual(splitCsvLine('a,b,"c,d"', ','), ['a', 'b', 'c,d']); +}); + +test('csvEscape protege valores com separador, aspas e quebra de linha', () => { + assert.equal(csvEscape('sem risco'), 'sem risco'); + assert.equal(csvEscape('a;b'), '"a;b"'); + assert.equal(csvEscape('diz "oi"'), '"diz ""oi"""'); + assert.equal(csvEscape(null), ''); +}); + +test('excelTextCell gera formula de texto para numeros longos', () => { + assert.equal(excelTextCell('557193335451'), '"=""557193335451"""'); +}); + +test('stripBom remove BOM apenas quando presente', () => { + assert.equal(stripBom('abc'), 'abc'); + assert.equal(stripBom('abc'), 'abc'); +}); + +test('streamCsvRows le arquivo com BOM, auto-detecta separador e normaliza headers', async () => { + const file = path.join(os.tmpdir(), `gti-test-csv-${Date.now()}.csv`); + await fs.writeFile(file, `${BOM}Nome Empresa;Telefone 1\n"ACME; Ltda";7599999\nBeta;`, 'utf8'); + + try { + const rows = []; + for await (const row of streamCsvRows(file, { + normalizeHeader: (h) => h.toLowerCase().replace(/\s+/g, '_') + })) { + rows.push(row); + } + + assert.equal(rows.length, 2); + assert.equal(rows[0].nome_empresa, 'ACME; Ltda'); + assert.equal(rows[0].telefone_1, '7599999'); + assert.equal(rows[1].telefone_1, ''); + } finally { + await fs.rm(file, { force: true }); + } +}); + +test('streamCsvRows suporta campo com quebra de linha dentro de aspas', async () => { + const file = path.join(os.tmpdir(), `gti-test-csvml-${Date.now()}.csv`); + await fs.writeFile(file, 'a;b\n1;"linha um\nlinha dois"\n2;simples', 'utf8'); + + try { + const rows = []; + for await (const row of streamCsvRows(file)) rows.push(row); + + assert.equal(rows.length, 2); + assert.equal(rows[0].b, 'linha um\nlinha dois'); + assert.equal(rows[1].b, 'simples'); + } finally { + await fs.rm(file, { force: true }); + } +}); + +test('createCsvWriter grava BOM, header e linhas', async () => { + const file = path.join(os.tmpdir(), `gti-test-csvw-${Date.now()}.csv`); + const writer = createCsvWriter(file, ['a', 'b']); + writer.writeRow(['1', csvEscape('x;y')]); + writer.writeRow(['2', excelTextCell('123')]); + await writer.end(); + + try { + const content = await fs.readFile(file, 'utf8'); + assert.ok(content.startsWith(BOM)); + const lines = stripBom(content).trim().split('\n'); + assert.equal(lines[0], 'a;b'); + assert.equal(lines[1], '1;"x;y"'); + assert.equal(lines[2], '2;"=""123"""'); + } finally { + await fs.rm(file, { force: true }); + } +}); diff --git a/gti-maquina-comercial/tests/dashboard.test.mjs b/gti-maquina-comercial/tests/dashboard.test.mjs new file mode 100644 index 0000000..b452b16 --- /dev/null +++ b/gti-maquina-comercial/tests/dashboard.test.mjs @@ -0,0 +1,139 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { openDb, createLeadInserter, setSetting } from '../src/db.mjs'; +import { dashboardSummary, dashboardDailyEvolution, dashboardMaiorOportunidade } from '../src/treatment/dashboard.mjs'; + +function seedLeads(db) { + const insert = createLeadInserter(db); + const base = { + legal_name: '', whatsapp: '', ddd: '', email: '', segmento: '', segmento_label: '', + interesse: '', cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]', + origem: 'cnpj-base', telefone_valido: 0, cnpj_valido: 0, empresa_valida: 1, + score: 50, classificacao: 'medio', motivo_classificacao: '', capture_run_id: null, + captured_at: null + }; + const hoje = new Date().toISOString(); + + insert({ ...base, lead_uid: 'A', dedup_key: 'k1', empresa: 'A', cidade: 'Salvador', uf: 'BA', prioridade: 1, exported_at: hoje }); + insert({ ...base, lead_uid: 'B', dedup_key: 'k2', empresa: 'B', cidade: 'Salvador', uf: 'BA', prioridade: 1, exported_at: null }); + insert({ ...base, lead_uid: 'C', dedup_key: 'k3', empresa: 'C', cidade: 'Ilheus', uf: 'BA', prioridade: 2, exported_at: null }); + insert({ ...base, lead_uid: 'D', dedup_key: 'k4', empresa: 'D', cidade: 'Ilheus', uf: 'BA', prioridade: 4, exported_at: null }); +} + +test('dashboardSummary calcula totais, percentuais e meta diaria sobre a base toda', () => { + const db = openDb(':memory:'); + seedLeads(db); + setSetting(db, 'meta_diaria', '2'); + + const s = dashboardSummary(db, {}); + + assert.equal(s.total, 4); + assert.equal(s.p1, 2); + assert.equal(s.p2, 1); + assert.equal(s.p3, 0); + assert.equal(s.p4, 1); + assert.equal(s.potencial_pct, 50); + assert.equal(s.p1_pct, 50); + assert.equal(s.p4_pct, 25); + assert.equal(s.exportados, 1); + assert.equal(s.disponiveis, 3); + assert.equal(s.cobertura_pct, 25); + assert.equal(s.hoje_exportados, 1); + assert.equal(s.meta_diaria, 2); + assert.equal(s.meta_pct, 50); + assert.equal(s.dias_estimados, 2); + + db.close(); +}); + +test('dashboardSummary respeita o filtro (cidade) ao calcular percentuais', () => { + const db = openDb(':memory:'); + seedLeads(db); + + const s = dashboardSummary(db, { cidade: 'Ilheus' }); + + assert.equal(s.total, 2); + assert.equal(s.p1, 0); + assert.equal(s.p2, 1); + assert.equal(s.p4, 1); + assert.equal(s.p2_pct, 50); + assert.equal(s.p4_pct, 50); + assert.equal(s.exportados, 0); + assert.equal(s.cobertura_pct, 0); + + db.close(); +}); + +test('dashboardSummary com base vazia nao divide por zero', () => { + const db = openDb(':memory:'); + + const s = dashboardSummary(db, {}); + + assert.equal(s.total, 0); + assert.equal(s.potencial_pct, 0); + assert.equal(s.cobertura_pct, 0); + assert.equal(s.meta_pct, 0); + assert.equal(s.dias_estimados, 0); + + db.close(); +}); + +function seedLeadsComProdutos(db) { + const insert = createLeadInserter(db); + const base = { + legal_name: '', whatsapp: '', ddd: '', email: '', segmento: '', segmento_label: '', + interesse: '', cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]', + origem: 'cnpj-base', telefone_valido: 0, cnpj_valido: 0, empresa_valida: 1, + score: 50, classificacao: 'medio', motivo_classificacao: '', capture_run_id: null, + captured_at: null, prioridade: 1, produtos_json: '[]' + }; + + insert({ ...base, lead_uid: 'A', dedup_key: 'k1', empresa: 'A', cidade: 'Salvador', uf: 'BA', produto_principal: 'ERP', exported_at: '2026-07-10T10:00:00.000Z' }); + insert({ ...base, lead_uid: 'B', dedup_key: 'k2', empresa: 'B', cidade: 'Salvador', uf: 'BA', produto_principal: 'ERP', exported_at: '2026-07-10T11:00:00.000Z' }); + insert({ ...base, lead_uid: 'C', dedup_key: 'k3', empresa: 'C', cidade: 'Salvador', uf: 'BA', produto_principal: 'G-Chat', exported_at: '2026-07-11T09:00:00.000Z' }); + insert({ ...base, lead_uid: 'D', dedup_key: 'k4', empresa: 'D', cidade: 'Ilheus', uf: 'BA', produto_principal: 'ERP', exported_at: null }); +} + +test('dashboardDailyEvolution agrupa exportacoes por dia em ordem cronologica', () => { + const db = openDb(':memory:'); + seedLeadsComProdutos(db); + + const evolution = dashboardDailyEvolution(db, {}, 14).map((r) => ({ ...r })); + + assert.deepEqual(evolution, [ + { dia: '2026-07-10', total: 2 }, + { dia: '2026-07-11', total: 1 } + ]); + + db.close(); +}); + +test('dashboardDailyEvolution respeita o filtro', () => { + const db = openDb(':memory:'); + seedLeadsComProdutos(db); + + const evolution = dashboardDailyEvolution(db, { cidade: 'Ilheus' }, 14); + + assert.deepEqual(evolution, []); + + db.close(); +}); + +test('dashboardMaiorOportunidade retorna a combinacao cidade+produto com mais empresas', () => { + const db = openDb(':memory:'); + seedLeadsComProdutos(db); + + const oportunidade = dashboardMaiorOportunidade(db, {}); + + assert.deepEqual({ ...oportunidade }, { cidade: 'Salvador', produto: 'ERP', total: 2 }); + + db.close(); +}); + +test('dashboardMaiorOportunidade retorna null quando nao ha dados', () => { + const db = openDb(':memory:'); + + assert.equal(dashboardMaiorOportunidade(db, {}), null); + + db.close(); +}); diff --git a/gti-maquina-comercial/tests/db.test.mjs b/gti-maquina-comercial/tests/db.test.mjs new file mode 100644 index 0000000..c29e1cd --- /dev/null +++ b/gti-maquina-comercial/tests/db.test.mjs @@ -0,0 +1,146 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { openDb, createLeadInserter, transaction, getSetting, setSetting, seedSegments, seedMunicipios, seedGeoCache, seedSettings, seedMessageTemplates, updateMessageTemplate } from '../src/db.mjs'; + +function sampleLead(overrides = {}) { + return { + lead_uid: 'GTI-TEST-1', + dedup_key: 'cnpj:57557263000113', + empresa: 'PREMIUM EXPRESS', + legal_name: 'PREMIUM EXPRESS LTDA', + whatsapp: '557193335451', + ddd: '71', + email: '', + cidade: 'Salvador', + uf: 'BA', + segmento: 'restaurantes', + segmento_label: 'Restaurantes', + interesse: 'Autoatendimento para WhatsApp', + cnpj: '57557263000113', + cnae: '5611203', + endereco: 'RUA X, 1', + website: '', + pains_json: '["atendimento"]', + origem: 'cnpj-base', + telefone_valido: 1, + cnpj_valido: 1, + empresa_valida: 1, + score: 100, + classificacao: 'bom', + motivo_classificacao: 'ok', + capture_run_id: null, + captured_at: '2026-07-09T14:26:18.560Z', + exported_at: null, + ...overrides + }; +} + +test('openDb cria esquema e aplica migrations uma unica vez', () => { + const db = openDb(':memory:'); + const version = db.prepare('SELECT MAX(version) AS v FROM schema_migrations').get().v; + assert.ok(version >= 1); + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name); + for (const table of ['leads', 'capture_runs', 'extractions', 'exports', 'export_leads', 'segments', 'municipios', 'geo_cache', 'settings']) { + assert.ok(tables.includes(table), `tabela ${table} ausente`); + } + db.close(); +}); + +test('createLeadInserter insere e ignora duplicata pela dedup_key', () => { + const db = openDb(':memory:'); + const insert = createLeadInserter(db); + + assert.equal(insert(sampleLead()), 'inserted'); + assert.equal(insert(sampleLead({ empresa: 'OUTRO NOME' })), 'duplicate'); + + const total = db.prepare('SELECT COUNT(*) AS t FROM leads').get().t; + assert.equal(total, 1); + db.close(); +}); + +test('transaction faz rollback em erro', () => { + const db = openDb(':memory:'); + const insert = createLeadInserter(db); + + assert.throws(() => + transaction(db, () => { + insert(sampleLead()); + throw new Error('boom'); + }) + ); + + assert.equal(db.prepare('SELECT COUNT(*) AS t FROM leads').get().t, 0); + db.close(); +}); + +test('settings: get/set e seed com defaults Z-PRO', () => { + const db = openDb(':memory:'); + seedSettings(db); + + assert.equal(getSetting(db, 'zpro.pipelineId'), '1'); + assert.equal(getSetting(db, 'zpro.stageId'), '1'); + assert.equal(getSetting(db, 'zpro.responsibleId'), '15'); + + setSetting(db, 'zpro.pipelineId', '9'); + assert.equal(getSetting(db, 'zpro.pipelineId'), '9'); + + // seed nao sobrescreve valor ja definido pelo usuario + seedSettings(db); + assert.equal(getSetting(db, 'zpro.pipelineId'), '9'); + db.close(); +}); + +test('message_templates salva variaveis WABA automaticas por produto', () => { + const db = openDb(':memory:'); + seedMessageTemplates(db, ['ERP']); + + const updated = updateMessageTemplate(db, 'ERP', { + canal: 'oficial', + templateWabaNome: 'gti_inicio_atendimento_solicitado', + templateWabaIdioma: 'pt_BR', + templateWabaVariaveis: ['{empresa}', 'Assunto: {produto}', ''] + }); + + assert.equal(updated.template_waba_nome, 'gti_inicio_atendimento_solicitado'); + assert.deepEqual(JSON.parse(updated.template_waba_variaveis_json), ['{empresa}', 'Assunto: {produto}']); + db.close(); +}); + +test('seeds de segments, municipios e geo_cache', () => { + const db = openDb(':memory:'); + + seedSegments(db, [ + { name: 'clinicas_medicas', queries: ['clinica'], cnaeCodes: ['8630501'], overpassTags: [{ key: 'amenity', value: 'clinic' }], interest: 'gestao de TI', pains: ['agenda'] } + ]); + const segment = db.prepare('SELECT * FROM segments WHERE name = ?').get('clinicas_medicas'); + assert.equal(segment.label, 'Clinicas Medicas'); + assert.deepEqual(JSON.parse(segment.cnae_codes_json), ['8630501']); + + seedMunicipios(db, [{ codigo_ibge: '2928703', nome: 'Santo Antônio de Jesus', uf: 'BA' }]); + const municipio = db.prepare('SELECT * FROM municipios WHERE uf = ?').get('BA'); + assert.equal(municipio.nome_clean, 'santo antonio de jesus'); + + seedGeoCache(db, [{ name: 'Santo Antonio de Jesus', state: 'BA', bbox: [-12.995, -39.325, -12.925, -39.225] }]); + const bbox = db.prepare('SELECT * FROM geo_cache WHERE key = ?').get('bbox:BA:santo antonio de jesus'); + assert.equal(bbox.south, -12.995); + assert.equal(bbox.provider, 'config-legacy'); + db.close(); +}); + +test('migracao v8 cria as tabelas de campanha de outreach', () => { + const db = openDb(':memory:'); + const tabelas = db.prepare( + `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'outreach_%'` + ).all().map((r) => r.name); + for (const nome of [ + 'outreach_campaigns', 'outreach_campaign_variants', 'outreach_campaign_queue', + 'outreach_lead_states', 'outreach_stage_events', 'outreach_appointments', 'outreach_opt_out' + ]) { + assert.ok(tabelas.includes(nome), `tabela ${nome} deveria existir`); + } + db.prepare(`INSERT INTO outreach_campaigns (nome, produto, segmentos_json) VALUES ('Teste', 'ERP', '["oficinas"]')`).run(); + const campanha = db.prepare('SELECT * FROM outreach_campaigns').get(); + assert.equal(campanha.status, 'rascunho'); + assert.equal(campanha.limite_diario_atual, 10); + db.close(); +}); diff --git a/gti-maquina-comercial/tests/export.test.mjs b/gti-maquina-comercial/tests/export.test.mjs new file mode 100644 index 0000000..183c2f5 --- /dev/null +++ b/gti-maquina-comercial/tests/export.test.mjs @@ -0,0 +1,119 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { openDb, createLeadInserter, seedSettings } from '../src/db.mjs'; +import { exportExcel } from '../src/export/excel.mjs'; +import { exportOportunidades, exportBulkDataInput } from '../src/export/zpro.mjs'; +import { stripBom } from '../src/util/csv.mjs'; + +function seedLeads(db) { + const insert = createLeadInserter(db); + insert({ + lead_uid: 'GTI-1', dedup_key: 'cnpj:57557263000113', empresa: 'PREMIUM EXPRESS', + legal_name: 'PREMIUM EXPRESS LTDA', whatsapp: '557193335451', ddd: '71', email: '', + cidade: 'Salvador', uf: 'BA', segmento: 'restaurantes', segmento_label: 'Restaurantes', + interesse: 'Autoatendimento para WhatsApp', cnpj: '57557263000113', cnae: '5611203', + endereco: '', website: '', pains_json: '[]', origem: 'cnpj-base', + telefone_valido: 1, cnpj_valido: 1, empresa_valida: 1, score: 100, + classificacao: 'bom', motivo_classificacao: 'ok', capture_run_id: null, + captured_at: null, exported_at: null + }); + insert({ + lead_uid: 'GTI-2', dedup_key: 'name:sem telefone:salvador', empresa: 'SEM TELEFONE', + legal_name: '', whatsapp: '', ddd: '', email: '', cidade: 'Salvador', uf: 'BA', + segmento: 'restaurantes', segmento_label: 'Restaurantes', interesse: '', cnpj: '', + cnae: '', endereco: '', website: '', pains_json: '[]', origem: 'cnpj-base', + telefone_valido: 0, cnpj_valido: 0, empresa_valida: 1, score: 35, + classificacao: 'ruim', motivo_classificacao: 'score baixo', capture_run_id: null, + captured_at: null, exported_at: null + }); +} + +async function withTempDir(fn) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gti-export-')); + try { + return await fn(dir); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} + +test('exportExcel gera CSV com formulas de texto e xls HTML', async () => { + const db = openDb(':memory:'); + seedLeads(db); + + await withTempDir(async (dir) => { + const ids = []; + const result = await exportExcel({ db, filtro: { uf: 'BA' }, dir, baseName: 'teste', collectIds: ids }); + + assert.equal(result.total, 2); + assert.deepEqual(result.files, ['teste.csv', 'teste.xls']); + assert.equal(ids.length, 2); + + const csv = stripBom(await fs.readFile(path.join(dir, 'teste.csv'), 'utf8')); + const lines = csv.trim().split('\n'); + assert.ok(lines[0].startsWith('empresa;whatsapp;ddd;cidade;uf;segmento;segmento_legivel')); + assert.ok(lines[1].includes('"=""557193335451"""'), 'whatsapp deve ser formula de texto'); + assert.ok(lines[1].includes('"=""57557263000113"""'), 'cnpj deve ser formula de texto'); + assert.ok(lines[1].includes('cnpj:57557263000113'), 'chave_duplicidade presente'); + + const xls = await fs.readFile(path.join(dir, 'teste.xls'), 'utf8'); + assert.ok(xls.includes('mso-number-format')); + assert.ok(xls.includes('557193335451')); + }); + + db.close(); +}); + +test('exportOportunidades gera JSONL no formato Z-PRO testado', async () => { + const db = openDb(':memory:'); + seedSettings(db); + seedLeads(db); + + await withTempDir(async (dir) => { + const result = await exportOportunidades({ + db, filtro: { classificacao: 'bom' }, params: {}, dir, baseName: 'op' + }); + + assert.equal(result.total, 1); + const lines = (await fs.readFile(path.join(dir, 'op.jsonl'), 'utf8')).trim().split('\n'); + const payload = JSON.parse(lines[0]); + + assert.equal(payload.number, '557193335451'); + assert.equal(payload.contactName, 'PREMIUM EXPRESS'); + assert.equal(payload.name, 'PREMIUM EXPRESS - Autoatendimento para WhatsApp'); + assert.equal(payload.pipelineId, 1); + assert.equal(payload.stageId, 1); + assert.equal(payload.responsibleId, 15); + assert.equal(payload.status, 'open'); + assert.equal(payload.validateNumber, true); + assert.match(payload.closingForecast, /^\d{4}-\d{2}-\d{2}$/); + assert.ok(payload.description.includes('CNPJ: 57557263000113')); + }); + + db.close(); +}); + +test('exportBulkDataInput inclui apenas leads com whatsapp', async () => { + const db = openDb(':memory:'); + seedSettings(db); + seedLeads(db); + + await withTempDir(async (dir) => { + const result = await exportBulkDataInput({ + db, filtro: {}, params: { mensagem: 'Ola {empresa}!' }, dir, baseName: 'bulk' + }); + + assert.equal(result.total, 1, 'lead sem whatsapp deve ficar de fora'); + const csv = (await fs.readFile(path.join(dir, 'bulk-datainput.csv'), 'utf8')).trim(); + assert.equal(csv, '557193335451,PREMIUM EXPRESS,Salvador,Restaurantes'); + + const info = await fs.readFile(path.join(dir, 'bulk-instrucoes.txt'), 'utf8'); + assert.ok(info.includes('Ola {empresa}!')); + assert.ok(info.includes('bulkSendMessageWithVariable')); + }); + + db.close(); +}); diff --git a/gti-maquina-comercial/tests/lead-engine.test.mjs b/gti-maquina-comercial/tests/lead-engine.test.mjs new file mode 100644 index 0000000..d96063e --- /dev/null +++ b/gti-maquina-comercial/tests/lead-engine.test.mjs @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + buildNextQuestion, + detectService, + normalizePhone, + processLead +} from '../src/lead-engine.mjs'; + +describe('lead engine', () => { + it('normaliza telefone brasileiro para formato internacional', () => { + assert.equal(normalizePhone('(75) 99999-1111'), '5575999991111'); + assert.equal(normalizePhone('+55 75 99999-1111'), '5575999991111'); + }); + + it('detecta servico por texto livre', () => { + assert.equal(detectService('quero trocar meu sistema de estoque'), 'erp'); + assert.equal(detectService('preciso de suporte em servidor e backup'), 'ti'); + assert.equal(detectService('automatizar atendimento no WhatsApp'), 'whatsapp'); + }); + + it('prioriza lead com WhatsApp para tratamento manual', () => { + const result = processLead({ + source: 'site', + contact: { name: 'Carlos Souza', phone: '(75) 99999-1111', email: 'carlos@mercado.com' }, + company: { + name: 'Mercado Sao Joao', + city: 'Santo Antonio de Jesus', + state: 'BA', + segment: 'mercado', + employees: 18 + }, + interest: 'ERP', + current_system: 'uso outro sistema, mas quero trocar', + pains: ['estoque', 'financeiro', 'emissao fiscal'], + urgency: 'alta', + consent: true + }); + + assert.equal(result.lead.service, 'erp'); + assert.equal(result.priority.priority, 'com_whatsapp'); + assert.equal(result.priority.hasWhatsapp, true); + assert.ok(result.whatsapp.internal.message.includes('Novo lead captado - COM WHATSAPP')); + assert.equal(result.nextAction, 'tratamento_manual'); + }); + + it('mantem lead de TI com dados parciais como captado', () => { + const result = processLead({ + origem: 'indicacao', + nome: 'Ana Lima', + telefone: '75988887777', + empresa: 'Clinica Vida', + cidade: 'Santo Antonio de Jesus', + uf: 'BA', + segmento: 'clinica', + servico: 'gestao de TI', + computadores: 8, + dor: 'backup; suporte tecnico', + urgencia: 'media' + }); + + assert.equal(result.lead.service, 'ti'); + assert.equal(result.priority.priority, 'com_whatsapp'); + assert.ok(result.crmRecord.whatsapp.startsWith('55')); + }); + + it('mantem lead sem WhatsApp abaixo na prioridade', () => { + const result = processLead({ + name: 'Lead Sem Servico', + message: 'quero mais informacoes' + }); + + assert.equal(result.lead.service, 'indefinido'); + assert.equal(result.priority.priority, 'sem_whatsapp'); + assert.equal(result.priority.hasWhatsapp, false); + assert.ok(buildNextQuestion(result.lead).includes('1 - Sistema ERP')); + }); + + it('reconhece segmentos compostos da prospeccao automatica como segmento-alvo', () => { + const compostos = processLead({ + name: 'Lead Prospeccao', + whatsapp: '75999993333', + company_name: 'Restaurante Sabor da Terra', + city: 'Amargosa', + state: 'BA', + segment: 'restaurante_pizzaria' + }); + assert.equal(compostos.lead.isTargetSegment, true); + + const materialConstrucao = processLead({ + name: 'Lead Prospeccao', + whatsapp: '75999994444', + company_name: 'Deposito Central', + segment: 'material_construcao_cimento_blocos' + }); + assert.equal(materialConstrucao.lead.isTargetSegment, true); + + const clinica = processLead({ + name: 'Lead Prospeccao', + whatsapp: '75999995555', + company_name: 'Clinica Odonto Vida', + segment: 'clinica_laboratorio_odonto_otica' + }); + assert.equal(clinica.lead.isTargetSegment, true); + }); + + it('nao marca como segmento-alvo um segmento fora da lista', () => { + const result = processLead({ + name: 'Lead Prospeccao', + whatsapp: '75999996666', + company_name: 'Posto Boa Vista', + segment: 'posto_combustivel' + }); + assert.equal(result.lead.isTargetSegment, false); + }); + + it('reconhece os novos nichos (beleza, celulares, transportadora) como segmento-alvo', () => { + const beleza = processLead({ + name: 'Lead Prospeccao', + whatsapp: '75999997777', + company_name: 'Studio Beleza Bahia', + segment: 'beleza_estetica_barbearia' + }); + assert.equal(beleza.lead.isTargetSegment, true); + + const celulares = processLead({ + name: 'Lead Prospeccao', + whatsapp: '75999998888', + company_name: 'Cell Center', + segment: 'celulares_eletronicos_assistencia' + }); + assert.equal(celulares.lead.isTargetSegment, true); + + const transportadora = processLead({ + name: 'Lead Prospeccao', + whatsapp: '75999999999', + company_name: 'Transportadora Recôncavo', + segment: 'transportadora_logistica' + }); + assert.equal(transportadora.lead.isTargetSegment, true); + }); + + it('direciona lead de WhatsApp para pergunta de volume quando a dor ja existe', () => { + const result = processLead({ + name: 'Mariana', + whatsapp: '75999992222', + company_name: 'Restaurante Sabor', + city: 'Santo Antonio de Jesus', + state: 'BA', + interest: 'autoatendimento WhatsApp', + pains: ['pedidos', 'triagem'] + }); + + assert.equal(result.lead.service, 'whatsapp'); + assert.ok(buildNextQuestion(result.lead).includes('quantas mensagens')); + }); +}); diff --git a/gti-maquina-comercial/tests/outreach-routes.test.mjs b/gti-maquina-comercial/tests/outreach-routes.test.mjs new file mode 100644 index 0000000..70770a4 --- /dev/null +++ b/gti-maquina-comercial/tests/outreach-routes.test.mjs @@ -0,0 +1,253 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { openDb, setSetting } from '../src/db.mjs'; +import { createRouter } from '../src/server/router.mjs'; +import { registerOutreachRoutes } from '../src/server/routes/outreach.mjs'; + +function setupRouter(db) { + const router = createRouter(); + registerOutreachRoutes(router, { db }); + return router; +} + +function fakeJson() { + const calls = []; + const json = (status, data) => calls.push({ status, data }); + return { json, calls }; +} + +function configurarCredenciais(db) { + setSetting(db, 'zpro.baseUrl', 'exemplo.zpro.com.br'); + setSetting(db, 'zpro.apiId', 'abc123'); + setSetting(db, 'zpro.bearerToken', 'token-teste'); +} + +test('POST /api/outreach/enviar-manual exige numero', async () => { + const db = openDb(':memory:'); + const router = setupRouter(db); + const { handler } = router.match('POST', '/api/outreach/enviar-manual'); + const { json, calls } = fakeJson(); + + await handler(null, null, { json, body: { numero: '', mensagem: 'oi' } }); + + assert.equal(calls[0].status, 400); + db.close(); +}); + +test('POST /api/outreach/enviar-manual rejeita numero mal formatado antes de chamar o Z-PRO', async () => { + const db = openDb(':memory:'); + configurarCredenciais(db); + const router = setupRouter(db); + const { handler } = router.match('POST', '/api/outreach/enviar-manual'); + const { json, calls } = fakeJson(); + + const originalFetch = global.fetch; + let fetchChamado = false; + global.fetch = async () => { fetchChamado = true; return { ok: true, text: async () => '{}' }; }; + try { + await handler(null, null, { json, body: { numero: '55759990917800', canal: 'nao_oficial', mensagem: 'oi' } }); + } finally { + global.fetch = originalFetch; + } + + assert.equal(calls[0].status, 400); + assert.match(calls[0].data.erro, /numero invalido/); + assert.equal(fetchChamado, false); + assert.equal(db.prepare('SELECT COUNT(*) c FROM outreach_sends').get().c, 0); + db.close(); +}); + +test('POST /api/outreach/enviar-manual manda texto livre (nao-oficial) e registra origem=manual', async () => { + const db = openDb(':memory:'); + configurarCredenciais(db); + const router = setupRouter(db); + const { handler } = router.match('POST', '/api/outreach/enviar-manual'); + const { json, calls } = fakeJson(); + + const originalFetch = global.fetch; + global.fetch = async () => ({ ok: true, text: async () => JSON.stringify({ data: { ticketId: 999 } }) }); + try { + await handler(null, null, { json, body: { numero: '5511999990001', canal: 'nao_oficial', mensagem: 'Oi, tudo bem?' } }); + } finally { + global.fetch = originalFetch; + } + + assert.deepEqual(calls[0], { status: 200, data: { ok: true, ticketId: '999' } }); + const row = db.prepare('SELECT * FROM outreach_sends').get(); + assert.equal(row.origem, 'manual'); + assert.equal(row.lead_id, null); + assert.equal(row.numero, '5511999990001'); + assert.equal(row.mensagem_enviada, 'Oi, tudo bem?'); + assert.equal(row.canal, 'nao_oficial'); + assert.equal(row.zpro_ticket_id, '999'); + db.close(); +}); + +test('POST /api/outreach/enviar-manual bloqueia quando limite diario acabou', async () => { + const db = openDb(':memory:'); + configurarCredenciais(db); + setSetting(db, 'outreach.mensagensPorDia', '1'); + db.prepare( + `INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, canal, origem) + VALUES (NULL, '', '5511999990001', 'ja enviado', 'enviado', 'nao_oficial', 'manual')` + ).run(); + const router = setupRouter(db); + const { handler } = router.match('POST', '/api/outreach/enviar-manual'); + const { json, calls } = fakeJson(); + + const originalFetch = global.fetch; + let fetchChamado = false; + global.fetch = async () => { fetchChamado = true; return { ok: true, text: async () => '{}' }; }; + try { + await handler(null, null, { json, body: { numero: '5511999990002', canal: 'nao_oficial', mensagem: 'Oi' } }); + } finally { + global.fetch = originalFetch; + } + + assert.equal(calls[0].status, 429); + assert.equal(calls[0].data.ok, false); + assert.match(calls[0].data.erro, /limite diario atingido/); + assert.equal(fetchChamado, false); + assert.equal(db.prepare('SELECT COUNT(*) c FROM outreach_sends').get().c, 1); + db.close(); +}); + +test('POST /api/outreach/enviar-manual canal oficial exige nome do template', async () => { + const db = openDb(':memory:'); + configurarCredenciais(db); + const router = setupRouter(db); + const { handler } = router.match('POST', '/api/outreach/enviar-manual'); + const { json, calls } = fakeJson(); + + await handler(null, null, { json, body: { numero: '5511999990001', canal: 'oficial' } }); + + assert.equal(calls[0].status, 400); + db.close(); +}); + +test('POST /api/outreach/enviar-manual canal oficial envia multiplas variaveis do template', async () => { + const db = openDb(':memory:'); + configurarCredenciais(db); + const router = setupRouter(db); + const { handler } = router.match('POST', '/api/outreach/enviar-manual'); + const { json, calls } = fakeJson(); + + const originalFetch = global.fetch; + let payload; + global.fetch = async (url, options) => { + payload = JSON.parse(options.body); + return { ok: true, status: 200, text: async () => '{"success":true,"data":{"ticketId":321}}' }; + }; + try { + await handler(null, null, { + json, + body: { + numero: '5511999990001', + canal: 'oficial', + templateWabaNome: 'gti_inicio_atendimento_solicitado', + templateWabaIdioma: 'pt_BR', + templateVariaveis: ['Gustavo', 'Chamado #123', ''] + } + }); + } finally { + global.fetch = originalFetch; + } + + assert.deepEqual(calls[0], { status: 200, data: { ok: true, ticketId: '321' } }); + assert.deepEqual(payload.templateData.template.components, [ + { type: 'body', parameters: [{ type: 'text', text: 'Gustavo' }, { type: 'text', text: 'Chamado #123' }] } + ]); + db.close(); +}); + +test('POST /api/outreach/enviar-manual resolve variaveis internas do GChat', async () => { + const db = openDb(':memory:'); + configurarCredenciais(db); + const router = setupRouter(db); + const { handler } = router.match('POST', '/api/outreach/enviar-manual'); + const { json } = fakeJson(); + + const originalFetch = global.fetch; + let payload; + global.fetch = async (url, options) => { + payload = JSON.parse(options.body); + return { ok: true, status: 200, text: async () => '{"success":true,"data":{"ticketId":321}}' }; + }; + try { + await handler(null, null, { + json, + body: { + numero: '5511999990001', + canal: 'oficial', + templateWabaNome: 'gti_teste', + templateWabaIdioma: 'pt_BR', + templateVariaveis: ['{{name}}', '{{protocol}}', '{{phoneNumber}}'] + } + }); + } finally { + global.fetch = originalFetch; + } + + assert.deepEqual(payload.templateData.template.components, [ + { type: 'body', parameters: [ + { type: 'text', text: 'Gustavo' }, + { type: 'text', text: 'Atendimento comercial' }, + { type: 'text', text: '5511999990001' } + ] } + ]); + db.close(); +}); + +test('POST /api/outreach/enviar-manual registra erro quando o Z-PRO falha', async () => { + const db = openDb(':memory:'); + configurarCredenciais(db); + const router = setupRouter(db); + const { handler } = router.match('POST', '/api/outreach/enviar-manual'); + const { json, calls } = fakeJson(); + + const originalFetch = global.fetch; + global.fetch = async () => ({ ok: false, status: 400, text: async () => '{"erro":"numero nao existe no whatsapp"}' }); + try { + await handler(null, null, { json, body: { numero: '5511999990001', canal: 'nao_oficial', mensagem: 'teste' } }); + } finally { + global.fetch = originalFetch; + } + + assert.equal(calls[0].data.ok, false); + const row = db.prepare('SELECT * FROM outreach_sends').get(); + assert.equal(row.status, 'erro'); + assert.match(row.erro, /numero nao existe/); + db.close(); +}); + +test('GET /api/outreach/status inclui envios manuais em "ultimos" (LEFT JOIN, sem lead)', async () => { + const db = openDb(':memory:'); + db.prepare( + `INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, canal, origem) + VALUES (NULL, '', '5511999990001', 'oi', 'enviado', 'nao_oficial', 'manual')` + ).run(); + const router = setupRouter(db); + const { handler } = router.match('GET', '/api/outreach/status'); + const { json, calls } = fakeJson(); + + await handler(null, null, { json }); + + const ultimo = calls[0].data.ultimos[0]; + assert.equal(ultimo.origem, 'manual'); + assert.equal(ultimo.numero, '5511999990001'); + db.close(); +}); + +test('GET /api/outreach/variables lista variaveis comerciais', async () => { + const db = openDb(':memory:'); + const router = setupRouter(db); + const { handler } = router.match('GET', '/api/outreach/variables'); + const { json, calls } = fakeJson(); + + await handler(null, null, { json }); + + assert.equal(calls[0].status, 200); + assert.ok(calls[0].data.variables.some((variable) => variable.key === 'empresa')); + assert.ok(calls[0].data.variables.some((variable) => variable.key === 'name')); + db.close(); +}); diff --git a/gti-maquina-comercial/tests/outreach.test.mjs b/gti-maquina-comercial/tests/outreach.test.mjs new file mode 100644 index 0000000..f5c9d2f --- /dev/null +++ b/gti-maquina-comercial/tests/outreach.test.mjs @@ -0,0 +1,276 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { openDb, createLeadInserter, setSetting, seedMessageTemplates, updateMessageTemplate } from '../src/db.mjs'; +import { renderTemplate } from '../src/outreach/templates.mjs'; +import { runOutreachBatch } from '../src/outreach/run.mjs'; + +function baseLead(overrides) { + return { + legal_name: '', ddd: '', email: '', segmento: '', segmento_label: '', + interesse: '', cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]', + origem: 'cnpj-base', telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, + score: 80, classificacao: 'bom', motivo_classificacao: '', capture_run_id: null, + captured_at: null, exported_at: null, prioridade: 1, + produtos_json: JSON.stringify([{ nome: 'ERP', estrelas: 5 }, { nome: 'G-Chat', estrelas: 3 }]), + produto_principal: 'ERP', + ...overrides + }; +} + +// libera a checagem de horario comercial em qualquer hora que o teste rode +function ativarOutreach(db, extra = {}) { + setSetting(db, 'outreach.ativo', 'true'); + setSetting(db, 'outreach.horaInicio', '00:00'); + setSetting(db, 'outreach.horaFim', '23:59'); + setSetting(db, 'outreach.mensagensPorDia', '1'); + setSetting(db, 'outreach.tentarProximoProduto', 'false'); + setSetting(db, 'outreach.diasEntreProdutos', '5'); + for (const [key, value] of Object.entries(extra)) setSetting(db, key, value); +} + +function fakeZproClient(overrides = {}) { + const calls = { sendText: [], bulkIndividual: [], addTagContact: [], createTag: [], sendTemplateWaba: [] }; + return { + calls, + sendText: async (args) => { + calls.sendText.push(args); + return { data: { ticketId: 123 } }; + }, + bulkIndividual: async (args) => { + calls.sendText.push(args); + return { data: { ticketId: 123 } }; + }, + sendTemplateWaba: async (args) => { + calls.sendTemplateWaba.push(args); + return { data: { ticketId: 456 } }; + }, + listTags: async () => ({ data: [] }), + createTag: async (args) => { + calls.createTag.push(args); + return { id: calls.createTag.length }; + }, + addTagContact: async (args) => { + calls.addTagContact.push(args); + return {}; + }, + ...overrides + }; +} + +test('renderTemplate substitui {empresa}/{cidade}/{segmento}', () => { + const lead = { empresa: 'Padaria Sol', cidade: 'Ilheus', segmento_label: 'Padarias' }; + const texto = renderTemplate('Ola {empresa} de {cidade} ({segmento})', lead); + assert.equal(texto, 'Ola Padaria Sol de Ilheus (Padarias)'); +}); + +test('renderTemplate usa fallback quando o lead nao tem o campo', () => { + const texto = renderTemplate('Ola {empresa} de {cidade}', {}); + assert.equal(texto, 'Ola sua empresa de sua regiao'); +}); + +test('renderTemplate substitui {produto}', () => { + const texto = renderTemplate('Oferta: {produto}', { produto: 'ERP' }); + assert.equal(texto, 'Oferta: ERP'); +}); + +test('renderTemplate substitui variaveis internas do GChat', () => { + const texto = renderTemplate('{{greeting}} {{name}} {{protocol}} {{businessName}} {{phoneNumber}} {{user}}', { + empresa: 'Padaria Sol', produto: 'ERP', whatsapp: '5511999990001' + }); + assert.equal(texto, 'Ola Padaria Sol ERP Padaria Sol 5511999990001 Grupo GTI'); +}); + +test('runOutreachBatch nao faz nada quando outreach.ativo=false', async () => { + const db = openDb(':memory:'); + const insert = createLeadInserter(db); + insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA' })); + seedMessageTemplates(db, ['ERP']); + setSetting(db, 'outreach.ativo', 'false'); + + const zproClient = fakeZproClient(); + const resumo = await runOutreachBatch({ db, zproClient, log: () => {} }); + + assert.deepEqual(resumo, { tentados: 0, enviados: 0, erros: 0, pulados: 0 }); + assert.equal(zproClient.calls.sendText.length, 0); + db.close(); +}); + +test('runOutreachBatch manda o produto principal, registra o envio e aplica tags', async () => { + const db = openDb(':memory:'); + const insert = createLeadInserter(db); + insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'Padaria Sol', whatsapp: '5511999990001', cidade: 'Ilheus', uf: 'BA' })); + seedMessageTemplates(db, ['ERP', 'G-Chat']); + ativarOutreach(db); + + const zproClient = fakeZproClient(); + const resumo = await runOutreachBatch({ db, zproClient, log: () => {} }); + + assert.equal(resumo.enviados, 1); + assert.equal(resumo.erros, 0); + assert.equal(zproClient.calls.sendText.length, 1); + assert.equal(zproClient.calls.sendText[0].number, '5511999990001'); + assert.match(zproClient.calls.sendText[0].message, /Padaria Sol/); + assert.equal(zproClient.calls.addTagContact.length, 1); + assert.deepEqual(zproClient.calls.addTagContact[0].number, '5511999990001'); + + const row = db.prepare("SELECT * FROM outreach_sends WHERE status = 'enviado'").get(); + assert.equal(row.produto, 'ERP'); + assert.equal(row.lead_id, 1); + + db.close(); +}); + +test('runOutreachBatch nao manda de novo o mesmo produto pro mesmo lead (mesmo com orcamento sobrando)', async () => { + const db = openDb(':memory:'); + const insert = createLeadInserter(db); + insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA' })); + seedMessageTemplates(db, ['ERP']); + ativarOutreach(db); + + // ja existe um envio 'enviado' pra esse lead+produto, simulando uma rodada anterior + // (sem chamar runOutreachBatch de novo, que dispararia o intervalo real de 3-8s). + db.prepare( + `INSERT INTO outreach_sends (lead_id, produto, mensagem_enviada, status) VALUES (1, 'ERP', 'msg', 'enviado')` + ).run(); + setSetting(db, 'outreach.mensagensPorDia', '10'); + + const zproClient = fakeZproClient(); + const resumo = await runOutreachBatch({ db, zproClient, log: () => {} }); + + assert.equal(zproClient.calls.sendText.length, 0); + assert.equal(resumo.enviados, 0); + assert.equal(resumo.pulados, 1); + db.close(); +}); + +test('runOutreachBatch ignora leads ja exportados', async () => { + const db = openDb(':memory:'); + const insert = createLeadInserter(db); + insert(baseLead({ + lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA', + exported_at: new Date().toISOString() + })); + seedMessageTemplates(db, ['ERP']); + ativarOutreach(db); + + const zproClient = fakeZproClient(); + const resumo = await runOutreachBatch({ db, zproClient, log: () => {} }); + + assert.equal(resumo.enviados, 0); + assert.equal(zproClient.calls.sendText.length, 0); + db.close(); +}); + +test('runOutreachBatch respeita o limite diario', async () => { + const db = openDb(':memory:'); + const insert = createLeadInserter(db); + insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA' })); + seedMessageTemplates(db, ['ERP']); + ativarOutreach(db); + setSetting(db, 'outreach.mensagensPorDia', '0'); + + const zproClient = fakeZproClient(); + const resumo = await runOutreachBatch({ db, zproClient, log: () => {} }); + + assert.deepEqual(resumo, { tentados: 0, enviados: 0, erros: 0, pulados: 0 }); + db.close(); +}); + +test('runOutreachBatch pula lead cujo template do produto esta inativo', async () => { + const db = openDb(':memory:'); + const insert = createLeadInserter(db); + insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA' })); + seedMessageTemplates(db, ['ERP']); + updateMessageTemplate(db, 'ERP', { ativo: false }); + ativarOutreach(db); + + const zproClient = fakeZproClient(); + const resumo = await runOutreachBatch({ db, zproClient, log: () => {} }); + + assert.equal(resumo.enviados, 0); + assert.equal(resumo.pulados, 1); + assert.equal(zproClient.calls.sendText.length, 0); + db.close(); +}); + +test('runOutreachBatch registra erro e segue quando o Z-PRO falha', async () => { + const db = openDb(':memory:'); + const insert = createLeadInserter(db); + insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA' })); + seedMessageTemplates(db, ['ERP']); + ativarOutreach(db); + + const zproClient = fakeZproClient({ + sendText: async () => { + throw new Error('falha simulada'); + } + }); + const resumo = await runOutreachBatch({ db, zproClient, log: () => {} }); + + assert.equal(resumo.erros, 1); + assert.equal(resumo.enviados, 0); + const row = db.prepare("SELECT * FROM outreach_sends WHERE status = 'erro'").get(); + assert.match(row.erro, /falha simulada/); + db.close(); +}); + +test('runOutreachBatch usa sendTemplateWaba (nao texto livre) quando o canal e oficial', async () => { + const db = openDb(':memory:'); + const insert = createLeadInserter(db); + insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA' })); + seedMessageTemplates(db, ['ERP']); + updateMessageTemplate(db, 'ERP', { + canal: 'oficial', + templateWabaNome: 'promo_erp', + templateWabaIdioma: 'pt_BR', + templateWabaVariaveis: ['{empresa}', 'Assunto: {produto} em {cidade}'] + }); + ativarOutreach(db); + + const zproClient = fakeZproClient(); + const resumo = await runOutreachBatch({ db, zproClient, log: () => {} }); + + assert.equal(resumo.enviados, 1); + assert.equal(zproClient.calls.sendText.length, 0); + assert.equal(zproClient.calls.sendTemplateWaba.length, 1); + assert.deepEqual(zproClient.calls.sendTemplateWaba[0], { + number: '5511999990001', templateName: 'promo_erp', languageCode: 'pt_BR', variables: ['A', 'Assunto: ERP em Salvador'] + }); + + const row = db.prepare("SELECT * FROM outreach_sends WHERE status = 'enviado'").get(); + assert.equal(row.canal, 'oficial'); + db.close(); +}); + +test('runOutreachBatch pula lead quando o canal e oficial mas o template nao foi configurado', async () => { + const db = openDb(':memory:'); + const insert = createLeadInserter(db); + insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA' })); + seedMessageTemplates(db, ['ERP']); + updateMessageTemplate(db, 'ERP', { canal: 'oficial' }); // sem templateWabaNome + ativarOutreach(db); + + const zproClient = fakeZproClient(); + const resumo = await runOutreachBatch({ db, zproClient, log: () => {} }); + + assert.equal(resumo.enviados, 0); + assert.equal(resumo.pulados, 1); + assert.equal(zproClient.calls.sendTemplateWaba.length, 0); + assert.equal(zproClient.calls.sendText.length, 0); + db.close(); +}); + +test('runOutreachBatch grava canal=nao_oficial por padrao', async () => { + const db = openDb(':memory:'); + const insert = createLeadInserter(db); + insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA' })); + seedMessageTemplates(db, ['ERP']); + ativarOutreach(db); + + const zproClient = fakeZproClient(); + await runOutreachBatch({ db, zproClient, log: () => {} }); + + const row = db.prepare("SELECT * FROM outreach_sends WHERE status = 'enviado'").get(); + assert.equal(row.canal, 'nao_oficial'); + db.close(); +}); diff --git a/gti-maquina-comercial/tests/runner.test.mjs b/gti-maquina-comercial/tests/runner.test.mjs new file mode 100644 index 0000000..e965b98 --- /dev/null +++ b/gti-maquina-comercial/tests/runner.test.mjs @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { openDb } from '../src/db.mjs'; +import { runCapture, createCaptureRun } from '../src/capture/runner.mjs'; + +const SEGMENTS = [ + { name: 'mercados_de_bairro', label: 'Mercados De Bairro', interest: 'ERP / sistema de gestao', queries: ['mercado'], cnaeCodes: ['4711302'], overpassTags: [], pains: ['estoque'] }, + { name: 'clinicas_medicas', label: 'Clinicas Medicas', interest: 'gestao de TI', queries: ['clinica'], cnaeCodes: ['8630501'], overpassTags: [], pains: ['agenda'] }, + { name: 'restaurantes', label: 'Restaurantes', interest: 'autoatendimento WhatsApp', queries: ['restaurante'], cnaeCodes: ['5611201'], overpassTags: [], pains: ['pedidos'] } +]; + +function captureParams() { + return { + sources: ['mock'], + uf: 'BA', + cities: ['Santo Antonio de Jesus'], + segments: SEGMENTS, + limits: { maxPerQuery: 10, queryLimit: 0 } + }; +} + +test('runCapture insere leads do mock e deduplica no rerun', async () => { + const db = openDb(':memory:'); + + const runId = createCaptureRun(db, {}); + const first = await runCapture({ db, runId, params: captureParams() }); + assert.equal(first.raw, 3); + assert.equal(first.new, 3); + assert.equal(first.dup, 0); + assert.equal(first.status, 'done'); + + const run = db.prepare('SELECT * FROM capture_runs WHERE id = ?').get(runId); + assert.equal(run.status, 'done'); + assert.equal(run.new_count, 3); + assert.ok(run.finished_at); + + const lead = db.prepare("SELECT * FROM leads WHERE segmento = 'restaurantes'").get(); + assert.equal(lead.uf, 'BA'); + assert.equal(lead.cidade, 'Santo Antonio de Jesus'); + assert.equal(lead.origem, 'mock'); + assert.equal(lead.telefone_valido, 1); + assert.ok(lead.dedup_key.startsWith('phone:')); + assert.equal(lead.capture_run_id, runId); + + // rerun: tudo duplicado + const runId2 = createCaptureRun(db, {}); + const second = await runCapture({ db, runId: runId2, params: captureParams() }); + assert.equal(second.new, 0); + assert.equal(second.dup, 3); + assert.equal(db.prepare('SELECT COUNT(*) AS t FROM leads').get().t, 3); + + db.close(); +}); + +test('runCapture respeita cancelamento', async () => { + const db = openDb(':memory:'); + const runId = createCaptureRun(db, {}); + + const result = await runCapture({ + db, + runId, + params: captureParams(), + isCancelled: () => true + }); + + assert.equal(result.status, 'cancelled'); + assert.equal(db.prepare('SELECT status FROM capture_runs WHERE id = ?').get(runId).status, 'cancelled'); + db.close(); +}); diff --git a/gti-maquina-comercial/tests/scoring.test.mjs b/gti-maquina-comercial/tests/scoring.test.mjs new file mode 100644 index 0000000..86ca584 --- /dev/null +++ b/gti-maquina-comercial/tests/scoring.test.mjs @@ -0,0 +1,122 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + validatePhone, validateCnpj, validateEmpresa, dedupKey, extractDdd, segmentLabel, + scoreQualidade, scoreComercial, scoreGeral, classificarScoreGeral +} from '../src/scoring.mjs'; + +test('validatePhone aceita celular e fixo com 55', () => { + assert.equal(validatePhone('557193335451').valid, true); + assert.equal(validatePhone('5571993335451').valid, true); +}); + +test('validatePhone rejeita vazio, curto e ddd invalido', () => { + assert.deepEqual(validatePhone(''), { valid: false, motivo: 'telefone vazio' }); + assert.equal(validatePhone('7199999').valid, false); + assert.equal(validatePhone('550599999999').valid, false); + assert.equal(validatePhone('5571000000000').valid, false); +}); + +test('validateCnpj valida digitos verificadores', () => { + // CNPJ real da base legada (PREMIUM EXPRESS) + assert.equal(validateCnpj('57557263000113'), true); + assert.equal(validateCnpj('57557263000114'), false); + assert.equal(validateCnpj('11111111111111'), false); + assert.equal(validateCnpj('123'), false); +}); + +test('validateEmpresa aplica regra critica do relatorio legado', () => { + assert.equal(validateEmpresa('PREMIUM EXPRESS').valid, true); + assert.deepEqual(validateEmpresa('57.563.316 JHONATAS SOUZA'), { valid: false, motivo: 'empresa comeca com numero' }); + assert.deepEqual(validateEmpresa('-'), { valid: false, motivo: 'empresa vazia ou com lixo' }); + assert.deepEqual(validateEmpresa(''), { valid: false, motivo: 'empresa vazia ou com lixo' }); + assert.equal(validateEmpresa('...***...').valid, false); +}); + +test('scoreQualidade: lead completo com website bate 100', () => { + const result = scoreQualidade({ + whatsapp: '557193335451', + cnpj: '57557263000113', + empresa: 'PREMIUM EXPRESS', + cidade: 'Salvador', + segmento: 'restaurantes', + cnae: '5611203', + website: 'https://premiumexpress.com.br' + }); + + assert.equal(result.score, 100); + assert.equal(result.telefoneValido, true); + assert.equal(result.cnpjValido, true); + assert.equal(result.empresaValida, true); +}); + +test('scoreQualidade: sem telefone/website perde 30+5 pontos', () => { + const result = scoreQualidade({ + whatsapp: '', + cnpj: '57557263000113', + empresa: 'PREMIUM EXPRESS', + cidade: 'Salvador', + segmento: 'restaurantes', + cnae: '5611203', + website: '' + }); + + assert.equal(result.score, 65); + assert.equal(result.telefoneValido, false); +}); + +test('scoreComercial: nicho alta aderencia + tudo valido bate 100', () => { + const score = scoreComercial({ + whatsapp: '557193335451', + cnpj: '57557263000113', + website: 'https://premiumexpress.com.br', + email: 'contato@premiumexpress.com.br', + affinity: 'alta' + }); + assert.equal(score, 100); +}); + +test('scoreComercial: nicho baixa aderencia sem website/email/whatsapp/cnpj fica so com os pontos do nicho', () => { + const score = scoreComercial({ whatsapp: '', cnpj: '', website: '', email: '', affinity: 'baixa' }); + assert.equal(score, 20); +}); + +test('scoreGeral combina 60% comercial + 40% qualidade', () => { + assert.equal(scoreGeral(100, 100), 100); + assert.equal(scoreGeral(50, 50), 50); + assert.equal(scoreGeral(0, 100), 60); + assert.equal(scoreGeral(100, 0), 40); +}); + +test('classificarScoreGeral aplica as 5 faixas', () => { + assert.equal(classificarScoreGeral(95).id, 'excelente'); + assert.equal(classificarScoreGeral(80).id, 'bom'); + assert.equal(classificarScoreGeral(60).id, 'medio'); + assert.equal(classificarScoreGeral(40).id, 'baixo'); + assert.equal(classificarScoreGeral(10).id, 'muito_baixo'); +}); + +test('dedupKey segue precedencia cnpj > phone > empresa+cidade', () => { + assert.equal( + dedupKey({ cnpj: '57557263000113', whatsapp: '557193335451', empresa: 'X', cidade: 'Y' }), + 'cnpj:57557263000113' + ); + assert.equal( + dedupKey({ cnpj: '123', whatsapp: '557193335451', empresa: 'X', cidade: 'Y' }), + 'phone:557193335451' + ); + assert.equal( + dedupKey({ cnpj: '', whatsapp: '', empresa: 'Premium Express', cidade: 'Salvador' }), + 'name:premium express:salvador' + ); +}); + +test('extractDdd retira o ddd do numero normalizado', () => { + assert.equal(extractDdd('557193335451'), '71'); + assert.equal(extractDdd('999'), ''); +}); + +test('segmentLabel formata igual ao segmento_legivel legado', () => { + assert.equal(segmentLabel('lojas_de_calcados'), 'Lojas De Calcados'); + assert.equal(segmentLabel('saloes_de_beleza'), 'Saloes De Beleza'); +}); diff --git a/gti-maquina-comercial/tests/stream-json.test.mjs b/gti-maquina-comercial/tests/stream-json.test.mjs new file mode 100644 index 0000000..cdb2240 --- /dev/null +++ b/gti-maquina-comercial/tests/stream-json.test.mjs @@ -0,0 +1,70 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { streamJsonArray } from '../src/util/stream-json.mjs'; + +async function collect(filePath, options) { + const items = []; + for await (const item of streamJsonArray(filePath, options)) { + items.push(item); + } + return items; +} + +async function withTempFile(content, fn) { + const file = path.join(os.tmpdir(), `gti-test-json-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + await fs.writeFile(file, content, 'utf8'); + try { + return await fn(file); + } finally { + await fs.rm(file, { force: true }); + } +} + +test('streamJsonArray le array pretty-printed com chaves e aspas dentro de strings', async () => { + const data = [ + { nome: 'Empresa {teste} "aspas"', valor: 10, aninhado: { lista: [1, 2, { x: '}' }] } }, + { nome: 'Segunda \\ barra', vazio: null } + ]; + + await withTempFile(JSON.stringify(data, null, 2), async (file) => { + const items = await collect(file); + assert.deepEqual(items, data); + }); +}); + +test('streamJsonArray funciona com JSON compacto e BOM', async () => { + const data = [{ a: 1 }, { b: '[{teste}]' }, { c: [{ d: 2 }] }]; + + await withTempFile('' + JSON.stringify(data), async (file) => { + const items = await collect(file); + assert.deepEqual(items, data); + }); +}); + +test('streamJsonArray processa elementos maiores que o chunk', async () => { + const data = [ + { texto: 'x'.repeat(500), fim: 'ok' }, + { texto: 'y'.repeat(300) } + ]; + + await withTempFile(JSON.stringify(data, null, 2), async (file) => { + const items = await collect(file, { highWaterMark: 64 }); + assert.deepEqual(items, data); + }); +}); + +test('streamJsonArray retorna vazio para array vazio', async () => { + await withTempFile('[]', async (file) => { + const items = await collect(file); + assert.deepEqual(items, []); + }); +}); + +test('streamJsonArray falha com erro claro em arquivo truncado', async () => { + await withTempFile('[\n { "a": 1 },\n { "b": 2', async (file) => { + await assert.rejects(() => collect(file), /JSON truncado/); + }); +}); diff --git a/gti-maquina-comercial/tests/waba-template-library.test.mjs b/gti-maquina-comercial/tests/waba-template-library.test.mjs new file mode 100644 index 0000000..07e7307 --- /dev/null +++ b/gti-maquina-comercial/tests/waba-template-library.test.mjs @@ -0,0 +1,40 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { openDb, getSetting, setSetting } from '../src/db.mjs'; +import { listWabaTemplateLibrary, syncWabaTemplateLibrary } from '../src/outreach/waba-template-library.mjs'; + +test('listWabaTemplateLibrary retorna modelos padrao', () => { + const db = openDb(':memory:'); + const templates = listWabaTemplateLibrary(db); + assert.ok(templates.some((template) => template.name === 'gti_inicio_atendimento_solicitado')); + db.close(); +}); + +test('syncWabaTemplateLibrary importa templates de resposta do GChat', async () => { + const db = openDb(':memory:'); + setSetting(db, 'gchat.templatesUrl', 'https://gchat.example/templates'); + const originalFetch = global.fetch; + global.fetch = async () => ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + data: [{ + name: 'novo_template', + language: 'pt_BR', + category: 'UTILITY', + components: [{ type: 'BODY', text: 'Ola {{1}}, assunto {{2}}' }] + }] + }) + }); + try { + const result = await syncWabaTemplateLibrary(db); + assert.equal(result.ok, true); + assert.equal(result.total, 1); + } finally { + global.fetch = originalFetch; + } + + const saved = JSON.parse(getSetting(db, 'gchat.wabaTemplatesJson')); + assert.deepEqual(saved[0].variables, ['{empresa}', '{produto}']); + db.close(); +}); diff --git a/gti-maquina-comercial/tests/zpro-client.test.mjs b/gti-maquina-comercial/tests/zpro-client.test.mjs new file mode 100644 index 0000000..3a76b6a --- /dev/null +++ b/gti-maquina-comercial/tests/zpro-client.test.mjs @@ -0,0 +1,236 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { openDb, setSetting } from '../src/db.mjs'; +import { zproConfigured, createZproClient } from '../src/outreach/zpro-client.mjs'; + +test('zproConfigured e false sem credenciais na tela nem no .env', () => { + const db = openDb(':memory:'); + delete process.env.ZPRO_BASE_URL; + delete process.env.ZPRO_API_ID; + delete process.env.ZPRO_BEARER_TOKEN; + assert.equal(zproConfigured(db), false); + db.close(); +}); + +test('zproConfigured fica true com credenciais salvas na tela (settings)', () => { + const db = openDb(':memory:'); + setSetting(db, 'zpro.baseUrl', 'exemplo.zpro.com.br'); + setSetting(db, 'zpro.apiId', 'abc123'); + setSetting(db, 'zpro.bearerToken', 'token-secreto'); + assert.equal(zproConfigured(db), true); + db.close(); +}); + +test('zpro.bearerToken vazio na tela cai pro ZPRO_BEARER_TOKEN do .env', () => { + const db = openDb(':memory:'); + setSetting(db, 'zpro.baseUrl', 'exemplo.zpro.com.br'); + setSetting(db, 'zpro.apiId', 'abc123'); + process.env.ZPRO_BEARER_TOKEN = 'token-do-env'; + assert.equal(zproConfigured(db), true); + delete process.env.ZPRO_BEARER_TOKEN; + db.close(); +}); + +test('valor preenchido na tela tem prioridade sobre o .env', () => { + const db = openDb(':memory:'); + setSetting(db, 'zpro.baseUrl', 'exemplo.zpro.com.br'); + setSetting(db, 'zpro.apiId', 'abc123'); + setSetting(db, 'zpro.bearerToken', 'token-da-tela'); + process.env.ZPRO_BEARER_TOKEN = 'token-do-env'; + assert.equal(zproConfigured(db), true); + delete process.env.ZPRO_BEARER_TOKEN; + db.close(); +}); + +test('baseUrl colado com https:// na frente nao vira URL duplicada (https://https://...)', async () => { + const db = openDb(':memory:'); + setSetting(db, 'zpro.baseUrl', 'https://apigchat.exemplo.com.br/'); + setSetting(db, 'zpro.apiId', 'abc123'); + setSetting(db, 'zpro.bearerToken', 'token-da-tela'); + + const originalFetch = global.fetch; + let urlChamada; + global.fetch = async (url) => { + urlChamada = String(url); + return { ok: true, text: async () => '{}' }; + }; + try { + await createZproClient(db).listTags(); + } finally { + global.fetch = originalFetch; + } + + assert.equal(urlChamada, 'https://apigchat.exemplo.com.br/v2/api/external/abc123/listTags?isActive=true'); + db.close(); +}); + +test('token colado com Bearer e normalizado antes de enviar', async () => { + const db = openDb(':memory:'); + setSetting(db, 'zpro.baseUrl', 'apigchat.exemplo.com.br'); + setSetting(db, 'zpro.apiId', 'abc123'); + setSetting(db, 'zpro.bearerToken', 'Bearer token-da-tela'); + + const originalFetch = global.fetch; + let authorization; + global.fetch = async (url, options) => { + authorization = options.headers.authorization; + return { ok: true, text: async () => '{}' }; + }; + try { + await createZproClient(db).listTags(); + } finally { + global.fetch = originalFetch; + } + + assert.equal(authorization, 'Bearer token-da-tela'); + db.close(); +}); + +test('erro de auth do Z-PRO vira mensagem acionavel', async () => { + const db = openDb(':memory:'); + setSetting(db, 'zpro.baseUrl', 'apigchat.exemplo.com.br'); + setSetting(db, 'zpro.apiId', 'abc123'); + setSetting(db, 'zpro.bearerToken', 'token-da-tela'); + + const originalFetch = global.fetch; + global.fetch = async () => ({ + ok: false, + status: 403, + text: async () => '{"error":"ERR_SESSION_NOT_AUTH_TOKEN"}' + }); + try { + await assert.rejects( + () => createZproClient(db).listTags(), + /Token e o API ID sao os de Admin > API/ + ); + } finally { + global.fetch = originalFetch; + } + + db.close(); +}); + +test('createTicket extrai ticketId de resposta com JSON embutido em message', async () => { + const db = openDb(':memory:'); + setSetting(db, 'zpro.baseUrl', 'apigchat.exemplo.com.br'); + setSetting(db, 'zpro.apiId', 'abc123'); + setSetting(db, 'zpro.bearerToken', 'token-da-tela'); + + const originalFetch = global.fetch; + global.fetch = async () => ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + success: true, + data: { message: 'Ticket create successfully {"id":444202,"status":"pending"}', error: false } + }) + }); + try { + const resposta = await createZproClient(db).createTicket({ + number: '5511999990001', + message: 'oi', + externalKey: 'teste', + userId: 15, + channelId: 207 + }); + assert.equal(resposta.ticketId, '444202'); + } finally { + global.fetch = originalFetch; + } + + db.close(); +}); + +test('createTicket tenta sem channelId quando Z-PRO rejeita o canal informado', async () => { + const db = openDb(':memory:'); + setSetting(db, 'zpro.baseUrl', 'apigchat.exemplo.com.br'); + setSetting(db, 'zpro.apiId', 'abc123'); + setSetting(db, 'zpro.bearerToken', 'token-da-tela'); + + const originalFetch = global.fetch; + const bodies = []; + global.fetch = async (url, options) => { + const body = JSON.parse(options.body); + bodies.push(body); + if (body.channelId) { + return { ok: false, status: 404, text: async () => '{"success":false,"error":true}' }; + } + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ success: true, data: { message: 'Ticket create successfully {"id":444206}' } }) + }; + }; + try { + const resposta = await createZproClient(db).createTicket({ + number: '5511999990001', + message: 'oi', + externalKey: 'teste', + userId: 15, + channelId: 207 + }); + assert.equal(resposta.ticketId, '444206'); + } finally { + global.fetch = originalFetch; + } + + assert.equal(bodies.length, 2); + assert.equal(bodies[0].channelId, 207); + assert.equal('channelId' in bodies[1], false); + db.close(); +}); + +test('sendTemplateWaba inclui multiplas variaveis no components.body.parameters', async () => { + const db = openDb(':memory:'); + setSetting(db, 'zpro.baseUrl', 'apigchat.exemplo.com.br'); + setSetting(db, 'zpro.apiId', 'abc123'); + setSetting(db, 'zpro.bearerToken', 'token-da-tela'); + + const originalFetch = global.fetch; + let payload; + global.fetch = async (url, options) => { + payload = JSON.parse(options.body); + return { ok: true, status: 200, text: async () => '{"success":true,"data":{"ticketId":123}}' }; + }; + try { + await createZproClient(db).sendTemplateWaba({ + number: '5511999990001', + templateName: 'confirma_suporte', + languageCode: 'pt_BR', + variables: ['Gustavo', 'Chamado #123'] + }); + } finally { + global.fetch = originalFetch; + } + + assert.deepEqual(payload.templateData.template.components, [ + { type: 'body', parameters: [{ type: 'text', text: 'Gustavo' }, { type: 'text', text: 'Chamado #123' }] } + ]); + db.close(); +}); + +test('sendTemplateWaba permite escolher rota templateBody', async () => { + const db = openDb(':memory:'); + setSetting(db, 'zpro.baseUrl', 'apigchat.exemplo.com.br'); + setSetting(db, 'zpro.apiId', 'abc123'); + setSetting(db, 'zpro.bearerToken', 'token-da-tela'); + + const originalFetch = global.fetch; + let urlChamada; + global.fetch = async (url) => { + urlChamada = String(url); + return { ok: true, status: 200, text: async () => '{"success":true}' }; + }; + try { + await createZproClient(db).sendTemplateWaba({ + number: '5511999990001', + templateName: 'confirma_suporte', + route: 'templateBody' + }); + } finally { + global.fetch = originalFetch; + } + + assert.equal(urlChamada, 'https://apigchat.exemplo.com.br/v2/api/external/abc123/templateBody'); + db.close(); +});