From b8d10b210c5884ef138c1a4deec6f8811d4f5640 Mon Sep 17 00:00:00 2001 From: "DESKTOP-F2O4A80\\User" Date: Sun, 12 Jul 2026 14:22:21 -0300 Subject: [PATCH] Commit inicial: GTI Maquina Comercial App local (Node + SQLite) para captura, tratamento e exportacao de leads comerciais, com dashboard de gestao, login/usuarios e integracao Z-PRO. Co-Authored-By: Claude --- .claude/launch.json | 11 + .env.example | 27 + .gitignore | 10 + INICIAR-GTI.bat | 10 + README.md | 89 + docs/01-visao-geral.md | 55 + docs/03-playbook-comercial.md | 83 + docs/04-lgpd-whatsapp.md | 48 + docs/07-prospeccao-automatica.md | 75 + docs/referencia/Z-PRO.postman_collection.json | 9244 +++++++++++++++++ launcher/GTI-Comercial.csproj | 10 + launcher/Program.cs | 198 + package.json | 18 + prospeccao/cnpj/README.md | 26 + prospeccao/config.json | 1965 ++++ public/css/app.css | 323 + public/index.html | 360 + public/js/api.js | 94 + public/js/app.js | 137 + public/js/captura.js | 405 + public/js/login.js | 16 + public/js/tratamento.js | 629 ++ public/login.html | 33 + scripts/baixar-base-rfb.sh | 44 + scripts/build-launcher.ps1 | 22 + scripts/capturar-leads.mjs | 113 + scripts/extrair-base-cnpj.mjs | 76 + scripts/recalcular-scores.mjs | 85 + scripts/servidor.mjs | 37 + src/auth.mjs | 19 + src/capture/capture-job.mjs | 110 + src/capture/geocode.mjs | 43 + src/capture/lead-row.mjs | 65 + src/capture/runner.mjs | 133 + src/db.mjs | 441 + src/env.mjs | 50 + src/export/excel.mjs | 104 + src/export/export-job.mjs | 93 + src/export/zpro.mjs | 122 + src/extract/extract-job.mjs | 75 + src/extract/rfb-extractor.mjs | 361 + src/jobs/manager.mjs | 160 + src/jobs/protocol.mjs | 40 + src/lead-engine.mjs | 411 + src/scoring.mjs | 152 + src/server/index.mjs | 126 + src/server/router.mjs | 33 + src/server/routes/auth.mjs | 74 + src/server/routes/capture.mjs | 112 + src/server/routes/exports.mjs | 96 + src/server/routes/extractions.mjs | 72 + src/server/routes/leads.mjs | 53 + src/server/routes/meta.mjs | 142 + src/server/session.mjs | 51 + src/server/sse.mjs | 33 + src/server/static.mjs | 39 + src/sources/cnpj-base.mjs | 172 + src/sources/common.mjs | 74 + src/sources/google-places.mjs | 103 + src/sources/index.mjs | 89 + src/sources/mock.mjs | 49 + src/sources/nominatim.mjs | 103 + src/sources/overpass.mjs | 228 + src/sources/wikidata.mjs | 104 + src/treatment/dashboard.mjs | 111 + src/treatment/queries.mjs | 157 + src/util/collections.mjs | 27 + src/util/csv.mjs | 117 + src/util/stream-json.mjs | 78 + tests/auth.test.mjs | 38 + tests/csv.test.mjs | 90 + tests/dashboard.test.mjs | 139 + tests/db.test.mjs | 112 + tests/export.test.mjs | 119 + tests/lead-engine.test.mjs | 157 + tests/runner.test.mjs | 69 + tests/scoring.test.mjs | 122 + tests/stream-json.test.mjs | 70 + 78 files changed, 19781 insertions(+) create mode 100644 .claude/launch.json create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 INICIAR-GTI.bat create mode 100644 README.md create mode 100644 docs/01-visao-geral.md create mode 100644 docs/03-playbook-comercial.md create mode 100644 docs/04-lgpd-whatsapp.md create mode 100644 docs/07-prospeccao-automatica.md create mode 100644 docs/referencia/Z-PRO.postman_collection.json create mode 100644 launcher/GTI-Comercial.csproj create mode 100644 launcher/Program.cs create mode 100644 package.json create mode 100644 prospeccao/cnpj/README.md create mode 100644 prospeccao/config.json create mode 100644 public/css/app.css create mode 100644 public/index.html create mode 100644 public/js/api.js create mode 100644 public/js/app.js create mode 100644 public/js/captura.js create mode 100644 public/js/login.js create mode 100644 public/js/tratamento.js create mode 100644 public/login.html create mode 100644 scripts/baixar-base-rfb.sh create mode 100644 scripts/build-launcher.ps1 create mode 100644 scripts/capturar-leads.mjs create mode 100644 scripts/extrair-base-cnpj.mjs create mode 100644 scripts/recalcular-scores.mjs create mode 100644 scripts/servidor.mjs create mode 100644 src/auth.mjs create mode 100644 src/capture/capture-job.mjs create mode 100644 src/capture/geocode.mjs create mode 100644 src/capture/lead-row.mjs create mode 100644 src/capture/runner.mjs create mode 100644 src/db.mjs create mode 100644 src/env.mjs create mode 100644 src/export/excel.mjs create mode 100644 src/export/export-job.mjs create mode 100644 src/export/zpro.mjs create mode 100644 src/extract/extract-job.mjs create mode 100644 src/extract/rfb-extractor.mjs create mode 100644 src/jobs/manager.mjs create mode 100644 src/jobs/protocol.mjs create mode 100644 src/lead-engine.mjs create mode 100644 src/scoring.mjs create mode 100644 src/server/index.mjs create mode 100644 src/server/router.mjs create mode 100644 src/server/routes/auth.mjs create mode 100644 src/server/routes/capture.mjs create mode 100644 src/server/routes/exports.mjs create mode 100644 src/server/routes/extractions.mjs create mode 100644 src/server/routes/leads.mjs create mode 100644 src/server/routes/meta.mjs create mode 100644 src/server/session.mjs create mode 100644 src/server/sse.mjs create mode 100644 src/server/static.mjs create mode 100644 src/sources/cnpj-base.mjs create mode 100644 src/sources/common.mjs create mode 100644 src/sources/google-places.mjs create mode 100644 src/sources/index.mjs create mode 100644 src/sources/mock.mjs create mode 100644 src/sources/nominatim.mjs create mode 100644 src/sources/overpass.mjs create mode 100644 src/sources/wikidata.mjs create mode 100644 src/treatment/dashboard.mjs create mode 100644 src/treatment/queries.mjs create mode 100644 src/util/collections.mjs create mode 100644 src/util/csv.mjs create mode 100644 src/util/stream-json.mjs create mode 100644 tests/auth.test.mjs create mode 100644 tests/csv.test.mjs create mode 100644 tests/dashboard.test.mjs create mode 100644 tests/db.test.mjs create mode 100644 tests/export.test.mjs create mode 100644 tests/lead-engine.test.mjs create mode 100644 tests/runner.test.mjs create mode 100644 tests/scoring.test.mjs create mode 100644 tests/stream-json.test.mjs diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..3c4400c --- /dev/null +++ b/.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/.env.example b/.env.example new file mode 100644 index 0000000..a8a643f --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# 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 + +# 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=45000 +#OVERPASS_TEXT_SEARCH=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c33c6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +.env +.env.local +data/ +prospeccao/cnpj/rfb-raw/ +prospeccao/cnpj/*.csv +prospeccao/cnpj/*.ndjson +*.log +GTI-Comercial.exe +launcher/publish/ diff --git a/INICIAR-GTI.bat b/INICIAR-GTI.bat new file mode 100644 index 0000000..e7a9c18 --- /dev/null +++ b/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/README.md b/README.md new file mode 100644 index 0000000..eca8b00 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# GTI Máquina Comercial + +Aplicativo local do Grupo GTI para **capturar** e **tratar** leads B2B em qualquer +estado/cidade do Brasil, com foco na base oficial de CNPJs da Receita Federal. + +## Como usar (dia a dia) + +1. Dê dois cliques em **`GTI-Comercial.exe`** (ou em `INICIAR-GTI.bat`). +2. O navegador abre em `http://127.0.0.1:3900` com duas abas: + - **📡 Capturar Leads** — escolha estado, cidades, nichos e fontes e rode a captura + com progresso ao vivo. Se a base CNPJ do estado ainda não existe, o botão + "Extrair base agora" prepara tudo (~40–60 min, uma única vez por estado). + - **📊 Tratar Leads** — dashboard com totais e rankings por cidade/nicho, navegador + de leads com filtros (qualidade, WhatsApp, exportado...) e o botão + **Gerar relatório / exportação** que produz os arquivos para envio. + +Tudo roda somente neste computador (porta local 127.0.0.1). Nenhum dado sai da máquina, +exceto as consultas às fontes públicas durante a captura. + +## Exportações disponíveis + +| Tipo | Para quê | +|---|---| +| 📊 Planilha (CSV + Excel) | Lista completa com score — números como texto, sem notação científica | +| 🗂️ Z-PRO Oportunidades (JSONL) | Um payload `createOpportunity` por linha, pronto para importar | +| 📨 Z-PRO Disparo em massa (CSV) | `dataInput` para `BulkSendMessageWithVariable` + instruções | + +Leads exportados ficam marcados — o filtro "ainda não exportados" evita reenvio. +Os IDs padrão do Z-PRO (pipeline/etapa/responsável) ficam no ⚙️ Configurações. + +> O `.xls` gerado é uma tabela HTML (o Excel avisa sobre o formato ao abrir — é normal, +> basta confirmar). Use o `.csv` se preferir. + +## Qualidade dos leads (score) + +Score: +35 telefone válido, +25 CNPJ válido, +25 empresa válida, +5 cidade, ++5 nicho, +5 CNAE. **Bom ≥ 85**, médio 60–84, ruim < 60. Empresa vazia, com lixo +ou começando com número é sempre **ruim** (regra crítica). Duplicidade: CNPJ → +WhatsApp → empresa+cidade. + +## Fontes de captura + +- **Base CNPJ (Receita Federal)** — principal: todas as empresas ativas da UF, ~97% com telefone. +- **OpenStreetMap** (Overpass e Nominatim) — grátis, complementar. +- **Wikidata** — grátis, volume baixo. +- **BrasilAPI** — enriquecimento opcional da base CNPJ. +- **Google Places** — paga; preencha `GOOGLE_MAPS_API_KEY` no `.env`. + +## Novo estado (ex.: expandir para Sergipe) + +1. Na aba Captura, escolha **SE — Sergipe**. +2. Clique em **Extrair base agora** e aguarde (usa os zips já baixados em + `prospeccao/cnpj/rfb-raw/`). +3. Pronto: capture qualquer cidade de SE normalmente. Nada muda na interface. + +Os zips brutos da Receita (7 GB, Brasil inteiro) já estão no projeto. Para atualizá-los +no futuro (a Receita publica mensalmente): `bash scripts/baixar-base-rfb.sh`. + +## Uso avançado (linha de comando) + +```bash +npm start # sobe o servidor +npm test # roda os testes +node scripts/capturar-leads.mjs --sources=cnpj-base --uf=BA --cities="Ilheus" +node scripts/extrair-base-cnpj.mjs --uf=SP # extrai base de outra UF +``` + +## Estrutura do projeto + +``` +GTI-Comercial.exe inicia tudo (servidor + navegador) +scripts/ entradas de linha de comando +src/ código do servidor, fontes de captura, exportações +public/ interface (HTML/CSS/JS) +data/gti.db banco local (SQLite) com todos os leads +data/exports/ arquivos de exportação gerados +prospeccao/cnpj/ base CNPJ extraída por UF + zips brutos da Receita +docs/ playbook comercial, LGPD e referência da API Z-PRO +``` + +## Requisitos + +- Windows com **Node.js 24+** (https://nodejs.org) +- Para extrair novas UFs: `unzip` (vem com o Git para Windows) ou o `tar` nativo do Windows + +## Log e problemas + +- Log do servidor: `data/server.log` +- Se a tela não abrir: rode `node scripts/servidor.mjs` num terminal e veja a mensagem. diff --git a/docs/01-visao-geral.md b/docs/01-visao-geral.md new file mode 100644 index 0000000..360cbb4 --- /dev/null +++ b/docs/01-visao-geral.md @@ -0,0 +1,55 @@ +# 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 Docker, sem serviços externos obrigatórios: tudo roda no computador +da GTI. O envio das mensagens é feito por fora (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. diff --git a/docs/03-playbook-comercial.md b/docs/03-playbook-comercial.md new file mode 100644 index 0000000..28e39b7 --- /dev/null +++ b/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/docs/04-lgpd-whatsapp.md b/docs/04-lgpd-whatsapp.md new file mode 100644 index 0000000..a77efd7 --- /dev/null +++ b/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/docs/07-prospeccao-automatica.md b/docs/07-prospeccao-automatica.md new file mode 100644 index 0000000..1e759ea --- /dev/null +++ b/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/docs/referencia/Z-PRO.postman_collection.json b/docs/referencia/Z-PRO.postman_collection.json new file mode 100644 index 0000000..96e50b1 --- /dev/null +++ b/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/launcher/GTI-Comercial.csproj b/launcher/GTI-Comercial.csproj new file mode 100644 index 0000000..5ef5e90 --- /dev/null +++ b/launcher/GTI-Comercial.csproj @@ -0,0 +1,10 @@ + + + Exe + net8.0 + enable + enable + GTI-Comercial + GtiComercialLauncher + + diff --git a/launcher/Program.cs b/launcher/Program.cs new file mode 100644 index 0000000..dc807c2 --- /dev/null +++ b/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/package.json b/package.json new file mode 100644 index 0000000..2a9ac77 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "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", + "build:launcher": "powershell -File scripts/build-launcher.ps1" + }, + "engines": { + "node": ">=24" + } +} diff --git a/prospeccao/cnpj/README.md b/prospeccao/cnpj/README.md new file mode 100644 index 0000000..708c859 --- /dev/null +++ b/prospeccao/cnpj/README.md @@ -0,0 +1,26 @@ +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 usar a base local: + +```bash +npm run prospect -- --source=cnpj-base --send +``` + +Com enriquecimento pela BrasilAPI, use: + +```bash +npm run prospect -- --source=brasilapi --send +``` + +O modo `brasilapi` usa os CNPJs encontrados nos CSVs locais para consultar dados atualizados por CNPJ. diff --git a/prospeccao/config.json b/prospeccao/config.json new file mode 100644 index 0000000..4db5e46 --- /dev/null +++ b/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/public/css/app.css b/public/css/app.css new file mode 100644 index 0000000..ee6378f --- /dev/null +++ b/public/css/app.css @@ -0,0 +1,323 @@ +/* GTI Máquina Comercial — tokens de design (paleta validada, light/dark) */ +:root { + --page: #f9f9f7; + --surface: #fcfcfb; + --ink: #0b0b0b; + --ink-2: #52514e; + --muted: #898781; + --grid: #e1e0d9; + --border: rgba(11, 11, 11, 0.10); + --accent: #2a78d6; /* series-1 azul */ + --accent-ink: #ffffff; + --bar: #5598e7; /* sequential 350 — barras de ranking */ + --good: #0ca30c; + --good-text: #006300; + --warning: #fab219; + --critical: #d03b3b; + --shadow: 0 1px 3px rgba(11, 11, 11, 0.08); +} +@media (prefers-color-scheme: dark) { + :root { + --page: #0d0d0d; + --surface: #1a1a19; + --ink: #ffffff; + --ink-2: #c3c2b7; + --muted: #898781; + --grid: #2c2c2a; + --border: rgba(255, 255, 255, 0.10); + --accent: #3987e5; + --bar: #3987e5; + --good-text: #0ca30c; + --shadow: none; + } +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + background: var(--page); + color: var(--ink); + font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; +} + +/* ---------- topo ---------- */ +.topbar { + display: flex; align-items: center; gap: 20px; + padding: 10px 20px; + background: var(--surface); + border-bottom: 1px solid var(--grid); + position: sticky; top: 0; z-index: 20; +} +.brand { display: flex; align-items: baseline; gap: 8px; } +.brand-mark { + background: var(--accent); color: var(--accent-ink); + font-weight: 800; padding: 3px 9px; border-radius: 6px; letter-spacing: 0.5px; +} +.brand-name { font-weight: 600; color: var(--ink-2); } +.tabs { display: flex; gap: 4px; flex: 1; } +.tab { + border: none; background: none; color: var(--ink-2); + font: inherit; font-weight: 600; padding: 8px 16px; + border-radius: 8px; cursor: pointer; +} +.tab:hover { background: var(--grid); } +.tab.active { background: var(--accent); color: var(--accent-ink); } + +main { max-width: 1280px; margin: 0 auto; padding: 16px 20px 60px; } +.tab-panel { display: none; } +.tab-panel.active { display: block; } + +/* ---------- cartões e grades ---------- */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px 18px; + box-shadow: var(--shadow); + margin-bottom: 16px; +} +.card h2 { margin: 0 0 12px; font-size: 16px; } +.captura-wrap { max-width: 720px; margin: 0 auto; } +.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; } +@media (max-width: 1100px) { + .grid-3 { grid-template-columns: 1fr 1fr; } +} +@media (max-width: 980px) { + .grid-3 { grid-template-columns: 1fr; } +} + +/* ---------- formulários ---------- */ +.field { margin-bottom: 18px; } +.field > label:first-child, .field > summary { font-weight: 600; display: block; margin-bottom: 6px; } +.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.row.space-between { justify-content: space-between; } +.row.wrap { flex-wrap: wrap; } +.radio { display: flex; align-items: center; gap: 6px; font-weight: 400; cursor: pointer; margin: 4px 0; } +select, input, textarea { + font: inherit; color: var(--ink); + background: var(--page); + border: 1px solid var(--grid); + border-radius: 8px; padding: 7px 10px; +} +select:focus, input:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: 1px; } +input[type="number"] { width: 90px; } +label > input[type="number"] { display: block; margin-top: 4px; } +details summary { cursor: pointer; } + +.btn, .btn-ghost, .btn-danger { + font: inherit; font-weight: 600; cursor: pointer; + border-radius: 8px; padding: 8px 14px; + border: 1px solid var(--grid); + background: var(--surface); color: var(--ink); +} +.btn:hover, .btn-ghost:hover { border-color: var(--accent); } +.btn-primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); } +.btn-primary:hover { filter: brightness(1.1); } +.btn-lg { width: 100%; padding: 12px; font-size: 15px; } +.btn-ghost { border-color: transparent; color: var(--ink-2); } +.btn-danger { color: var(--critical); border-color: var(--critical); background: none; } +.btn-danger:hover { background: var(--critical); color: #fff; } +button:disabled { opacity: 0.5; cursor: not-allowed; } + +.zona-risco { border: 1px solid var(--critical); border-radius: 10px; padding: 10px 12px; margin-top: 14px; } +.zona-risco label { color: var(--critical); font-weight: 700; } + +/* ---------- login ---------- */ +.login-body { display: flex; min-height: 100vh; } +.login-wrap { margin: auto; width: 100%; max-width: 360px; padding: 20px; } +.login-card { display: flex; flex-direction: column; gap: 14px; } +.login-brand { justify-content: center; margin-bottom: 4px; } +.login-erro { color: var(--critical); min-height: 1em; margin: 0; } + +/* ---------- usuarios ---------- */ +#lista-usuarios { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; } +#lista-usuarios > div { padding: 6px 0; border-bottom: 1px solid var(--grid); } +#form-novo-usuario select { border: 1px solid var(--grid); border-radius: 8px; padding: 7px 10px; background: var(--surface); color: var(--ink); } + +.chip { + font-size: 12px; font-weight: 600; + padding: 3px 10px; border-radius: 999px; + background: var(--grid); color: var(--ink-2); +} +.chip.ok { background: color-mix(in srgb, var(--good) 15%, transparent); color: var(--good-text); } +.chip.warn { background: color-mix(in srgb, var(--warning) 20%, transparent); color: var(--ink); } + +.notice { + margin-top: 10px; padding: 12px; + border: 1px solid var(--warning); + border-radius: 8px; + background: color-mix(in srgb, var(--warning) 8%, transparent); +} +.notice p { margin: 0 0 10px; } + +/* pickers de cidade/nicho */ +.picker { margin-top: 8px; } +.picker input[type="search"] { width: 100%; margin-bottom: 6px; } +.sugestoes { display: flex; flex-direction: column; gap: 2px; max-height: 180px; overflow-y: auto; } +.sugestoes button { + text-align: left; font: inherit; border: none; background: none; + color: var(--ink); padding: 6px 8px; border-radius: 6px; cursor: pointer; +} +.sugestoes button:hover { background: var(--grid); } +.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } +.chips .chip { cursor: pointer; } +.chips .chip::after { content: " ✕"; color: var(--muted); } +.nichos-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: 2px 12px; max-height: 300px; overflow-y: auto; margin-top: 8px; +} +.nichos-grid label { display: flex; gap: 6px; align-items: center; padding: 3px 4px; border-radius: 6px; cursor: pointer; } +.nichos-grid label:hover { background: var(--grid); } + +.cnae-avulso { margin-top: 10px; border-top: 1px dashed var(--grid); padding-top: 10px; } +.cnae-avulso summary { cursor: pointer; font-size: 13px; color: var(--ink-2); font-weight: 600; } +.cnae-avulso-hint { margin: 6px 0 8px; font-size: 12.5px; } +.cnae-avulso input[type="search"] { width: 100%; margin-bottom: 6px; } +.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: 6px; } +.fonte { + display: flex; gap: 10px; align-items: flex-start; + border: 1px solid var(--grid); border-radius: 8px; padding: 8px 10px; cursor: pointer; +} +.fonte input { margin-top: 3px; } +.fonte .desc { font-size: 12.5px; color: var(--ink-2); } +.fonte.indisponivel { opacity: 0.55; } + +/* ---------- progresso ---------- */ +.progress-bar { height: 8px; border-radius: 999px; background: var(--grid); overflow: hidden; margin: 10px 0 8px; } +.progress-fill { height: 100%; width: 3%; background: var(--accent); border-radius: 999px; transition: width 0.4s; } +.progress-msg { color: var(--ink-2); margin: 4px 0 10px; min-height: 1.4em; } +.stat-row { display: flex; gap: 18px; margin-bottom: 10px; } +.stat-row .mini { display: flex; flex-direction: column; } +.stat-row .mini b { font-size: 20px; } +.stat-row .mini span { font-size: 12px; color: var(--muted); } +.log { + font: 12px/1.5 ui-monospace, Consolas, monospace; + background: var(--page); border: 1px solid var(--grid); border-radius: 8px; + padding: 8px 10px; max-height: 160px; overflow-y: auto; white-space: pre-wrap; +} + +.lista { display: flex; flex-direction: column; gap: 8px; } +.lista .item { + display: flex; justify-content: space-between; align-items: center; gap: 10px; + border-bottom: 1px solid var(--grid); padding: 8px 2px; flex-wrap: wrap; +} +.lista .item:last-child { border-bottom: none; } +.lista .item .sub { font-size: 12px; color: var(--muted); } + +/* ---------- tratamento: tiles e rankings ---------- */ +.filtros-card { padding: 12px 16px; } +.filtros { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } +.filtros select, .filtros input { min-width: 130px; } +#f-busca { flex: 1; min-width: 200px; } + +.stat-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 16px; } +.tile { + background: var(--surface); border: 1px solid var(--border); border-radius: 12px; + padding: 14px 16px; box-shadow: var(--shadow); +} +.tile .valor { font-size: 26px; font-weight: 700; } +.tile .rotulo { font-size: 12.5px; color: var(--ink-2); margin-top: 2px; } +.tile .detalhe { font-size: 12px; color: var(--muted); } +.tile.t-good .valor { color: var(--good-text); } +.tile.t-warn .valor { color: var(--ink); } +.tile.tile-wide { grid-column: span 2; } +@media (max-width: 640px) { .tile.tile-wide { grid-column: span 1; } } +.meta-input { width: 70px; text-align: right; padding: 3px 6px; border-radius: 6px; border: 1px solid var(--border); background: var(--surface); color: var(--ink); } + +/* ---------- funil comercial ---------- */ +.funil { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; } +.funil-etapa { display: grid; grid-template-columns: 190px 1fr 70px; align-items: center; gap: 8px; padding: 1px 4px; } +.funil-etapa.pendente { opacity: 0.4; } +.funil-label { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.funil-barra { height: 14px; border-radius: 4px; overflow: hidden; background: none; } +.funil-fill { height: 100%; background: var(--bar); border-radius: 0 4px 4px 0; min-width: 2px; } +.funil-valor { font-size: 12.5px; color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; } + +/* barras de ranking: magnitude = um matiz (azul), rótulo direto, sem legenda */ +.ranking { display: flex; flex-direction: column; gap: 7px; } +.rank-row { display: grid; grid-template-columns: 150px 1fr 70px; gap: 8px; align-items: center; cursor: pointer; border-radius: 6px; padding: 1px 4px; } +.rank-row:hover { background: var(--grid); } +.rank-row.ativo { background: color-mix(in srgb, var(--accent) 16%, transparent); font-weight: 600; } +.rank-row.nao-clicavel { cursor: default; } +.rank-row.nao-clicavel:hover { background: none; } +.oportunidade-clicavel { cursor: pointer; border-radius: 8px; padding: 6px 8px; } +.oportunidade-clicavel:hover { background: var(--grid); } +.oportunidade-clicavel.ativo { background: color-mix(in srgb, var(--accent) 16%, transparent); } +.rank-label { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.rank-track { height: 14px; border-radius: 4px; overflow: hidden; background: none; } +.rank-fill { height: 100%; background: var(--bar); border-radius: 0 4px 4px 0; min-width: 2px; } +.rank-value { font-size: 12.5px; color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; } + +/* ---------- tabela ---------- */ +.table-wrap { overflow-x: auto; margin: 0 -4px; } +table { border-collapse: collapse; width: 100%; } +th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--grid); white-space: nowrap; } +th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); } +td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; } +tbody tr { cursor: pointer; } +tbody tr:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); } +.badge { font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 999px; white-space: nowrap; } +/* Escala sequencial ordinal (excelente -> muito baixo), verde -> ambar -> vermelho, + construida so com os tokens ja validados (--good/--warning/--critical). */ +.badge.excelente { background: color-mix(in srgb, var(--good) 20%, 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) 22%, transparent); color: var(--ink); } +.badge.baixo { background: color-mix(in srgb, var(--warning) 18%, var(--critical) 12%); color: var(--critical); } +.badge.muito_baixo { background: color-mix(in srgb, var(--critical) 16%, transparent); color: var(--critical); } + +.badge.prioridade-1 { background: color-mix(in srgb, var(--good) 20%, transparent); color: var(--good-text); } +.badge.prioridade-2 { background: color-mix(in srgb, var(--warning) 22%, 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; } +.pontos-fortes { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 3px; font-size: 13px; } +.resumo-comercial { border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; margin-top: 12px; } +.resumo-comercial h3 { margin: 0 0 8px; font-size: 14px; } +.empresa-cell { max-width: 280px; overflow: hidden; text-overflow: ellipsis; } + +/* ---------- explicacao de score (v2.1) ---------- */ +.score-breakdown-wrap { border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; margin-top: 12px; } +.score-breakdown { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 3px; font-size: 13px; } +.score-breakdown li.ok { color: var(--ink); } +.score-breakdown li.nok { color: var(--muted); } + +/* ---------- atalhos de exportacao (v2.1) ---------- */ +.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 12px; } +.chip { + border: 1px solid var(--border); background: var(--surface); color: var(--ink); + border-radius: 999px; padding: 4px 12px; font-size: 12.5px; cursor: pointer; +} +.chip:hover { background: var(--grid); border-color: var(--accent); } + +/* ---------- modais e toast ---------- */ +dialog { + border: 1px solid var(--border); border-radius: 14px; + background: var(--surface); color: var(--ink); + max-width: 560px; width: calc(100vw - 40px); + padding: 0; +} +dialog::backdrop { background: rgba(0, 0, 0, 0.45); } +.modal-body { padding: 20px 22px; } +.modal-body h2 { margin-top: 0; } +#modal-historico { max-width: 620px; } +#modal-historico .lista { max-height: 60vh; overflow-y: auto; margin-top: 10px; } +textarea { width: 100%; resize: vertical; } + +.toast { + position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); + background: var(--ink); color: var(--page); + padding: 10px 18px; border-radius: 10px; font-weight: 600; + z-index: 100; max-width: 80vw; +} +.hidden { display: none !important; } +.muted { color: var(--muted); font-weight: 400; } + +/* detalhe do lead */ +.detalhe-grid { display: grid; grid-template-columns: 140px 1fr; gap: 4px 12px; font-size: 13.5px; } +.detalhe-grid dt { color: var(--muted); } +.detalhe-grid dd { margin: 0; word-break: break-word; } diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..20f2c08 --- /dev/null +++ b/public/index.html @@ -0,0 +1,360 @@ + + + + + +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

+ +
+
+ + + + + + + + + +
EmpresaWhatsAppCidadeNichoPrioridadeGeralClassificaçãoProdutoExportado
+
+
+ + + +
+
+ +
+

Exportações geradas

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/js/api.js b/public/js/api.js new file mode 100644 index 0000000..e91a329 --- /dev/null +++ b/public/js/api.js @@ -0,0 +1,94 @@ +/* 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; +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/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..ce54b2f --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,137 @@ +/* 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(); + }); + } + + // configurações + 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-forecast').value = settings['zpro.closingForecastDays'] || 30; + el('modal-settings').showModal(); + }); + + 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.closingForecastDays': el('cfg-forecast').value + }); + 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('modal-settings').close(); + el('modal-reset-base').showModal(); + }); + + el('btn-cancelar-reset-base').addEventListener('click', () => { + el('modal-reset-base').close(); + }); + + el('btn-confirmar-reset-base').addEventListener('click', async () => { + const senha = el('reset-base-senha').value; + if (!senha) { + toast('Informe a senha de segurança'); + 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) { + toast(`Erro: ${error.message}`); + } + }); + + // 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/public/js/captura.js b/public/js/captura.js new file mode 100644 index 0000000..32449d4 --- /dev/null +++ b/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/public/js/login.js b/public/js/login.js new file mode 100644 index 0000000..1dc6db2 --- /dev/null +++ b/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/public/js/tratamento.js b/public/js/tratamento.js new file mode 100644 index 0000000..ffef7de --- /dev/null +++ b/public/js/tratamento.js @@ -0,0 +1,629 @@ +/* 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: '9', class: 'muted' }, 'Nenhum lead com este filtro.'))); + return; + } + + tbody.replaceChildren(...data.rows.map((lead) => h('tr', { onclick: () => abrirLead(lead.id) }, + 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/public/login.html b/public/login.html new file mode 100644 index 0000000..0364bde --- /dev/null +++ b/public/login.html @@ -0,0 +1,33 @@ + + + + + +GTI Máquina Comercial — Login + + + + + +
+ +
+ + + + + + diff --git a/scripts/baixar-base-rfb.sh b/scripts/baixar-base-rfb.sh new file mode 100644 index 0000000..8f20595 --- /dev/null +++ b/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/scripts/build-launcher.ps1 b/scripts/build-launcher.ps1 new file mode 100644 index 0000000..9093d00 --- /dev/null +++ b/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/scripts/capturar-leads.mjs b/scripts/capturar-leads.mjs new file mode 100644 index 0000000..d567cc6 --- /dev/null +++ b/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/scripts/extrair-base-cnpj.mjs b/scripts/extrair-base-cnpj.mjs new file mode 100644 index 0000000..b8866f7 --- /dev/null +++ b/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/scripts/recalcular-scores.mjs b/scripts/recalcular-scores.mjs new file mode 100644 index 0000000..e533142 --- /dev/null +++ b/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/scripts/servidor.mjs b/scripts/servidor.mjs new file mode 100644 index 0000000..e9215ba --- /dev/null +++ b/scripts/servidor.mjs @@ -0,0 +1,37 @@ +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'; + +// 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 }); + +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/src/auth.mjs b/src/auth.mjs new file mode 100644 index 0000000..c871d2f --- /dev/null +++ b/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/src/capture/capture-job.mjs b/src/capture/capture-job.mjs new file mode 100644 index 0000000..390498b --- /dev/null +++ b/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/src/capture/geocode.mjs b/src/capture/geocode.mjs new file mode 100644 index 0000000..4decbf2 --- /dev/null +++ b/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/src/capture/lead-row.mjs b/src/capture/lead-row.mjs new file mode 100644 index 0000000..1c0af97 --- /dev/null +++ b/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/src/capture/runner.mjs b/src/capture/runner.mjs new file mode 100644 index 0000000..43b6e0c --- /dev/null +++ b/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', 30000) + }, + overpassTextSearch: envString('OVERPASS_TEXT_SEARCH', 'true') !== 'false', + 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/src/db.mjs b/src/db.mjs new file mode 100644 index 0000000..a3aaca1 --- /dev/null +++ b/src/db.mjs @@ -0,0 +1,441 @@ +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')) + ); + ` +]; + +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.status': 'open', + 'zpro.closingForecastDays': '30', + 'zpro.validateNumber': 'true', + 'meta_diaria': '100' +}; + +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; +} + +// 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 || []); + log(`seeds: ${countRows(db, 'segments')} segmentos, ${countRows(db, 'geo_cache')} bboxes`); + } 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/src/env.mjs b/src/env.mjs new file mode 100644 index 0000000..784cc58 --- /dev/null +++ b/src/env.mjs @@ -0,0 +1,50 @@ +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 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; + } + } 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; +} + +export const GTI_PORT = envNumber('GTI_PORT', 3900); diff --git a/src/export/excel.mjs b/src/export/excel.mjs new file mode 100644 index 0000000..98155c5 --- /dev/null +++ b/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/src/export/export-job.mjs b/src/export/export-job.mjs new file mode 100644 index 0000000..27e8a83 --- /dev/null +++ b/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/src/export/zpro.mjs b/src/export/zpro.mjs new file mode 100644 index 0000000..1530154 --- /dev/null +++ b/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/src/extract/extract-job.mjs b/src/extract/extract-job.mjs new file mode 100644 index 0000000..f0c6f62 --- /dev/null +++ b/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/src/extract/rfb-extractor.mjs b/src/extract/rfb-extractor.mjs new file mode 100644 index 0000000..a07a999 --- /dev/null +++ b/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/src/jobs/manager.mjs b/src/jobs/manager.mjs new file mode 100644 index 0000000..1df7259 --- /dev/null +++ b/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/src/jobs/protocol.mjs b/src/jobs/protocol.mjs new file mode 100644 index 0000000..b3ae16a --- /dev/null +++ b/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/src/lead-engine.mjs b/src/lead-engine.mjs new file mode 100644 index 0000000..ab5bb74 --- /dev/null +++ b/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/src/scoring.mjs b/src/scoring.mjs new file mode 100644 index 0000000..d620e82 --- /dev/null +++ b/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/src/server/index.mjs b/src/server/index.mjs new file mode 100644 index 0000000..accb696 --- /dev/null +++ b/src/server/index.mjs @@ -0,0 +1,126 @@ +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 { 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); + + // 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/src/server/router.mjs b/src/server/router.mjs new file mode 100644 index 0000000..8dcb263 --- /dev/null +++ b/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/src/server/routes/auth.mjs b/src/server/routes/auth.mjs new file mode 100644 index 0000000..f46f6b5 --- /dev/null +++ b/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/src/server/routes/capture.mjs b/src/server/routes/capture.mjs new file mode 100644 index 0000000..2473373 --- /dev/null +++ b/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/src/server/routes/exports.mjs b/src/server/routes/exports.mjs new file mode 100644 index 0000000..c85bf87 --- /dev/null +++ b/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/src/server/routes/extractions.mjs b/src/server/routes/extractions.mjs new file mode 100644 index 0000000..c778401 --- /dev/null +++ b/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/src/server/routes/leads.mjs b/src/server/routes/leads.mjs new file mode 100644 index 0000000..15aac45 --- /dev/null +++ b/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/src/server/routes/meta.mjs b/src/server/routes/meta.mjs new file mode 100644 index 0000000..6881f1f --- /dev/null +++ b/src/server/routes/meta.mjs @@ -0,0 +1,142 @@ +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). +const RESET_BASE_PASSWORD = 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. + router.add('POST', '/api/admin/reset', async (req, res, { json, body }) => { + if (!body || body.senha !== RESET_BASE_PASSWORD) { + json(401, { 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/src/server/session.mjs b/src/server/session.mjs new file mode 100644 index 0000000..d25b12f --- /dev/null +++ b/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/src/server/sse.mjs b/src/server/sse.mjs new file mode 100644 index 0000000..a7d0dda --- /dev/null +++ b/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/src/server/static.mjs b/src/server/static.mjs new file mode 100644 index 0000000..59ba465 --- /dev/null +++ b/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/src/sources/cnpj-base.mjs b/src/sources/cnpj-base.mjs new file mode 100644 index 0000000..a4db927 --- /dev/null +++ b/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/src/sources/common.mjs b/src/sources/common.mjs new file mode 100644 index 0000000..d9488e9 --- /dev/null +++ b/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/src/sources/google-places.mjs b/src/sources/google-places.mjs new file mode 100644 index 0000000..4211fa7 --- /dev/null +++ b/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/src/sources/index.mjs b/src/sources/index.mjs new file mode 100644 index 0000000..de3312b --- /dev/null +++ b/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/src/sources/mock.mjs b/src/sources/mock.mjs new file mode 100644 index 0000000..194aa29 --- /dev/null +++ b/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/src/sources/nominatim.mjs b/src/sources/nominatim.mjs new file mode 100644 index 0000000..b5e11d7 --- /dev/null +++ b/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/src/sources/overpass.mjs b/src/sources/overpass.mjs new file mode 100644 index 0000000..a29dff3 --- /dev/null +++ b/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/src/sources/wikidata.mjs b/src/sources/wikidata.mjs new file mode 100644 index 0000000..74f2422 --- /dev/null +++ b/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/src/treatment/dashboard.mjs b/src/treatment/dashboard.mjs new file mode 100644 index 0000000..51f3b0b --- /dev/null +++ b/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/src/treatment/queries.mjs b/src/treatment/queries.mjs new file mode 100644 index 0000000..95db219 --- /dev/null +++ b/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/src/util/collections.mjs b/src/util/collections.mjs new file mode 100644 index 0000000..5092e42 --- /dev/null +++ b/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/src/util/csv.mjs b/src/util/csv.mjs new file mode 100644 index 0000000..099671b --- /dev/null +++ b/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/src/util/stream-json.mjs b/src/util/stream-json.mjs new file mode 100644 index 0000000..602dae7 --- /dev/null +++ b/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/tests/auth.test.mjs b/tests/auth.test.mjs new file mode 100644 index 0000000..186a1e7 --- /dev/null +++ b/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/tests/csv.test.mjs b/tests/csv.test.mjs new file mode 100644 index 0000000..027d848 --- /dev/null +++ b/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/tests/dashboard.test.mjs b/tests/dashboard.test.mjs new file mode 100644 index 0000000..b452b16 --- /dev/null +++ b/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/tests/db.test.mjs b/tests/db.test.mjs new file mode 100644 index 0000000..2fa0374 --- /dev/null +++ b/tests/db.test.mjs @@ -0,0 +1,112 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { openDb, createLeadInserter, transaction, getSetting, setSetting, seedSegments, seedMunicipios, seedGeoCache, seedSettings } 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('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(); +}); diff --git a/tests/export.test.mjs b/tests/export.test.mjs new file mode 100644 index 0000000..183c2f5 --- /dev/null +++ b/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/tests/lead-engine.test.mjs b/tests/lead-engine.test.mjs new file mode 100644 index 0000000..d96063e --- /dev/null +++ b/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/tests/runner.test.mjs b/tests/runner.test.mjs new file mode 100644 index 0000000..e965b98 --- /dev/null +++ b/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/tests/scoring.test.mjs b/tests/scoring.test.mjs new file mode 100644 index 0000000..86ca584 --- /dev/null +++ b/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/tests/stream-json.test.mjs b/tests/stream-json.test.mjs new file mode 100644 index 0000000..cdb2240 --- /dev/null +++ b/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/); + }); +});